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}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { normalizePreviewPageNumber } from "./preview-page";
|
||||
|
||||
interface PreviewPageControlProps {
|
||||
current: number;
|
||||
total: number;
|
||||
onNavigate: (pageNumber: number) => void;
|
||||
}
|
||||
|
||||
export function PreviewPageControl({
|
||||
current,
|
||||
total,
|
||||
onNavigate
|
||||
}: PreviewPageControlProps) {
|
||||
const [draft, setDraft] = useState(String(current));
|
||||
const editingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingRef.current) {
|
||||
setDraft(String(current));
|
||||
}
|
||||
}, [current, total]);
|
||||
|
||||
function commitPageNumber() {
|
||||
const pageNumber = normalizePreviewPageNumber(draft, current, total);
|
||||
setDraft(String(pageNumber));
|
||||
if (pageNumber !== current) {
|
||||
onNavigate(pageNumber);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="preview-page-control">
|
||||
<span>第</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={total}
|
||||
step={1}
|
||||
value={draft}
|
||||
aria-label="跳转页码"
|
||||
onBlur={() => {
|
||||
editingRef.current = false;
|
||||
commitPageNumber();
|
||||
}}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onFocus={(event) => {
|
||||
editingRef.current = true;
|
||||
event.currentTarget.select();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span aria-live="polite">/ {total} 页</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
getPaperDimensionsMm,
|
||||
lengthToMillimeters,
|
||||
millimetersToCssPixels,
|
||||
type ExportConfig
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
export interface DiagramDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface DiagramPageFit extends DiagramDimensions {
|
||||
scaled: boolean;
|
||||
}
|
||||
|
||||
export interface SvgDiagramFitOptions {
|
||||
containerSelector: string;
|
||||
svgSelector?: string;
|
||||
resolvePageContent?: (
|
||||
container: HTMLElement,
|
||||
pageContent: DiagramDimensions
|
||||
) => DiagramDimensions;
|
||||
onFit?: (
|
||||
container: HTMLElement,
|
||||
svg: SVGSVGElement,
|
||||
fit: DiagramPageFit
|
||||
) => void;
|
||||
}
|
||||
|
||||
const PAGE_FIT_EPSILON_PX = 1;
|
||||
|
||||
export function parseSvgViewBox(
|
||||
value: string | null
|
||||
): DiagramDimensions | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const values = value
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map((part) => Number(part));
|
||||
const [, , width = Number.NaN, height = Number.NaN] = values;
|
||||
|
||||
if (
|
||||
values.length !== 4 ||
|
||||
!Number.isFinite(width) ||
|
||||
!Number.isFinite(height) ||
|
||||
width <= 0 ||
|
||||
height <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function parseSvgDimensions(
|
||||
svg: SVGSVGElement
|
||||
): DiagramDimensions | undefined {
|
||||
const width = Number.parseFloat(svg.getAttribute("width") ?? "");
|
||||
const height = Number.parseFloat(
|
||||
svg.getAttribute("height") ?? ""
|
||||
);
|
||||
if (
|
||||
!Number.isFinite(width) ||
|
||||
!Number.isFinite(height) ||
|
||||
width <= 0 ||
|
||||
height <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
export function getPageContentDimensions(
|
||||
config: ExportConfig
|
||||
): DiagramDimensions {
|
||||
const paper = getPaperDimensionsMm(
|
||||
config.paper.format,
|
||||
config.paper.orientation
|
||||
);
|
||||
const width =
|
||||
paper.width -
|
||||
lengthToMillimeters(config.paper.margins.left) -
|
||||
lengthToMillimeters(config.paper.margins.right);
|
||||
const height =
|
||||
paper.height -
|
||||
lengthToMillimeters(config.paper.margins.top) -
|
||||
lengthToMillimeters(config.paper.margins.bottom);
|
||||
|
||||
return {
|
||||
width: millimetersToCssPixels(width),
|
||||
height: millimetersToCssPixels(height)
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateDiagramPageFit(
|
||||
intrinsic: DiagramDimensions,
|
||||
pageContent: DiagramDimensions
|
||||
): DiagramPageFit {
|
||||
const heightAfterWidthFit =
|
||||
(pageContent.width * intrinsic.height) / intrinsic.width;
|
||||
|
||||
if (heightAfterWidthFit <= pageContent.height) {
|
||||
return {
|
||||
width: pageContent.width,
|
||||
height: heightAfterWidthFit,
|
||||
scaled: false
|
||||
};
|
||||
}
|
||||
|
||||
const height = Math.max(
|
||||
0,
|
||||
pageContent.height - PAGE_FIT_EPSILON_PX
|
||||
);
|
||||
return {
|
||||
width: (height * intrinsic.width) / intrinsic.height,
|
||||
height,
|
||||
scaled: true
|
||||
};
|
||||
}
|
||||
|
||||
export function fitSvgDiagramsToPage(
|
||||
root: ParentNode,
|
||||
config: ExportConfig,
|
||||
options: SvgDiagramFitOptions
|
||||
) {
|
||||
const pageContent = getPageContentDimensions(config);
|
||||
const containers = Array.from(
|
||||
root.querySelectorAll<HTMLElement>(options.containerSelector)
|
||||
);
|
||||
|
||||
for (const container of containers) {
|
||||
const svg = container.querySelector<SVGSVGElement>(
|
||||
options.svgSelector ?? "svg"
|
||||
);
|
||||
const intrinsic = svg
|
||||
? parseSvgViewBox(svg.getAttribute("viewBox")) ??
|
||||
parseSvgDimensions(svg)
|
||||
: undefined;
|
||||
if (!svg || !intrinsic) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const availablePageContent =
|
||||
options.resolvePageContent?.(container, pageContent) ??
|
||||
pageContent;
|
||||
const fit = calculateDiagramPageFit(
|
||||
intrinsic,
|
||||
availablePageContent
|
||||
);
|
||||
if (!fit.scaled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
container.dataset.pageHeightFitted = "true";
|
||||
container.style.setProperty("margin-block", "0", "important");
|
||||
if (!svg.getAttribute("viewBox")) {
|
||||
svg.setAttribute(
|
||||
"viewBox",
|
||||
`0 0 ${intrinsic.width} ${intrinsic.height}`
|
||||
);
|
||||
}
|
||||
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
||||
svg.style.setProperty("display", "block");
|
||||
svg.style.setProperty("width", `${fit.width}px`, "important");
|
||||
svg.style.setProperty("height", `${fit.height}px`, "important");
|
||||
svg.style.setProperty("max-width", "100%", "important");
|
||||
svg.style.setProperty(
|
||||
"max-height",
|
||||
`${fit.height}px`,
|
||||
"important"
|
||||
);
|
||||
options.onFit?.(container, svg, fit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ExportConfig } from "@md-to-pdf/core";
|
||||
import { fitSvgDiagramsToPage } from "./diagram-page-fit";
|
||||
|
||||
export function fitOversizedEChartsToPage(
|
||||
container: ParentNode,
|
||||
config: ExportConfig
|
||||
) {
|
||||
fitSvgDiagramsToPage(container, config, {
|
||||
containerSelector: ".md-echarts",
|
||||
svgSelector: ".md-echarts-host svg",
|
||||
resolvePageContent(figure, pageContent) {
|
||||
const host = figure.querySelector<HTMLElement>(
|
||||
".md-echarts-host"
|
||||
);
|
||||
const figureHeight = figure.getBoundingClientRect().height;
|
||||
const hostHeight = host?.getBoundingClientRect().height ?? 0;
|
||||
const nonChartHeight = Math.max(
|
||||
0,
|
||||
figureHeight - hostHeight
|
||||
);
|
||||
return {
|
||||
width: pageContent.width,
|
||||
height: Math.max(0, pageContent.height - nonChartHeight)
|
||||
};
|
||||
},
|
||||
onFit(figure, _svg, fit) {
|
||||
const host = figure.querySelector<HTMLElement>(
|
||||
".md-echarts-host"
|
||||
);
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
host.style.setProperty(
|
||||
"width",
|
||||
`${fit.width}px`,
|
||||
"important"
|
||||
);
|
||||
host.style.setProperty(
|
||||
"height",
|
||||
`${fit.height}px`,
|
||||
"important"
|
||||
);
|
||||
host.style.setProperty("margin-inline", "auto", "important");
|
||||
host.style.removeProperty("aspect-ratio");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,123 +1,26 @@
|
||||
import type { ExportConfig } from "@md-to-pdf/core";
|
||||
import {
|
||||
getPaperDimensionsMm,
|
||||
lengthToMillimeters,
|
||||
millimetersToCssPixels,
|
||||
type ExportConfig
|
||||
} from "@md-to-pdf/core";
|
||||
calculateDiagramPageFit,
|
||||
fitSvgDiagramsToPage,
|
||||
getPageContentDimensions,
|
||||
parseSvgViewBox,
|
||||
type DiagramDimensions,
|
||||
type DiagramPageFit
|
||||
} from "./diagram-page-fit";
|
||||
|
||||
export interface MermaidDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface MermaidPageFit extends MermaidDimensions {
|
||||
scaled: boolean;
|
||||
}
|
||||
|
||||
const PAGE_FIT_EPSILON_PX = 1;
|
||||
|
||||
export function parseSvgViewBox(
|
||||
value: string | null
|
||||
): MermaidDimensions | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const values = value
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map((part) => Number(part));
|
||||
const [, , width = Number.NaN, height = Number.NaN] = values;
|
||||
|
||||
if (
|
||||
values.length !== 4 ||
|
||||
!Number.isFinite(width) ||
|
||||
!Number.isFinite(height) ||
|
||||
width <= 0 ||
|
||||
height <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
export function getPageContentDimensions(
|
||||
config: ExportConfig
|
||||
): MermaidDimensions {
|
||||
const paper = getPaperDimensionsMm(
|
||||
config.paper.format,
|
||||
config.paper.orientation
|
||||
);
|
||||
const width =
|
||||
paper.width -
|
||||
lengthToMillimeters(config.paper.margins.left) -
|
||||
lengthToMillimeters(config.paper.margins.right);
|
||||
const height =
|
||||
paper.height -
|
||||
lengthToMillimeters(config.paper.margins.top) -
|
||||
lengthToMillimeters(config.paper.margins.bottom);
|
||||
|
||||
return {
|
||||
width: millimetersToCssPixels(width),
|
||||
height: millimetersToCssPixels(height)
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateMermaidPageFit(
|
||||
intrinsic: MermaidDimensions,
|
||||
pageContent: MermaidDimensions
|
||||
): MermaidPageFit {
|
||||
const heightAfterWidthFit =
|
||||
(pageContent.width * intrinsic.height) / intrinsic.width;
|
||||
|
||||
if (heightAfterWidthFit <= pageContent.height) {
|
||||
return {
|
||||
width: pageContent.width,
|
||||
height: heightAfterWidthFit,
|
||||
scaled: false
|
||||
};
|
||||
}
|
||||
|
||||
const height = Math.max(
|
||||
0,
|
||||
pageContent.height - PAGE_FIT_EPSILON_PX
|
||||
);
|
||||
return {
|
||||
width: (height * intrinsic.width) / intrinsic.height,
|
||||
height,
|
||||
scaled: true
|
||||
};
|
||||
}
|
||||
export type MermaidDimensions = DiagramDimensions;
|
||||
export type MermaidPageFit = DiagramPageFit;
|
||||
export {
|
||||
getPageContentDimensions,
|
||||
parseSvgViewBox
|
||||
};
|
||||
export const calculateMermaidPageFit = calculateDiagramPageFit;
|
||||
|
||||
export function fitOversizedMermaidToPage(
|
||||
container: ParentNode,
|
||||
config: ExportConfig
|
||||
) {
|
||||
const pageContent = getPageContentDimensions(config);
|
||||
const diagrams = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(".mermaid")
|
||||
);
|
||||
|
||||
for (const diagram of diagrams) {
|
||||
const svg = diagram.querySelector<SVGSVGElement>("svg");
|
||||
const intrinsic = parseSvgViewBox(svg?.getAttribute("viewBox") ?? null);
|
||||
if (!svg || !intrinsic) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fit = calculateMermaidPageFit(intrinsic, pageContent);
|
||||
if (!fit.scaled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
diagram.dataset.pageHeightFitted = "true";
|
||||
diagram.style.setProperty("margin-block", "0", "important");
|
||||
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
||||
svg.style.setProperty("display", "block");
|
||||
svg.style.setProperty("width", `${fit.width}px`, "important");
|
||||
svg.style.setProperty("height", `${fit.height}px`, "important");
|
||||
svg.style.setProperty("max-width", "100%", "important");
|
||||
svg.style.setProperty("max-height", `${fit.height}px`, "important");
|
||||
}
|
||||
fitSvgDiagramsToPage(container, config, {
|
||||
containerSelector: ".mermaid"
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import highlightCss from "highlight.js/styles/github.css?inline";
|
||||
import katexCss from "katex/dist/katex.min.css?inline";
|
||||
import echartsCss from "@md-to-pdf/markdown-echarts/styles.css?inline";
|
||||
import type {
|
||||
PagedDocumentRenderResult,
|
||||
PagedDocumentTimings
|
||||
} from "@md-to-pdf/core";
|
||||
import { Previewer } from "pagedjs";
|
||||
import { fitOversizedEChartsToPage } from "./echarts-page-fit";
|
||||
import { createMermaidSiteConfig } from "./mermaid-config";
|
||||
import {
|
||||
fitOversizedMermaidToPage,
|
||||
@@ -82,6 +84,7 @@ function mountMeasurementContainer(
|
||||
documentBaseCss,
|
||||
highlightCss,
|
||||
katexCss,
|
||||
echartsCss,
|
||||
themeCss,
|
||||
documentGeometryCss
|
||||
].join("\n");
|
||||
@@ -159,6 +162,11 @@ export class PagedDocumentRuntime {
|
||||
private mermaidPromise:
|
||||
| Promise<(typeof import("mermaid"))["default"]>
|
||||
| undefined;
|
||||
private echartsPromise:
|
||||
| Promise<
|
||||
typeof import("@md-to-pdf/markdown-echarts/browser")
|
||||
>
|
||||
| undefined;
|
||||
|
||||
constructor(private readonly root: HTMLElement) {}
|
||||
|
||||
@@ -188,6 +196,42 @@ export class PagedDocumentRuntime {
|
||||
return this.mermaidPromise;
|
||||
}
|
||||
|
||||
private loadECharts() {
|
||||
if (!this.echartsPromise) {
|
||||
this.echartsPromise = import(
|
||||
"@md-to-pdf/markdown-echarts/browser"
|
||||
);
|
||||
}
|
||||
return this.echartsPromise;
|
||||
}
|
||||
|
||||
private async renderECharts(
|
||||
container: ParentNode,
|
||||
payload: PagedPreviewPayload
|
||||
) {
|
||||
if (!payload.features.includes("echarts")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { renderEChartsBlocks } = await this.loadECharts();
|
||||
const outcomes = await renderEChartsBlocks(container, {
|
||||
outputMode: "inline-svg",
|
||||
animation: false,
|
||||
concurrency: 2,
|
||||
timeoutMs: 5_000
|
||||
});
|
||||
|
||||
return outcomes.flatMap((outcome, index) =>
|
||||
outcome.success
|
||||
? []
|
||||
: [
|
||||
`图表 ${index + 1}:${
|
||||
outcome.error?.message ?? "未知错误"
|
||||
}`
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private async renderMermaid(
|
||||
container: DocumentFragment,
|
||||
payload: PagedPreviewPayload
|
||||
@@ -276,9 +320,15 @@ export class PagedDocumentRuntime {
|
||||
const mermaidErrors = await this.renderMermaid(content, payload);
|
||||
const mermaidMs = performance.now() - mermaidStartedAt;
|
||||
|
||||
let echartsErrors: string[] = [];
|
||||
let echartsMs = 0;
|
||||
let echartsFitMs = 0;
|
||||
let mermaidFitMs = 0;
|
||||
let mermaidConversionMs = 0;
|
||||
if (content.querySelector(".mermaid svg")) {
|
||||
if (
|
||||
payload.features.includes("echarts") ||
|
||||
content.querySelector(".mermaid svg")
|
||||
) {
|
||||
const measurement = mountMeasurementContainer(
|
||||
documentRef,
|
||||
content,
|
||||
@@ -287,8 +337,23 @@ export class PagedDocumentRuntime {
|
||||
);
|
||||
try {
|
||||
await documentRef.fonts.ready;
|
||||
|
||||
const echartsStartedAt = performance.now();
|
||||
echartsErrors = await this.renderECharts(
|
||||
measurement.host,
|
||||
payload
|
||||
);
|
||||
echartsMs = performance.now() - echartsStartedAt;
|
||||
await waitForImages(measurement.host);
|
||||
|
||||
const echartsFitStartedAt = performance.now();
|
||||
fitOversizedEChartsToPage(
|
||||
measurement.host,
|
||||
payload.exportConfig
|
||||
);
|
||||
echartsFitMs =
|
||||
performance.now() - echartsFitStartedAt;
|
||||
|
||||
const mermaidFitStartedAt = performance.now();
|
||||
fitOversizedMermaidToPage(
|
||||
measurement.host,
|
||||
@@ -326,6 +391,7 @@ export class PagedDocumentRuntime {
|
||||
stylesheet(documentRef, documentBaseCss, "document-base"),
|
||||
stylesheet(documentRef, highlightCss, "highlight"),
|
||||
stylesheet(documentRef, katexCss, "katex"),
|
||||
stylesheet(documentRef, echartsCss, "echarts"),
|
||||
stylesheet(documentRef, payload.themeCss, "theme"),
|
||||
stylesheet(
|
||||
documentRef,
|
||||
@@ -355,9 +421,12 @@ export class PagedDocumentRuntime {
|
||||
|
||||
return {
|
||||
pageCount: flow.total,
|
||||
echartsErrors,
|
||||
mermaidErrors,
|
||||
timings: {
|
||||
setupMs,
|
||||
echartsMs,
|
||||
echartsFitMs,
|
||||
mermaidMs,
|
||||
mermaidFitMs,
|
||||
mermaidConversionMs,
|
||||
|
||||
@@ -62,6 +62,7 @@ async function renderPagedPreview(
|
||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||
type: "rendered",
|
||||
requestId,
|
||||
contentHeight: Math.ceil(document.documentElement.scrollHeight),
|
||||
...result
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
type MarkdownDocumentMetadata,
|
||||
type PagedDocumentPayload
|
||||
} 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 type PreviewMetadata = MarkdownDocumentMetadata;
|
||||
@@ -33,6 +31,8 @@ export type PagedPreviewFrameMessage =
|
||||
type: "rendered";
|
||||
requestId: number;
|
||||
pageCount: number;
|
||||
contentHeight: number;
|
||||
echartsErrors: string[];
|
||||
mermaidErrors: string[];
|
||||
}
|
||||
| {
|
||||
@@ -92,6 +92,11 @@ svg {
|
||||
background: #fef2f2;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.md-echarts {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
`;
|
||||
|
||||
export const documentGeometryCss = `
|
||||
@@ -293,7 +298,8 @@ ${buildFooterCss(config)}
|
||||
#write pre,
|
||||
#write blockquote,
|
||||
#write .katex-display,
|
||||
#write .mermaid {
|
||||
#write .mermaid,
|
||||
#write .md-echarts {
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
@@ -329,16 +335,7 @@ ${
|
||||
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;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
html[data-render-target="preview"] body {
|
||||
@@ -413,10 +410,26 @@ export function isPagedPreviewFrameMessage(
|
||||
}
|
||||
|
||||
const candidate = value as Partial<PagedPreviewFrameMessage>;
|
||||
return (
|
||||
candidate.scope === PAGED_PREVIEW_MESSAGE_SCOPE &&
|
||||
["ready", "rendering", "rendered", "error"].includes(
|
||||
if (
|
||||
candidate.scope !== PAGED_PREVIEW_MESSAGE_SCOPE ||
|
||||
!["ready", "rendering", "rendered", "error"].includes(
|
||||
candidate.type ?? ""
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (candidate.type !== "rendered") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
Number.isInteger(candidate.requestId) &&
|
||||
Number.isInteger(candidate.pageCount) &&
|
||||
typeof candidate.contentHeight === "number" &&
|
||||
Number.isFinite(candidate.contentHeight) &&
|
||||
candidate.contentHeight > 0 &&
|
||||
Array.isArray(candidate.echartsErrors) &&
|
||||
Array.isArray(candidate.mermaidErrors)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface PdfExportResult {
|
||||
blob: Blob;
|
||||
fileName: string;
|
||||
pageCount: number | undefined;
|
||||
echartsErrorCount: number;
|
||||
mermaidErrorCount: number;
|
||||
}
|
||||
|
||||
@@ -117,6 +118,10 @@ export async function requestPdfExport(
|
||||
pageCount: parseNumericHeader(
|
||||
response.headers.get("x-pdf-page-count")
|
||||
),
|
||||
echartsErrorCount:
|
||||
parseNumericHeader(
|
||||
response.headers.get("x-echarts-error-count")
|
||||
) ?? 0,
|
||||
mermaidErrorCount:
|
||||
parseNumericHeader(
|
||||
response.headers.get("x-mermaid-error-count")
|
||||
|
||||
@@ -3,7 +3,50 @@ export interface PageVerticalRange {
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export const PREVIEW_SCROLLBAR_WIDTH_PX = 14;
|
||||
export function getPreviewPageScrollTop(
|
||||
currentScrollTop: number,
|
||||
pageViewportTop: number,
|
||||
scrollerViewportTop: number
|
||||
) {
|
||||
if (
|
||||
!Number.isFinite(currentScrollTop) ||
|
||||
!Number.isFinite(pageViewportTop) ||
|
||||
!Number.isFinite(scrollerViewportTop)
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
0,
|
||||
currentScrollTop + pageViewportTop - scrollerViewportTop
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizePreviewPageNumber(
|
||||
value: string,
|
||||
current: number,
|
||||
total: number
|
||||
) {
|
||||
if (!Number.isInteger(total) || total < 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const normalizedValue = value.trim();
|
||||
const fallback = Number.isInteger(current)
|
||||
? Math.min(Math.max(current, 1), total)
|
||||
: 1;
|
||||
|
||||
if (normalizedValue.length === 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Number(normalizedValue);
|
||||
if (!Number.isInteger(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.min(Math.max(parsed, 1), total);
|
||||
}
|
||||
|
||||
export function getNearestPageNumber(
|
||||
viewportCenter: number,
|
||||
|
||||
+173
-10
@@ -63,6 +63,39 @@ button:disabled {
|
||||
background: rgb(247 248 246 / 92%);
|
||||
}
|
||||
|
||||
.topbar-primary {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar-brand {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.document-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
max-width: min(42vw, 520px);
|
||||
padding-left: 28px;
|
||||
border-left: 1px solid #d4dad7;
|
||||
}
|
||||
|
||||
.document-summary > strong {
|
||||
overflow: hidden;
|
||||
font-size: 0.82rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.document-summary .render-status {
|
||||
max-width: 100%;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 4px;
|
||||
color: #3b755d;
|
||||
@@ -82,6 +115,7 @@ h1 {
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -104,6 +138,11 @@ h1 {
|
||||
grid-template-columns: minmax(360px, 0.8fr) minmax(520px, 1.2fr);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
transition: grid-template-columns 180ms ease;
|
||||
}
|
||||
|
||||
.editor-layout.is-source-collapsed {
|
||||
grid-template-columns: 48px minmax(520px, 1fr);
|
||||
}
|
||||
|
||||
.editor-panel,
|
||||
@@ -119,6 +158,45 @@ h1 {
|
||||
background: #f6f7f5;
|
||||
}
|
||||
|
||||
.source-panel-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.source-panel-content[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.source-panel-toggle {
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
border-color: #c9d4ce;
|
||||
background: #f8fbf9;
|
||||
color: #4f695e;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.source-panel-restore {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 14px 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: #f0f3f1;
|
||||
color: #4f695e;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
writing-mode: vertical-rl;
|
||||
}
|
||||
|
||||
.source-panel-restore:hover {
|
||||
background: #e4ebe7;
|
||||
color: #356c55;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -181,12 +259,43 @@ h1 {
|
||||
flex: none;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 6px 20px;
|
||||
border-bottom: 1px solid #d9dedb;
|
||||
background: rgb(255 255 255 / 58%);
|
||||
}
|
||||
|
||||
.preview-page-control {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #5d7168;
|
||||
font-size: 0.7rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-page-control input {
|
||||
width: 48px;
|
||||
height: 30px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid #c9d4ce;
|
||||
border-radius: 7px;
|
||||
outline: 0;
|
||||
background: #fff;
|
||||
color: #356c55;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-page-control input:focus {
|
||||
border-color: #74a88f;
|
||||
box-shadow: 0 0 0 3px rgb(116 168 143 / 16%);
|
||||
}
|
||||
|
||||
.preview-zoom-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -248,14 +357,6 @@ h1 {
|
||||
color: #9ba49f;
|
||||
}
|
||||
|
||||
.preview-page-indicator {
|
||||
flex: none;
|
||||
color: #5d7168;
|
||||
font-size: 0.7rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-mode-control {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
@@ -362,6 +463,28 @@ textarea:focus {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0 30px;
|
||||
scrollbar-color: #8d9993 #dfe4e1;
|
||||
scrollbar-width: auto;
|
||||
}
|
||||
|
||||
.preview-scroll::-webkit-scrollbar {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.preview-scroll::-webkit-scrollbar-track {
|
||||
background: #dfe4e1;
|
||||
}
|
||||
|
||||
.preview-scroll::-webkit-scrollbar-thumb {
|
||||
min-height: 48px;
|
||||
border: 3px solid #dfe4e1;
|
||||
border-radius: 999px;
|
||||
background: #8d9993;
|
||||
}
|
||||
|
||||
.preview-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: #6f7d76;
|
||||
}
|
||||
|
||||
.paper {
|
||||
@@ -808,6 +931,21 @@ textarea:focus {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.topbar-primary {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.document-summary {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
padding-top: 10px;
|
||||
padding-left: 0;
|
||||
border-top: 1px solid #d4dad7;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -816,6 +954,10 @@ textarea:focus {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.editor-layout.is-source-collapsed {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.editor-panel,
|
||||
.preview-panel {
|
||||
min-height: 650px;
|
||||
@@ -830,6 +972,19 @@ textarea:focus {
|
||||
border-bottom: 1px solid #cbd2ce;
|
||||
}
|
||||
|
||||
.editor-layout.is-source-collapsed .editor-panel {
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.source-panel-restore {
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
letter-spacing: 0;
|
||||
writing-mode: horizontal-tb;
|
||||
}
|
||||
|
||||
.preview-scroll {
|
||||
padding: 0 10px;
|
||||
}
|
||||
@@ -847,11 +1002,13 @@ textarea:focus {
|
||||
}
|
||||
|
||||
.preview-toolbar {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.preview-zoom-control {
|
||||
flex: 1 0 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -865,6 +1022,12 @@ textarea:focus {
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.editor-layout {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.settings-drawer {
|
||||
width: 100vw;
|
||||
|
||||
Reference in New Issue
Block a user