mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 10:28:44 +00:00
fix(snapshot): preserve code layout and export full images
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
+11
@@ -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`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user