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/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..bc6f939eb --- /dev/null +++ b/src/components/properties/ExportSection.vue @@ -0,0 +1,159 @@ + + + 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..ac731e1db --- /dev/null +++ b/src/engine/render-image.ts @@ -0,0 +1,158 @@ +import type { SkiaRenderer } from './renderer' +import type { SceneGraph } from './scene-graph' +import type { CanvasKit } 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 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 mimeForFormat(format: ExportFormat): string { + switch (format) { + case 'JPG': + return 'image/jpeg' + case 'WEBP': + return 'image/webp' + default: + return 'image/png' + } +} + +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 +): Promise { + 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 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 + + const quality = options.quality ?? (options.format === 'PNG' ? 100 : 90) + return reencodeImage(png, options.format, quality) +} + +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) + + 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) + }) +} diff --git a/src/stores/editor.ts b/src/stores/editor.ts index 476bde696..06d08e3f2 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,96 @@ export function createEditorStore() { } } + async function renderExportImage( + nodeIds: string[], + scale: number, + format: ExportFormat + ): Promise { + 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 = await renderExportImage(ids, scale, format) + 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' + 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 +1788,8 @@ export function createEditorStore() { saveFigFile, setCanvasKit, saveFigFileAs, + renderExportImage, + exportSelection, updateNode, setLayoutMode, wrapInAutoLayout,