调整了多个页面的工具栏样式

main
mula.liu 2026-05-21 17:51:53 +08:00
parent 5256d20ac9
commit 9f395a10ac
10 changed files with 505 additions and 414 deletions

View File

@ -0,0 +1,189 @@
.floating-toc {
position: fixed;
top: 50%;
right: 24px;
z-index: 30;
width: 48px;
height: 180px;
transform: translateY(-50%);
outline: none;
}
.floating-toc-tab {
position: absolute;
top: 0;
right: 0;
width: 48px;
height: 180px;
border: 1px solid color-mix(in srgb, var(--border-color) 82%, var(--text-color-secondary));
border-radius: 8px;
background: color-mix(in srgb, var(--card-bg) 92%, transparent);
color: var(--text-color-secondary);
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.12);
backdrop-filter: blur(12px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
cursor: default;
transition: opacity 0.18s ease, transform 0.18s ease, border-color 0.18s ease, color 0.18s ease;
}
.floating-toc-tab span {
writing-mode: vertical-rl;
text-orientation: mixed;
font-size: 12px;
line-height: 1;
letter-spacing: 0;
white-space: nowrap;
}
.floating-toc-panel {
position: absolute;
top: 0;
right: 0;
width: min(420px, calc(100vw - 320px));
min-width: 300px;
max-height: min(58vh, 460px);
border: 1px solid color-mix(in srgb, var(--border-color) 78%, var(--text-color-secondary));
border-radius: 8px;
background: color-mix(in srgb, var(--card-bg) 96%, transparent);
color: var(--text-color);
box-shadow: 0 22px 48px rgba(15, 23, 42, 0.18);
backdrop-filter: blur(16px);
opacity: 0;
pointer-events: none;
transform: translateX(10px) scale(0.98);
transform-origin: top right;
overflow: hidden;
transition: opacity 0.18s ease, transform 0.18s ease;
}
.floating-toc:hover .floating-toc-tab,
.floating-toc:focus-within .floating-toc-tab {
opacity: 0;
transform: translateX(8px) scale(0.96);
pointer-events: none;
}
.floating-toc:hover .floating-toc-panel,
.floating-toc:focus-within .floating-toc-panel {
opacity: 1;
pointer-events: auto;
transform: translateX(0) scale(1);
}
.floating-toc-header {
min-height: 48px;
padding: 0 16px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--text-color);
font-size: 14px;
font-weight: 600;
}
.floating-toc-count {
min-width: 34px;
height: 22px;
padding: 0 8px;
border-radius: 999px;
background: var(--item-hover-bg);
color: var(--text-color-secondary);
font-size: 12px;
font-weight: 600;
line-height: 22px;
text-align: center;
}
.floating-toc-content {
max-height: calc(min(58vh, 460px) - 49px);
overflow-y: auto;
overflow-x: hidden;
padding: 10px 8px 12px;
}
.floating-toc-content .ant-anchor {
padding-left: 0;
}
.floating-toc-content .ant-anchor::before {
display: none;
}
.floating-toc-content .ant-anchor-ink {
display: none;
}
.floating-toc-content .ant-anchor-link {
padding: 2px 0;
}
.floating-toc-content .ant-anchor-link-title {
border-radius: 6px;
color: var(--text-color-secondary);
line-height: 1.45;
transition: background 0.16s ease, color 0.16s ease;
}
.floating-toc-content .ant-anchor-link-title:hover,
.floating-toc-content .ant-anchor-link-active > .ant-anchor-link-title {
background: var(--item-hover-bg);
color: var(--link-color);
}
.floating-toc-item {
min-height: 34px;
padding: 7px 8px;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.floating-toc-item-icon {
flex: none;
font-size: 12px;
color: currentColor;
opacity: 0.72;
}
.floating-toc-item-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.floating-toc-empty {
padding: 28px 12px;
color: var(--text-color-secondary);
text-align: center;
font-size: 13px;
}
body.dark .floating-toc-tab,
body.dark .floating-toc-panel {
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.38);
}
@media (max-width: 1024px) {
.floating-toc {
right: 16px;
}
.floating-toc-panel {
width: min(360px, calc(100vw - 300px));
}
}
@media (max-width: 768px) {
.floating-toc {
display: none;
}
}

View File

