Merge remote-tracking branch 'origin/export-image'
This commit is contained in:
commit
cb6c042c07
1
components.d.ts
vendored
1
components.d.ts
vendored
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
159
src/components/properties/ExportSection.vue
Normal file
159
src/components/properties/ExportSection.vue
Normal 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>
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
158
src/engine/render-image.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue