{previewPayload ? (
) : (
正在生成文档预览…
diff --git a/apps/web/src/mermaid-config.ts b/apps/web/src/mermaid-config.ts
index bc880cb..96918f1 100644
--- a/apps/web/src/mermaid-config.ts
+++ b/apps/web/src/mermaid-config.ts
@@ -17,6 +17,9 @@ export function createMermaidSiteConfig(): MermaidConfig {
theme: "default",
look: "classic",
fontFamily: "Segoe UI, Microsoft YaHei, sans-serif",
+ flowchart: {
+ wrappingWidth: 320
+ },
maxTextSize: 50_000,
maxEdges: 500,
suppressErrorRendering: true,
diff --git a/apps/web/src/paged-break-token.ts b/apps/web/src/paged-break-token.ts
new file mode 100644
index 0000000..709dfd2
--- /dev/null
+++ b/apps/web/src/paged-break-token.ts
@@ -0,0 +1,15 @@
+const ELEMENT_NODE_TYPE = 1;
+
+export function resolveBreakTokenElement(
+ node: Node | undefined
+): Element | undefined {
+ if (!node) {
+ return undefined;
+ }
+
+ if (node.nodeType === ELEMENT_NODE_TYPE) {
+ return node as Element;
+ }
+
+ return node.parentElement ?? undefined;
+}
diff --git a/apps/web/src/paged-preview-frame.ts b/apps/web/src/paged-preview-frame.ts
index b67d7ea..96aa7c0 100644
--- a/apps/web/src/paged-preview-frame.ts
+++ b/apps/web/src/paged-preview-frame.ts
@@ -25,6 +25,7 @@ function getPreviewRoot() {
}
const previewRoot = getPreviewRoot();
+document.documentElement.dataset.renderTarget = "preview";
let latestRequestId = 0;
let renderQueue = Promise.resolve();
diff --git a/apps/web/src/paged-preview.ts b/apps/web/src/paged-preview.ts
index 594ad66..c6a0bc3 100644
--- a/apps/web/src/paged-preview.ts
+++ b/apps/web/src/paged-preview.ts
@@ -2,6 +2,7 @@ import {
getPaperDimensionsMm,
type ExportConfig
} from "@md-to-pdf/core";
+import { PREVIEW_SCROLLBAR_WIDTH_PX } from "./preview-page";
export const PAGED_PREVIEW_MESSAGE_SCOPE = "md-to-pdf:paged-preview";
@@ -335,7 +336,27 @@ ${
width: max-content;
min-width: 100%;
margin: 0 auto;
- padding: 0;
+ padding: 34px 0;
+}
+
+html[data-render-target="preview"] {
+ height: 100%;
+ overflow-x: hidden;
+ overflow-y: scroll;
+ scrollbar-gutter: stable;
+}
+
+html[data-render-target="preview"]::-webkit-scrollbar {
+ width: ${PREVIEW_SCROLLBAR_WIDTH_PX}px;
+}
+
+html[data-render-target="preview"]::-webkit-scrollbar:horizontal {
+ height: 0;
+}
+
+html[data-render-target="preview"] body {
+ min-height: 100%;
+ overflow: visible;
}
.pagedjs_page {
@@ -354,6 +375,7 @@ html[data-render-target="pdf"] .pagedjs_pages {
display: block;
width: auto;
min-width: 0;
+ padding: 0;
}
html[data-render-target="pdf"] .pagedjs_page {
diff --git a/apps/web/src/paged-table-handler.ts b/apps/web/src/paged-table-handler.ts
index 2f7bb0b..40b4b16 100644
--- a/apps/web/src/paged-table-handler.ts
+++ b/apps/web/src/paged-table-handler.ts
@@ -1,7 +1,8 @@
import { Handler, registerHandlers } from "pagedjs";
+import { resolveBreakTokenElement } from "./paged-break-token";
interface PagedBreakToken {
- node?: Element;
+ node?: Node;
}
interface PagedChunker {
@@ -47,14 +48,14 @@ class RepeatTableHeadersHandler extends Handler {
breakToken?: PagedBreakToken
) {
this.splitTableRefs = [];
- const node = breakToken?.node;
- if (!node) {
+ const element = resolveBreakTokenElement(breakToken?.node);
+ if (!element) {
return;
}
- const tables = elementAncestors(node, "table");
- if (node.matches("table")) {
- tables.push(node);
+ const tables = elementAncestors(element, "table");
+ if (element.matches("table")) {
+ tables.push(element);
}
this.splitTableRefs = Array.from(
diff --git a/apps/web/src/preview-page.ts b/apps/web/src/preview-page.ts
new file mode 100644
index 0000000..ae40cd9
--- /dev/null
+++ b/apps/web/src/preview-page.ts
@@ -0,0 +1,34 @@
+export interface PageVerticalRange {
+ top: number;
+ bottom: number;
+}
+
+export const PREVIEW_SCROLLBAR_WIDTH_PX = 14;
+
+export function getNearestPageNumber(
+ viewportCenter: number,
+ pages: readonly PageVerticalRange[]
+) {
+ if (pages.length === 0 || !Number.isFinite(viewportCenter)) {
+ return 0;
+ }
+
+ let nearestPage = 1;
+ let nearestDistance = Number.POSITIVE_INFINITY;
+
+ for (const [index, page] of pages.entries()) {
+ const distance =
+ viewportCenter < page.top
+ ? page.top - viewportCenter
+ : viewportCenter > page.bottom
+ ? viewportCenter - page.bottom
+ : 0;
+
+ if (distance < nearestDistance) {
+ nearestPage = index + 1;
+ nearestDistance = distance;
+ }
+ }
+
+ return nearestPage;
+}
diff --git a/apps/web/src/scroll-sync.ts b/apps/web/src/scroll-sync.ts
new file mode 100644
index 0000000..7ba0990
--- /dev/null
+++ b/apps/web/src/scroll-sync.ts
@@ -0,0 +1,29 @@
+export interface ScrollMetrics {
+ scrollTop: number;
+ scrollHeight: number;
+ clientHeight: number;
+}
+
+function clamp(value: number, minimum: number, maximum: number) {
+ return Math.min(maximum, Math.max(minimum, value));
+}
+
+export function getScrollProgress(metrics: ScrollMetrics) {
+ const range = metrics.scrollHeight - metrics.clientHeight;
+ if (range <= 0) {
+ return 0;
+ }
+
+ return clamp(metrics.scrollTop / range, 0, 1);
+}
+
+export function getSyncedScrollTop(
+ source: ScrollMetrics,
+ target: ScrollMetrics
+) {
+ const targetRange = Math.max(
+ 0,
+ target.scrollHeight - target.clientHeight
+ );
+ return getScrollProgress(source) * targetRange;
+}
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index 09525bd..fc8ba56 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -166,12 +166,29 @@ h1 {
color: #a23d3d;
}
-.theme-control {
+.preview-heading-actions {
display: flex;
flex: none;
align-items: center;
+ justify-content: flex-end;
+ gap: 10px;
+ min-width: 0;
+ max-width: 68%;
+}
+
+.preview-page-indicator {
+ flex: none;
+ color: #5d7168;
+ font-size: 0.7rem;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+
+.theme-control {
+ display: flex;
+ min-width: 0;
+ align-items: center;
gap: 6px;
- max-width: 56%;
}
.theme-control select {
@@ -245,11 +262,13 @@ textarea:focus {
.preview-scroll {
flex: 1;
+ min-height: 0;
overflow: auto;
- padding: 34px;
+ padding: 0 30px;
}
.paper {
+ height: 100%;
margin: 0 auto;
background: transparent;
}
@@ -257,7 +276,8 @@ textarea:focus {
.preview-frame {
display: block;
width: 100%;
- min-height: 500px;
+ height: 100%;
+ min-height: 0;
border: 0;
background: transparent;
}
@@ -576,7 +596,7 @@ textarea:focus {
}
.preview-scroll {
- padding: 18px 10px;
+ padding: 0 10px;
}
.paper {
diff --git a/apps/web/tests/mermaid-config.test.ts b/apps/web/tests/mermaid-config.test.ts
index cc2304e..9c0ca44 100644
--- a/apps/web/tests/mermaid-config.test.ts
+++ b/apps/web/tests/mermaid-config.test.ts
@@ -11,6 +11,7 @@ describe("Mermaid 站点配置", () => {
expect(config.look).toBe("classic");
expect(config.securityLevel).toBe("strict");
expect(config.startOnLoad).toBe(false);
+ expect(config.flowchart?.wrappingWidth).toBe(320);
});
it("锁定安全限制和原始主题 CSS", () => {
diff --git a/apps/web/tests/paged-break-token.test.ts b/apps/web/tests/paged-break-token.test.ts
new file mode 100644
index 0000000..96d919c
--- /dev/null
+++ b/apps/web/tests/paged-break-token.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from "vitest";
+import { resolveBreakTokenElement } from "../src/paged-break-token";
+
+describe("Paged.js 分页断点", () => {
+ it("直接返回元素断点", () => {
+ const element = {
+ nodeType: 1,
+ parentElement: null
+ } as unknown as Element;
+
+ expect(resolveBreakTokenElement(element)).toBe(element);
+ });
+
+ it("文本断点使用父元素继续查找", () => {
+ const parentElement = {} as Element;
+ const textNode = {
+ nodeType: 3,
+ parentElement
+ } as unknown as Node;
+
+ expect(resolveBreakTokenElement(textNode)).toBe(parentElement);
+ });
+
+ it("安全跳过空断点和无父元素节点", () => {
+ const detachedTextNode = {
+ nodeType: 3,
+ parentElement: null
+ } as unknown as Node;
+
+ expect(resolveBreakTokenElement(undefined)).toBeUndefined();
+ expect(
+ resolveBreakTokenElement(detachedTextNode)
+ ).toBeUndefined();
+ });
+});
diff --git a/apps/web/tests/paged-preview.test.ts b/apps/web/tests/paged-preview.test.ts
index bee3d52..d7947a2 100644
--- a/apps/web/tests/paged-preview.test.ts
+++ b/apps/web/tests/paged-preview.test.ts
@@ -38,7 +38,12 @@ describe("分页预览协议", () => {
expect(css).toContain("display: table-header-group");
expect(css).toContain("break-inside: avoid");
expect(css).toContain(".pagedjs_pages");
+ expect(css).toContain("padding: 34px 0");
+ expect(css).toContain('html[data-render-target="preview"]');
+ expect(css).toContain("overflow-x: hidden");
+ expect(css).toContain("overflow-y: scroll");
expect(css).toContain('html[data-render-target="pdf"]');
+ expect(css).toContain("padding: 0");
expect(css).toContain("break-after: page");
expect(css).not.toContain("@media print");
});
diff --git a/apps/web/tests/preview-page.test.ts b/apps/web/tests/preview-page.test.ts
new file mode 100644
index 0000000..6341379
--- /dev/null
+++ b/apps/web/tests/preview-page.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest";
+import { getNearestPageNumber } from "../src/preview-page";
+
+const pages = [
+ { top: 0, bottom: 100 },
+ { top: 124, bottom: 224 },
+ { top: 248, bottom: 348 }
+];
+
+describe("getNearestPageNumber", () => {
+ it("返回视口中心所在的页面", () => {
+ expect(getNearestPageNumber(180, pages)).toBe(2);
+ });
+
+ it("在页间空隙中返回距离最近的页面", () => {
+ expect(getNearestPageNumber(110, pages)).toBe(1);
+ expect(getNearestPageNumber(116, pages)).toBe(2);
+ });
+
+ it("没有页面或中心坐标无效时返回 0", () => {
+ expect(getNearestPageNumber(20, [])).toBe(0);
+ expect(getNearestPageNumber(Number.NaN, pages)).toBe(0);
+ });
+});
diff --git a/apps/web/tests/scroll-sync.test.ts b/apps/web/tests/scroll-sync.test.ts
new file mode 100644
index 0000000..b989f58
--- /dev/null
+++ b/apps/web/tests/scroll-sync.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from "vitest";
+import {
+ getScrollProgress,
+ getSyncedScrollTop
+} from "../src/scroll-sync";
+
+describe("编辑与预览滚动同步", () => {
+ it("按可滚动范围计算阅读进度", () => {
+ expect(
+ getScrollProgress({
+ scrollTop: 600,
+ scrollHeight: 1600,
+ clientHeight: 400
+ })
+ ).toBe(0.5);
+ });
+
+ it("把源区域进度映射到目标区域", () => {
+ expect(
+ getSyncedScrollTop(
+ {
+ scrollTop: 600,
+ scrollHeight: 1600,
+ clientHeight: 400
+ },
+ {
+ scrollTop: 0,
+ scrollHeight: 5000,
+ clientHeight: 1000
+ }
+ )
+ ).toBe(2000);
+ });
+
+ it("处理无滚动范围和越界位置", () => {
+ expect(
+ getScrollProgress({
+ scrollTop: 100,
+ scrollHeight: 500,
+ clientHeight: 500
+ })
+ ).toBe(0);
+ expect(
+ getScrollProgress({
+ scrollTop: 1800,
+ scrollHeight: 1600,
+ clientHeight: 400
+ })
+ ).toBe(1);
+ });
+});