fix(desktop): add legacy webview fallbacks for snapshots and dialogs

This commit is contained in:
知食分子 authored and GitHub committed 2026-08-27 10:55:05 +08:00
1 parent afcfa59d0b
commit ba8df70fe6
8 files changed
+232 -13

No files matched your search

@@ -1,5 +1,12 @@
// @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CODE_SNAPSHOT_CSS, renderCodeSnapshotHtml, savePngDataUrlToFile, snapshotElementToPng } from "@/lib/codeSnapshot/codeSnapshot";
import { CODE_SNAPSHOT_CSS, materializeSnapshotCloneLineNumbers, renderCodeSnapshotHtml, savePngDataUrlToFile, snapshotElementToPng } from "@/lib/codeSnapshot/codeSnapshot";
import { LEGACY_WEBVIEW_CLASS } from "@/lib/ui/legacyWebView";
const codeSnapshotDialogSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/codeSnapshot/CodeSnapshotDialog.vue"), "utf8");
const { createCodeHighlighter, highlightCode, toPng, isTauriRuntime, save, writeFile } = vi.hoisted(() => {
const highlightCode = vi.fn((content: string, _lang: string) => `<span class="line">${content}</span>`);
@@ -78,6 +85,7 @@ describe("renderCodeSnapshotHtml", () => {
expect(html).toContain(".dbx-code-snapshot *");
expect(html).toContain("border: 0");
expect(html).toContain("outline: 0");
expect(html).toContain(".dbx-code-snapshot__line-number");
});
it("renders macOS traffic lights and an optional title by default", async () => {
@@ -124,8 +132,10 @@ describe("renderCodeSnapshotHtml", () => {
expect(dark).toContain("background:#0d1117");
expect(dark).toContain("--dbx-snapshot-line-number:#484f58");
expect(dark).toContain("color:#e1e4e8");
expect(light).toContain("background:#ffffff");
expect(light).toContain("--dbx-snapshot-line-number:#d0d7de");
expect(light).toContain("color:#24292f");
});
it("applies custom padding and font size", async () => {
@@ -135,12 +145,35 @@ describe("renderCodeSnapshotHtml", () => {
expect(html).toContain("padding:0 24px 24px");
});
it("keeps the snapshot dialog responsive layout classes in source", () => {
expect(codeSnapshotDialogSource).toContain("sm:max-w-[860px]");
expect(codeSnapshotDialogSource).toContain("md:flex-row");
expect(codeSnapshotDialogSource).toContain("md:w-52");
});
it("materializes real line-number nodes in the export clone", () => {
document.body.innerHTML = '<div class="dbx-code-snapshot"><pre class="dbx-code-snapshot__pre dbx-code-snapshot__pre--numbered"><code><span class="line">SELECT 1</span><span class="line">FROM dual</span></code></pre></div>';
const root = document.body.firstElementChild as HTMLElement;
materializeSnapshotCloneLineNumbers(root);
const pre = root.querySelector(".dbx-code-snapshot__pre") as HTMLElement;
const lines = Array.from(root.querySelectorAll<HTMLElement>("code > .line"));
expect(pre.classList.contains("dbx-code-snapshot__pre--numbered")).toBe(false);
expect(lines).toHaveLength(2);
expect(lines[0]?.querySelector(".dbx-code-snapshot__line-number")?.textContent).toBe("1");
expect(lines[1]?.querySelector(".dbx-code-snapshot__line-number")?.textContent).toBe("2");
expect(lines[0]?.querySelector(".dbx-code-snapshot__line-content")?.textContent).toBe("SELECT 1");
expect(lines[1]?.querySelector(".dbx-code-snapshot__line-content")?.textContent).toBe("FROM dual");
});
it("exports at a capped device-pixel ratio for crisp high-DPI snapshots", async () => {
toPng.mockResolvedValue("data:image/png;base64,test");
vi.stubGlobal("window", { devicePixelRatio: 3 });
try {
await snapshotElementToPng({ offsetWidth: 320, offsetHeight: 160, scrollWidth: 640, scrollHeight: 240 } as HTMLElement);
const options = toPng.mock.calls.at(-1)?.[1];
expect(toPng).toHaveBeenLastCalledWith(
expect.anything(),
expect.objectContaining({
@@ -153,6 +186,8 @@ describe("renderCodeSnapshotHtml", () => {
},
}),
);
expect(options?.adjustPseudoElement).toBeUndefined();
expect(options?.onclone).toBeUndefined();
vi.stubGlobal("window", { devicePixelRatio: 0 });
await snapshotElementToPng({ offsetWidth: 320, offsetHeight: 160, scrollWidth: 320, scrollHeight: 160 } as HTMLElement);
@@ -162,6 +197,31 @@ describe("renderCodeSnapshotHtml", () => {
}
});
it("enables the legacy WebView export fallback only when the runtime is marked legacy", async () => {
toPng.mockResolvedValue("data:image/png;base64,test");
document.documentElement.classList.add(LEGACY_WEBVIEW_CLASS);
try {
await snapshotElementToPng({ offsetWidth: 320, offsetHeight: 160, scrollWidth: 320, scrollHeight: 160 } as HTMLElement);
const options = toPng.mock.calls.at(-1)?.[1];
expect(typeof options?.adjustPseudoElement).toBe("function");
expect(typeof options?.onclone).toBe("function");
expect(options.adjustPseudoElement?.(document.createElement("span"), ":after", {} as CSSStyleDeclaration)).toBeUndefined();
const clone = document.createElement("div");
clone.innerHTML = '<div class="dbx-code-snapshot"><pre class="dbx-code-snapshot__pre dbx-code-snapshot__pre--numbered" style="--dbx-snapshot-line-number:#d0d7de"><code><span class="line">SELECT 1</span><span class="line">FROM dual</span></code></pre></div>';
options.onclone?.(clone);
const line = clone.querySelector<HTMLElement>(".dbx-code-snapshot__line-number");
expect(line?.textContent).toBe("1");
expect(line?.getAttribute("style")).toContain("display: inline-block");
expect(line?.getAttribute("style")).toContain("margin-right: 1.4em");
expect(line?.getAttribute("style")).toContain("color: #d0d7de");
} finally {
document.documentElement.classList.remove(LEGACY_WEBVIEW_CLASS);
}
});
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 });
@@ -14,6 +14,7 @@
import type { AppThemeAppearance } from "@/lib/app/appTheme";
import { createAiShikiBlockCodeHighlighter, type AiCodeHighlighter } from "@/lib/ai/aiCodeHighlighter";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { LEGACY_WEBVIEW_CLASS } from "@/lib/ui/legacyWebView";
export interface CodeSnapshotSource {
code: string;
@@ -27,7 +28,7 @@ export interface CodeSnapshotStyleOptions {
appearance: AppThemeAppearance;
/** Render the macOS-style traffic light dots. Defaults to true. */
showTrafficLights?: boolean;
/** Render line numbers via CSS counters. Defaults to true. */
/** Render line numbers. Defaults to true. */
showLineNumbers?: boolean;
/** Padding (px) around the code area. Defaults to 16. */
padding?: number;
@@ -55,6 +56,11 @@ const SNAPSHOT_LINE_NUMBER: Record<AppThemeAppearance, string> = {
dark: "#484f58",
};
const SNAPSHOT_CODE_TEXT: Record<AppThemeAppearance, string> = {
light: "#24292f",
dark: "#e1e4e8",
};
const TRAFFIC_LIGHT_COLORS = ["#ff5f57", "#febc2e", "#28c840"] as const;
const MAX_EXPORT_PIXEL_RATIO = 2;
const MAX_EXPORT_CANVAS_EDGE = 16_384;
@@ -112,6 +118,16 @@ export const CODE_SNAPSHOT_CSS = `
display: block;
min-height: 1.6em;
}
.dbx-code-snapshot__line-number {
display: inline-block;
min-width: 2.2em;
margin-right: 1.4em;
text-align: right;
color: var(--dbx-snapshot-line-number, #8b949e);
font-variant-numeric: tabular-nums;
user-select: none;
-webkit-user-select: none;
}
.dbx-code-snapshot__pre--numbered {
counter-reset: dbx-snapshot-line;
}
@@ -160,6 +176,48 @@ function renderSnapshotBar(title: string | undefined, appearance: AppThemeAppear
return `<div class="dbx-code-snapshot__bar" style="background:${SNAPSHOT_BAR_BACKGROUND[appearance]};color:${SNAPSHOT_BAR_TEXT[appearance]}">${dots}${titleHtml}</div>`;
}
function isSnapshotNumberedLineNode(node: Node): node is Element {
return node instanceof Element && node.classList.contains("line") && node.closest(".dbx-code-snapshot__pre--numbered") !== null;
}
function hasMaterializedLineNumber(line: Element): boolean {
return Array.from(line.children).some((child) => child.classList.contains("dbx-code-snapshot__line-number"));
}
function legacyWebViewExportFallbackEnabled(): boolean {
return typeof document !== "undefined" && document.documentElement.classList.contains(LEGACY_WEBVIEW_CLASS);
}
/**
* Keep preview rendering on CSS counters, but rewrite the cloned export DOM to
* use real line-number nodes. Older WebKit foreignObject exports can flatten
* every counter() value to "1", while the live preview still looks correct.
*/
export function materializeSnapshotCloneLineNumbers(root: ParentNode): void {
for (const block of Array.from(root.querySelectorAll<HTMLElement>(".dbx-code-snapshot__pre--numbered"))) {
block.classList.remove("dbx-code-snapshot__pre--numbered");
const lineNumberColor = block.style.getPropertyValue("--dbx-snapshot-line-number").trim() || SNAPSHOT_LINE_NUMBER.dark;
const lines = Array.from(block.querySelectorAll<HTMLElement>("code > .line"));
for (const [index, line] of lines.entries()) {
if (hasMaterializedLineNumber(line)) continue;
const lineNumber = block.ownerDocument.createElement("span");
lineNumber.className = "dbx-code-snapshot__line-number";
lineNumber.setAttribute("aria-hidden", "true");
lineNumber.textContent = String(index + 1);
lineNumber.style.cssText = ["display:inline-block", "min-width:2.2em", "margin-right:1.4em", "text-align:right", `color:${lineNumberColor}`, "font-variant-numeric:tabular-nums", "user-select:none", "-webkit-user-select:none"].join(";");
const lineContent = block.ownerDocument.createElement("span");
lineContent.className = "dbx-code-snapshot__line-content";
lineContent.style.display = "inline";
while (line.firstChild) {
lineContent.append(line.firstChild);
}
line.append(lineNumber, lineContent);
}
}
}
/**
* Render a self-contained snapshot DOM (as an HTML string) for the given code.
* The returned string includes its own `<style>` tag and can be injected via
@@ -189,7 +247,7 @@ export async function renderCodeSnapshotHtml(source: CodeSnapshotSource, options
`<style>${CODE_SNAPSHOT_CSS}</style>` +
`<div class="dbx-code-snapshot" data-snapshot-appearance="${appearance}" style="background:${SNAPSHOT_BACKGROUND[appearance]};font-size:${fontSize}px">` +
bar +
`<pre class="${preClass}" style="--dbx-snapshot-line-number:${lineNumberColor};padding:0 ${padding}px ${padding}px"><code>${body}</code></pre>` +
`<pre class="${preClass}" style="--dbx-snapshot-line-number:${lineNumberColor};color:${SNAPSHOT_CODE_TEXT[appearance]};padding:0 ${padding}px ${padding}px"><code>${body}</code></pre>` +
`</div>`
);
}
@@ -207,12 +265,28 @@ export async function snapshotElementToPng(element: HTMLElement): Promise<string
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));
const useLegacyLineNumberFallback = legacyWebViewExportFallbackEnabled();
return domtoimage.toPng(element, {
quality: 1,
width,
height,
// dom-to-image-more 使用 scale 放大实际画布;pixelRatio 不是该库支持的参数。
scale,
adjustPseudoElement: useLegacyLineNumberFallback
? (node: Node, pseudoElement: ":before" | ":after") => {
if (pseudoElement === ":before" && isSnapshotNumberedLineNode(node)) {
return false;
}
return undefined;
}
: undefined,
onclone: useLegacyLineNumberFallback
? (clone: Node) => {
if (typeof (clone as ParentNode).querySelectorAll === "function") {
materializeSnapshotCloneLineNumbers(clone as ParentNode);
}
}
: undefined,
style: {
width: `${width}px`,
height: `${height}px`,