@ -0,0 +1,55 @@
import { Anchor } from 'antd'
import { FileTextOutlined, MenuOutlined } from '@ant-design/icons'
import './FloatingToc.css'
export default function FloatingToc({
items = [],
getContainer,
searchKeyword = '',
renderTitle,
className = '',
}) {
const anchorItems = items.map((item) => ({
key: item.key,
href: item.href,
title: (
<div className="floating-toc-item" style={{ paddingLeft: `${(item.level - 1) * 12}px` }}>
<FileTextOutlined className="floating-toc-item-icon" />
<span className="floating-toc-item-title">
{renderTitle ? renderTitle(item, searchKeyword) : item.title}
</span>
</div>
),
}))
return (
<aside
className={`floating-toc ${className}`.trim()}
tabIndex={0}
aria-label="文档索引"
>
<div className="floating-toc-tab">
<MenuOutlined />
<span>文档索引</span>
</div>
<div className="floating-toc-panel">
<div className="floating-toc-header">
<span>文档索引</span>
{items.length > 0 && <span className="floating-toc-count">{items.length}</span>}
</div>
<div className="floating-toc-content">
{items.length > 0 ? (
<Anchor
affix={false}
offsetTop={0}
getContainer={getContainer}
items={anchorItems}
/>
) : (
<div className="floating-toc-empty">当前文档无标题</div>
)}
</div>
</div>
</aside>
)
}

View File

