diff --git a/desktop/bun.lock b/desktop/bun.lock index fb56d1ac..8650597c 100644 --- a/desktop/bun.lock +++ b/desktop/bun.lock @@ -10,6 +10,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "dompurify": "^3.3.3", + "html2canvas": "^1.4.1", "katex": "^0.16.45", "lucide-react": "^0.469.0", "marked": "^15.0.7", @@ -532,6 +533,8 @@ "balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="], + "brace-expansion": ["brace-expansion@5.0.5", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], "cac": ["cac@6.7.14", "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], @@ -586,6 +589,8 @@ "cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "css-line-break": ["css-line-break@2.1.0", "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="], + "css.escape": ["css.escape@1.5.1", "https://registry.npmmirror.com/css.escape/-/css.escape-1.5.1.tgz", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], "cssesc": ["cssesc@3.0.0", "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", { "bin": "bin/cssesc" }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], @@ -780,6 +785,8 @@ "html-void-elements": ["html-void-elements@3.0.0", "https://registry.npmmirror.com/html-void-elements/-/html-void-elements-3.0.0.tgz", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + "html2canvas": ["html2canvas@1.4.1", "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], @@ -1138,6 +1145,8 @@ "test-exclude": ["test-exclude@7.0.2", "https://registry.npmmirror.com/test-exclude/-/test-exclude-7.0.2.tgz", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", "minimatch": "^10.2.2" } }, "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw=="], + "text-segmentation": ["text-segmentation@1.0.3", "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="], + "tinybench": ["tinybench@2.9.0", "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@0.3.2", "https://registry.npmmirror.com/tinyexec/-/tinyexec-0.3.2.tgz", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], @@ -1180,6 +1189,8 @@ "util-deprecate": ["util-deprecate@1.0.2", "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "utrie": ["utrie@1.0.2", "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="], + "uuid": ["uuid@11.1.0", "https://registry.npmmirror.com/uuid/-/uuid-11.1.0.tgz", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], "vfile": ["vfile@6.0.3", "https://registry.npmmirror.com/vfile/-/vfile-6.0.3.tgz", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], diff --git a/desktop/package.json b/desktop/package.json index e7a3d0b9..78e14c03 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -22,6 +22,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "dompurify": "^3.3.3", + "html2canvas": "^1.4.1", "katex": "^0.16.45", "lucide-react": "^0.469.0", "marked": "^15.0.7", diff --git a/desktop/src/preview-agent/screenshot.test.ts b/desktop/src/preview-agent/screenshot.test.ts new file mode 100644 index 00000000..92e8b130 --- /dev/null +++ b/desktop/src/preview-agent/screenshot.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' + +const html2canvasMock = vi.fn() +vi.mock('html2canvas', () => ({ default: (...args: unknown[]) => html2canvasMock(...args) })) +vi.mock('../lib/imageCompress', () => ({ compressDataUrl: vi.fn(async (d: string) => `compressed:${d}`) })) + +import { captureToDataUrl } from './screenshot' +import { compressDataUrl } from '../lib/imageCompress' + +beforeEach(() => { + html2canvasMock.mockReset() + html2canvasMock.mockResolvedValue({ toDataURL: () => 'data:image/png;base64,RAW' }) + vi.mocked(compressDataUrl).mockClear() +}) + +describe('captureToDataUrl', () => { + it('captures document.body for full and compresses the result', async () => { + const out = await captureToDataUrl('full') + expect(html2canvasMock).toHaveBeenCalledWith(document.body, expect.any(Object)) + expect(compressDataUrl).toHaveBeenCalledWith('data:image/png;base64,RAW') + expect(out).toBe('compressed:data:image/png;base64,RAW') + }) + it('captures the given element for element kind', async () => { + const el = document.createElement('div') + await captureToDataUrl('element', el) + expect(html2canvasMock).toHaveBeenCalledWith(el, expect.any(Object)) + }) + it('falls back to document.body for element kind without element', async () => { + await captureToDataUrl('element') + expect(html2canvasMock).toHaveBeenCalledWith(document.body, expect.any(Object)) + }) + it('passes viewport height option for viewport kind', async () => { + await captureToDataUrl('viewport') + const opts = html2canvasMock.mock.calls[0]![1] as Record + expect(opts.height).toBe(window.innerHeight) + expect(opts.windowWidth).toBe(window.innerWidth) + }) +}) diff --git a/desktop/src/preview-agent/screenshot.ts b/desktop/src/preview-agent/screenshot.ts new file mode 100644 index 00000000..5fbd04c3 --- /dev/null +++ b/desktop/src/preview-agent/screenshot.ts @@ -0,0 +1,16 @@ +import html2canvas from 'html2canvas' +import { compressDataUrl } from '../lib/imageCompress' + +export type CaptureKind = 'full' | 'viewport' | 'element' + +export async function captureToDataUrl(kind: CaptureKind, element?: Element): Promise { + const target = (kind === 'element' && element ? element : document.body) as HTMLElement + const canvas = await html2canvas(target, { + ...(kind === 'viewport' + ? { windowWidth: window.innerWidth, height: window.innerHeight } + : {}), + useCORS: true, + logging: false, + }) + return compressDataUrl(canvas.toDataURL('image/png')) +}