Merge remote-tracking branch 'origin/export-image'

This commit is contained in:
Danila Poyarkov 2026-03-01 00:20:00 +03:00
commit cb6c042c07
9 changed files with 435 additions and 60 deletions

1
components.d.ts vendored
View file

@ -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']

View file

@ -241,6 +241,13 @@ const menuClass =
<span>{{ isLocked ? 'Unlock' : 'Lock' }}</span>
<span class="text-[11px] text-muted">⇧⌘L</span>
</ContextMenuItem>
<ContextMenuSeparator class="my-1 h-px bg-border" />
<ContextMenuItem :class="itemClass" @select="store.exportSelection(1, 'PNG')">
<span>Export as PNG</span>
<span class="text-[11px] text-muted">⇧⌘E</span>
</ContextMenuItem>
</template>
</ContextMenuContent>
</ContextMenuPortal>

View file

@ -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(() => {
<StrokeSection />
<EffectsSection />
<!-- Export -->
<div class="border-b border-border px-3 py-2">
<label class="mb-1.5 block text-[11px] text-muted">Export</label>
</div>
<ExportSection />
</div>
<div v-else class="flex-1 overflow-y-auto pb-4">

View file

@ -0,0 +1,159 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useEditorStore } from '@/stores/editor'
import type { ExportFormat } from '@/engine/render-image'
const store = useEditorStore()
interface ExportSetting {
scale: number
format: ExportFormat
}
const settings = ref<ExportSetting[]>([{ scale: 1, format: 'PNG' }])
const previewUrl = ref<string | null>(null)
const showPreview = ref(false)
const exporting = ref(false)
const SCALES = [0.5, 0.75, 1, 1.5, 2, 3, 4] as const
const FORMATS: ExportFormat[] = ['PNG', 'JPG', 'WEBP']
const nodeName = computed(() => {
void store.state.renderVersion
if (store.state.selectedIds.size === 1) {
const id = [...store.state.selectedIds][0]
return store.graph.getNode(id)?.name ?? 'Export'
}
return `${store.state.selectedIds.size} layers`
})
function addSetting() {
const last = settings.value[settings.value.length - 1]
const nextScale = SCALES.find((s) => s > (last?.scale ?? 1)) ?? 2
settings.value.push({ scale: nextScale, format: last?.format ?? 'PNG' })
}
function removeSetting(index: number) {
settings.value.splice(index, 1)
}
async function doExport() {
exporting.value = true
try {
for (const setting of settings.value) {
await store.exportSelection(setting.scale, setting.format)
}
} finally {
exporting.value = false
}
}
const PREVIEW_WIDTH = 480
async function updatePreview() {
if (!showPreview.value) return
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
previewUrl.value = null
}
const ids = [...store.state.selectedIds]
if (ids.length === 0) return
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 = await store.renderExportImage(ids, scale, 'PNG')
if (data) {
previewUrl.value = URL.createObjectURL(new Blob([data], { type: 'image/png' }))
}
}
watch(
() => [showPreview.value, store.state.renderVersion],
() => updatePreview(),
{ flush: 'post' }
)
function formatScale(scale: number): string {
return scale % 1 === 0 ? `${scale}x` : `${scale}x`
}
</script>
<template>
<div class="border-b border-border px-3 py-2">
<div class="flex items-center justify-between">
<label class="mb-1 block text-[11px] text-muted">Export</label>
<button
class="flex size-5 cursor-pointer items-center justify-center rounded border-none bg-transparent text-sm leading-none text-muted hover:bg-hover hover:text-surface"
@click="addSetting"
>
+
</button>
</div>
<div v-for="(setting, i) in settings" :key="i" class="flex items-center gap-1.5 py-0.5">
<select
class="min-w-0 flex-1 cursor-pointer appearance-none rounded border border-border bg-input px-1.5 py-1 text-xs text-surface outline-none"
:value="setting.scale"
@change="setting.scale = Number(($event.target as HTMLSelectElement).value)"
>
<option v-for="s in SCALES" :key="s" :value="s" class="bg-panel text-surface">
{{ formatScale(s) }}
</option>
</select>
<select
class="min-w-0 flex-1 cursor-pointer appearance-none rounded border border-border bg-input px-1.5 py-1 text-xs text-surface outline-none"
:value="setting.format"
@change="setting.format = ($event.target as HTMLSelectElement).value as ExportFormat"
>
<option v-for="f in FORMATS" :key="f" :value="f" class="bg-panel text-surface">
{{ f }}
</option>
</select>
<button
class="flex size-5 shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent text-sm leading-none text-muted hover:bg-hover hover:text-surface"
@click="removeSetting(i)"
>
−
</button>
</div>
<button
v-if="settings.length > 0"
class="mt-1.5 w-full cursor-pointer rounded bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700 disabled:cursor-default disabled:opacity-50"
:disabled="exporting"
@click="doExport"
>
Export {{ nodeName }}
</button>
<button
v-if="settings.length > 0"
class="mt-1 flex w-full cursor-pointer items-center gap-1 rounded border-none bg-transparent px-0 py-1 text-[11px] text-muted hover:text-surface"
@click="showPreview = !showPreview"
>
<icon-lucide-chevron-down v-if="showPreview" class="size-3" />
<icon-lucide-chevron-right v-else class="size-3" />
Preview
</button>
<div v-if="showPreview && previewUrl" class="mt-1 overflow-hidden rounded border border-border">
<img
:src="previewUrl"
class="block w-full"
style="
image-rendering: auto;
background: repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%) 50% / 16px 16px;
"
/>
</div>
</div>
</template>

View file

@ -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) {

View file

@ -58,7 +58,10 @@ const MENU_ACTIONS: Record<string, (store: EditorStore) => 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) {

View file

@ -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({

158
src/engine/render-image.ts Normal file
View file

@ -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<Uint8Array> {
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<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 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)
})
}

View file

@ -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<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 = 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,