fix: 修复长文档分页与滚动同步
This commit is contained in:
+155
-47
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user