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,