feat(editor): add code snapshot export
This commit is contained in:
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("<script>");
|
||||
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user