fix(snapshot): preserve code layout and export full images

This commit is contained in:
dadong
2026-08-20 22:11:49 +08:00
committed by GitHub
parent 68d2919219
commit 81872165da
4 changed files with 102 additions and 16 deletions
@@ -156,7 +156,9 @@ const previewVisible = computed(() => open.value && !!props.source);
<template>
<Dialog v-model:open="open">
<DialogContent class="max-h-[86vh] overflow-y-auto border border-border !bg-background text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[860px]">
<!-- 对话框整体限高:高度上限跟随 --dbx-viewport-height(兼容旧版 WebView 中 vh 不准的情况);
内部采用纵向 flex 布局,header/footer 固定,只有中间预览区滚动,保证底部按钮始终可见 -->
<DialogContent class="flex max-h-[calc(var(--dbx-viewport-height)-2rem)] flex-col overflow-hidden border border-border !bg-background text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[860px]">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Camera class="h-5 w-5 text-primary" />
@@ -164,11 +166,14 @@ const previewVisible = computed(() => open.value && !!props.source);
</DialogTitle>
</DialogHeader>
<div v-if="previewVisible" class="flex flex-col gap-4 md:flex-row">
<!-- min-h-0 flex-1:让内容区占满剩余高度并允许收缩,超高时由预览区内部滚动而不是撑破对话框 -->
<div v-if="previewVisible" class="flex min-h-0 flex-1 flex-col gap-4 md:flex-row">
<!-- Snapshot preview -->
<div class="min-w-0 flex-1">
<div ref="previewWrapRef" class="max-h-[56vh] overflow-auto rounded-md border bg-muted/30 p-3">
<div v-html="snapshotHtml" class="inline-block min-w-[320px]" />
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
<!-- 预览容器随父级伸缩(flex-1 + min-h-0),代码/图片过长时仅此处出现滚动条 -->
<div ref="previewWrapRef" class="min-h-0 flex-1 overflow-auto rounded-md border bg-muted/30 p-3">
<!-- 截图内容按代码的固有宽度展开,不跟随外层 flex 容器收缩;外层预览容器负责横向滚动 -->
<div v-html="snapshotHtml" class="flex w-max min-w-[320px] flex-none" />
</div>
</div>
@@ -0,0 +1,11 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dialogSource = readFileSync(new URL("../CodeSnapshotDialog.vue", import.meta.url), "utf8");
describe("CodeSnapshotDialog layout", () => {
it("keeps the exported snapshot at its intrinsic width inside the scrollable preview", () => {
// 截图节点不能被外层 flex 布局压缩,否则长代码导出时会被裁切或错误换行。
expect(dialogSource).toContain('v-html="snapshotHtml" class="flex w-max min-w-[320px] flex-none"');
});
});
@@ -32,6 +32,9 @@ describe("renderCodeSnapshotHtml", () => {
expect(html).toContain('class="dbx-code-snapshot"');
expect(html).toContain('data-snapshot-appearance="dark"');
expect(html).toContain('class="dbx-code-snapshot__pre dbx-code-snapshot__pre--numbered"');
expect(html).toContain(".dbx-code-snapshot *");
expect(html).toContain("border: 0");
expect(html).toContain("outline: 0");
});
it("renders macOS traffic lights and an optional title by default", async () => {
@@ -93,25 +96,72 @@ describe("renderCodeSnapshotHtml", () => {
toPng.mockResolvedValue("data:image/png;base64,test");
vi.stubGlobal("window", { devicePixelRatio: 3 });
try {
await snapshotElementToPng({ offsetWidth: 320, offsetHeight: 160 } as HTMLElement);
await snapshotElementToPng({ offsetWidth: 320, offsetHeight: 160, scrollWidth: 640, scrollHeight: 240 } as HTMLElement);
expect(toPng).toHaveBeenLastCalledWith(
expect.anything(),
expect.objectContaining({
width: 320,
height: 160,
pixelRatio: 2,
width: 640,
height: 240,
scale: 2,
style: {
width: "640px",
height: "240px",
},
}),
);
vi.stubGlobal("window", { devicePixelRatio: 0 });
await snapshotElementToPng({ offsetWidth: 320, offsetHeight: 160 } as HTMLElement);
expect(toPng).toHaveBeenLastCalledWith(expect.anything(), expect.objectContaining({ pixelRatio: 1 }));
await snapshotElementToPng({ offsetWidth: 320, offsetHeight: 160, scrollWidth: 320, scrollHeight: 160 } as HTMLElement);
expect(toPng).toHaveBeenLastCalledWith(expect.anything(), expect.objectContaining({ scale: 1 }));
} finally {
vi.unstubAllGlobals();
}
});
it("reduces the export scale when a wide snapshot reaches the canvas edge limit", async () => {
toPng.mockResolvedValue("data:image/png;base64,test");
vi.stubGlobal("window", { devicePixelRatio: 2 });
try {
await snapshotElementToPng({ offsetWidth: 10_000, offsetHeight: 100, scrollWidth: 10_000, scrollHeight: 100 } as HTMLElement);
expect(toPng).toHaveBeenLastCalledWith(expect.anything(), expect.objectContaining({ scale: 16_384 / 10_000 }));
} finally {
vi.unstubAllGlobals();
}
});
it("reduces the export scale when a tall snapshot reaches the canvas edge limit", async () => {
toPng.mockResolvedValue("data:image/png;base64,test");
vi.stubGlobal("window", { devicePixelRatio: 2 });
try {
await snapshotElementToPng({ offsetWidth: 100, offsetHeight: 10_000, scrollWidth: 100, scrollHeight: 10_000 } as HTMLElement);
expect(toPng).toHaveBeenLastCalledWith(expect.anything(), expect.objectContaining({ scale: 16_384 / 10_000 }));
} finally {
vi.unstubAllGlobals();
}
});
it("reduces the export scale when a dense snapshot reaches the total pixel limit", async () => {
toPng.mockResolvedValue("data:image/png;base64,test");
vi.stubGlobal("window", { devicePixelRatio: 2 });
try {
await snapshotElementToPng({ offsetWidth: 6_000, offsetHeight: 6_000, scrollWidth: 6_000, scrollHeight: 6_000 } as HTMLElement);
expect(toPng).toHaveBeenLastCalledWith(expect.anything(), expect.objectContaining({ scale: Math.sqrt((64 * 1024 * 1024) / (6_000 * 6_000)) }));
} finally {
vi.unstubAllGlobals();
}
});
it("rejects snapshots that exceed the safe rendering budget at 1x", async () => {
toPng.mockClear();
await expect(snapshotElementToPng({ offsetWidth: 20_000, offsetHeight: 100, scrollWidth: 20_000, scrollHeight: 100 } as HTMLElement)).rejects.toThrow("Snapshot is too large to export safely (20000 × 100px)");
expect(toPng).not.toHaveBeenCalled();
});
it("propagates a failed native save instead of reporting a browser download", async () => {
isTauriRuntime.mockReturnValue(true);
save.mockResolvedValue("C:\\Users\\example\\snapshot.png");
@@ -57,12 +57,19 @@ const SNAPSHOT_LINE_NUMBER: Record<AppThemeAppearance, string> = {
const TRAFFIC_LIGHT_COLORS = ["#ff5f57", "#febc2e", "#28c840"] as const;
const MAX_EXPORT_PIXEL_RATIO = 2;
const MAX_EXPORT_CANVAS_EDGE = 16_384;
const MAX_EXPORT_CANVAS_PIXELS = 64 * 1024 * 1024;
/**
* Self-contained stylesheet embedded in every snapshot DOM. Keep it prefix
* namespaced (`dbx-code-snapshot`) so it never leaks into the app chrome.
*/
export const CODE_SNAPSHOT_CSS = `
.dbx-code-snapshot,
.dbx-code-snapshot * {
border: 0;
outline: 0;
}
.dbx-code-snapshot {
border-radius: 8px;
overflow: hidden;
@@ -180,13 +187,26 @@ export async function renderCodeSnapshotHtml(source: CodeSnapshotSource, options
/** Convert a snapshot DOM element into a PNG data URL. */
export async function snapshotElementToPng(element: HTMLElement): Promise<string> {
const domtoimage = (await import("dom-to-image-more")).default;
// 预览区允许滚动,因此导出尺寸必须覆盖完整内容,而不是只取当前可见宽度。
const width = Math.max(element.offsetWidth, element.scrollWidth);
const height = Math.max(element.offsetHeight, element.scrollHeight);
const basePixels = width * height;
if (width > MAX_EXPORT_CANVAS_EDGE || height > MAX_EXPORT_CANVAS_EDGE || basePixels > MAX_EXPORT_CANVAS_PIXELS) {
throw new Error(`Snapshot is too large to export safely (${width} × ${height}px). Reduce the code size and try again.`);
}
const targetScale = Math.min(MAX_EXPORT_PIXEL_RATIO, Math.max(1, typeof window === "undefined" ? 1 : window.devicePixelRatio || 1));
const scale = Math.min(targetScale, MAX_EXPORT_CANVAS_EDGE / width, MAX_EXPORT_CANVAS_EDGE / height, Math.sqrt(MAX_EXPORT_CANVAS_PIXELS / basePixels));
return domtoimage.toPng(element, {
quality: 1,
width: element.offsetWidth,
height: element.offsetHeight,
// Preserve text sharpness on high-DPI displays without letting a large
// selected block allocate an unbounded export canvas.
pixelRatio: Math.min(MAX_EXPORT_PIXEL_RATIO, Math.max(1, typeof window === "undefined" ? 1 : window.devicePixelRatio || 1)),
width,
height,
// dom-to-image-more 使用 scale 放大实际画布;pixelRatio 不是该库支持的参数。
scale,
style: {
width: `${width}px`,
height: `${height}px`,
},
});
}