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);
}
.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 {
min-height: 48px;
padding: 0 16px;
@ -167,6 +179,16 @@
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-panel {
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 './FloatingToc.css'
export default function FloatingToc({
items = [],
getContainer,
searchKeyword = '',
renderTitle,
className = '',
}) {
const anchorItems = items.map((item) => ({
function buildAnchorItems(items, searchKeyword, renderTitle) {
return items.map((item) => ({
key: item.key,
href: item.href,
title: (
@ -21,12 +16,74 @@ export default function FloatingToc({
</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 (
<aside
className={`floating-toc ${className}`.trim()}
className={`floating-toc ${dismissed ? 'floating-toc-dismissed' : ''} ${className}`.trim()}
tabIndex={0}
aria-label="文档索引"
onMouseEnter={() => setDismissed(false)}
onFocus={() => setDismissed(false)}
>
<div className="floating-toc-tab">
<MenuOutlined />
@ -37,18 +94,13 @@ export default function FloatingToc({
<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}
<TocContent
items={items}
getContainer={getContainer}
items={anchorItems}
searchKeyword={searchKeyword}
renderTitle={renderTitle}
onItemClick={() => setDismissed(true)}
/>
) : (
<div className="floating-toc-empty">当前文档无标题</div>
)}
</div>
</div>
</aside>
)

View File

@ -30,6 +30,20 @@
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 {
flex: 1;
overflow: auto;

View File

@ -1,7 +1,7 @@
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
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 {
ZoomInOutlined,
ZoomOutOutlined,
@ -17,7 +17,7 @@ import './VirtualPDFViewer.css'
// PDF.js worker
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 [scale, setScale] = useState(1.0)
const [pdfOriginalSize, setPdfOriginalSize] = useState({ width: 595, height: 842 }) // A4
@ -160,7 +160,31 @@ function VirtualPDFViewer({ url, filename, toolbarTarget }) {
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">
<Space>
<Button icon={<ZoomOutOutlined />} onClick={zoomOut} size="small">

View File

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

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Layout, Modal, Input, Spin, Button, Space } from 'antd'
import { CloseOutlined, LockOutlined, FileTextOutlined, FilePdfOutlined, VerticalAlignTopOutlined, CloudDownloadOutlined } from '@ant-design/icons'
import { Layout, Modal, Input, Spin, Button, Space, Tooltip } from 'antd'
import { CloseOutlined, LockOutlined, FileTextOutlined, FilePdfOutlined, VerticalAlignTopOutlined, CloudDownloadOutlined, MenuOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
@ -9,7 +9,7 @@ import rehypeSlug from 'rehype-slug'
import 'highlight.js/styles/github.css'
import GithubSlugger from 'github-slugger'
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 {
getFileSharePublicInfo,
@ -31,6 +31,7 @@ function FileSharePage() {
const [loading, setLoading] = useState(true)
const [isMobile, setIsMobile] = useState(false)
const [tocItems, setTocItems] = useState([])
const [tocDrawerVisible, setTocDrawerVisible] = useState(false)
const [passwordModalVisible, setPasswordModalVisible] = useState(false)
const [password, setPassword] = useState('')
@ -181,6 +182,37 @@ function FileSharePage() {
<span className="preview-header-text">{headerLabel}</span>
</h3>
{contentInfo?.type === 'markdown' && (
isMobile ? (
<Space className="preview-header-actions preview-compact-actions" size={4}>
<Tooltip title="回到顶部">
<Button
icon={<VerticalAlignTopOutlined />}
onClick={scrollContentToTop}
size="small"
type="text"
aria-label="回到顶部"
/>
</Tooltip>
<Tooltip title="下载PDF">
<Button
icon={<CloudDownloadOutlined />}
onClick={handleExportPDF}
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 />}
@ -197,6 +229,7 @@ function FileSharePage() {
下载PDF
</Button>
</Space>
)
)}
{contentInfo?.type === 'pdf' && <div className="preview-header-actions pdf-header-toolbar" ref={setPdfToolbarTarget} />}
</div>
@ -213,6 +246,7 @@ function FileSharePage() {
url={contentInfo.document_url}
filename={contentInfo.filename}
toolbarTarget={pdfToolbarTarget}
compactToolbar={isMobile}
/>
) : (
<div className="markdown-body">
@ -239,6 +273,13 @@ function FileSharePage() {
</Layout>
</div>
<TocDrawer
open={tocDrawerVisible}
onClose={() => setTocDrawerVisible(false)}
items={tocItems}
getContainer={() => contentRef.current}
/>
<Modal
title={<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><LockOutlined /><span>访问验证</span></div>}
open={passwordModalVisible}

View File

@ -170,6 +170,29 @@
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 {
display: flex;
align-items: center;
@ -365,31 +388,23 @@
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) {
.preview-content-header {
padding: 12px 12px;
gap: 8px;
}
.file-share-content-header {
gap: 8px;
}
.preview-content-wrapper {
padding: 16px;
padding-top: 60px; /* 为移动端菜单按钮留出空间 */
}
.preview-content-wrapper.pdf-mode {
padding: 0;
}
.markdown-body {
@ -444,6 +459,10 @@
padding: 12px;
}
.preview-content-wrapper.pdf-mode {
padding: 0;
}
.markdown-body {
font-size: 14px;
}

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useSearchParams, useNavigate } from 'react-router-dom'
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 remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
@ -11,7 +11,7 @@ import Mark from 'mark.js'
import Highlighter from 'react-highlight-words'
import GithubSlugger from 'github-slugger'
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 {
getProjectSharePublicInfo,
@ -54,6 +54,7 @@ function ProjectSharePage() {
const [password, setPassword] = useState('')
const [siderCollapsed, setSiderCollapsed] = useState(false)
const [mobileDrawerVisible, setMobileDrawerVisible] = useState(false)
const [tocDrawerVisible, setTocDrawerVisible] = useState(false)
const [isMobile, setIsMobile] = useState(false)
const [pdfUrl, setPdfUrl] = useState('')
const [pdfFilename, setPdfFilename] = useState('')
@ -418,20 +419,6 @@ function ProjectSharePage() {
<Layout className="preview-layout">
{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
title={projectInfo?.name || '项目分享'}
placement="left"
@ -457,7 +444,10 @@ function ProjectSharePage() {
openKeys={openKeys}
onOpenChange={setOpenKeys}
items={menuItems}
onClick={({ key }) => openSharedFile(key)}
onClick={({ key }) => {
openSharedFile(key)
setMobileDrawerVisible(false)
}}
className="preview-menu"
/>
) : (
@ -512,11 +502,64 @@ function ProjectSharePage() {
<Layout className="preview-content-layout">
<Content className="preview-content" ref={contentRef}>
<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">
<HeaderIcon className="preview-header-icon" style={isHeaderPdf ? { color: '#f5222d' } : undefined} />
<span className="preview-header-text">{headerLabel}</span>
</h3>
{viewMode === 'markdown' && (
isMobile ? (
<Space className="preview-header-actions preview-compact-actions" size={4}>
<Tooltip title="回到顶部">
<Button
icon={<VerticalAlignTopOutlined />}
onClick={scrollContentToTop}
size="small"
type="text"
aria-label="回到顶部"
/>
</Tooltip>
<Tooltip title="下载PDF">
<Button
icon={<CloudDownloadOutlined />}
onClick={handleExportPDF}
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 />}
@ -533,6 +576,7 @@ function ProjectSharePage() {
下载PDF
</Button>
</Space>
)
)}
{viewMode === 'pdf' && <div className="preview-header-actions pdf-header-toolbar" ref={setPdfToolbarTarget} />}
</div>
@ -544,7 +588,7 @@ function ProjectSharePage() {
</Spin>
</div>
) : viewMode === 'pdf' ? (
<VirtualPDFViewer url={pdfUrl} filename={pdfFilename} toolbarTarget={pdfToolbarTarget} />
<VirtualPDFViewer url={pdfUrl} filename={pdfFilename} toolbarTarget={pdfToolbarTarget} compactToolbar={isMobile} />
) : (
<div className="markdown-body" onClick={(e) => {
if (e.defaultPrevented) return
@ -578,6 +622,15 @@ function ProjectSharePage() {
</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
title={<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><LockOutlined /><span>访问验证</span></div>}
open={passwordModalVisible}