Cache effect ImageFilters and reuse layer paint
Eliminates per-frame WASM allocations for shadows and blurs: - Reusable effectLayerPaint instead of new Paint() per effect - ImageFilter cache keyed by params (drop shadow, blur, decal blur) - Zero allocations during SkPicture recording for cached effects
This commit is contained in:
parent
faa02d8ab0
commit
8ced3201cf
1
.gitattributes
vendored
1
.gitattributes
vendored
|
|
@ -1 +1,2 @@
|
|||
tests/fixtures/*.fig filter=lfs diff=lfs merge=lfs -text
|
||||
packages/core/vendor/canvaskit-webgpu/*.wasm filter=lfs diff=lfs merge=lfs -text
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -14,6 +14,7 @@ dist-ssr
|
|||
|
||||
# Copied from node_modules at build time
|
||||
public/canvaskit.wasm
|
||||
public/canvaskit-webgpu/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
{
|
||||
"identifier": "fs:allow-write-file",
|
||||
"allow": [{ "path": "**" }]
|
||||
}
|
||||
},
|
||||
"fs:allow-watch",
|
||||
"fs:allow-unwatch"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,19 +2,64 @@ import CanvasKitInit, { type CanvasKit } from 'canvaskit-wasm'
|
|||
|
||||
let instance: CanvasKit | null = null
|
||||
|
||||
export type GpuBackend = 'webgl' | 'webgpu'
|
||||
|
||||
let activeBackend: GpuBackend = 'webgl'
|
||||
|
||||
export function getGpuBackend(): GpuBackend {
|
||||
return activeBackend
|
||||
}
|
||||
|
||||
export interface CanvasKitOptions {
|
||||
locateFile?: (file: string) => string
|
||||
backend?: GpuBackend
|
||||
}
|
||||
|
||||
function detectBackend(): GpuBackend {
|
||||
if (typeof window === 'undefined') return 'webgl'
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.get('gpu') === 'webgpu' && 'gpu' in navigator) return 'webgpu'
|
||||
return 'webgl'
|
||||
}
|
||||
|
||||
function loadCanvasKitWebGPU(): Promise<(opts?: Record<string, unknown>) => Promise<CanvasKit>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
script.src = '/canvaskit-webgpu/canvaskit.js'
|
||||
script.onload = () => {
|
||||
const init = (globalThis as Record<string, unknown>).CanvasKitInit as
|
||||
(opts?: Record<string, unknown>) => Promise<CanvasKit>
|
||||
if (!init) return reject(new Error('CanvasKitInit not found after loading WebGPU build'))
|
||||
resolve(init)
|
||||
}
|
||||
script.onerror = () => reject(new Error('Failed to load canvaskit-webgpu/canvaskit.js'))
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
export async function getCanvasKit(options?: CanvasKitOptions): Promise<CanvasKit> {
|
||||
if (instance) return instance
|
||||
instance = await CanvasKitInit({
|
||||
locateFile:
|
||||
options?.locateFile ??
|
||||
((file) => {
|
||||
if (typeof window !== 'undefined') return `/${file}`
|
||||
return file
|
||||
})
|
||||
})
|
||||
return instance
|
||||
|
||||
const backend = options?.backend ?? detectBackend()
|
||||
activeBackend = backend
|
||||
|
||||
const defaultLocate = (file: string) => {
|
||||
if (typeof window !== 'undefined') return `/${file}`
|
||||
return file
|
||||
}
|
||||
|
||||
if (backend === 'webgpu') {
|
||||
const init = await loadCanvasKitWebGPU()
|
||||
instance = await init({
|
||||
locateFile:
|
||||
options?.locateFile ??
|
||||
((file: string) => `/canvaskit-webgpu/${file}`)
|
||||
})
|
||||
} else {
|
||||
instance = await CanvasKitInit({
|
||||
locateFile: options?.locateFile ?? defaultLocate
|
||||
})
|
||||
}
|
||||
|
||||
return instance!
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export { ALL_TOOLS, defineTool, toolsToAI } from './tools'
|
|||
export type { ToolDef, ParamDef, ParamType } from './tools'
|
||||
export { SkiaRenderer, type RenderOverlays } from './renderer'
|
||||
export { computeLayout, computeAllLayouts } from './layout'
|
||||
export { getCanvasKit, type CanvasKitOptions } from './canvaskit'
|
||||
export { getCanvasKit, getGpuBackend, type CanvasKitOptions, type GpuBackend } from './canvaskit'
|
||||
export {
|
||||
loadFont,
|
||||
listFamilies,
|
||||
|
|
|
|||
|
|
@ -75,7 +75,8 @@ import type {
|
|||
FontMgr,
|
||||
FontWeight,
|
||||
TypefaceFontProvider,
|
||||
SkPicture
|
||||
SkPicture,
|
||||
ImageFilter
|
||||
} from 'canvaskit-wasm'
|
||||
|
||||
export interface RenderOverlays {
|
||||
|
|
@ -125,6 +126,8 @@ export class SkiaRenderer {
|
|||
private auxFill: Paint
|
||||
private auxStroke: Paint
|
||||
private opacityPaint: Paint
|
||||
private effectLayerPaint: Paint
|
||||
private imageFilterCache = new Map<string, ImageFilter>()
|
||||
private textFont: Font | null = null
|
||||
private labelFont: Font | null = null
|
||||
private sizeFont: Font | null = null
|
||||
|
|
@ -216,6 +219,7 @@ export class SkiaRenderer {
|
|||
this.auxStroke.setAntiAlias(true)
|
||||
|
||||
this.opacityPaint = new ck.Paint()
|
||||
this.effectLayerPaint = new ck.Paint()
|
||||
|
||||
this.textFont = new ck.Font(null, DEFAULT_FONT_SIZE)
|
||||
|
||||
|
|
@ -961,17 +965,8 @@ export class SkiaRenderer {
|
|||
|
||||
const layerBlur = node.effects.find((e) => e.visible && e.type === 'LAYER_BLUR')
|
||||
if (layerBlur) {
|
||||
const blurPaint = new this.ck.Paint()
|
||||
blurPaint.setImageFilter(
|
||||
this.ck.ImageFilter.MakeBlur(
|
||||
layerBlur.radius / 2,
|
||||
layerBlur.radius / 2,
|
||||
this.ck.TileMode.Clamp,
|
||||
null
|
||||
)
|
||||
)
|
||||
canvas.saveLayer(blurPaint)
|
||||
blurPaint.delete()
|
||||
this.effectLayerPaint.setImageFilter(this.getCachedBlur(layerBlur.radius / 2))
|
||||
canvas.saveLayer(this.effectLayerPaint)
|
||||
}
|
||||
|
||||
const rotation =
|
||||
|
|
@ -1552,6 +1547,41 @@ export class SkiaRenderer {
|
|||
)
|
||||
}
|
||||
|
||||
private getCachedDropShadow(
|
||||
dx: number,
|
||||
dy: number,
|
||||
sigma: number,
|
||||
color: Float32Array
|
||||
): ImageFilter {
|
||||
const key = `ds:${dx},${dy},${sigma},${color[0]},${color[1]},${color[2]},${color[3]}`
|
||||
let filter = this.imageFilterCache.get(key)
|
||||
if (!filter) {
|
||||
filter = this.ck.ImageFilter.MakeDropShadowOnly(dx, dy, sigma, sigma, color, null)
|
||||
this.imageFilterCache.set(key, filter)
|
||||
}
|
||||
return filter
|
||||
}
|
||||
|
||||
private getCachedBlur(sigma: number): ImageFilter {
|
||||
const key = `blur:${sigma}`
|
||||
let filter = this.imageFilterCache.get(key)
|
||||
if (!filter) {
|
||||
filter = this.ck.ImageFilter.MakeBlur(sigma, sigma, this.ck.TileMode.Clamp, null)
|
||||
this.imageFilterCache.set(key, filter)
|
||||
}
|
||||
return filter
|
||||
}
|
||||
|
||||
private getCachedDecalBlur(sigma: number): ImageFilter {
|
||||
const key = `dblur:${sigma}`
|
||||
let filter = this.imageFilterCache.get(key)
|
||||
if (!filter) {
|
||||
filter = this.ck.ImageFilter.MakeBlur(sigma, sigma, this.ck.TileMode.Decal, null)
|
||||
this.imageFilterCache.set(key, filter)
|
||||
}
|
||||
return filter
|
||||
}
|
||||
|
||||
private applyClippedBlur(
|
||||
canvas: Canvas,
|
||||
node: SceneNode,
|
||||
|
|
@ -1561,14 +1591,10 @@ export class SkiaRenderer {
|
|||
): void {
|
||||
canvas.save()
|
||||
this.clipNodeShape(canvas, node, rect, hasRadius)
|
||||
const blurPaint = new this.ck.Paint()
|
||||
blurPaint.setImageFilter(
|
||||
this.ck.ImageFilter.MakeBlur(sigma, sigma, this.ck.TileMode.Clamp, null)
|
||||
)
|
||||
canvas.saveLayer(blurPaint)
|
||||
this.effectLayerPaint.setImageFilter(this.getCachedBlur(sigma))
|
||||
canvas.saveLayer(this.effectLayerPaint)
|
||||
canvas.restore()
|
||||
canvas.restore()
|
||||
blurPaint.delete()
|
||||
}
|
||||
|
||||
private clipNodeShape(canvas: Canvas, node: SceneNode, rect: Float32Array, hasRadius: boolean): void {
|
||||
|
|
@ -1694,33 +1720,20 @@ export class SkiaRenderer {
|
|||
)
|
||||
const sigma = effect.radius / 2
|
||||
|
||||
const dropFilter = this.getCachedDropShadow(
|
||||
effect.offset.x,
|
||||
effect.offset.y,
|
||||
sigma,
|
||||
shadowColor
|
||||
)
|
||||
this.effectLayerPaint.setImageFilter(dropFilter)
|
||||
|
||||
if (node.type === 'TEXT') {
|
||||
const dropFilter = this.ck.ImageFilter.MakeDropShadowOnly(
|
||||
effect.offset.x,
|
||||
effect.offset.y,
|
||||
sigma,
|
||||
sigma,
|
||||
shadowColor,
|
||||
null
|
||||
)
|
||||
const layerPaint = new this.ck.Paint()
|
||||
layerPaint.setImageFilter(dropFilter)
|
||||
canvas.saveLayer(layerPaint)
|
||||
canvas.saveLayer(this.effectLayerPaint)
|
||||
this.renderText(canvas, node)
|
||||
canvas.restore()
|
||||
layerPaint.delete()
|
||||
} else {
|
||||
const dropFilter = this.ck.ImageFilter.MakeDropShadowOnly(
|
||||
effect.offset.x,
|
||||
effect.offset.y,
|
||||
sigma,
|
||||
sigma,
|
||||
shadowColor,
|
||||
null
|
||||
)
|
||||
const layerPaint = new this.ck.Paint()
|
||||
layerPaint.setImageFilter(dropFilter)
|
||||
canvas.saveLayer(layerPaint)
|
||||
canvas.saveLayer(this.effectLayerPaint)
|
||||
this.auxFill.setColor(this.ck.WHITE)
|
||||
this.auxFill.setImageFilter(null)
|
||||
if (node.type === 'ELLIPSE') {
|
||||
|
|
@ -1733,7 +1746,6 @@ export class SkiaRenderer {
|
|||
canvas.drawRect(spreadRect, this.auxFill)
|
||||
}
|
||||
canvas.restore()
|
||||
layerPaint.delete()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1746,36 +1758,27 @@ export class SkiaRenderer {
|
|||
|
||||
if (pass === 'front' && effect.type === 'INNER_SHADOW') {
|
||||
if (node.type === 'TEXT') {
|
||||
const blurFilter = this.ck.ImageFilter.MakeBlur(
|
||||
effect.radius,
|
||||
effect.radius,
|
||||
this.ck.TileMode.Decal,
|
||||
null
|
||||
)
|
||||
const layerPaint = new this.ck.Paint()
|
||||
layerPaint.setImageFilter(blurFilter)
|
||||
layerPaint.setColorFilter(
|
||||
this.effectLayerPaint.setImageFilter(this.getCachedDecalBlur(effect.radius))
|
||||
this.effectLayerPaint.setColorFilter(
|
||||
this.ck.ColorFilter.MakeBlend(
|
||||
this.ck.Color4f(effect.color.r, effect.color.g, effect.color.b, effect.color.a),
|
||||
this.ck.BlendMode.SrcIn
|
||||
)
|
||||
)
|
||||
canvas.saveLayer(layerPaint)
|
||||
canvas.saveLayer(this.effectLayerPaint)
|
||||
canvas.save()
|
||||
canvas.translate(effect.offset.x, effect.offset.y)
|
||||
this.renderText(canvas, node)
|
||||
canvas.restore()
|
||||
canvas.restore()
|
||||
layerPaint.delete()
|
||||
this.effectLayerPaint.setColorFilter(null)
|
||||
continue
|
||||
}
|
||||
const sp = effect.spread
|
||||
this.auxFill.setColor(
|
||||
this.ck.Color4f(effect.color.r, effect.color.g, effect.color.b, effect.color.a)
|
||||
)
|
||||
this.auxFill.setImageFilter(
|
||||
this.ck.ImageFilter.MakeBlur(effect.radius, effect.radius, this.ck.TileMode.Decal, null)
|
||||
)
|
||||
this.auxFill.setImageFilter(this.getCachedDecalBlur(effect.radius))
|
||||
|
||||
canvas.save()
|
||||
if (node.type === 'ELLIPSE') {
|
||||
|
|
@ -2628,6 +2631,9 @@ export class SkiaRenderer {
|
|||
this.penHandlePaint.delete()
|
||||
this.penVertexFill.delete()
|
||||
this.penVertexStroke.delete()
|
||||
this.effectLayerPaint.delete()
|
||||
for (const filter of this.imageFilterCache.values()) filter.delete()
|
||||
this.imageFilterCache.clear()
|
||||
this.scenePicture?.delete()
|
||||
this.surface.delete()
|
||||
}
|
||||
|
|
|
|||
21
packages/core/vendor/canvaskit-webgpu/canvaskit.js
vendored
Normal file
21
packages/core/vendor/canvaskit-webgpu/canvaskit.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
packages/core/vendor/canvaskit-webgpu/canvaskit.wasm
vendored
Executable file
3
packages/core/vendor/canvaskit-webgpu/canvaskit.wasm
vendored
Executable file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6a337e1ca02e7eb9bb05a7fd7986fde3e0a845f95ddc06193751ef69c106918a
|
||||
size 9697190
|
||||
33
patches/skia-webgpu/build.sh
Executable file
33
patches/skia-webgpu/build.sh
Executable file
|
|
@ -0,0 +1,33 @@
|
|||
#!/bin/bash
|
||||
# Build CanvasKit with Graphite+WebGPU from Skia source
|
||||
# Requires: git, python3, cmake, ninja
|
||||
# Output: canvaskit.js + canvaskit.wasm in out/canvaskit_wasm/
|
||||
set -euo pipefail
|
||||
|
||||
SKIA_DIR="${SKIA_DIR:-$HOME/Development/skia}"
|
||||
|
||||
if [ ! -d "$SKIA_DIR" ]; then
|
||||
echo "Cloning Skia..."
|
||||
git clone --depth 1 https://skia.googlesource.com/skia.git "$SKIA_DIR"
|
||||
fi
|
||||
|
||||
cd "$SKIA_DIR"
|
||||
|
||||
echo "Syncing dependencies..."
|
||||
python3 tools/git-sync-deps
|
||||
|
||||
echo "Activating emsdk..."
|
||||
python3 bin/activate-emsdk
|
||||
|
||||
echo "Applying patches..."
|
||||
PATCHES_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
git apply "$PATCHES_DIR/skia.patch" 2>/dev/null || true
|
||||
(cd third_party/externals/dawn && git apply "$PATCHES_DIR/dawn-tint.patch" 2>/dev/null || true)
|
||||
|
||||
echo "Building CanvasKit with WebGPU..."
|
||||
# CPATH may contain /opt/homebrew/include which leaks host headers into emscripten
|
||||
unset CPATH C_INCLUDE_PATH CPLUS_INCLUDE_PATH
|
||||
bash modules/canvaskit/compile.sh webgpu
|
||||
|
||||
echo "Done!"
|
||||
ls -lh out/canvaskit_wasm/canvaskit.js out/canvaskit_wasm/canvaskit.wasm
|
||||
|
|
@ -1,15 +1,46 @@
|
|||
import { useRafFn, useResizeObserver } from '@vueuse/core'
|
||||
import { onMounted, onUnmounted, type Ref } from 'vue'
|
||||
|
||||
import { getCanvasKit } from '@/engine/canvaskit'
|
||||
import { getCanvasKit, getGpuBackend } from '@/engine/canvaskit'
|
||||
import { SkiaRenderer } from '@/engine/renderer'
|
||||
|
||||
import type { EditorStore } from '@/stores/editor'
|
||||
import type { CanvasKit } from 'canvaskit-wasm'
|
||||
|
||||
interface WebGPUContext {
|
||||
device: GPUDevice
|
||||
deviceContext: unknown
|
||||
}
|
||||
|
||||
interface CanvasKitWebGPU {
|
||||
MakeGPUDeviceContext(device: GPUDevice): unknown
|
||||
MakeGPUCanvasContext(ctx: unknown, canvas: HTMLCanvasElement, opts?: unknown): unknown
|
||||
MakeGPUCanvasSurface(
|
||||
ctx: unknown,
|
||||
colorSpace?: unknown,
|
||||
width?: number,
|
||||
height?: number
|
||||
): ReturnType<CanvasKit['MakeSurface']>
|
||||
}
|
||||
|
||||
function asWebGPU(ck: CanvasKit): CanvasKitWebGPU {
|
||||
return ck as unknown as CanvasKitWebGPU
|
||||
}
|
||||
|
||||
async function initWebGPU(ck: CanvasKit): Promise<WebGPUContext | null> {
|
||||
if (!('gpu' in navigator)) return null
|
||||
const adapter = await navigator.gpu.requestAdapter()
|
||||
if (!adapter) return null
|
||||
const device = await adapter.requestDevice()
|
||||
const deviceContext = asWebGPU(ck).MakeGPUDeviceContext?.(device)
|
||||
if (!deviceContext) return null
|
||||
return { device, deviceContext }
|
||||
}
|
||||
|
||||
export function useCanvas(canvasRef: Ref<HTMLCanvasElement | null>, store: EditorStore) {
|
||||
let renderer: SkiaRenderer | null = null
|
||||
let ck: CanvasKit | null = null
|
||||
let gpuCtx: WebGPUContext | null = null
|
||||
let destroyed = false
|
||||
let dirty = true
|
||||
let lastRenderVersion = -1
|
||||
|
|
@ -22,6 +53,14 @@ export function useCanvas(canvasRef: Ref<HTMLCanvasElement | null>, store: Edito
|
|||
ck = await getCanvasKit()
|
||||
if (destroyed) return
|
||||
|
||||
if (getGpuBackend() === 'webgpu') {
|
||||
gpuCtx = await initWebGPU(ck)
|
||||
if (!gpuCtx) {
|
||||
console.warn('WebGPU init failed, reload without ?gpu=webgpu to use WebGL')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((r) => requestAnimationFrame(r))
|
||||
createSurface(canvas)
|
||||
|
||||
|
|
@ -44,15 +83,26 @@ export function useCanvas(canvasRef: Ref<HTMLCanvasElement | null>, store: Edito
|
|||
canvas.width = w * dpr
|
||||
canvas.height = h * dpr
|
||||
|
||||
const isTest = new URLSearchParams(window.location.search).has('test')
|
||||
const surface = ck.MakeWebGLCanvasSurface(
|
||||
canvas,
|
||||
undefined,
|
||||
isTest ? { preserveDrawingBuffer: 1 } : undefined
|
||||
)
|
||||
if (!surface) {
|
||||
console.error('Failed to create WebGL surface')
|
||||
return
|
||||
let surface
|
||||
if (getGpuBackend() === 'webgpu' && gpuCtx) {
|
||||
const gpu = asWebGPU(ck)
|
||||
const canvasCtx = gpu.MakeGPUCanvasContext(gpuCtx.deviceContext, canvas)
|
||||
surface = gpu.MakeGPUCanvasSurface(canvasCtx, ck.ColorSpace.SRGB, canvas.width, canvas.height)
|
||||
if (!surface) {
|
||||
console.error('Failed to create WebGPU surface')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
const isTest = new URLSearchParams(window.location.search).has('test')
|
||||
surface = ck.MakeWebGLCanvasSurface(
|
||||
canvas,
|
||||
undefined,
|
||||
isTest ? { preserveDrawingBuffer: 1 } : undefined
|
||||
)
|
||||
if (!surface) {
|
||||
console.error('Failed to create WebGL surface')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
renderer = new SkiaRenderer(ck, surface)
|
||||
|
|
|
|||
|
|
@ -1 +1,6 @@
|
|||
export { getCanvasKit, type CanvasKitOptions } from '@open-pencil/core'
|
||||
export {
|
||||
getCanvasKit,
|
||||
getGpuBackend,
|
||||
type CanvasKitOptions,
|
||||
type GpuBackend
|
||||
} from '@open-pencil/core'
|
||||
|
|
|
|||
|
|
@ -121,6 +121,8 @@ export function createEditorStore() {
|
|||
let downloadName: string | null = null
|
||||
let savedVersion = 0
|
||||
let autosaveTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let lastWriteTime = 0
|
||||
let unwatchFile: (() => void) | null = null
|
||||
let _ck: import('canvaskit-wasm').CanvasKit | null = null
|
||||
let _renderer: import('@/engine/renderer').SkiaRenderer | null = null
|
||||
let _textEditor: TextEditor | null = null
|
||||
|
|
@ -578,6 +580,7 @@ export function createEditorStore() {
|
|||
state.zoom = 1
|
||||
state.pageColor = { ...CANVAS_BG_COLOR }
|
||||
requestRender()
|
||||
startWatchingFile()
|
||||
} catch (e) {
|
||||
console.error('Failed to open .fig file:', e)
|
||||
}
|
||||
|
|
@ -624,6 +627,7 @@ export function createEditorStore() {
|
|||
.pop()
|
||||
?.replace(/\.fig$/i, '') ?? 'Untitled'
|
||||
await writeFile(data)
|
||||
startWatchingFile()
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -642,6 +646,7 @@ export function createEditorStore() {
|
|||
filePath = null
|
||||
state.documentName = handle.name.replace(/\.fig$/i, '')
|
||||
await writeFile(data)
|
||||
startWatchingFile()
|
||||
return
|
||||
} catch (e) {
|
||||
if ((e as Error).name === 'AbortError') return
|
||||
|
|
@ -656,6 +661,7 @@ export function createEditorStore() {
|
|||
}
|
||||
|
||||
async function writeFile(data: Uint8Array) {
|
||||
lastWriteTime = Date.now()
|
||||
if (filePath && IS_TAURI) {
|
||||
const { writeFile: tauriWrite } = await import('@tauri-apps/plugin-fs')
|
||||
await tauriWrite(filePath, data)
|
||||
|
|
@ -670,6 +676,88 @@ export function createEditorStore() {
|
|||
}
|
||||
}
|
||||
|
||||
const WATCH_DEBOUNCE_MS = 1000
|
||||
|
||||
async function reloadFromDisk() {
|
||||
const viewport = { panX: state.panX, panY: state.panY, zoom: state.zoom }
|
||||
const pageId = state.currentPageId
|
||||
|
||||
if (filePath && IS_TAURI) {
|
||||
const { readFile: tauriRead } = await import('@tauri-apps/plugin-fs')
|
||||
const bytes = await tauriRead(filePath)
|
||||
const blob = new Blob([bytes])
|
||||
const file = new File([blob], state.documentName + '.fig')
|
||||
const imported = await readFigFile(file)
|
||||
graph = imported
|
||||
computeAllLayouts(graph)
|
||||
} else if (fileHandle) {
|
||||
const file = await fileHandle.getFile()
|
||||
const imported = await readFigFile(file)
|
||||
graph = imported
|
||||
computeAllLayouts(graph)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
undo.clear()
|
||||
savedVersion = state.sceneVersion
|
||||
state.selectedIds = new Set()
|
||||
if (graph.getNode(pageId)) {
|
||||
state.currentPageId = pageId
|
||||
} else {
|
||||
state.currentPageId = graph.getPages()[0]?.id ?? graph.rootId
|
||||
}
|
||||
state.panX = viewport.panX
|
||||
state.panY = viewport.panY
|
||||
state.zoom = viewport.zoom
|
||||
requestRender()
|
||||
}
|
||||
|
||||
function stopWatchingFile() {
|
||||
if (unwatchFile) {
|
||||
unwatchFile()
|
||||
unwatchFile = null
|
||||
}
|
||||
}
|
||||
|
||||
async function startWatchingFile() {
|
||||
stopWatchingFile()
|
||||
|
||||
if (filePath && IS_TAURI) {
|
||||
const { watch: tauriWatch } = await import('@tauri-apps/plugin-fs')
|
||||
const path = filePath
|
||||
const unwatch = await tauriWatch(
|
||||
path,
|
||||
(event) => {
|
||||
if (typeof event.type !== 'object' || !('modify' in event.type)) return
|
||||
if (Date.now() - lastWriteTime < WATCH_DEBOUNCE_MS) return
|
||||
reloadFromDisk()
|
||||
},
|
||||
{ delayMs: 500 }
|
||||
)
|
||||
unwatchFile = () => unwatch()
|
||||
} else if (fileHandle) {
|
||||
let lastModified = (await fileHandle.getFile()).lastModified
|
||||
const interval = setInterval(async () => {
|
||||
if (!fileHandle) {
|
||||
clearInterval(interval)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const file = await fileHandle.getFile()
|
||||
if (file.lastModified > lastModified) {
|
||||
lastModified = file.lastModified
|
||||
if (Date.now() - lastWriteTime < WATCH_DEBOUNCE_MS) return
|
||||
reloadFromDisk()
|
||||
}
|
||||
} catch {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, 2000)
|
||||
unwatchFile = () => clearInterval(interval)
|
||||
}
|
||||
}
|
||||
|
||||
async function renderExportImage(
|
||||
nodeIds: string[],
|
||||
scale: number,
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ describe('Shadow spread support', () => {
|
|||
)
|
||||
expect(dropShadowSection).toContain('effect.spread')
|
||||
expect(dropShadowSection).toContain('makeRRectWithSpread')
|
||||
expect(dropShadowSection).toContain('MakeDropShadowOnly')
|
||||
expect(dropShadowSection).toContain('getCachedDropShadow')
|
||||
})
|
||||
|
||||
test('inner shadow uses spread for cutout contraction', () => {
|
||||
|
|
@ -81,7 +81,7 @@ describe('Text shadow renders on glyphs, not bounding box', () => {
|
|||
)
|
||||
expect(dropShadowBlock).toContain("node.type === 'TEXT'")
|
||||
expect(dropShadowBlock).toContain('renderText')
|
||||
expect(dropShadowBlock).toContain('MakeDropShadowOnly')
|
||||
expect(dropShadowBlock).toContain('getCachedDropShadow')
|
||||
})
|
||||
|
||||
test('inner shadow has TEXT-specific branch', () => {
|
||||
|
|
@ -97,10 +97,9 @@ describe('Text shadow renders on glyphs, not bounding box', () => {
|
|||
describe('Blur effects use saveLayer pattern', () => {
|
||||
test('layer blur wraps node content in a blurred saveLayer', () => {
|
||||
expect(rendererSource).toContain("e.type === 'LAYER_BLUR'")
|
||||
// Applied at the renderNode level, not in renderEffects
|
||||
const layerBlurIdx = rendererSource.indexOf("e.type === 'LAYER_BLUR'")
|
||||
const layerBlurSection = rendererSource.slice(layerBlurIdx - 200, layerBlurIdx + 300)
|
||||
expect(layerBlurSection).toContain('MakeBlur')
|
||||
expect(layerBlurSection).toContain('getCachedBlur')
|
||||
expect(layerBlurSection).toContain('saveLayer')
|
||||
})
|
||||
|
||||
|
|
@ -114,7 +113,7 @@ describe('Blur effects use saveLayer pattern', () => {
|
|||
const idx = rendererSource.indexOf('private applyClippedBlur')
|
||||
const section = rendererSource.slice(idx, idx + 500)
|
||||
expect(section).toContain('clipNodeShape')
|
||||
expect(section).toContain('MakeBlur')
|
||||
expect(section).toContain('getCachedBlur')
|
||||
expect(section).toContain('saveLayer')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import tailwindcss from '@tailwindcss/vite'
|
|||
import Icons from 'unplugin-icons/vite'
|
||||
import IconsResolver from 'unplugin-icons/resolver'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { copyFileSync, existsSync } from 'fs'
|
||||
import { copyFileSync, existsSync, mkdirSync } from 'fs'
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST
|
||||
|
|
@ -27,6 +27,21 @@ export default defineConfig(async () => ({
|
|||
if (existsSync(src) && !existsSync(dest)) {
|
||||
copyFileSync(src, dest)
|
||||
}
|
||||
|
||||
const webgpuSrc = 'packages/core/vendor/canvaskit-webgpu/canvaskit.wasm'
|
||||
const webgpuDir = 'public/canvaskit-webgpu'
|
||||
const webgpuDest = `${webgpuDir}/canvaskit.wasm`
|
||||
if (existsSync(webgpuSrc) && !existsSync(webgpuDest)) {
|
||||
mkdirSync(webgpuDir, { recursive: true })
|
||||
copyFileSync(webgpuSrc, webgpuDest)
|
||||
}
|
||||
|
||||
const webgpuJsSrc = 'packages/core/vendor/canvaskit-webgpu/canvaskit.js'
|
||||
const webgpuJsDest = `${webgpuDir}/canvaskit.js`
|
||||
if (existsSync(webgpuJsSrc) && !existsSync(webgpuJsDest)) {
|
||||
mkdirSync(webgpuDir, { recursive: true })
|
||||
copyFileSync(webgpuJsSrc, webgpuJsDest)
|
||||
}
|
||||
}
|
||||
},
|
||||
tailwindcss(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue