feat(editor): add code snapshot export

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-08-19 15:49:15 +08:00
1 parent a40dc02825
commit 30f5c34729
18 files changed
+774 -7

No files matched your search

@@ -0,0 +1,123 @@
import { describe, expect, it, vi } from "vitest";
import { CODE_SNAPSHOT_CSS, renderCodeSnapshotHtml, savePngDataUrlToFile, snapshotElementToPng } from "@/lib/codeSnapshot/codeSnapshot";
const { toPng, isTauriRuntime, save, writeFile } = vi.hoisted(() => ({
toPng: vi.fn(),
isTauriRuntime: vi.fn(),
save: vi.fn(),
writeFile: vi.fn(),
}));
vi.mock("@/lib/ai/aiCodeHighlighter", () => ({
createAiShikiCodeHighlighter: vi.fn().mockResolvedValue((content: string, _lang: string) => `<span class="line">${content}</span>`),
}));
vi.mock("dom-to-image-more", () => ({
default: { toPng },
}));
vi.mock("@/lib/backend/tauriRuntime", () => ({
isTauriRuntime,
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({ save }));
vi.mock("@tauri-apps/plugin-fs", () => ({ writeFile }));
describe("renderCodeSnapshotHtml", () => {
it("renders a self-contained snapshot with embedded styles", async () => {
const html = await renderCodeSnapshotHtml({ code: "SELECT 1", lang: "sql" }, { appearance: "dark" });
expect(html).toContain("<style>");
expect(html).toContain(CODE_SNAPSHOT_CSS);
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"');
});
it("renders macOS traffic lights and an optional title by default", async () => {
const html = await renderCodeSnapshotHtml({ code: "SELECT 1", lang: "sql", title: "Query example" }, { appearance: "dark" });
expect(html).toContain('class="dbx-code-snapshot__bar"');
expect(html).toContain("#ff5f57");
expect(html).toContain("#febc2e");
expect(html).toContain("#28c840");
expect(html).toContain("Query example");
});
it("hides the bar when traffic lights are disabled and no title is set", async () => {
const html = await renderCodeSnapshotHtml({ code: "SELECT 1", lang: "sql" }, { appearance: "light", showTrafficLights: false });
expect(html).not.toContain('class="dbx-code-snapshot__bar"');
});
it("keeps the title bar but hides traffic lights when window controls are disabled", async () => {
const html = await renderCodeSnapshotHtml({ code: "SELECT 1", lang: "sql", title: "Query example" }, { appearance: "dark", showTrafficLights: false });
expect(html).toContain('class="dbx-code-snapshot__bar"');
expect(html).toContain("Query example");
expect(html).not.toContain('class="dbx-code-snapshot__dot"');
});
it("hides line numbers when disabled", async () => {
const html = await renderCodeSnapshotHtml({ code: "SELECT 1", lang: "sql" }, { appearance: "light", showLineNumbers: false });
expect(html).toContain('class="dbx-code-snapshot__pre"');
expect(html).not.toContain('class="dbx-code-snapshot__pre dbx-code-snapshot__pre--numbered"');
});
it("escapes the snapshot title", async () => {
const html = await renderCodeSnapshotHtml({ code: "SELECT 1", lang: "sql", title: `<script>alert(1)</script>` }, { appearance: "dark" });
expect(html).toContain("&lt;script&gt;");
expect(html).not.toContain("<script>alert(1)</script>");
});
it("applies the appearance-specific background and line-number color", async () => {
const dark = await renderCodeSnapshotHtml({ code: "SELECT 1", lang: "sql" }, { appearance: "dark" });
const light = await renderCodeSnapshotHtml({ code: "SELECT 1", lang: "sql" }, { appearance: "light" });
expect(dark).toContain("background:#0d1117");
expect(dark).toContain("--dbx-snapshot-line-number:#484f58");
expect(light).toContain("background:#ffffff");
expect(light).toContain("--dbx-snapshot-line-number:#d0d7de");
});
it("applies custom padding and font size", async () => {
const html = await renderCodeSnapshotHtml({ code: "SELECT 1", lang: "sql" }, { appearance: "dark", padding: 24, fontSize: 15 });
expect(html).toContain("font-size:15px");
expect(html).toContain("padding:0 24px 24px");
});
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 } as HTMLElement);
expect(toPng).toHaveBeenLastCalledWith(
expect.anything(),
expect.objectContaining({
width: 320,
height: 160,
pixelRatio: 2,
}),
);
vi.stubGlobal("window", { devicePixelRatio: 0 });
await snapshotElementToPng({ offsetWidth: 320, offsetHeight: 160 } as HTMLElement);
expect(toPng).toHaveBeenLastCalledWith(expect.anything(), expect.objectContaining({ pixelRatio: 1 }));
} finally {
vi.unstubAllGlobals();
}
});
it("propagates a failed native save instead of reporting a browser download", async () => {
isTauriRuntime.mockReturnValue(true);
save.mockResolvedValue("C:\\Users\\example\\snapshot.png");
writeFile.mockRejectedValue(new Error("disk full"));
await expect(savePngDataUrlToFile("data:image/png;base64,AA==", "snapshot.png")).rejects.toThrow("disk full");
expect(writeFile).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,246 @@
/**
* Code snapshot rendering & export utilities (CodeSnap-style).
*
* Flow: selected code text -> shiki re-render (reusing the AI highlighter
* singleton) -> CodeSnap-like shell DOM (traffic lights / title / line
* numbers / background) -> PNG export either to the system clipboard
* (Tauri `writeImage`) or to a file (dialog + fs, with a browser-download
* fallback for web mode).
*
* The rendered snapshot DOM is fully self-contained: it embeds its own
* `<style>` tag so `dom-to-image-more` captures identical styling when it
* clones the node into a foreignObject.
*/
import type { AppThemeAppearance } from "@/lib/app/appTheme";
import { createAiShikiCodeHighlighter, type AiCodeHighlighter } from "@/lib/ai/aiCodeHighlighter";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
export interface CodeSnapshotSource {
code: string;
/** Shiki language name or AI label (e.g. "sql", "python", "JSON"). */
lang: string;
/** Optional title shown in the snapshot bar next to the traffic lights. */
title?: string;
}
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. */
showLineNumbers?: boolean;
/** Padding (px) around the code area. Defaults to 16. */
padding?: number;
/** Code font size (px). Defaults to 13. */
fontSize?: number;
}
const SNAPSHOT_BACKGROUND: Record<AppThemeAppearance, string> = {
light: "#ffffff",
dark: "#0d1117",
};
const SNAPSHOT_BAR_BACKGROUND: Record<AppThemeAppearance, string> = {
light: "#f6f8fa",
dark: "#161b22",
};
const SNAPSHOT_BAR_TEXT: Record<AppThemeAppearance, string> = {
light: "#57606a",
dark: "#8b949e",
};
const SNAPSHOT_LINE_NUMBER: Record<AppThemeAppearance, string> = {
light: "#d0d7de",
dark: "#484f58",
};
const TRAFFIC_LIGHT_COLORS = ["#ff5f57", "#febc2e", "#28c840"] as const;
const MAX_EXPORT_PIXEL_RATIO = 2;
/**
* 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 {
border-radius: 8px;
overflow: hidden;
font-family: "SF Mono", "Cascadia Code", "JetBrains Mono", Consolas, "Courier New", monospace;
text-align: left;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}
.dbx-code-snapshot__bar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
}
.dbx-code-snapshot__dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex: none;
}
.dbx-code-snapshot__title {
margin-left: 4px;
font-size: 12px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dbx-code-snapshot__pre {
margin: 0;
overflow: hidden;
line-height: 1.6;
tab-size: 4;
}
.dbx-code-snapshot__pre code {
font-family: inherit;
font-size: inherit;
background: transparent;
}
.dbx-code-snapshot__pre .line {
display: block;
min-height: 1.6em;
}
.dbx-code-snapshot__pre--numbered {
counter-reset: dbx-snapshot-line;
}
.dbx-code-snapshot__pre--numbered .line {
counter-increment: dbx-snapshot-line;
}
.dbx-code-snapshot__pre--numbered .line::before {
content: counter(dbx-snapshot-line);
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;
}
`;
let highlighterPromise: Promise<AiCodeHighlighter> | undefined;
/** Lazy singleton reusing the AI assistant's shiki highlighter instance. */
export function getCodeSnapshotHighlighter(): Promise<AiCodeHighlighter> {
highlighterPromise ??= createAiShikiCodeHighlighter({
appearance: () => "dark",
}).catch((err: unknown) => {
// Reset so a transient highlighter failure can be retried on the next
// render instead of permanently poisoning the singleton.
highlighterPromise = undefined;
throw err;
});
return highlighterPromise;
}
function escapeHtml(value: string): string {
return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
}
function renderSnapshotBar(title: string | undefined, appearance: AppThemeAppearance, showTrafficLights: boolean): string {
const dots = showTrafficLights ? TRAFFIC_LIGHT_COLORS.map((color) => `<span class="dbx-code-snapshot__dot" style="background:${color}"></span>`).join("") : "";
const titleHtml = title ? `<span class="dbx-code-snapshot__title" style="color:${SNAPSHOT_BAR_TEXT[appearance]}">${escapeHtml(title)}</span>` : "";
return `<div class="dbx-code-snapshot__bar" style="background:${SNAPSHOT_BAR_BACKGROUND[appearance]};color:${SNAPSHOT_BAR_TEXT[appearance]}">${dots}${titleHtml}</div>`;
}
/**
* 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
* `v-html`, then exported with `snapshotElementToPng`.
*/
export async function renderCodeSnapshotHtml(source: CodeSnapshotSource, options: CodeSnapshotStyleOptions): Promise<string> {
const highlight = await getCodeSnapshotHighlighter();
const body = highlight(source.code, source.lang, options.appearance);
const appearance = options.appearance;
const showBar = options.showTrafficLights !== false || !!source.title;
const showLineNumbers = options.showLineNumbers !== false;
const padding = options.padding ?? 16;
const fontSize = options.fontSize ?? 13;
const bar = showBar ? renderSnapshotBar(source.title, appearance, options.showTrafficLights !== false) : "";
const preClass = `dbx-code-snapshot__pre${showLineNumbers ? " dbx-code-snapshot__pre--numbered" : ""}`;
const lineNumberColor = SNAPSHOT_LINE_NUMBER[appearance];
return (
`<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>` +
`</div>`
);
}
/** 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;
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)),
});
}
async function dataUrlToBlob(dataUrl: string): Promise<Blob> {
return await (await fetch(dataUrl)).blob();
}
/**
* Copy a PNG data URL to the system clipboard. Uses the Tauri
* clipboard-manager `writeImage` on desktop and falls back to the Web
* Clipboard API (`ClipboardItem`) for web mode.
*/
export async function copyPngDataUrlToClipboard(dataUrl: string): Promise<void> {
const blob = await dataUrlToBlob(dataUrl);
if (isTauriRuntime()) {
const { writeImage } = await import("@tauri-apps/plugin-clipboard-manager");
await writeImage(new Uint8Array(await blob.arrayBuffer()));
return;
}
if (typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) {
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
return;
}
throw new Error("Clipboard image write is not supported in this environment");
}
/**
* Save a PNG data URL to a file. On desktop this opens the native save
* dialog; on web it downloads the image. Returns false when the user
* cancels the native save dialog.
*/
export async function savePngDataUrlToFile(dataUrl: string, defaultName: string): Promise<boolean> {
const blob = await dataUrlToBlob(dataUrl);
if (isTauriRuntime()) {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeFile } = await import("@tauri-apps/plugin-fs");
const path = await save({
defaultPath: defaultName,
filters: [{ name: "PNG", extensions: ["png"] }],
});
if (!path) return false;
// Native write errors must reach the caller so the UI never claims the
// user-selected path was saved when the filesystem rejected it.
await writeFile(path, new Uint8Array(await blob.arrayBuffer()));
return true;
}
const url = URL.createObjectURL(blob);
try {
const a = document.createElement("a");
a.href = url;
a.download = defaultName;
a.click();
} finally {
URL.revokeObjectURL(url);
}
return true;
}