@ -7,22 +7,42 @@
.pdf-toolbar { .pdf-toolbar {
display: flex; display: flex;
justify-content: space-between; justify-content: flex-end;
align-items: center; align-items: center;
padding: 12px 16px; gap: 16px;
background: var(--card-bg); min-width: 0;
border-bottom: 1px solid var(--border-color); background: transparent;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
z-index: 10; z-index: 10;
color: var(--text-color); color: var(--text-color);
} }
.pdf-toolbar-scale {
min-width: 50px;
text-align: center;
color: var(--text-color);
font-size: 13px;
}
.pdf-toolbar-divider {
width: 1px;
height: 18px;
background: var(--border-color);
display: inline-block;
}
.pdf-content { .pdf-content {
flex: 1; flex: 1;
overflow: auto; overflow: auto;
position: relative; position: relative;
} }
.virtual-pdf-viewer-container > .pdf-toolbar {
padding: 12px 16px;
background: var(--card-bg);
border-bottom: 1px solid var(--border-color);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.pdf-virtual-list { .pdf-virtual-list {
background: var(--bg-color-secondary); background: var(--bg-color-secondary);
} }

View File

@ -1,4 +1,5 @@
import { useState, useMemo, useRef, useEffect, useCallback } from 'react' import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { Document, Page, pdfjs } from 'react-pdf' import { Document, Page, pdfjs } from 'react-pdf'
import { Button, Space, InputNumber, message, Spin } from 'antd' import { Button, Space, InputNumber, message, Spin } from 'antd'
import { import {
@ -16,7 +17,7 @@ import './VirtualPDFViewer.css'
// PDF.js worker // PDF.js worker
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf-worker/pdf.worker.min.mjs' pdfjs.GlobalWorkerOptions.workerSrc = '/pdf-worker/pdf.worker.min.mjs'
function VirtualPDFViewer({ url, filename }) { function VirtualPDFViewer({ url, filename, toolbarTarget }) {
const [numPages, setNumPages] = useState(null) const [numPages, setNumPages] = useState(null)
const [scale, setScale] = useState(1.0) const [scale, setScale] = useState(1.0)
const [pdfOriginalSize, setPdfOriginalSize] = useState({ width: 595, height: 842 }) // A4 const [pdfOriginalSize, setPdfOriginalSize] = useState({ width: 595, height: 842 }) // A4
@ -159,11 +160,18 @@ function VirtualPDFViewer({ url, filename }) {
document.body.removeChild(link) document.body.removeChild(link)
} }
return ( const toolbar = (
<div className="virtual-pdf-viewer-container">
{/* 工具栏 */}
<div className="pdf-toolbar"> <div className="pdf-toolbar">
<Space> <Space>
<Button icon={<ZoomOutOutlined />} onClick={zoomOut} size="small">
缩小
</Button>
<span className="pdf-toolbar-scale">
{Math.round(scale * 100)}%
</span>
<Button icon={<ZoomInOutlined />} onClick={zoomIn} size="small">
放大
</Button>
<Button <Button
icon={<LeftOutlined />} icon={<LeftOutlined />}
onClick={() => handlePageChange(currentPage - 1)} onClick={() => handlePageChange(currentPage - 1)}
@ -189,6 +197,7 @@ function VirtualPDFViewer({ url, filename }) {
disabled={currentPage >= (numPages || 0)} disabled={currentPage >= (numPages || 0)}
size="small" size="small"
/> />
<span className="pdf-toolbar-divider" />
<Button <Button
icon={<VerticalAlignTopOutlined />} icon={<VerticalAlignTopOutlined />}
onClick={scrollToTop} onClick={scrollToTop}
@ -205,19 +214,12 @@ function VirtualPDFViewer({ url, filename }) {
下载PDF 下载PDF
</Button> </Button>
</Space> </Space>
<Space>
<Button icon={<ZoomOutOutlined />} onClick={zoomOut} size="small">
缩小
</Button>
<span style={{ minWidth: 50, textAlign: 'center' }}>
{Math.round(scale * 100)}%
</span>
<Button icon={<ZoomInOutlined />} onClick={zoomIn} size="small">
放大
</Button>
</Space>
</div> </div>
)
return (
<div className="virtual-pdf-viewer-container">
{toolbarTarget ? createPortal(toolbar, toolbarTarget) : toolbar}
{/* PDF内容区 - 自定义虚拟滚动 */} {/* PDF内容区 - 自定义虚拟滚动 */}
<div className="pdf-content" ref={containerRef}> <div className="pdf-content" ref={containerRef}>

View File

@ -283,6 +283,12 @@
background-color: var(--item-hover-bg); background-color: var(--item-hover-bg);
} }
/* The fixed editor layout does not use Bytemd's built-in sidebar modes. */
.bytemd-toolbar-right .bytemd-toolbar-icon:nth-child(1),
.bytemd-toolbar-right .bytemd-toolbar-icon:nth-child(2) {
display: none;
}
/* 编辑和预览区域容器 */ /* 编辑和预览区域容器 */
.bytemd-body { .bytemd-body {
flex: 1 !important; flex: 1 !important;
@ -297,15 +303,10 @@
background-color: var(--bg-color); background-color: var(--bg-color);
} }
/* 编辑区域 - 固定50%宽度 */ /* 编辑区域 - 默认分栏,保留 Bytemd 内联样式对仅编辑/仅预览的控制 */
.bytemd-editor { .bytemd-editor {
width: 50% !important;
flex: 0 0 50% !important;
display: flex !important;
flex-direction: column !important;
overflow: hidden; overflow: hidden;
min-height: 0; min-height: 0;
max-width: 50% !important;
box-sizing: border-box; box-sizing: border-box;
/* Added for consistent box model */ /* Added for consistent box model */
min-width: 0; min-width: 0;
@ -314,16 +315,13 @@
border-right: 1px solid var(--border-color); border-right: 1px solid var(--border-color);
} }
/* 预览区域 - 固定50%宽度 */ /* 预览区域 - 默认分栏,保留 Bytemd 内联样式对仅编辑/仅预览的控制 */
.bytemd-preview { .bytemd-preview {
width: 50% !important;
flex: 0 0 50% !important;
overflow-y: auto !important; overflow-y: auto !important;
overflow-x: hidden !important; overflow-x: hidden !important;
padding: 16px; padding: 16px;
font-size: 14px; font-size: 14px;
line-height: 1.8; line-height: 1.8;
max-width: 50% !important;
box-sizing: border-box; box-sizing: border-box;
/* Added for consistent box model */ /* Added for consistent box model */
min-width: 0; min-width: 0;

View File

@ -169,86 +169,6 @@
background: var(--bg-color); background: var(--bg-color);
} }
.docs-toc-sider {
border-left: 1px solid var(--border-color);
background: var(--bg-color-secondary) !important;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.docs-toc-sider .ant-layout-sider-children {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.toc-header {
padding: 16px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
background: var(--header-bg);
}
.toc-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--text-color);
}
.toc-content {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: auto;
padding: 16px;
}
.toc-content .ant-anchor {
display: block;
min-height: max-content;
padding-left: 0;
padding-bottom: 65px;
/* 给Anchor组件添加底部内边距避免最后一项被遮挡 */
}
.toc-content .ant-anchor-link {
padding: 6px 0;
}
.toc-content .ant-anchor-link-title {
font-size: 13px;
color: var(--text-color-secondary);
line-height: 1.5;
white-space: nowrap;
}
.toc-content .ant-anchor-link-active>.ant-anchor-link-title {
color: var(--link-color);
font-weight: 500;
}
.toc-empty {
color: var(--text-color-secondary);
text-align: center;
margin-top: 40px;
font-size: 13px;
}
.toc-toggle-btn {
position: fixed;
right: 24px;
top: 50%;
transform: translateY(-50%);
z-index: 100;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.docs-content { .docs-content {
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
@ -265,13 +185,15 @@
background: var(--header-bg); background: var(--header-bg);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
gap: 16px;
} }
.docs-header-title { .docs-header-title {
display: flex; display: flex;
align-items: center; align-items: center;
min-width: 0; min-width: 0;
max-width: 100%; flex: 1;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
gap: 0; gap: 0;
@ -301,6 +223,20 @@
white-space: nowrap; white-space: nowrap;
} }
.docs-header-actions {
flex: none;
}
.pdf-header-toolbar {
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
}
.pdf-header-toolbar .pdf-toolbar {
white-space: nowrap;
}
.docs-content-wrapper { .docs-content-wrapper {
max-width: 900px; max-width: 900px;
margin: 0 auto; margin: 0 auto;

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom' import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { Layout, Menu, Spin, FloatButton, Button, Tooltip, message, Anchor, Modal, Input, Space, Dropdown, Empty, Switch } from 'antd' import { Layout, Menu, Spin, Button, Tooltip, message, Modal, Input, Space, Dropdown, Empty, Switch } from 'antd'
import { VerticalAlignTopOutlined, ShareAltOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, CopyOutlined, LockOutlined, CloudDownloadOutlined, CloudUploadOutlined, DownOutlined, SearchOutlined, ArrowLeftOutlined, MenuOutlined, ReloadOutlined } from '@ant-design/icons' import { ShareAltOutlined, FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, CopyOutlined, LockOutlined, CloudDownloadOutlined, CloudUploadOutlined, DownOutlined, ArrowLeftOutlined, ReloadOutlined, VerticalAlignTopOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw' import rehypeRaw from 'rehype-raw'
@ -15,7 +15,7 @@ import { gitPull, gitPush, getGitRepos } from '@/api/project'
import { getFileShareInfo, createOrUpdateFileShare, deleteFileShare } from '@/api/share' import { getFileShareInfo, createOrUpdateFileShare, deleteFileShare } from '@/api/share'
import { searchDocuments } from '@/api/search' import { searchDocuments } from '@/api/search'
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
import DocFloatActions from '@/components/DocFloatActions/DocFloatActions' import FloatingToc from '@/components/FloatingToc/FloatingToc'
import Toast from '@/components/Toast/Toast' import Toast from '@/components/Toast/Toast'
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch' import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
import './DocumentPage.css' import './DocumentPage.css'
@ -45,7 +45,6 @@ function DocumentPage() {
const [markdownContent, setMarkdownContent] = useState('') const [markdownContent, setMarkdownContent] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [openKeys, setOpenKeys] = useState([]) const [openKeys, setOpenKeys] = useState([])
const [tocCollapsed, setTocCollapsed] = useState(false)
const [tocItems, setTocItems] = useState([]) const [tocItems, setTocItems] = useState([])
const [shareModalVisible, setShareModalVisible] = useState(false) const [shareModalVisible, setShareModalVisible] = useState(false)
const [shareInfo, setShareInfo] = useState(null) const [shareInfo, setShareInfo] = useState(null)
@ -68,6 +67,7 @@ function DocumentPage() {
const contentRef = useRef(null) const contentRef = useRef(null)
const modeSwitchingRef = useRef(false) const modeSwitchingRef = useRef(false)
const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null)
const getHeaderDisplay = (filePath) => { const getHeaderDisplay = (filePath) => {
const resolvedPath = filePath || 'README.md' const resolvedPath = filePath || 'README.md'
@ -414,6 +414,22 @@ function DocumentPage() {
} }
} }
const scrollContentToTop = () => {
if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
}
}
const handleExportMarkdownPDF = () => {
if (!selectedFile) return
let url = getExportPdfUrl(projectId, selectedFile)
const token = localStorage.getItem('access_token')
if (token) {
url += `&token=${encodeURIComponent(token)}`
}
window.open(url, '_blank')
}
// //
const resolveRelativePath = (currentPath, relativePath) => { const resolveRelativePath = (currentPath, relativePath) => {
// //
@ -1031,12 +1047,33 @@ function DocumentPage() {
{(() => { {(() => {
const { fileName, FileIcon, isPdf } = getHeaderDisplay(selectedFile) const { fileName, FileIcon, isPdf } = getHeaderDisplay(selectedFile)
return ( return (
<>
<div className="docs-header-title"> <div className="docs-header-title">
<span className="docs-header-item"> <span className="docs-header-item">
<FileIcon className="docs-header-icon" style={isPdf ? { color: '#f5222d' } : undefined} /> <FileIcon className="docs-header-icon" style={isPdf ? { color: '#f5222d' } : undefined} />
<span className="docs-header-text">{fileName}</span> <span className="docs-header-text">{fileName}</span>
</span> </span>
</div> </div>
{viewMode === 'pdf' && <div className="docs-header-actions pdf-header-toolbar" ref={setPdfToolbarTarget} />}
{viewMode === 'markdown' && (
<Space className="docs-header-actions">
<Button
icon={<VerticalAlignTopOutlined />}
onClick={scrollContentToTop}
size="small"
>
回到顶部
</Button>
<Button
icon={<CloudDownloadOutlined />}
onClick={handleExportMarkdownPDF}
size="small"
>
下载PDF
</Button>
</Space>
)}
</>
) )
})()} })()}
</div> </div>
@ -1051,6 +1088,7 @@ function DocumentPage() {
<VirtualPDFViewer <VirtualPDFViewer
url={pdfUrl} url={pdfUrl}
filename={pdfFilename} filename={pdfFilename}
toolbarTarget={pdfToolbarTarget}
/> />
) : ( ) : (
<div className="markdown-body"> <div className="markdown-body">
@ -1065,72 +1103,17 @@ function DocumentPage() {
)} )}
</div> </div>
{/* 浮动按钮组 - 仅在markdown模式显示 */}
{viewMode === 'markdown' && (
<DocFloatActions
scrollRef={contentRef}
right={tocCollapsed ? 24 : 280}
onExportPDF={() => {
if (!selectedFile) return
let url = getExportPdfUrl(projectId, selectedFile)
const token = localStorage.getItem('access_token')
if (token) {
url += `&token=${encodeURIComponent(token)}`
}
window.open(url, '_blank')
}}
/>
)}
</Content> </Content>
{/* 右侧TOC面板 - 仅在markdown模式显示 */} {viewMode === 'markdown' && (
{viewMode === 'markdown' && !tocCollapsed && ( <FloatingToc
<Sider width={250} theme="light" className="docs-toc-sider"> items={tocItems}
<div className="toc-header"> searchKeyword={searchKeyword}
<h3>文档索引</h3>
<Button
type="text"
size="small"
icon={<MenuFoldOutlined />}
onClick={() => setTocCollapsed(true)}
/>
</div>
<div className="toc-content">
{tocItems.length > 0 ? (
<Anchor
affix={false}
offsetTop={0}
getContainer={() => contentRef.current} getContainer={() => contentRef.current}
items={tocItems.map((item) => ({ renderTitle={(item, keyword) => <HighlightText text={item.title} keyword={keyword} />}
key: item.key,
href: item.href,
title: (
<div style={{ paddingLeft: `${(item.level - 1) * 12}px`, display: 'flex', alignItems: 'center', gap: '4px' }}>
<FileTextOutlined style={{ fontSize: '12px', color: '#8c8c8c' }} />
<HighlightText text={item.title} keyword={searchKeyword} />
</div>
),
}))}
/> />
) : (
<div className="toc-empty">当前文档无标题</div>
)}
</div>
</Sider>
)} )}
</Layout> </Layout>
{/* TOC展开按钮 */}
{tocCollapsed && (
<Button
type="primary"
icon={<MenuUnfoldOutlined />}
className="toc-toggle-btn"
onClick={() => setTocCollapsed(false)}
>
文档索引
</Button>
)}
</Layout> </Layout>
{/* 分享模态框 */} {/* 分享模态框 */}

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { Layout, Button, Modal, Input, Spin, Anchor } from 'antd' import { Layout, Modal, Input, Spin, Button, Space } from 'antd'
import { CloseOutlined, LockOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FilePdfOutlined } from '@ant-design/icons' import { CloseOutlined, LockOutlined, FileTextOutlined, FilePdfOutlined, VerticalAlignTopOutlined, CloudDownloadOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight' import rehypeHighlight from 'rehype-highlight'
@ -9,7 +9,7 @@ import rehypeSlug from 'rehype-slug'
import 'highlight.js/styles/github.css' import 'highlight.js/styles/github.css'
import GithubSlugger from 'github-slugger' import GithubSlugger from 'github-slugger'
import Toast from '@/components/Toast/Toast' import Toast from '@/components/Toast/Toast'
import DocFloatActions from '@/components/DocFloatActions/DocFloatActions' import FloatingToc from '@/components/FloatingToc/FloatingToc'
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
import { import {
getFileSharePublicInfo, getFileSharePublicInfo,
@ -19,17 +19,17 @@ import {
} from '@/api/share' } from '@/api/share'
import './PreviewPage.css' import './PreviewPage.css'
const { Content, Sider } = Layout const { Content } = Layout
function FileSharePage() { function FileSharePage() {
const { shareCode } = useParams() const { shareCode } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const contentRef = useRef(null) const contentRef = useRef(null)
const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null)
const [shareInfo, setShareInfo] = useState(null) const [shareInfo, setShareInfo] = useState(null)
const [contentInfo, setContentInfo] = useState(null) const [contentInfo, setContentInfo] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [isMobile, setIsMobile] = useState(false) const [isMobile, setIsMobile] = useState(false)
const [tocCollapsed, setTocCollapsed] = useState(false)
const [tocItems, setTocItems] = useState([]) const [tocItems, setTocItems] = useState([])
const [passwordModalVisible, setPasswordModalVisible] = useState(false) const [passwordModalVisible, setPasswordModalVisible] = useState(false)
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
@ -117,6 +117,12 @@ function FileSharePage() {
window.open(exportFileSharePDF(shareCode), '_blank') window.open(exportFileSharePDF(shareCode), '_blank')
} }
const scrollContentToTop = () => {
if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
}
}
const isExternalHref = (href) => { const isExternalHref = (href) => {
return Boolean(href && (/^[a-z][a-z\d+.-]*:/i.test(href) || href.startsWith('//'))) return Boolean(href && (/^[a-z][a-z\d+.-]*:/i.test(href) || href.startsWith('//')))
} }
@ -174,6 +180,25 @@ function FileSharePage() {
<HeaderIcon className="preview-header-icon" style={isHeaderPdf ? { color: '#f5222d' } : undefined} /> <HeaderIcon className="preview-header-icon" style={isHeaderPdf ? { color: '#f5222d' } : undefined} />
<span className="preview-header-text">{headerLabel}</span> <span className="preview-header-text">{headerLabel}</span>
</h3> </h3>
{contentInfo?.type === 'markdown' && (
<Space className="preview-header-actions">
<Button
icon={<VerticalAlignTopOutlined />}
onClick={scrollContentToTop}
size="small"
>
回到顶部
</Button>
<Button
icon={<CloudDownloadOutlined />}
onClick={handleExportPDF}
size="small"
>
下载PDF
</Button>
</Space>
)}
{contentInfo?.type === 'pdf' && <div className="preview-header-actions pdf-header-toolbar" ref={setPdfToolbarTarget} />}
</div> </div>
{loading ? ( {loading ? (
<div className="preview-loading"> <div className="preview-loading">
@ -184,7 +209,11 @@ function FileSharePage() {
) : ( ) : (
<div className={`preview-content-wrapper ${contentInfo?.type === 'pdf' ? 'pdf-mode' : ''}`}> <div className={`preview-content-wrapper ${contentInfo?.type === 'pdf' ? 'pdf-mode' : ''}`}>
{contentInfo?.type === 'pdf' ? ( {contentInfo?.type === 'pdf' ? (
<VirtualPDFViewer url={contentInfo.document_url} filename={contentInfo.filename} /> <VirtualPDFViewer
url={contentInfo.document_url}
filename={contentInfo.filename}
toolbarTarget={pdfToolbarTarget}
/>
) : ( ) : (
<div className="markdown-body"> <div className="markdown-body">
<ReactMarkdown <ReactMarkdown
@ -199,56 +228,15 @@ function FileSharePage() {
</div> </div>
)} )}
{contentInfo?.type === 'markdown' && (
<DocFloatActions
scrollRef={contentRef}
right={!isMobile && !tocCollapsed ? 280 : 24}
onExportPDF={handleExportPDF}
/>
)}
</Content> </Content>
{!isMobile && contentInfo?.type === 'markdown' && !tocCollapsed && ( {!isMobile && contentInfo?.type === 'markdown' && (
<Sider width={250} theme="light" className="preview-toc-sider"> <FloatingToc
<div className="toc-header"> items={tocItems}
<h3>文档索引</h3>
<Button type="text" size="small" icon={<MenuFoldOutlined />} onClick={() => setTocCollapsed(true)} />
</div>
<div className="toc-content">
{tocItems.length > 0 ? (
<Anchor
affix={false}
offsetTop={0}
getContainer={() => contentRef.current} getContainer={() => contentRef.current}
items={tocItems.map((item) => ({
key: item.key,
href: item.href,
title: (
<div style={{ paddingLeft: `${(item.level - 1) * 12}px`, display: 'flex', alignItems: 'center', gap: '4px' }}>
<FileTextOutlined style={{ fontSize: '12px', color: '#8c8c8c' }} />
<span>{item.title}</span>
</div>
),
}))}
/> />
) : (
<div className="toc-empty">当前文档无标题</div>
)}
</div>
</Sider>
)} )}
</Layout> </Layout>
{!isMobile && contentInfo?.type === 'markdown' && tocCollapsed && (
<Button
type="primary"
icon={<MenuUnfoldOutlined />}
className="toc-toggle-btn"
onClick={() => setTocCollapsed(false)}
>
文档索引
</Button>
)}
</div> </div>
<Modal <Modal

View File

@ -150,85 +150,6 @@
background: var(--bg-color); background: var(--bg-color);
} }
.preview-toc-sider {
border-left: 1px solid var(--border-color);
background: var(--bg-color-secondary) !important;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.preview-toc-sider .ant-layout-sider-children {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.toc-header {
padding: 16px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
background: var(--header-bg);
}
.toc-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--text-color);
}
.toc-content {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: auto;
padding: 16px;
}
.toc-content .ant-anchor {
display: block;
min-height: max-content;
padding-left: 0;
padding-bottom: 65px;
}
.toc-content .ant-anchor-link {
padding: 6px 0;
}
.toc-content .ant-anchor-link-title {
font-size: 13px;
color: var(--text-color-secondary);
line-height: 1.5;
white-space: nowrap;
}
.toc-content .ant-anchor-link-active > .ant-anchor-link-title {
color: var(--link-color);
font-weight: 500;
}
.toc-empty {
color: var(--text-color-secondary);
text-align: center;
margin-top: 40px;
font-size: 13px;
}
.toc-toggle-btn {
position: fixed;
right: 24px;
top: 50%;
transform: translateY(-50%);
z-index: 100;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.preview-content { .preview-content {
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
@ -245,12 +166,15 @@
background: var(--header-bg); background: var(--header-bg);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
gap: 16px;
} }
.preview-header-title { .preview-header-title {
display: flex; display: flex;
align-items: center; align-items: center;
min-width: 0; min-width: 0;
flex: 1;
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
@ -274,6 +198,20 @@
white-space: nowrap; white-space: nowrap;
} }
.preview-header-actions {
flex: none;
}
.pdf-header-toolbar {
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
}
.pdf-header-toolbar .pdf-toolbar {
white-space: nowrap;
}
.preview-content-header h3:not(.preview-header-title) { .preview-content-header h3:not(.preview-header-title) {
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;
@ -494,10 +432,6 @@
width: 240px !important; width: 240px !important;
} }
.preview-toc-sider {
width: 200px !important;
}
.preview-content-wrapper { .preview-content-wrapper {
max-width: 100%; max-width: 100%;
padding: 20px; padding: 20px;

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useSearchParams, useNavigate } from 'react-router-dom' import { useParams, useSearchParams, useNavigate } from 'react-router-dom'
import { Layout, Menu, Spin, Button, Modal, Input, Drawer, Anchor, Empty, Tooltip } from 'antd' import { Layout, Menu, Spin, Button, Modal, Input, Drawer, Empty, Tooltip, Space } from 'antd'
import { MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, LockOutlined, MenuOutlined, CloseOutlined } from '@ant-design/icons' import { FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, LockOutlined, MenuOutlined, CloseOutlined, VerticalAlignTopOutlined, CloudDownloadOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight' import rehypeHighlight from 'rehype-highlight'
@ -11,7 +11,7 @@ import Mark from 'mark.js'
import Highlighter from 'react-highlight-words' import Highlighter from 'react-highlight-words'
import GithubSlugger from 'github-slugger' import GithubSlugger from 'github-slugger'
import Toast from '@/components/Toast/Toast' import Toast from '@/components/Toast/Toast'
import DocFloatActions from '@/components/DocFloatActions/DocFloatActions' import FloatingToc from '@/components/FloatingToc/FloatingToc'
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
import { import {
getProjectSharePublicInfo, getProjectSharePublicInfo,
@ -49,7 +49,6 @@ function ProjectSharePage() {
const [markdownContent, setMarkdownContent] = useState('') const [markdownContent, setMarkdownContent] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [openKeys, setOpenKeys] = useState([]) const [openKeys, setOpenKeys] = useState([])
const [tocCollapsed, setTocCollapsed] = useState(false)
const [tocItems, setTocItems] = useState([]) const [tocItems, setTocItems] = useState([])
const [passwordModalVisible, setPasswordModalVisible] = useState(false) const [passwordModalVisible, setPasswordModalVisible] = useState(false)
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
@ -64,6 +63,7 @@ function ProjectSharePage() {
const [isSearching, setIsSearching] = useState(false) const [isSearching, setIsSearching] = useState(false)
const contentRef = useRef(null) const contentRef = useRef(null)
const viewerRef = useRef(null) const viewerRef = useRef(null)
const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null)
const handleClose = () => { const handleClose = () => {
if (window.history.length > 1) { if (window.history.length > 1) {
@ -88,7 +88,7 @@ function ProjectSharePage() {
instance.mark(searchKeyword, { instance.mark(searchKeyword, {
element: 'span', element: 'span',
className: 'search-highlight', className: 'search-highlight',
exclude: ['pre', 'code', '.toc-content'], exclude: ['pre', 'code', '.floating-toc'],
}) })
} }
} }
@ -381,6 +381,12 @@ function ProjectSharePage() {
window.open(exportProjectSharePDF(shareCode, selectedFile), '_blank') window.open(exportProjectSharePDF(shareCode, selectedFile), '_blank')
} }
const scrollContentToTop = () => {
if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
}
}
const menuItems = useMemo( const menuItems = useMemo(
() => convertTreeToMenuItems(filteredTreeData), () => convertTreeToMenuItems(filteredTreeData),
[filteredTreeData, openKeys] [filteredTreeData, openKeys]
@ -510,6 +516,25 @@ function ProjectSharePage() {
<HeaderIcon className="preview-header-icon" style={isHeaderPdf ? { color: '#f5222d' } : undefined} /> <HeaderIcon className="preview-header-icon" style={isHeaderPdf ? { color: '#f5222d' } : undefined} />
<span className="preview-header-text">{headerLabel}</span> <span className="preview-header-text">{headerLabel}</span>
</h3> </h3>
{viewMode === 'markdown' && (
<Space className="preview-header-actions">
<Button
icon={<VerticalAlignTopOutlined />}
onClick={scrollContentToTop}
size="small"
>
回到顶部
</Button>
<Button
icon={<CloudDownloadOutlined />}
onClick={handleExportPDF}
size="small"
>
下载PDF
</Button>
</Space>
)}
{viewMode === 'pdf' && <div className="preview-header-actions pdf-header-toolbar" ref={setPdfToolbarTarget} />}
</div> </div>
<div className={`preview-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''}`}> <div className={`preview-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''}`}>
{loading ? ( {loading ? (
@ -519,7 +544,7 @@ function ProjectSharePage() {
</Spin> </Spin>
</div> </div>
) : viewMode === 'pdf' ? ( ) : viewMode === 'pdf' ? (
<VirtualPDFViewer url={pdfUrl} filename={pdfFilename} /> <VirtualPDFViewer url={pdfUrl} filename={pdfFilename} toolbarTarget={pdfToolbarTarget} />
) : ( ) : (
<div className="markdown-body" onClick={(e) => { <div className="markdown-body" onClick={(e) => {
if (e.defaultPrevented) return if (e.defaultPrevented) return
@ -540,56 +565,17 @@ function ProjectSharePage() {
)} )}
</div> </div>
{viewMode === 'markdown' && (
<DocFloatActions
scrollRef={contentRef}
right={!isMobile && !tocCollapsed ? 280 : 24}
onExportPDF={handleExportPDF}
/>
)}
</Content> </Content>
{!isMobile && viewMode === 'markdown' && !tocCollapsed && ( {!isMobile && viewMode === 'markdown' && (
<Sider width={250} theme="light" className="preview-toc-sider"> <FloatingToc
<div className="toc-header"> items={tocItems}
<h3>文档索引</h3> searchKeyword={searchKeyword}
<Button type="text" size="small" icon={<MenuFoldOutlined />} onClick={() => setTocCollapsed(true)} />
</div>
<div className="toc-content">
{tocItems.length > 0 ? (
<Anchor
affix={false}
offsetTop={0}
getContainer={() => contentRef.current} getContainer={() => contentRef.current}
items={tocItems.map((item) => ({ renderTitle={(item, keyword) => <HighlightText text={item.title} keyword={keyword} />}
key: item.key,
href: item.href,
title: (
<div style={{ paddingLeft: `${(item.level - 1) * 12}px`, display: 'flex', alignItems: 'center', gap: '4px' }}>
<FileTextOutlined style={{ fontSize: '12px', color: '#8c8c8c' }} />
<HighlightText text={item.title} keyword={searchKeyword} />
</div>
),
}))}
/> />
) : (
<div className="toc-empty">当前文档无标题</div>
)}
</div>
</Sider>
)} )}
</Layout> </Layout>
{!isMobile && tocCollapsed && (
<Button
type="primary"
icon={<MenuUnfoldOutlined />}
className="toc-toggle-btn"
onClick={() => setTocCollapsed(false)}
>
文档索引
</Button>
)}
</Layout> </Layout>
<Modal <Modal