feat: 增加 ECharts 渲染与预览交互增强
- 新增可复用的 markdown-echarts 工作区,支持安全的 YAML 围栏、默认值、语义校验、错误占位和 SVG 渲染 - 将 ECharts 接入统一 Markdown、快速预览、精确预览与 PDF 链路,并复用 Mermaid 的不可跨页和超高图单页适配策略 - 默认示例加入 ECharts,增加源文本折叠、顶部文档状态、页码输入跳转与滚动同步 - 快速预览改用外层容器滚动,使滚动条与精确预览统一贴在预览区域右侧,并兼容显示缩放 - 完善 Docker 工作区复制、可配置 Compose 镜像、测试覆盖和进度文档
This commit is contained in:
+228
-63
@@ -16,6 +16,7 @@ import {
|
||||
type ExportConfig
|
||||
} from "@md-to-pdf/core";
|
||||
import { ExportSettingsDrawer } from "./ExportSettingsDrawer";
|
||||
import { PreviewPageControl } from "./PreviewPageControl";
|
||||
import { PreviewZoomControl } from "./PreviewZoomControl";
|
||||
import {
|
||||
loadExportConfig,
|
||||
@@ -29,7 +30,7 @@ import {
|
||||
} from "./paged-preview";
|
||||
import {
|
||||
getNearestPageNumber,
|
||||
PREVIEW_SCROLLBAR_WIDTH_PX
|
||||
getPreviewPageScrollTop
|
||||
} from "./preview-page";
|
||||
import {
|
||||
createPdfExportCacheKey,
|
||||
@@ -73,7 +74,7 @@ keywords:
|
||||
|
||||
- [x] Markdown 实时预览
|
||||
- [x] 表格与任务列表
|
||||
- [x] 数学公式与 Mermaid
|
||||
- [x] 数学公式、Mermaid 与 ECharts
|
||||
- [ ] Chromium PDF 导出
|
||||
|
||||
| 功能 | 当前状态 |
|
||||
@@ -91,6 +92,22 @@ flowchart LR
|
||||
C --> E["PDF 导出"]
|
||||
\`\`\`
|
||||
|
||||
## ECharts 图表
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 各模块文档数量
|
||||
height: 72mm
|
||||
option:
|
||||
xAxis:
|
||||
type: category
|
||||
data: [研发, 产品, 设计, 运营]
|
||||
yAxis:
|
||||
type: value
|
||||
series:
|
||||
- type: bar
|
||||
data: [28, 18, 15, 22]
|
||||
\`\`\`
|
||||
|
||||
> 预览和 PDF 将共用同一份 HTML 与 CSS。
|
||||
`;
|
||||
|
||||
@@ -100,8 +117,10 @@ export function App() {
|
||||
const [exportConfig, setExportConfig] =
|
||||
useState<ExportConfig>(loadExportConfig);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [sourcePanelCollapsed, setSourcePanelCollapsed] = useState(false);
|
||||
const [status, setStatus] = useState("正在准备预览…");
|
||||
const [appError, setAppError] = useState("");
|
||||
const [echartsError, setEChartsError] = useState("");
|
||||
const [mermaidError, setMermaidError] = useState("");
|
||||
const [paginationError, setPaginationError] = useState("");
|
||||
const [pdfError, setPdfError] = useState("");
|
||||
@@ -111,6 +130,8 @@ export function App() {
|
||||
const [previewZoom, setPreviewZoom] = useState(loadPreviewZoom);
|
||||
const [pdfCache, setPdfCache] = useState<CachedPdfExport>();
|
||||
const [previewFrameReady, setPreviewFrameReady] = useState(false);
|
||||
const [quickPreviewContentHeight, setQuickPreviewContentHeight] =
|
||||
useState<number>();
|
||||
const [previewPagination, setPreviewPagination] = useState({
|
||||
current: 0,
|
||||
total: 0
|
||||
@@ -148,6 +169,7 @@ export function App() {
|
||||
[]
|
||||
);
|
||||
const handleRenderStart = useCallback(() => {
|
||||
setEChartsError("");
|
||||
setMermaidError("");
|
||||
}, []);
|
||||
const { error: renderError, result } = useMarkdownRender(
|
||||
@@ -167,6 +189,7 @@ export function App() {
|
||||
renderError ||
|
||||
themeError ||
|
||||
appError ||
|
||||
echartsError ||
|
||||
mermaidError ||
|
||||
paginationError ||
|
||||
pdfError;
|
||||
@@ -177,17 +200,23 @@ export function App() {
|
||||
exportConfig.paper.orientation
|
||||
);
|
||||
const previewScale = previewZoom / 100;
|
||||
const naturalPaperWidth =
|
||||
millimetersToCssPixels(paperDimensions.width) +
|
||||
PREVIEW_SCROLLBAR_WIDTH_PX;
|
||||
const naturalPaperWidth = millimetersToCssPixels(paperDimensions.width);
|
||||
const scaledPaperWidth = naturalPaperWidth * previewScale;
|
||||
const paperStyle = {
|
||||
width: `${scaledPaperWidth}px`,
|
||||
minWidth: `${scaledPaperWidth}px`
|
||||
minWidth: `${scaledPaperWidth}px`,
|
||||
...(quickPreviewContentHeight
|
||||
? {
|
||||
height: `${quickPreviewContentHeight * previewScale}px`,
|
||||
minHeight: `${quickPreviewContentHeight * previewScale}px`
|
||||
}
|
||||
: {})
|
||||
} satisfies CSSProperties;
|
||||
const previewFrameStyle = {
|
||||
width: `${100 / previewScale}%`,
|
||||
height: `${100 / previewScale}%`,
|
||||
height: quickPreviewContentHeight
|
||||
? `${quickPreviewContentHeight}px`
|
||||
: `${100 / previewScale}%`,
|
||||
transform: `scale(${previewScale})`,
|
||||
transformOrigin: "top left"
|
||||
} satisfies CSSProperties;
|
||||
@@ -271,7 +300,9 @@ export function App() {
|
||||
|
||||
if (event.data.type === "rendering") {
|
||||
setPaginationError("");
|
||||
setEChartsError("");
|
||||
setMermaidError("");
|
||||
setQuickPreviewContentHeight(undefined);
|
||||
setPreviewPagination({ current: 0, total: 0 });
|
||||
setStatus("正在分页…");
|
||||
return;
|
||||
@@ -283,12 +314,18 @@ export function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
setEChartsError(
|
||||
event.data.echartsErrors.length > 0
|
||||
? `ECharts:${event.data.echartsErrors.join(";")}`
|
||||
: ""
|
||||
);
|
||||
setMermaidError(
|
||||
event.data.mermaidErrors.length > 0
|
||||
? `Mermaid:${event.data.mermaidErrors.join(";")}`
|
||||
: ""
|
||||
);
|
||||
setStatus(`预览已分页(${event.data.pageCount} 页)`);
|
||||
setQuickPreviewContentHeight(event.data.contentHeight);
|
||||
setPreviewPagination({
|
||||
current: event.data.pageCount > 0 ? 1 : 0,
|
||||
total: event.data.pageCount
|
||||
@@ -305,22 +342,15 @@ export function App() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const frameWindow = previewFrameRef.current?.contentWindow;
|
||||
if (!frameWindow || !previewFrameReady) {
|
||||
return;
|
||||
function handleWindowScroll() {
|
||||
if (getOuterPreviewScroller() === document.scrollingElement) {
|
||||
updatePreviewCurrentPage();
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreviewScroll() {
|
||||
synchronizeScroll("preview");
|
||||
updatePreviewCurrentPage();
|
||||
}
|
||||
|
||||
frameWindow.addEventListener("scroll", handlePreviewScroll, {
|
||||
passive: true
|
||||
});
|
||||
return () =>
|
||||
frameWindow.removeEventListener("scroll", handlePreviewScroll);
|
||||
}, [previewFrameReady]);
|
||||
window.addEventListener("scroll", handleWindowScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", handleWindowScroll);
|
||||
}, [previewMode, quickPreviewContentHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -346,12 +376,19 @@ export function App() {
|
||||
);
|
||||
}, [previewFrameReady, previewPayload]);
|
||||
|
||||
function getPreviewScroller() {
|
||||
if (previewModeRef.current === "precise") {
|
||||
return previewScrollRef.current;
|
||||
function getOuterPreviewScroller() {
|
||||
const previewScroller = previewScrollRef.current;
|
||||
if (!previewScroller) {
|
||||
return null;
|
||||
}
|
||||
return previewFrameRef.current?.contentDocument
|
||||
?.scrollingElement as HTMLElement | null;
|
||||
|
||||
return previewScroller.scrollHeight > previewScroller.clientHeight + 1
|
||||
? previewScroller
|
||||
: (document.scrollingElement as HTMLElement | null);
|
||||
}
|
||||
|
||||
function getPreviewScroller() {
|
||||
return getOuterPreviewScroller();
|
||||
}
|
||||
|
||||
function synchronizeScroll(origin: "editor" | "preview") {
|
||||
@@ -392,15 +429,28 @@ export function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollerViewportTop =
|
||||
scrollElement === document.scrollingElement
|
||||
? 0
|
||||
: scrollElement.getBoundingClientRect().top;
|
||||
const frameViewportTop = frame.getBoundingClientRect().top;
|
||||
const viewportCenter =
|
||||
scrollElement.scrollTop + frame.clientHeight / 2;
|
||||
scrollElement.scrollTop + scrollElement.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
|
||||
top: getPreviewPageScrollTop(
|
||||
scrollElement.scrollTop,
|
||||
frameViewportTop + rect.top * previewScale,
|
||||
scrollerViewportTop
|
||||
),
|
||||
bottom: getPreviewPageScrollTop(
|
||||
scrollElement.scrollTop,
|
||||
frameViewportTop + rect.bottom * previewScale,
|
||||
scrollerViewportTop
|
||||
)
|
||||
};
|
||||
});
|
||||
const current = getNearestPageNumber(viewportCenter, pages);
|
||||
@@ -417,20 +467,35 @@ export function App() {
|
||||
}
|
||||
|
||||
function updatePrecisePreviewCurrentPage() {
|
||||
const scrollElement = previewScrollRef.current;
|
||||
const scrollElement = getOuterPreviewScroller();
|
||||
if (!scrollElement) {
|
||||
return;
|
||||
}
|
||||
const scrollerViewportTop =
|
||||
scrollElement === document.scrollingElement
|
||||
? 0
|
||||
: scrollElement.getBoundingClientRect().top;
|
||||
const viewportCenter =
|
||||
scrollElement.scrollTop + scrollElement.clientHeight / 2;
|
||||
const pages = Array.from(
|
||||
scrollElement.querySelectorAll<HTMLElement>(
|
||||
".precise-pdf-page"
|
||||
)
|
||||
).map((page) => ({
|
||||
top: page.offsetTop,
|
||||
bottom: page.offsetTop + page.offsetHeight
|
||||
}));
|
||||
).map((page) => {
|
||||
const rect = page.getBoundingClientRect();
|
||||
return {
|
||||
top: getPreviewPageScrollTop(
|
||||
scrollElement.scrollTop,
|
||||
rect.top,
|
||||
scrollerViewportTop
|
||||
),
|
||||
bottom: getPreviewPageScrollTop(
|
||||
scrollElement.scrollTop,
|
||||
rect.bottom,
|
||||
scrollerViewportTop
|
||||
)
|
||||
};
|
||||
});
|
||||
const current = getNearestPageNumber(viewportCenter, pages);
|
||||
|
||||
setPreviewPagination((pagination) =>
|
||||
@@ -444,6 +509,60 @@ export function App() {
|
||||
);
|
||||
}
|
||||
|
||||
function handlePreviewPageChange(pageNumber: number) {
|
||||
const scrollElement = getPreviewScroller();
|
||||
if (!scrollElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
let targetTop: number | undefined;
|
||||
if (previewModeRef.current === "precise") {
|
||||
const page = scrollElement.querySelectorAll<HTMLElement>(
|
||||
".precise-pdf-page"
|
||||
)[pageNumber - 1];
|
||||
if (page) {
|
||||
const scrollerViewportTop =
|
||||
scrollElement === document.scrollingElement
|
||||
? 0
|
||||
: scrollElement.getBoundingClientRect().top;
|
||||
targetTop = getPreviewPageScrollTop(
|
||||
scrollElement.scrollTop,
|
||||
page.getBoundingClientRect().top,
|
||||
scrollerViewportTop
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const page =
|
||||
previewFrameRef.current?.contentDocument?.querySelectorAll<HTMLElement>(
|
||||
".pagedjs_page"
|
||||
)[pageNumber - 1];
|
||||
if (page) {
|
||||
const scrollerViewportTop =
|
||||
scrollElement === document.scrollingElement
|
||||
? 0
|
||||
: scrollElement.getBoundingClientRect().top;
|
||||
const frameViewportTop =
|
||||
previewFrameRef.current?.getBoundingClientRect().top ?? 0;
|
||||
targetTop = getPreviewPageScrollTop(
|
||||
scrollElement.scrollTop,
|
||||
frameViewportTop +
|
||||
page.getBoundingClientRect().top * previewScale,
|
||||
scrollerViewportTop
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetTop === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPreviewPagination((pagination) => ({
|
||||
...pagination,
|
||||
current: pageNumber
|
||||
}));
|
||||
scrollElement.scrollTo({ top: Math.max(0, targetTop) });
|
||||
}
|
||||
|
||||
const handlePrecisePageCount = useCallback((pageCount: number) => {
|
||||
setPreviewPagination({
|
||||
current: pageCount > 0 ? 1 : 0,
|
||||
@@ -502,8 +621,12 @@ export function App() {
|
||||
exported.mermaidErrorCount > 0
|
||||
? `,${exported.mermaidErrorCount} 个 Mermaid 图表渲染失败`
|
||||
: "";
|
||||
const echartsDescription =
|
||||
exported.echartsErrorCount > 0
|
||||
? `,${exported.echartsErrorCount} 个 ECharts 图表渲染失败`
|
||||
: "";
|
||||
setStatus(
|
||||
`${download ? "PDF 已导出" : "精确预览已生成"}${pageDescription}${mermaidDescription}`
|
||||
`${download ? "PDF 已导出" : "精确预览已生成"}${pageDescription}${echartsDescription}${mermaidDescription}`
|
||||
);
|
||||
} catch (reason) {
|
||||
setPdfError(
|
||||
@@ -521,6 +644,7 @@ export function App() {
|
||||
}
|
||||
setPreviewMode(mode);
|
||||
setPreviewFrameReady(false);
|
||||
setQuickPreviewContentHeight(undefined);
|
||||
setPreviewPagination({ current: 0, total: 0 });
|
||||
previewScrollRef.current?.scrollTo({ top: 0 });
|
||||
if (mode === "precise" && !cachedPdf) {
|
||||
@@ -529,19 +653,29 @@ export function App() {
|
||||
}
|
||||
|
||||
function handlePreviewScroll() {
|
||||
if (previewModeRef.current !== "precise") {
|
||||
return;
|
||||
}
|
||||
synchronizeScroll("preview");
|
||||
updatePrecisePreviewCurrentPage();
|
||||
updatePreviewCurrentPage();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="workspace">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<p className="eyebrow">内网文档工具</p>
|
||||
<h1>Markdown PDF 导出器</h1>
|
||||
<div className="topbar-primary">
|
||||
<div className="topbar-brand">
|
||||
<p className="eyebrow">内网文档工具</p>
|
||||
<h1>Markdown PDF 导出器</h1>
|
||||
</div>
|
||||
<div className="document-summary">
|
||||
<span className="panel-kicker">源文件</span>
|
||||
<strong title={fileName}>{fileName}</strong>
|
||||
<span
|
||||
className={`render-status${error ? " is-error" : ""}`}
|
||||
aria-live="polite"
|
||||
title={error || status}
|
||||
>
|
||||
{error || status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<label className="file-button">
|
||||
@@ -565,25 +699,52 @@ export function App() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="editor-layout">
|
||||
<section
|
||||
className={`editor-layout${
|
||||
sourcePanelCollapsed ? " is-source-collapsed" : ""
|
||||
}`}
|
||||
>
|
||||
<aside className="editor-panel" aria-label="Markdown 编辑区">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<span className="panel-kicker">源文件</span>
|
||||
<strong>{fileName}</strong>
|
||||
<div
|
||||
id="markdown-source-content"
|
||||
className="source-panel-content"
|
||||
hidden={sourcePanelCollapsed}
|
||||
>
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<span className="panel-kicker">源文本</span>
|
||||
<strong>Markdown 原文</strong>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="source-panel-toggle"
|
||||
aria-controls="markdown-source-content"
|
||||
aria-expanded="true"
|
||||
onClick={() => setSourcePanelCollapsed(true)}
|
||||
>
|
||||
收起
|
||||
</button>
|
||||
</div>
|
||||
<span className={`render-status${error ? " is-error" : ""}`}>
|
||||
{error || status}
|
||||
</span>
|
||||
<textarea
|
||||
ref={editorRef}
|
||||
aria-label="Markdown 内容"
|
||||
value={markdown}
|
||||
spellCheck={false}
|
||||
onChange={(event) => setMarkdown(event.target.value)}
|
||||
onScroll={() => synchronizeScroll("editor")}
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
ref={editorRef}
|
||||
aria-label="Markdown 内容"
|
||||
value={markdown}
|
||||
spellCheck={false}
|
||||
onChange={(event) => setMarkdown(event.target.value)}
|
||||
onScroll={() => synchronizeScroll("editor")}
|
||||
/>
|
||||
{sourcePanelCollapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
className="source-panel-restore"
|
||||
aria-controls="markdown-source-content"
|
||||
aria-expanded="false"
|
||||
onClick={() => setSourcePanelCollapsed(false)}
|
||||
>
|
||||
展开源文本
|
||||
</button>
|
||||
) : null}
|
||||
</aside>
|
||||
|
||||
<section className="preview-panel" aria-label="文档预览">
|
||||
@@ -593,11 +754,6 @@ export function App() {
|
||||
<strong>{result?.metadata.title || "未命名文档"}</strong>
|
||||
</div>
|
||||
<span className="preview-heading-actions">
|
||||
{previewPagination.total > 0 ? (
|
||||
<span className="preview-page-indicator" aria-live="polite">
|
||||
第 {previewPagination.current} / {previewPagination.total} 页
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className="preview-mode-control"
|
||||
role="group"
|
||||
@@ -654,6 +810,15 @@ export function App() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="preview-toolbar">
|
||||
{previewPagination.total > 0 ? (
|
||||
<PreviewPageControl
|
||||
current={previewPagination.current}
|
||||
total={previewPagination.total}
|
||||
onNavigate={handlePreviewPageChange}
|
||||
/>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<PreviewZoomControl
|
||||
value={previewZoom}
|
||||
onChange={setPreviewZoom}
|
||||
|
||||
Reference in New Issue
Block a user