main
mula.liu 2026-05-27 19:07:23 +08:00
parent 4b41763ef4
commit fd10178367
8 changed files with 325 additions and 101 deletions

View File

@ -74,6 +74,18 @@
transform: translateX(0) scale(1); transform: translateX(0) scale(1);
} }
.floating-toc.floating-toc-dismissed .floating-toc-tab {
opacity: 1;
transform: none;
pointer-events: auto;
}
.floating-toc.floating-toc-dismissed .floating-toc-panel {
opacity: 0;
pointer-events: none;
transform: translateX(10px) scale(0.98);
}
.floating-toc-header { .floating-toc-header {
min-height: 48px; min-height: 48px;
padding: 0 16px; padding: 0 16px;
@ -167,6 +179,16 @@
font-size: 13px; font-size: 13px;
} }
.floating-toc-drawer .ant-drawer-body {
padding: 0;
}
.floating-toc-drawer .floating-toc-content {
max-height: none;
height: 100%;
padding: 10px 8px 12px;
}
body.dark .floating-toc-tab, body.dark .floating-toc-tab,
body.dark .floating-toc-panel { body.dark .floating-toc-panel {
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.38); box-shadow: 0 22px 48px rgba(0, 0, 0, 0.38);

View File

@ -1,15 +1,10 @@
import { Anchor } from 'antd' import { useState } from 'react'
import { Anchor, Drawer } from 'antd'
import { FileTextOutlined, MenuOutlined } from '@ant-design/icons' import { FileTextOutlined, MenuOutlined } from '@ant-design/icons'
import './FloatingToc.css' import './FloatingToc.css'
export default function FloatingToc({ function buildAnchorItems(items, searchKeyword, renderTitle) {
items = [], return items.map((item) => ({
getContainer,
searchKeyword = '',
renderTitle,
className = '',
}) {
const anchorItems = items.map((item) => ({
key: item.key, key: item.key,
href: item.href, href: item.href,
title: ( title: (
@ -21,12 +16,74 @@ export default function FloatingToc({
</div> </div>
), ),
})) }))
}
function TocContent({ items = [], getContainer, searchKeyword = '', renderTitle, onItemClick }) {
const anchorItems = buildAnchorItems(items, searchKeyword, renderTitle)
return (
<div className="floating-toc-content">
{items.length > 0 ? (
<Anchor
affix={false}
offsetTop={0}
getContainer={getContainer}
items={anchorItems}
onClick={(e, link) => {
window.setTimeout(() => onItemClick?.(link), 120)
}}
/>
) : (
<div className="floating-toc-empty">当前文档无标题</div>
)}
</div>
)
}
export function TocDrawer({
open,
onClose,
items = [],
getContainer,
searchKeyword = '',
renderTitle,
}) {
return (
<Drawer
title="文档索引"
placement="right"
onClose={onClose}
open={open}
width="82%"
className="floating-toc-drawer"
>
<TocContent
items={items}
getContainer={getContainer}
searchKeyword={searchKeyword}
renderTitle={renderTitle}
onItemClick={onClose}
/>
</Drawer>
)
}
export default function FloatingToc({
items = [],
getContainer,
searchKeyword = '',
renderTitle,
className = '',
}) {
const [dismissed, setDismissed] = useState(false)
return ( return (
<aside <aside
className={`floating-toc ${className}`.trim()} className={`floating-toc ${dismissed ? 'floating-toc-dismissed' : ''} ${className}`.trim()}
tabIndex={0} tabIndex={0}
aria-label="文档索引" aria-label="文档索引"
onMouseEnter={() => setDismissed(false)}
onFocus={() => setDismissed(false)}
> >
<div className="floating-toc-tab"> <div className="floating-toc-tab">
<MenuOutlined /> <MenuOutlined />
@ -37,18 +94,13 @@ export default function FloatingToc({
<span>文档索引</span> <span>文档索引</span>
{items.length > 0 && <span className="floating-toc-count">{items.length}</span>} {items.length > 0 && <span className="floating-toc-count">{items.length}</span>}
</div> </div>
<div className="floating-toc-content"> <TocContent
{items.length > 0 ? ( items={items}
<Anchor getContainer={getContainer}
affix={false} searchKeyword={searchKeyword}
offsetTop={0} renderTitle={renderTitle}
getContainer={getContainer} onItemClick={() => setDismissed(true)}
items={anchorItems} />
/>
) : (
<div className="floating-toc-empty">当前文档无标题</div>
)}
</div>
</div> </div>
</aside> </aside>
) )

View File

@ -30,6 +30,20 @@
display: inline-block; display: inline-block;
} }
.pdf-toolbar-compact .ant-btn {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-color-secondary);
}
.pdf-toolbar-compact .ant-btn:hover {
background: var(--item-hover-bg);
color: var(--link-color);
}
.pdf-content { .pdf-content {
flex: 1; flex: 1;
overflow: auto; overflow: auto;

View File

@ -1,7 +1,7 @@
import { useState, useMemo, useRef, useEffect, useCallback } from 'react' import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom' 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, Tooltip } from 'antd'
import { import {
ZoomInOutlined, ZoomInOutlined,
ZoomOutOutlined, ZoomOutOutlined,
@ -17,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, toolbarTarget }) { function VirtualPDFViewer({ url, filename, toolbarTarget, compactToolbar = false }) {
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
@ -160,7 +160,31 @@ function VirtualPDFViewer({ url, filename, toolbarTarget }) {
document.body.removeChild(link) document.body.removeChild(link)
} }
const toolbar = ( const toolbar = compactToolbar ? (
<div className="pdf-toolbar pdf-toolbar-compact">
<Space size={4}>
<Tooltip title="回到顶部">
<Button
icon={<VerticalAlignTopOutlined />}
onClick={scrollToTop}
size="small"
type="text"
disabled={currentPage === 1}
aria-label="回到顶部"
/>
</Tooltip>
<Tooltip title="下载PDF">
<Button
icon={<CloudDownloadOutlined />}
onClick={handleDownload}
size="small"
type="text"
aria-label="下载PDF"
/>
</Tooltip>
</Space>
</div>
) : (
<div className="pdf-toolbar"> <div className="pdf-toolbar">
<Space> <Space>
<Button icon={<ZoomOutOutlined />} onClick={zoomOut} size="small"> <Button icon={<ZoomOutOutlined />} onClick={zoomOut} size="small">

View File

@ -283,8 +283,7 @@
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. */ /* Keep the TOC entry visible; hide the help sidebar button. */
.bytemd-toolbar-right .bytemd-toolbar-icon:nth-child(1),
.bytemd-toolbar-right .bytemd-toolbar-icon:nth-child(2) { .bytemd-toolbar-right .bytemd-toolbar-icon:nth-child(2) {
display: none; display: none;
} }

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, Modal, Input, Spin, Button, Space } from 'antd' import { Layout, Modal, Input, Spin, Button, Space, Tooltip } from 'antd'
import { CloseOutlined, LockOutlined, FileTextOutlined, FilePdfOutlined, VerticalAlignTopOutlined, CloudDownloadOutlined } from '@ant-design/icons' import { CloseOutlined, LockOutlined, FileTextOutlined, FilePdfOutlined, VerticalAlignTopOutlined, CloudDownloadOutlined, MenuOutlined } 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 FloatingToc from '@/components/FloatingToc/FloatingToc' import FloatingToc, { TocDrawer } from '@/components/FloatingToc/FloatingToc'
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
import { import {
getFileSharePublicInfo, getFileSharePublicInfo,
@ -31,6 +31,7 @@ function FileSharePage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [isMobile, setIsMobile] = useState(false) const [isMobile, setIsMobile] = useState(false)
const [tocItems, setTocItems] = useState([]) const [tocItems, setTocItems] = useState([])
const [tocDrawerVisible, setTocDrawerVisible] = useState(false)
const [passwordModalVisible, setPasswordModalVisible] = useState(false) const [passwordModalVisible, setPasswordModalVisible] = useState(false)
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
@ -181,22 +182,54 @@ function FileSharePage() {
<span className="preview-header-text">{headerLabel}</span> <span className="preview-header-text">{headerLabel}</span>
</h3> </h3>
{contentInfo?.type === 'markdown' && ( {contentInfo?.type === 'markdown' && (
<Space className="preview-header-actions"> isMobile ? (
<Button <Space className="preview-header-actions preview-compact-actions" size={4}>
icon={<VerticalAlignTopOutlined />} <Tooltip title="回到顶部">
onClick={scrollContentToTop} <Button
size="small" icon={<VerticalAlignTopOutlined />}
> onClick={scrollContentToTop}
回到顶部 size="small"
</Button> type="text"
<Button aria-label="回到顶部"
icon={<CloudDownloadOutlined />} />
onClick={handleExportPDF} </Tooltip>
size="small" <Tooltip title="下载PDF">
> <Button
下载PDF icon={<CloudDownloadOutlined />}
</Button> onClick={handleExportPDF}
</Space> size="small"
type="text"
aria-label="下载PDF"
/>
</Tooltip>
<Tooltip title="文档索引">
<Button
icon={<MenuOutlined />}
onClick={() => setTocDrawerVisible(true)}
size="small"
type="text"
aria-label="文档索引"
/>
</Tooltip>
</Space>
) : (
<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} />} {contentInfo?.type === 'pdf' && <div className="preview-header-actions pdf-header-toolbar" ref={setPdfToolbarTarget} />}
</div> </div>
@ -213,6 +246,7 @@ function FileSharePage() {
url={contentInfo.document_url} url={contentInfo.document_url}
filename={contentInfo.filename} filename={contentInfo.filename}
toolbarTarget={pdfToolbarTarget} toolbarTarget={pdfToolbarTarget}
compactToolbar={isMobile}
/> />
) : ( ) : (
<div className="markdown-body"> <div className="markdown-body">
@ -239,6 +273,13 @@ function FileSharePage() {
</Layout> </Layout>
</div> </div>
<TocDrawer
open={tocDrawerVisible}
onClose={() => setTocDrawerVisible(false)}
items={tocItems}
getContainer={() => contentRef.current}
/>
<Modal <Modal
title={<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><LockOutlined /><span>访问验证</span></div>} title={<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><LockOutlined /><span>访问验证</span></div>}
open={passwordModalVisible} open={passwordModalVisible}

View File

@ -170,6 +170,29 @@
gap: 16px; gap: 16px;
} }
.preview-header-leading-actions {
display: inline-flex;
align-items: center;
gap: 4px;
flex: none;
}
.preview-header-leading-actions .ant-btn,
.preview-compact-actions .ant-btn {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-color-secondary);
}
.preview-header-leading-actions .ant-btn:hover,
.preview-compact-actions .ant-btn:hover {
background: var(--item-hover-bg);
color: var(--link-color);
}
.preview-header-title { .preview-header-title {
display: flex; display: flex;
align-items: center; align-items: center;
@ -365,31 +388,23 @@
margin-bottom: 4px; margin-bottom: 4px;
} }
/* 移动端菜单按钮 */
.mobile-menu-btn {
position: fixed;
top: 16px;
left: 16px;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.mobile-close-btn {
position: fixed;
top: 16px;
right: 16px;
z-index: 1000;
background: var(--header-bg);
border: 1px solid var(--border-color);
border-radius: 999px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
/* 移动端响应式样式 */ /* 移动端响应式样式 */
@media (max-width: 768px) { @media (max-width: 768px) {
.preview-content-header {
padding: 12px 12px;
gap: 8px;
}
.file-share-content-header {
gap: 8px;
}
.preview-content-wrapper { .preview-content-wrapper {
padding: 16px; padding: 16px;
padding-top: 60px; /* 为移动端菜单按钮留出空间 */ }
.preview-content-wrapper.pdf-mode {
padding: 0;
} }
.markdown-body { .markdown-body {
@ -444,6 +459,10 @@
padding: 12px; padding: 12px;
} }
.preview-content-wrapper.pdf-mode {
padding: 0;
}
.markdown-body { .markdown-body {
font-size: 14px; font-size: 14px;
} }

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, Empty, Tooltip, Space } from 'antd' import { Layout, Menu, Spin, Button, Modal, Input, Drawer, Empty, Tooltip, Space } from 'antd'
import { FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, LockOutlined, MenuOutlined, CloseOutlined, VerticalAlignTopOutlined, CloudDownloadOutlined } from '@ant-design/icons' import { FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, LockOutlined, MenuOutlined, CloseOutlined, VerticalAlignTopOutlined, CloudDownloadOutlined, UnorderedListOutlined } 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 FloatingToc from '@/components/FloatingToc/FloatingToc' import FloatingToc, { TocDrawer } from '@/components/FloatingToc/FloatingToc'
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
import { import {
getProjectSharePublicInfo, getProjectSharePublicInfo,
@ -54,6 +54,7 @@ function ProjectSharePage() {
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [siderCollapsed, setSiderCollapsed] = useState(false) const [siderCollapsed, setSiderCollapsed] = useState(false)
const [mobileDrawerVisible, setMobileDrawerVisible] = useState(false) const [mobileDrawerVisible, setMobileDrawerVisible] = useState(false)
const [tocDrawerVisible, setTocDrawerVisible] = useState(false)
const [isMobile, setIsMobile] = useState(false) const [isMobile, setIsMobile] = useState(false)
const [pdfUrl, setPdfUrl] = useState('') const [pdfUrl, setPdfUrl] = useState('')
const [pdfFilename, setPdfFilename] = useState('') const [pdfFilename, setPdfFilename] = useState('')
@ -418,20 +419,6 @@ function ProjectSharePage() {
<Layout className="preview-layout"> <Layout className="preview-layout">
{isMobile ? ( {isMobile ? (
<> <>
<Button
type="text"
icon={<CloseOutlined />}
className="mobile-close-btn"
onClick={handleClose}
/>
<Button
type="primary"
icon={<MenuOutlined />}
className="mobile-menu-btn"
onClick={() => setMobileDrawerVisible(true)}
>
目录索引
</Button>
<Drawer <Drawer
title={projectInfo?.name || '项目分享'} title={projectInfo?.name || '项目分享'}
placement="left" placement="left"
@ -457,7 +444,10 @@ function ProjectSharePage() {
openKeys={openKeys} openKeys={openKeys}
onOpenChange={setOpenKeys} onOpenChange={setOpenKeys}
items={menuItems} items={menuItems}
onClick={({ key }) => openSharedFile(key)} onClick={({ key }) => {
openSharedFile(key)
setMobileDrawerVisible(false)
}}
className="preview-menu" className="preview-menu"
/> />
) : ( ) : (
@ -512,27 +502,81 @@ function ProjectSharePage() {
<Layout className="preview-content-layout"> <Layout className="preview-content-layout">
<Content className="preview-content" ref={contentRef}> <Content className="preview-content" ref={contentRef}>
<div className="preview-content-header"> <div className="preview-content-header">
{isMobile && (
<div className="preview-header-leading-actions">
<Tooltip title="关闭">
<Button
type="text"
icon={<CloseOutlined />}
onClick={handleClose}
size="small"
aria-label="关闭项目分享"
/>
</Tooltip>
<Tooltip title="目录索引">
<Button
type="text"
icon={<UnorderedListOutlined />}
onClick={() => setMobileDrawerVisible(true)}
size="small"
aria-label="目录索引"
/>
</Tooltip>
</div>
)}
<h3 className="preview-header-title"> <h3 className="preview-header-title">
<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' && ( {viewMode === 'markdown' && (
<Space className="preview-header-actions"> isMobile ? (
<Button <Space className="preview-header-actions preview-compact-actions" size={4}>
icon={<VerticalAlignTopOutlined />} <Tooltip title="回到顶部">
onClick={scrollContentToTop} <Button
size="small" icon={<VerticalAlignTopOutlined />}
> onClick={scrollContentToTop}
回到顶部 size="small"
</Button> type="text"
<Button aria-label="回到顶部"
icon={<CloudDownloadOutlined />} />
onClick={handleExportPDF} </Tooltip>
size="small" <Tooltip title="下载PDF">
> <Button
下载PDF icon={<CloudDownloadOutlined />}
</Button> onClick={handleExportPDF}
</Space> size="small"
type="text"
aria-label="下载PDF"
/>
</Tooltip>
<Tooltip title="文档索引">
<Button
icon={<MenuOutlined />}
onClick={() => setTocDrawerVisible(true)}
size="small"
type="text"
aria-label="文档索引"
/>
</Tooltip>
</Space>
) : (
<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} />} {viewMode === 'pdf' && <div className="preview-header-actions pdf-header-toolbar" ref={setPdfToolbarTarget} />}
</div> </div>
@ -544,7 +588,7 @@ function ProjectSharePage() {
</Spin> </Spin>
</div> </div>
) : viewMode === 'pdf' ? ( ) : viewMode === 'pdf' ? (
<VirtualPDFViewer url={pdfUrl} filename={pdfFilename} toolbarTarget={pdfToolbarTarget} /> <VirtualPDFViewer url={pdfUrl} filename={pdfFilename} toolbarTarget={pdfToolbarTarget} compactToolbar={isMobile} />
) : ( ) : (
<div className="markdown-body" onClick={(e) => { <div className="markdown-body" onClick={(e) => {
if (e.defaultPrevented) return if (e.defaultPrevented) return
@ -578,6 +622,15 @@ function ProjectSharePage() {
</Layout> </Layout>
</Layout> </Layout>
<TocDrawer
open={tocDrawerVisible}
onClose={() => setTocDrawerVisible(false)}
items={tocItems}
searchKeyword={searchKeyword}
getContainer={() => contentRef.current}
renderTitle={(item, keyword) => <HighlightText text={item.title} keyword={keyword} />}
/>
<Modal <Modal
title={<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><LockOutlined /><span>访问验证</span></div>} title={<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><LockOutlined /><span>访问验证</span></div>}
open={passwordModalVisible} open={passwordModalVisible}