fix: 修复长文档分页与滚动同步
This commit is contained in:
@@ -12,11 +12,12 @@
|
|||||||
- 动态主题清单、CSS 和静态资源接口;
|
- 动态主题清单、CSS 和静态资源接口;
|
||||||
- Markdown、Front Matter、安全过滤和扩展语法渲染核心;
|
- Markdown、Front Matter、安全过滤和扩展语法渲染核心;
|
||||||
- 表格、任务列表、脚注、代码高亮、KaTeX 和 Mermaid;
|
- 表格、任务列表、脚注、代码高亮、KaTeX 和 Mermaid;
|
||||||
- iframe 隔离的 A4 页面预览与主题切换;
|
- iframe 隔离的真实分页预览、双向滚动同步与主题切换;
|
||||||
|
- 五种纸张、页边距、页眉、页码和浏览器配置缓存;
|
||||||
- 内置 Typora 风格主题,以及仅供本机使用的三套白色 Typora 默认主题导入工具;
|
- 内置 Typora 风格主题,以及仅供本机使用的三套白色 Typora 默认主题导入工具;
|
||||||
- Docker 与 PDF 渲染目录预留。
|
- Docker 与 PDF 渲染目录预留。
|
||||||
|
|
||||||
Chromium PDF 下载、完整导出配置面板和本地资源文件处理将在后续阶段实现。
|
Chromium PDF 下载和本地资源文件处理将在后续阶段实现。
|
||||||
|
|
||||||
## 本地开发
|
## 本地开发
|
||||||
|
|
||||||
@@ -35,6 +36,44 @@ npm run dev
|
|||||||
|
|
||||||
页面支持直接编辑 Markdown,或选择本地 `.md` 文件。文件内容将读取到当前浏览器页面并发送给内网转换服务,不写入数据库。
|
页面支持直接编辑 Markdown,或选择本地 `.md` 文件。文件内容将读取到当前浏览器页面并发送给内网转换服务,不写入数据库。
|
||||||
|
|
||||||
|
编辑区和分页预览使用双向比例滚动同步。在任意一侧滚动时,另一侧会跟随到相同的全文阅读进度,方便定位并修改较长文档。
|
||||||
|
|
||||||
|
## Mermaid 长标签
|
||||||
|
|
||||||
|
流程图节点标签的默认最大宽度为 320px。单个 Mermaid 区块可以通过 config frontmatter 覆盖:
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
```mermaid
|
||||||
|
---
|
||||||
|
config:
|
||||||
|
flowchart:
|
||||||
|
wrappingWidth: 360
|
||||||
|
---
|
||||||
|
flowchart TB
|
||||||
|
A["较长的节点名称<br/>第二行说明"] --> 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 官方默认主题。如本机已安装 Typora,可以将 GitHub、Pixyll 和 Whitey 三套适合打印的白色默认主题及 Typora 基础 CSS 复制到 Git 忽略的本地主题目录:
|
项目不会打包或提交 Typora 官方默认主题。如本机已安装 Typora,可以将 GitHub、Pixyll 和 Whitey 三套适合打印的白色默认主题及 Typora 基础 CSS 复制到 Git 忽略的本地主题目录:
|
||||||
|
|||||||
+155
-47
@@ -8,7 +8,6 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
getPaperDimensionsMm,
|
getPaperDimensionsMm,
|
||||||
millimetersToCssPixels,
|
|
||||||
type ExportConfig
|
type ExportConfig
|
||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
import { ExportSettingsDrawer } from "./ExportSettingsDrawer";
|
import { ExportSettingsDrawer } from "./ExportSettingsDrawer";
|
||||||
@@ -22,6 +21,11 @@ import {
|
|||||||
isPagedPreviewFrameMessage,
|
isPagedPreviewFrameMessage,
|
||||||
type PagedPreviewPayload
|
type PagedPreviewPayload
|
||||||
} from "./paged-preview";
|
} from "./paged-preview";
|
||||||
|
import {
|
||||||
|
getNearestPageNumber,
|
||||||
|
PREVIEW_SCROLLBAR_WIDTH_PX
|
||||||
|
} from "./preview-page";
|
||||||
|
import { getSyncedScrollTop } from "./scroll-sync";
|
||||||
|
|
||||||
interface RenderedMarkdown {
|
interface RenderedMarkdown {
|
||||||
articleHtml: string;
|
articleHtml: string;
|
||||||
@@ -97,9 +101,18 @@ export function App() {
|
|||||||
const [mermaidError, setMermaidError] = useState("");
|
const [mermaidError, setMermaidError] = useState("");
|
||||||
const [paginationError, setPaginationError] = useState("");
|
const [paginationError, setPaginationError] = useState("");
|
||||||
const [previewFrameReady, setPreviewFrameReady] = useState(false);
|
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 previewFrameRef = useRef<HTMLIFrameElement>(null);
|
||||||
const previewRequestIdRef = useRef(0);
|
const previewRequestIdRef = useRef(0);
|
||||||
|
const scrollSyncFrameRef = useRef<number | undefined>(undefined);
|
||||||
|
const scrollSyncOriginRef = useRef<
|
||||||
|
"editor" | "preview" | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
const error =
|
const error =
|
||||||
renderError || themeError || mermaidError || paginationError;
|
renderError || themeError || mermaidError || paginationError;
|
||||||
@@ -110,9 +123,8 @@ export function App() {
|
|||||||
exportConfig.paper.orientation
|
exportConfig.paper.orientation
|
||||||
);
|
);
|
||||||
const paperStyle = {
|
const paperStyle = {
|
||||||
width: `${paperDimensions.width}mm`,
|
width: `calc(${paperDimensions.width}mm + ${PREVIEW_SCROLLBAR_WIDTH_PX}px)`,
|
||||||
minWidth: `${paperDimensions.width}mm`,
|
minWidth: `calc(${paperDimensions.width}mm + ${PREVIEW_SCROLLBAR_WIDTH_PX}px)`
|
||||||
minHeight: `${paperDimensions.height}mm`
|
|
||||||
} satisfies CSSProperties;
|
} satisfies CSSProperties;
|
||||||
const previewPayload = useMemo<PagedPreviewPayload | undefined>(
|
const previewPayload = useMemo<PagedPreviewPayload | undefined>(
|
||||||
() =>
|
() =>
|
||||||
@@ -274,6 +286,7 @@ export function App() {
|
|||||||
if (event.data.type === "rendering") {
|
if (event.data.type === "rendering") {
|
||||||
setPaginationError("");
|
setPaginationError("");
|
||||||
setMermaidError("");
|
setMermaidError("");
|
||||||
|
setPreviewPagination({ current: 0, total: 0 });
|
||||||
setStatus("正在分页…");
|
setStatus("正在分页…");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -284,24 +297,52 @@ export function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setPreviewHeight(
|
|
||||||
Math.max(
|
|
||||||
event.data.contentHeight,
|
|
||||||
millimetersToCssPixels(paperDimensions.height)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setMermaidError(
|
setMermaidError(
|
||||||
event.data.mermaidErrors.length > 0
|
event.data.mermaidErrors.length > 0
|
||||||
? `Mermaid:${event.data.mermaidErrors.join(";")}`
|
? `Mermaid:${event.data.mermaidErrors.join(";")}`
|
||||||
: ""
|
: ""
|
||||||
);
|
);
|
||||||
setStatus(`预览已分页(${event.data.pageCount} 页)`);
|
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);
|
window.addEventListener("message", handlePreviewFrameMessage);
|
||||||
return () =>
|
return () =>
|
||||||
window.removeEventListener("message", handlePreviewFrameMessage);
|
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(() => {
|
useEffect(() => {
|
||||||
const frameWindow = previewFrameRef.current?.contentWindow;
|
const frameWindow = previewFrameRef.current?.contentWindow;
|
||||||
@@ -310,9 +351,6 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
previewRequestIdRef.current += 1;
|
previewRequestIdRef.current += 1;
|
||||||
setPreviewHeight(
|
|
||||||
Math.ceil(millimetersToCssPixels(paperDimensions.height))
|
|
||||||
);
|
|
||||||
frameWindow.postMessage(
|
frameWindow.postMessage(
|
||||||
createPagedPreviewRenderRequest(
|
createPagedPreviewRenderRequest(
|
||||||
previewRequestIdRef.current,
|
previewRequestIdRef.current,
|
||||||
@@ -320,11 +358,70 @@ export function App() {
|
|||||||
),
|
),
|
||||||
window.location.origin
|
window.location.origin
|
||||||
);
|
);
|
||||||
}, [
|
}, [previewFrameReady, previewPayload]);
|
||||||
paperDimensions.height,
|
|
||||||
previewFrameReady,
|
function getPreviewScroller() {
|
||||||
previewPayload
|
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>) {
|
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
@@ -379,10 +476,12 @@ export function App() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={editorRef}
|
||||||
aria-label="Markdown 内容"
|
aria-label="Markdown 内容"
|
||||||
value={markdown}
|
value={markdown}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
onChange={(event) => setMarkdown(event.target.value)}
|
onChange={(event) => setMarkdown(event.target.value)}
|
||||||
|
onScroll={() => synchronizeScroll("editor")}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -392,34 +491,44 @@ export function App() {
|
|||||||
<span className="panel-kicker">实时预览</span>
|
<span className="panel-kicker">实时预览</span>
|
||||||
<strong>{result?.metadata.title || "未命名文档"}</strong>
|
<strong>{result?.metadata.title || "未命名文档"}</strong>
|
||||||
</div>
|
</div>
|
||||||
<label className="theme-control">
|
<span className="preview-heading-actions">
|
||||||
<span className="sr-only">预览主题</span>
|
{previewPagination.total > 0 ? (
|
||||||
<select
|
<span className="preview-page-indicator" aria-live="polite">
|
||||||
aria-label="预览主题"
|
第 {previewPagination.current} / {previewPagination.total} 页
|
||||||
value={themeId}
|
</span>
|
||||||
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}
|
) : 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>
|
||||||
<div className="preview-scroll">
|
<div
|
||||||
|
ref={previewScrollRef}
|
||||||
|
className="preview-scroll"
|
||||||
|
>
|
||||||
<div className="paper" style={paperStyle}>
|
<div className="paper" style={paperStyle}>
|
||||||
{previewPayload ? (
|
{previewPayload ? (
|
||||||
<iframe
|
<iframe
|
||||||
@@ -428,7 +537,6 @@ export function App() {
|
|||||||
title="文档内容预览"
|
title="文档内容预览"
|
||||||
sandbox="allow-same-origin allow-scripts"
|
sandbox="allow-same-origin allow-scripts"
|
||||||
src="/preview-frame.html"
|
src="/preview-frame.html"
|
||||||
style={{ height: `${previewHeight}px` }}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="preview-loading">正在生成文档预览…</div>
|
<div className="preview-loading">正在生成文档预览…</div>
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export function createMermaidSiteConfig(): MermaidConfig {
|
|||||||
theme: "default",
|
theme: "default",
|
||||||
look: "classic",
|
look: "classic",
|
||||||
fontFamily: "Segoe UI, Microsoft YaHei, sans-serif",
|
fontFamily: "Segoe UI, Microsoft YaHei, sans-serif",
|
||||||
|
flowchart: {
|
||||||
|
wrappingWidth: 320
|
||||||
|
},
|
||||||
maxTextSize: 50_000,
|
maxTextSize: 50_000,
|
||||||
maxEdges: 500,
|
maxEdges: 500,
|
||||||
suppressErrorRendering: true,
|
suppressErrorRendering: true,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ function getPreviewRoot() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const previewRoot = getPreviewRoot();
|
const previewRoot = getPreviewRoot();
|
||||||
|
document.documentElement.dataset.renderTarget = "preview";
|
||||||
|
|
||||||
let latestRequestId = 0;
|
let latestRequestId = 0;
|
||||||
let renderQueue = Promise.resolve();
|
let renderQueue = Promise.resolve();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
getPaperDimensionsMm,
|
getPaperDimensionsMm,
|
||||||
type ExportConfig
|
type ExportConfig
|
||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
|
import { PREVIEW_SCROLLBAR_WIDTH_PX } from "./preview-page";
|
||||||
|
|
||||||
export const PAGED_PREVIEW_MESSAGE_SCOPE = "md-to-pdf:paged-preview";
|
export const PAGED_PREVIEW_MESSAGE_SCOPE = "md-to-pdf:paged-preview";
|
||||||
|
|
||||||
@@ -335,7 +336,27 @@ ${
|
|||||||
width: max-content;
|
width: max-content;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
margin: 0 auto;
|
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 {
|
.pagedjs_page {
|
||||||
@@ -354,6 +375,7 @@ html[data-render-target="pdf"] .pagedjs_pages {
|
|||||||
display: block;
|
display: block;
|
||||||
width: auto;
|
width: auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-render-target="pdf"] .pagedjs_page {
|
html[data-render-target="pdf"] .pagedjs_page {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Handler, registerHandlers } from "pagedjs";
|
import { Handler, registerHandlers } from "pagedjs";
|
||||||
|
import { resolveBreakTokenElement } from "./paged-break-token";
|
||||||
|
|
||||||
interface PagedBreakToken {
|
interface PagedBreakToken {
|
||||||
node?: Element;
|
node?: Node;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PagedChunker {
|
interface PagedChunker {
|
||||||
@@ -47,14 +48,14 @@ class RepeatTableHeadersHandler extends Handler {
|
|||||||
breakToken?: PagedBreakToken
|
breakToken?: PagedBreakToken
|
||||||
) {
|
) {
|
||||||
this.splitTableRefs = [];
|
this.splitTableRefs = [];
|
||||||
const node = breakToken?.node;
|
const element = resolveBreakTokenElement(breakToken?.node);
|
||||||
if (!node) {
|
if (!element) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tables = elementAncestors(node, "table");
|
const tables = elementAncestors(element, "table");
|
||||||
if (node.matches("table")) {
|
if (element.matches("table")) {
|
||||||
tables.push(node);
|
tables.push(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.splitTableRefs = Array.from(
|
this.splitTableRefs = Array.from(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+25
-5
@@ -166,12 +166,29 @@ h1 {
|
|||||||
color: #a23d3d;
|
color: #a23d3d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-control {
|
.preview-heading-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: none;
|
flex: none;
|
||||||
align-items: center;
|
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;
|
gap: 6px;
|
||||||
max-width: 56%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-control select {
|
.theme-control select {
|
||||||
@@ -245,11 +262,13 @@ textarea:focus {
|
|||||||
|
|
||||||
.preview-scroll {
|
.preview-scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 34px;
|
padding: 0 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paper {
|
.paper {
|
||||||
|
height: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
@@ -257,7 +276,8 @@ textarea:focus {
|
|||||||
.preview-frame {
|
.preview-frame {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 500px;
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
@@ -576,7 +596,7 @@ textarea:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.preview-scroll {
|
.preview-scroll {
|
||||||
padding: 18px 10px;
|
padding: 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paper {
|
.paper {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ describe("Mermaid 站点配置", () => {
|
|||||||
expect(config.look).toBe("classic");
|
expect(config.look).toBe("classic");
|
||||||
expect(config.securityLevel).toBe("strict");
|
expect(config.securityLevel).toBe("strict");
|
||||||
expect(config.startOnLoad).toBe(false);
|
expect(config.startOnLoad).toBe(false);
|
||||||
|
expect(config.flowchart?.wrappingWidth).toBe(320);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("锁定安全限制和原始主题 CSS", () => {
|
it("锁定安全限制和原始主题 CSS", () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -38,7 +38,12 @@ describe("分页预览协议", () => {
|
|||||||
expect(css).toContain("display: table-header-group");
|
expect(css).toContain("display: table-header-group");
|
||||||
expect(css).toContain("break-inside: avoid");
|
expect(css).toContain("break-inside: avoid");
|
||||||
expect(css).toContain(".pagedjs_pages");
|
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('html[data-render-target="pdf"]');
|
||||||
|
expect(css).toContain("padding: 0");
|
||||||
expect(css).toContain("break-after: page");
|
expect(css).toContain("break-after: page");
|
||||||
expect(css).not.toContain("@media print");
|
expect(css).not.toContain("@media print");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user