fix: 修复长文档分页与滚动同步

This commit is contained in:
SkyJourney
2026-07-26 08:43:56 +08:00
parent 1d93f31f85
commit d62552384f
15 changed files with 449 additions and 61 deletions
+155 -47
View File
@@ -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<HTMLTextAreaElement>(null);
const previewScrollRef = useRef<HTMLDivElement>(null);
const previewFrameRef = useRef<HTMLIFrameElement>(null);
const previewRequestIdRef = useRef(0);
const scrollSyncFrameRef = useRef<number | undefined>(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<PagedPreviewPayload | undefined>(
() =>
@@ -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<HTMLElement>(".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<HTMLInputElement>) {
const file = event.target.files?.[0];
@@ -379,10 +476,12 @@ export function App() {
</span>
</div>
<textarea
ref={editorRef}
aria-label="Markdown 内容"
value={markdown}
spellCheck={false}
onChange={(event) => setMarkdown(event.target.value)}
onScroll={() => synchronizeScroll("editor")}
/>
</aside>
@@ -392,34 +491,44 @@ export function App() {
<span className="panel-kicker"></span>
<strong>{result?.metadata.title || "未命名文档"}</strong>
</div>
<label className="theme-control">
<span className="sr-only"></span>
<select
aria-label="预览主题"
value={themeId}
onChange={(event) =>
setExportConfig((currentConfig) => ({
...currentConfig,
themeId: event.target.value
}))
}
>
{themes.length === 0 ? (
<option value={themeId}></option>
) : (
themes.map((theme) => (
<option key={theme.id} value={theme.id}>
{theme.name}
</option>
))
)}
</select>
{selectedTheme?.source === "local" ? (
<span className="local-theme-mark"></span>
<span className="preview-heading-actions">
{previewPagination.total > 0 ? (
<span className="preview-page-indicator" aria-live="polite">
{previewPagination.current} / {previewPagination.total}
</span>
) : null}
</label>
<label className="theme-control">
<span className="sr-only"></span>
<select
aria-label="预览主题"
value={themeId}
onChange={(event) =>
setExportConfig((currentConfig) => ({
...currentConfig,
themeId: event.target.value
}))
}
>
{themes.length === 0 ? (
<option value={themeId}></option>
) : (
themes.map((theme) => (
<option key={theme.id} value={theme.id}>
{theme.name}
</option>
))
)}
</select>
{selectedTheme?.source === "local" ? (
<span className="local-theme-mark"></span>
) : null}
</label>
</span>
</div>
<div className="preview-scroll">
<div
ref={previewScrollRef}
className="preview-scroll"
>
<div className="paper" style={paperStyle}>
{previewPayload ? (
<iframe
@@ -428,7 +537,6 @@ export function App() {
title="文档内容预览"
sandbox="allow-same-origin allow-scripts"
src="/preview-frame.html"
style={{ height: `${previewHeight}px` }}
/>
) : (
<div className="preview-loading"></div>