From d62552384f2d2a5447baba4224b4f46f1c0ea58a Mon Sep 17 00:00:00 2001 From: SkyJourney Date: Sun, 26 Jul 2026 08:43:56 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=95=BF=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=88=86=E9=A1=B5=E4=B8=8E=E6=BB=9A=E5=8A=A8=E5=90=8C?= =?UTF-8?q?=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 43 ++++- apps/web/src/App.tsx | 202 +++++++++++++++++------ apps/web/src/mermaid-config.ts | 3 + apps/web/src/paged-break-token.ts | 15 ++ apps/web/src/paged-preview-frame.ts | 1 + apps/web/src/paged-preview.ts | 24 ++- apps/web/src/paged-table-handler.ts | 13 +- apps/web/src/preview-page.ts | 34 ++++ apps/web/src/scroll-sync.ts | 29 ++++ apps/web/src/styles.css | 30 +++- apps/web/tests/mermaid-config.test.ts | 1 + apps/web/tests/paged-break-token.test.ts | 35 ++++ apps/web/tests/paged-preview.test.ts | 5 + apps/web/tests/preview-page.test.ts | 24 +++ apps/web/tests/scroll-sync.test.ts | 51 ++++++ 15 files changed, 449 insertions(+), 61 deletions(-) create mode 100644 apps/web/src/paged-break-token.ts create mode 100644 apps/web/src/preview-page.ts create mode 100644 apps/web/src/scroll-sync.ts create mode 100644 apps/web/tests/paged-break-token.test.ts create mode 100644 apps/web/tests/preview-page.test.ts create mode 100644 apps/web/tests/scroll-sync.test.ts diff --git a/README.md b/README.md index d119ff5..8697a4e 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,12 @@ - 动态主题清单、CSS 和静态资源接口; - Markdown、Front Matter、安全过滤和扩展语法渲染核心; - 表格、任务列表、脚注、代码高亮、KaTeX 和 Mermaid; -- iframe 隔离的 A4 页面预览与主题切换; +- iframe 隔离的真实分页预览、双向滚动同步与主题切换; +- 五种纸张、页边距、页眉、页码和浏览器配置缓存; - 内置 Typora 风格主题,以及仅供本机使用的三套白色 Typora 默认主题导入工具; - Docker 与 PDF 渲染目录预留。 -Chromium PDF 下载、完整导出配置面板和本地资源文件处理将在后续阶段实现。 +Chromium PDF 下载和本地资源文件处理将在后续阶段实现。 ## 本地开发 @@ -35,6 +36,44 @@ npm run dev 页面支持直接编辑 Markdown,或选择本地 `.md` 文件。文件内容将读取到当前浏览器页面并发送给内网转换服务,不写入数据库。 +编辑区和分页预览使用双向比例滚动同步。在任意一侧滚动时,另一侧会跟随到相同的全文阅读进度,方便定位并修改较长文档。 + +## Mermaid 长标签 + +流程图节点标签的默认最大宽度为 320px。单个 Mermaid 区块可以通过 config frontmatter 覆盖: + +````markdown +```mermaid +--- +config: + flowchart: + wrappingWidth: 360 +--- +flowchart TB + A["较长的节点名称
第二行说明"] --> B["下一个节点"] +``` +```` + +需要自动换行时可以使用 Mermaid Markdown String: + +````markdown +```mermaid +flowchart LR + A["`这是一段可以由 Mermaid 自动换行的较长节点文字`"] +``` +```` + +个别节点仍需缩小字体时,优先使用 Mermaid 自带的 `classDef`: + +````markdown +```mermaid +flowchart LR + A["较长节点"] --> B["普通节点"] + classDef compact font-size:12px; + class A compact; +``` +```` + ## 本地导入 Typora 默认主题 项目不会打包或提交 Typora 官方默认主题。如本机已安装 Typora,可以将 GitHub、Pixyll 和 Whitey 三套适合打印的白色默认主题及 Typora 基础 CSS 复制到 Git 忽略的本地主题目录: diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 62c5d4f..2c6d3d2 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -8,7 +8,6 @@ import { } from "react"; import { getPaperDimensionsMm, - millimetersToCssPixels, type ExportConfig } from "@md-to-pdf/core"; import { ExportSettingsDrawer } from "./ExportSettingsDrawer"; @@ -22,6 +21,11 @@ import { isPagedPreviewFrameMessage, type PagedPreviewPayload } from "./paged-preview"; +import { + getNearestPageNumber, + PREVIEW_SCROLLBAR_WIDTH_PX +} from "./preview-page"; +import { getSyncedScrollTop } from "./scroll-sync"; interface RenderedMarkdown { articleHtml: string; @@ -97,9 +101,18 @@ export function App() { const [mermaidError, setMermaidError] = useState(""); const [paginationError, setPaginationError] = useState(""); const [previewFrameReady, setPreviewFrameReady] = useState(false); - const [previewHeight, setPreviewHeight] = useState(500); + const [previewPagination, setPreviewPagination] = useState({ + current: 0, + total: 0 + }); + const editorRef = useRef(null); + const previewScrollRef = useRef(null); const previewFrameRef = useRef(null); const previewRequestIdRef = useRef(0); + const scrollSyncFrameRef = useRef(undefined); + const scrollSyncOriginRef = useRef< + "editor" | "preview" | undefined + >(undefined); const error = renderError || themeError || mermaidError || paginationError; @@ -110,9 +123,8 @@ export function App() { exportConfig.paper.orientation ); const paperStyle = { - width: `${paperDimensions.width}mm`, - minWidth: `${paperDimensions.width}mm`, - minHeight: `${paperDimensions.height}mm` + width: `calc(${paperDimensions.width}mm + ${PREVIEW_SCROLLBAR_WIDTH_PX}px)`, + minWidth: `calc(${paperDimensions.width}mm + ${PREVIEW_SCROLLBAR_WIDTH_PX}px)` } satisfies CSSProperties; const previewPayload = useMemo( () => @@ -274,6 +286,7 @@ export function App() { if (event.data.type === "rendering") { setPaginationError(""); setMermaidError(""); + setPreviewPagination({ current: 0, total: 0 }); setStatus("正在分页…"); return; } @@ -284,24 +297,52 @@ export function App() { return; } - setPreviewHeight( - Math.max( - event.data.contentHeight, - millimetersToCssPixels(paperDimensions.height) - ) - ); setMermaidError( event.data.mermaidErrors.length > 0 ? `Mermaid:${event.data.mermaidErrors.join(";")}` : "" ); setStatus(`预览已分页(${event.data.pageCount} 页)`); + setPreviewPagination({ + current: event.data.pageCount > 0 ? 1 : 0, + total: event.data.pageCount + }); + window.requestAnimationFrame(() => { + synchronizeScroll("editor"); + window.requestAnimationFrame(updatePreviewCurrentPage); + }); } window.addEventListener("message", handlePreviewFrameMessage); return () => window.removeEventListener("message", handlePreviewFrameMessage); - }, [paperDimensions.height]); + }, []); + + useEffect(() => { + const frameWindow = previewFrameRef.current?.contentWindow; + if (!frameWindow || !previewFrameReady) { + return; + } + + function handlePreviewScroll() { + synchronizeScroll("preview"); + updatePreviewCurrentPage(); + } + + frameWindow.addEventListener("scroll", handlePreviewScroll, { + passive: true + }); + return () => + frameWindow.removeEventListener("scroll", handlePreviewScroll); + }, [previewFrameReady]); + + useEffect(() => { + return () => { + if (scrollSyncFrameRef.current !== undefined) { + window.cancelAnimationFrame(scrollSyncFrameRef.current); + } + }; + }, []); useEffect(() => { const frameWindow = previewFrameRef.current?.contentWindow; @@ -310,9 +351,6 @@ export function App() { } previewRequestIdRef.current += 1; - setPreviewHeight( - Math.ceil(millimetersToCssPixels(paperDimensions.height)) - ); frameWindow.postMessage( createPagedPreviewRenderRequest( previewRequestIdRef.current, @@ -320,11 +358,70 @@ export function App() { ), window.location.origin ); - }, [ - paperDimensions.height, - previewFrameReady, - previewPayload - ]); + }, [previewFrameReady, previewPayload]); + + function getPreviewScroller() { + return previewFrameRef.current?.contentDocument + ?.scrollingElement as HTMLElement | null; + } + + function synchronizeScroll(origin: "editor" | "preview") { + const source = + origin === "editor" ? editorRef.current : getPreviewScroller(); + const target = + origin === "editor" ? getPreviewScroller() : editorRef.current; + if (!source || !target) { + return; + } + + const activeOrigin = scrollSyncOriginRef.current; + if (activeOrigin && activeOrigin !== origin) { + return; + } + + scrollSyncOriginRef.current = origin; + target.scrollTop = getSyncedScrollTop(source, target); + + if (scrollSyncFrameRef.current !== undefined) { + window.cancelAnimationFrame(scrollSyncFrameRef.current); + } + scrollSyncFrameRef.current = window.requestAnimationFrame(() => { + scrollSyncOriginRef.current = undefined; + scrollSyncFrameRef.current = undefined; + }); + } + + function updatePreviewCurrentPage() { + const frame = previewFrameRef.current; + const frameDocument = frame?.contentDocument; + const scrollElement = getPreviewScroller(); + if (!frame || !frameDocument || !scrollElement) { + return; + } + + const viewportCenter = + scrollElement.scrollTop + frame.clientHeight / 2; + const pages = Array.from( + frameDocument.querySelectorAll(".pagedjs_page") + ).map((page) => { + const rect = page.getBoundingClientRect(); + return { + top: rect.top + scrollElement.scrollTop, + bottom: rect.bottom + scrollElement.scrollTop + }; + }); + const current = getNearestPageNumber(viewportCenter, pages); + + setPreviewPagination((pagination) => + pagination.current === current && + pagination.total === pages.length + ? pagination + : { + current, + total: pages.length + } + ); + } async function handleFileChange(event: ChangeEvent) { const file = event.target.files?.[0]; @@ -379,10 +476,12 @@ export function App() {