From 55b0b377895c4e58d7f8af410836364fb4f6ab02 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 28 Feb 2026 23:59:39 +0300 Subject: [PATCH 1/4] Add image export (PNG/JPG/WEBP) with shared rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract renderThumbnail and renderNodesToImage into render-image.ts, reusing the same offscreen CanvasKit surface approach that fig-export used for thumbnails. - ExportSection in properties panel with scale/format selectors, multi-export support, and live preview with checkerboard background - Export via context menu and ⇧⌘E shortcut - Tauri save dialog, File System Access API, and download fallback - JPG renders with white background, PNG/WEBP with transparency --- src/components/CanvasContextMenu.vue | 7 + src/components/PropertiesPanel.vue | 8 +- src/components/properties/ExportSection.vue | 149 ++++++++++++++++++++ src/composables/use-keyboard.ts | 7 + src/composables/use-menu.ts | 5 +- src/engine/fig-export.ts | 56 +------- src/engine/render-image.ts | 139 ++++++++++++++++++ src/stores/editor.ts | 89 ++++++++++++ 8 files changed, 400 insertions(+), 60 deletions(-) create mode 100644 src/components/properties/ExportSection.vue create mode 100644 src/engine/render-image.ts diff --git a/src/components/CanvasContextMenu.vue b/src/components/CanvasContextMenu.vue index a0107628e..f3d13d056 100644 --- a/src/components/CanvasContextMenu.vue +++ b/src/components/CanvasContextMenu.vue @@ -241,6 +241,13 @@ const menuClass = {{ isLocked ? 'Unlock' : 'Lock' }} ⇧⌘L + + + + + Export as PNG + ⇧⌘E + diff --git a/src/components/PropertiesPanel.vue b/src/components/PropertiesPanel.vue index fe6783dff..f76fbd4a2 100644 --- a/src/components/PropertiesPanel.vue +++ b/src/components/PropertiesPanel.vue @@ -4,12 +4,13 @@ import { computed } from 'vue' import { useEditorStore } from '@/stores/editor' import AppearanceSection from './properties/AppearanceSection.vue' +import EffectsSection from './properties/EffectsSection.vue' +import ExportSection from './properties/ExportSection.vue' import FillSection from './properties/FillSection.vue' import LayoutSection from './properties/LayoutSection.vue' import PageSection from './properties/PageSection.vue' import PositionSection from './properties/PositionSection.vue' import StrokeSection from './properties/StrokeSection.vue' -import EffectsSection from './properties/EffectsSection.vue' import TypographySection from './properties/TypographySection.vue' const store = useEditorStore() @@ -81,10 +82,7 @@ const isComponentType = computed(() => { - -
- -
+
diff --git a/src/components/properties/ExportSection.vue b/src/components/properties/ExportSection.vue new file mode 100644 index 000000000..98e34438a --- /dev/null +++ b/src/components/properties/ExportSection.vue @@ -0,0 +1,149 @@ + + + diff --git a/src/composables/use-keyboard.ts b/src/composables/use-keyboard.ts index 4af387d74..44460f163 100644 --- a/src/composables/use-keyboard.ts +++ b/src/composables/use-keyboard.ts @@ -69,6 +69,13 @@ export function useKeyboard(store: EditorStore) { store.toggleLock() return } + if (e.code === 'KeyE') { + e.preventDefault() + if (store.state.selectedIds.size > 0) { + store.exportSelection(1, 'PNG') + } + return + } } if (e.metaKey || e.ctrlKey) { diff --git a/src/composables/use-menu.ts b/src/composables/use-menu.ts index 5310f8f57..258e9314c 100644 --- a/src/composables/use-menu.ts +++ b/src/composables/use-menu.ts @@ -58,7 +58,10 @@ const MENU_ACTIONS: Record void> = { 'create-component': (store) => store.createComponentFromSelection(), 'create-component-set': (store) => store.createComponentSetFromComponents(), 'detach-instance': (store) => store.detachInstance(), - 'zoom-fit': (store) => store.zoomToFit() + 'zoom-fit': (store) => store.zoomToFit(), + export: (store) => { + if (store.state.selectedIds.size > 0) store.exportSelection(1, 'PNG') + } } export function useMenu(store: EditorStore) { diff --git a/src/engine/fig-export.ts b/src/engine/fig-export.ts index 23ce4c75f..8cb153425 100644 --- a/src/engine/fig-export.ts +++ b/src/engine/fig-export.ts @@ -3,6 +3,7 @@ import { zipSync, deflateSync } from 'fflate' import { IS_TAURI } from '@/constants' import { initCodec, getCompiledSchema, getSchemaBytes } from '@/kiwi/codec' +import { renderThumbnail } from './render-image' import { encodeVectorNetworkBlob } from './vector' import type { SkiaRenderer } from './renderer' @@ -230,59 +231,6 @@ function buildFigKiwi(schemaDeflated: Uint8Array, dataCompressed: Uint8Array): U const THUMBNAIL_WIDTH = 400 const THUMBNAIL_HEIGHT = 225 -function generateThumbnail( - ck: CanvasKit, - renderer: SkiaRenderer, - graph: SceneGraph, - pageId: string -): Uint8Array | null { - const page = graph.getNode(pageId) - if (!page || page.childIds.length === 0) return null - - let minX = Infinity, - minY = Infinity, - maxX = -Infinity, - maxY = -Infinity - for (const childId of page.childIds) { - const node = graph.getNode(childId) - if (!node || !node.visible) continue - const abs = graph.getAbsolutePosition(childId) - minX = Math.min(minX, abs.x) - minY = Math.min(minY, abs.y) - maxX = Math.max(maxX, abs.x + node.width) - maxY = Math.max(maxY, abs.y + node.height) - } - if (!isFinite(minX)) return null - - const contentW = maxX - minX - const contentH = maxY - minY - if (contentW <= 0 || contentH <= 0) return null - - const scale = Math.min(THUMBNAIL_WIDTH / contentW, THUMBNAIL_HEIGHT / contentH, 2) - const surface = ck.MakeSurface(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT) - if (!surface) return null - - try { - const canvas = surface.getCanvas() - canvas.clear(ck.Color4f(renderer.pageColor.r, renderer.pageColor.g, renderer.pageColor.b, 1)) - - const offsetX = (THUMBNAIL_WIDTH - contentW * scale) / 2 - minX * scale - const offsetY = (THUMBNAIL_HEIGHT - contentH * scale) / 2 - minY * scale - canvas.translate(offsetX, offsetY) - canvas.scale(scale, scale) - - renderer.renderSceneToCanvas(canvas, graph, pageId) - - surface.flush() - const image = surface.makeImageSnapshot() - const encoded = image.encodeToBytes(ck.ImageFormat.PNG, 90) - image.delete() - return encoded ? new Uint8Array(encoded) : null - } finally { - surface.delete() - } -} - export async function exportFigFile( graph: SceneGraph, ck?: CanvasKit, @@ -359,7 +307,7 @@ export async function exportFigFile( const currentPageId = pageId ?? pages[0]?.id const thumbnailPng = (ck && renderer && currentPageId - ? generateThumbnail(ck, renderer, graph, currentPageId) + ? renderThumbnail(ck, renderer, graph, currentPageId, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT) : null) ?? THUMBNAIL_1X1 const metaJson = JSON.stringify({ diff --git a/src/engine/render-image.ts b/src/engine/render-image.ts new file mode 100644 index 000000000..756305147 --- /dev/null +++ b/src/engine/render-image.ts @@ -0,0 +1,139 @@ +import type { SkiaRenderer } from './renderer' +import type { SceneGraph } from './scene-graph' +import type { CanvasKit, EncodedImageFormat } from 'canvaskit-wasm' + +export type ExportFormat = 'PNG' | 'JPG' | 'WEBP' + +interface RenderOptions { + scale: number + format: ExportFormat + quality?: number +} + +function computeContentBounds( + graph: SceneGraph, + nodeIds: string[] +): { minX: number; minY: number; maxX: number; maxY: number } | null { + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity + + for (const id of nodeIds) { + const node = graph.getNode(id) + if (!node || !node.visible) continue + const abs = graph.getAbsolutePosition(id) + minX = Math.min(minX, abs.x) + minY = Math.min(minY, abs.y) + maxX = Math.max(maxX, abs.x + node.width) + maxY = Math.max(maxY, abs.y + node.height) + } + + if (!isFinite(minX)) return null + return { minX, minY, maxX, maxY } +} + +function ckImageFormat(ck: CanvasKit, format: ExportFormat): EncodedImageFormat { + switch (format) { + case 'JPG': + return ck.ImageFormat.JPEG + case 'WEBP': + return ck.ImageFormat.WEBP + default: + return ck.ImageFormat.PNG + } +} + +function defaultQuality(format: ExportFormat): number { + return format === 'PNG' ? 100 : 90 +} + +export function renderNodesToImage( + ck: CanvasKit, + renderer: SkiaRenderer, + graph: SceneGraph, + pageId: string, + nodeIds: string[], + options: RenderOptions +): Uint8Array | null { + const bounds = computeContentBounds(graph, nodeIds) + if (!bounds) return null + + const contentW = bounds.maxX - bounds.minX + const contentH = bounds.maxY - bounds.minY + if (contentW <= 0 || contentH <= 0) return null + + const pixelW = Math.ceil(contentW * options.scale) + const pixelH = Math.ceil(contentH * options.scale) + if (pixelW <= 0 || pixelH <= 0) return null + + const surface = ck.MakeSurface(pixelW, pixelH) + if (!surface) return null + + try { + const canvas = surface.getCanvas() + + if (options.format === 'JPG') { + canvas.clear(ck.WHITE) + } else { + canvas.clear(ck.TRANSPARENT) + } + + canvas.scale(options.scale, options.scale) + canvas.translate(-bounds.minX, -bounds.minY) + + renderer.renderSceneToCanvas(canvas, graph, pageId) + + surface.flush() + const image = surface.makeImageSnapshot() + const quality = options.quality ?? defaultQuality(options.format) + const encoded = image.encodeToBytes(ckImageFormat(ck, options.format), quality) + image.delete() + return encoded ? new Uint8Array(encoded) : null + } finally { + surface.delete() + } +} + +export function renderThumbnail( + ck: CanvasKit, + renderer: SkiaRenderer, + graph: SceneGraph, + pageId: string, + width: number, + height: number +): Uint8Array | null { + const page = graph.getNode(pageId) + if (!page || page.childIds.length === 0) return null + + const bounds = computeContentBounds(graph, page.childIds) + if (!bounds) return null + + const contentW = bounds.maxX - bounds.minX + const contentH = bounds.maxY - bounds.minY + if (contentW <= 0 || contentH <= 0) return null + + const scale = Math.min(width / contentW, height / contentH, 2) + const surface = ck.MakeSurface(width, height) + if (!surface) return null + + try { + const canvas = surface.getCanvas() + canvas.clear(ck.Color4f(renderer.pageColor.r, renderer.pageColor.g, renderer.pageColor.b, 1)) + + const offsetX = (width - contentW * scale) / 2 - bounds.minX * scale + const offsetY = (height - contentH * scale) / 2 - bounds.minY * scale + canvas.translate(offsetX, offsetY) + canvas.scale(scale, scale) + + renderer.renderSceneToCanvas(canvas, graph, pageId) + + surface.flush() + const image = surface.makeImageSnapshot() + const encoded = image.encodeToBytes(ck.ImageFormat.PNG, 90) + image.delete() + return encoded ? new Uint8Array(encoded) : null + } finally { + surface.delete() + } +} diff --git a/src/stores/editor.ts b/src/stores/editor.ts index 476bde696..e608a792e 100644 --- a/src/stores/editor.ts +++ b/src/stores/editor.ts @@ -19,11 +19,13 @@ import { } from '@/engine/clipboard' import { exportFigFile } from '@/engine/fig-export' import { computeLayout, computeAllLayouts } from '@/engine/layout' +import { renderNodesToImage } from '@/engine/render-image' import { SceneGraph } from '@/engine/scene-graph' import { UndoManager } from '@/engine/undo' import { computeVectorBounds } from '@/engine/vector' import { readFigFile } from '@/kiwi/fig-file' +import type { ExportFormat } from '@/engine/render-image' import type { SceneNode, NodeType, @@ -581,6 +583,91 @@ export function createEditorStore() { } } + function renderExportImage( + nodeIds: string[], + scale: number, + format: ExportFormat + ): Uint8Array | null { + if (!_ck || !_renderer) return null + const ids = + nodeIds.length > 0 ? nodeIds : graph.getChildren(state.currentPageId).map((n) => n.id) + if (ids.length === 0) return null + return renderNodesToImage(_ck, _renderer, graph, state.currentPageId, ids, { scale, format }) + } + + function exportImageExtension(format: ExportFormat): string { + switch (format) { + case 'JPG': + return '.jpg' + case 'WEBP': + return '.webp' + default: + return '.png' + } + } + + function exportImageMime(format: ExportFormat): string { + switch (format) { + case 'JPG': + return 'image/jpeg' + case 'WEBP': + return 'image/webp' + default: + return 'image/png' + } + } + + async function exportSelection(scale: number, format: ExportFormat) { + const ids = [...state.selectedIds] + const data = renderExportImage(ids, scale, format) + if (!data) return + + const node = ids.length === 1 ? graph.getNode(ids[0]) : undefined + const baseName = node?.name ?? 'Export' + const ext = exportImageExtension(format) + const fileName = `${baseName}@${scale}x${ext}` + + if (IS_TAURI) { + const { save } = await import('@tauri-apps/plugin-dialog') + const path = await save({ + defaultPath: fileName, + filters: [{ name: format, extensions: [ext.slice(1)] }] + }) + if (!path) return + const { writeFile: tauriWrite } = await import('@tauri-apps/plugin-fs') + await tauriWrite(path, data) + return + } + + if (window.showSaveFilePicker) { + try { + const handle = await window.showSaveFilePicker({ + suggestedName: fileName, + types: [ + { + description: `${format} image`, + accept: { [exportImageMime(format)]: [ext] } + } + ] + }) + const writable = await handle.createWritable() + await writable.write(new Uint8Array(data)) + await writable.close() + return + } catch (e) { + if ((e as Error).name === 'AbortError') return + } + } + + const blob = new Blob([new Uint8Array(data)], { type: exportImageMime(format) }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = fileName + a.click() + URL.revokeObjectURL(url) + } + function runLayoutForNode(id: string) { const node = graph.getNode(id) if (!node) return @@ -1696,6 +1783,8 @@ export function createEditorStore() { saveFigFile, setCanvasKit, saveFigFileAs, + renderExportImage, + exportSelection, updateNode, setLayoutMode, wrapInAutoLayout, From 7a5a77c098d751e4e2887c262193d86f23417c31 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 1 Mar 2026 00:05:19 +0300 Subject: [PATCH 2/4] Render export preview at panel-appropriate resolution Scale preview to fit 480px width (covers retina panels) instead of fixed 0.5x which was far too blurry. --- components.d.ts | 1 + src/components/properties/ExportSection.vue | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/components.d.ts b/components.d.ts index e174cd2a5..4ebb50bce 100644 --- a/components.d.ts +++ b/components.d.ts @@ -16,6 +16,7 @@ declare module 'vue' { ColorPicker: typeof import('./src/components/ColorPicker.vue')['default'] EditorCanvas: typeof import('./src/components/EditorCanvas.vue')['default'] EffectsSection: typeof import('./src/components/properties/EffectsSection.vue')['default'] + ExportSection: typeof import('./src/components/properties/ExportSection.vue')['default'] FillPicker: typeof import('./src/components/FillPicker.vue')['default'] FillSection: typeof import('./src/components/properties/FillSection.vue')['default'] IconLucideALargeSmall: typeof import('~icons/lucide/a-large-small')['default'] diff --git a/src/components/properties/ExportSection.vue b/src/components/properties/ExportSection.vue index 98e34438a..6e59c6693 100644 --- a/src/components/properties/ExportSection.vue +++ b/src/components/properties/ExportSection.vue @@ -50,6 +50,8 @@ async function doExport() { } } +const PREVIEW_WIDTH = 480 + function updatePreview() { if (!showPreview.value) return if (previewUrl.value) { @@ -58,7 +60,15 @@ function updatePreview() { } const ids = [...store.state.selectedIds] if (ids.length === 0) return - const data = store.renderExportImage(ids, 0.5, 'PNG') + + let maxW = 0 + for (const id of ids) { + const node = store.graph.getNode(id) + if (node) maxW = Math.max(maxW, node.width) + } + const scale = maxW > 0 ? Math.min(PREVIEW_WIDTH / maxW, 2) : 1 + + const data = store.renderExportImage(ids, scale, 'PNG') if (data) { previewUrl.value = URL.createObjectURL(new Blob([data], { type: 'image/png' })) } From 7d4b821e77ba5e34fe71ae7e0d312865343fa4ee Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 1 Mar 2026 00:09:35 +0300 Subject: [PATCH 3/4] Use full CanvasKit build for JPEG/WEBP encoding support The default canvaskit.wasm doesn't include JPEG/WEBP encoders (encodeToBytes returns null). The full build is only ~1MB larger. --- src/engine/render-image.ts | 6 +++++- src/stores/editor.ts | 5 ++++- vite.config.ts | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/engine/render-image.ts b/src/engine/render-image.ts index 756305147..bf7bad195 100644 --- a/src/engine/render-image.ts +++ b/src/engine/render-image.ts @@ -87,8 +87,12 @@ export function renderNodesToImage( surface.flush() const image = surface.makeImageSnapshot() const quality = options.quality ?? defaultQuality(options.format) - const encoded = image.encodeToBytes(ckImageFormat(ck, options.format), quality) + const fmt = ckImageFormat(ck, options.format) + const encoded = image.encodeToBytes(fmt, quality) image.delete() + if (!encoded) { + console.error(`encodeToBytes returned null for format=${options.format} quality=${quality}`) + } return encoded ? new Uint8Array(encoded) : null } finally { surface.delete() diff --git a/src/stores/editor.ts b/src/stores/editor.ts index e608a792e..76947a513 100644 --- a/src/stores/editor.ts +++ b/src/stores/editor.ts @@ -620,7 +620,10 @@ export function createEditorStore() { async function exportSelection(scale: number, format: ExportFormat) { const ids = [...state.selectedIds] const data = renderExportImage(ids, scale, format) - if (!data) return + if (!data) { + console.error(`Export failed: renderExportImage returned null for format=${format} scale=${scale}`) + return + } const node = ids.length === 1 ? graph.getNode(ids[0]) : undefined const baseName = node?.name ?? 'Export' diff --git a/vite.config.ts b/vite.config.ts index c82a9923c..07ce3c8b1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -21,7 +21,7 @@ export default defineConfig(async () => ({ { name: 'copy-canvaskit-wasm', buildStart() { - const src = 'node_modules/canvaskit-wasm/bin/canvaskit.wasm' + const src = 'node_modules/canvaskit-wasm/bin/full/canvaskit.wasm' const dest = 'public/canvaskit.wasm' if (existsSync(src) && !existsSync(dest)) { copyFileSync(src, dest) From 103c5617cf1c8f7abe5dd11ec756e2dd0c9fd6fb Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 1 Mar 2026 00:11:48 +0300 Subject: [PATCH 4/4] Re-encode JPG/WEBP via OffscreenCanvas instead of CanvasKit The default CanvasKit WASM build lacks JPEG/WEBP encoders. Instead of switching to the full build (+1MB), render PNG via CanvasKit then re-encode to the target format using OffscreenCanvas.convertToBlob(). --- src/components/properties/ExportSection.vue | 4 +- src/engine/render-image.ts | 127 +++++++++++--------- src/stores/editor.ts | 10 +- vite.config.ts | 2 +- 4 files changed, 80 insertions(+), 63 deletions(-) diff --git a/src/components/properties/ExportSection.vue b/src/components/properties/ExportSection.vue index 6e59c6693..bc6f939eb 100644 --- a/src/components/properties/ExportSection.vue +++ b/src/components/properties/ExportSection.vue @@ -52,7 +52,7 @@ async function doExport() { const PREVIEW_WIDTH = 480 -function updatePreview() { +async function updatePreview() { if (!showPreview.value) return if (previewUrl.value) { URL.revokeObjectURL(previewUrl.value) @@ -68,7 +68,7 @@ function updatePreview() { } const scale = maxW > 0 ? Math.min(PREVIEW_WIDTH / maxW, 2) : 1 - const data = store.renderExportImage(ids, scale, 'PNG') + const data = await store.renderExportImage(ids, scale, 'PNG') if (data) { previewUrl.value = URL.createObjectURL(new Blob([data], { type: 'image/png' })) } diff --git a/src/engine/render-image.ts b/src/engine/render-image.ts index bf7bad195..ac731e1db 100644 --- a/src/engine/render-image.ts +++ b/src/engine/render-image.ts @@ -1,6 +1,6 @@ import type { SkiaRenderer } from './renderer' import type { SceneGraph } from './scene-graph' -import type { CanvasKit, EncodedImageFormat } from 'canvaskit-wasm' +import type { CanvasKit } from 'canvaskit-wasm' export type ExportFormat = 'PNG' | 'JPG' | 'WEBP' @@ -33,29 +33,79 @@ function computeContentBounds( return { minX, minY, maxX, maxY } } -function ckImageFormat(ck: CanvasKit, format: ExportFormat): EncodedImageFormat { - switch (format) { - case 'JPG': - return ck.ImageFormat.JPEG - case 'WEBP': - return ck.ImageFormat.WEBP - default: - return ck.ImageFormat.PNG +function renderToSurface( + ck: CanvasKit, + renderer: SkiaRenderer, + graph: SceneGraph, + pageId: string, + width: number, + height: number, + setup: (canvas: import('canvaskit-wasm').Canvas) => void +): Uint8Array | null { + const surface = ck.MakeSurface(width, height) + if (!surface) return null + + try { + const canvas = surface.getCanvas() + setup(canvas) + renderer.renderSceneToCanvas(canvas, graph, pageId) + surface.flush() + const image = surface.makeImageSnapshot() + const encoded = image.encodeToBytes(ck.ImageFormat.PNG, 100) + image.delete() + return encoded ? new Uint8Array(encoded) : null + } finally { + surface.delete() } } -function defaultQuality(format: ExportFormat): number { - return format === 'PNG' ? 100 : 90 +function mimeForFormat(format: ExportFormat): string { + switch (format) { + case 'JPG': + return 'image/jpeg' + case 'WEBP': + return 'image/webp' + default: + return 'image/png' + } } -export function renderNodesToImage( +async function reencodeImage( + pngBytes: Uint8Array, + format: ExportFormat, + quality: number +): Promise { + if (format === 'PNG') return pngBytes + + const blob = new Blob([new Uint8Array(pngBytes)], { type: 'image/png' }) + const bitmap = await createImageBitmap(blob) + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height) + const ctx = canvas.getContext('2d') + if (!ctx) return pngBytes + + if (format === 'JPG') { + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, bitmap.width, bitmap.height) + } + + ctx.drawImage(bitmap, 0, 0) + bitmap.close() + + const outBlob = await canvas.convertToBlob({ + type: mimeForFormat(format), + quality: quality / 100 + }) + return new Uint8Array(await outBlob.arrayBuffer()) +} + +export async function renderNodesToImage( ck: CanvasKit, renderer: SkiaRenderer, graph: SceneGraph, pageId: string, nodeIds: string[], options: RenderOptions -): Uint8Array | null { +): Promise { const bounds = computeContentBounds(graph, nodeIds) if (!bounds) return null @@ -67,36 +117,15 @@ export function renderNodesToImage( const pixelH = Math.ceil(contentH * options.scale) if (pixelW <= 0 || pixelH <= 0) return null - const surface = ck.MakeSurface(pixelW, pixelH) - if (!surface) return null - - try { - const canvas = surface.getCanvas() - - if (options.format === 'JPG') { - canvas.clear(ck.WHITE) - } else { - canvas.clear(ck.TRANSPARENT) - } - + const png = renderToSurface(ck, renderer, graph, pageId, pixelW, pixelH, (canvas) => { + canvas.clear(ck.TRANSPARENT) canvas.scale(options.scale, options.scale) canvas.translate(-bounds.minX, -bounds.minY) + }) + if (!png) return null - renderer.renderSceneToCanvas(canvas, graph, pageId) - - surface.flush() - const image = surface.makeImageSnapshot() - const quality = options.quality ?? defaultQuality(options.format) - const fmt = ckImageFormat(ck, options.format) - const encoded = image.encodeToBytes(fmt, quality) - image.delete() - if (!encoded) { - console.error(`encodeToBytes returned null for format=${options.format} quality=${quality}`) - } - return encoded ? new Uint8Array(encoded) : null - } finally { - surface.delete() - } + const quality = options.quality ?? (options.format === 'PNG' ? 100 : 90) + return reencodeImage(png, options.format, quality) } export function renderThumbnail( @@ -118,26 +147,12 @@ export function renderThumbnail( if (contentW <= 0 || contentH <= 0) return null const scale = Math.min(width / contentW, height / contentH, 2) - const surface = ck.MakeSurface(width, height) - if (!surface) return null - try { - const canvas = surface.getCanvas() + return renderToSurface(ck, renderer, graph, pageId, width, height, (canvas) => { canvas.clear(ck.Color4f(renderer.pageColor.r, renderer.pageColor.g, renderer.pageColor.b, 1)) - const offsetX = (width - contentW * scale) / 2 - bounds.minX * scale const offsetY = (height - contentH * scale) / 2 - bounds.minY * scale canvas.translate(offsetX, offsetY) canvas.scale(scale, scale) - - renderer.renderSceneToCanvas(canvas, graph, pageId) - - surface.flush() - const image = surface.makeImageSnapshot() - const encoded = image.encodeToBytes(ck.ImageFormat.PNG, 90) - image.delete() - return encoded ? new Uint8Array(encoded) : null - } finally { - surface.delete() - } + }) } diff --git a/src/stores/editor.ts b/src/stores/editor.ts index 76947a513..06d08e3f2 100644 --- a/src/stores/editor.ts +++ b/src/stores/editor.ts @@ -583,11 +583,11 @@ export function createEditorStore() { } } - function renderExportImage( + async function renderExportImage( nodeIds: string[], scale: number, format: ExportFormat - ): Uint8Array | null { + ): Promise { if (!_ck || !_renderer) return null const ids = nodeIds.length > 0 ? nodeIds : graph.getChildren(state.currentPageId).map((n) => n.id) @@ -619,9 +619,11 @@ export function createEditorStore() { async function exportSelection(scale: number, format: ExportFormat) { const ids = [...state.selectedIds] - const data = renderExportImage(ids, scale, format) + const data = await renderExportImage(ids, scale, format) if (!data) { - console.error(`Export failed: renderExportImage returned null for format=${format} scale=${scale}`) + console.error( + `Export failed: renderExportImage returned null for format=${format} scale=${scale}` + ) return } diff --git a/vite.config.ts b/vite.config.ts index 07ce3c8b1..c82a9923c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -21,7 +21,7 @@ export default defineConfig(async () => ({ { name: 'copy-canvaskit-wasm', buildStart() { - const src = 'node_modules/canvaskit-wasm/bin/full/canvaskit.wasm' + const src = 'node_modules/canvaskit-wasm/bin/canvaskit.wasm' const dest = 'public/canvaskit.wasm' if (existsSync(src) && !existsSync(dest)) { copyFileSync(src, dest)