From 631fc25ee6e314ac27d616a27b6f393e1aac0ce5 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 13 Aug 2026 21:39:35 +0300 Subject: [PATCH 1/7] fix(app): retain recovery after closing unsaved tabs (#505) * fix(app): retain recovery after closing unsaved tabs - Persist source-less tab snapshots before disposing editors - Keep retained snapshots available for startup restore or explicit discard - Cover close, reload, and restore behavior in Playwright * fix(app): surface recovery persistence failures - Propagate explicit close and reload snapshot failures - Keep background debounce failures logged without unhandled rejections - Exercise close-time persistence in recovery coverage * test(app): cover recovery persistence retry * docs: restore recovery retention note --- CHANGELOG.md | 1 + src/app/document/recovery/controller.ts | 10 ++-- src/app/tabs/index.ts | 14 +----- tests/e2e/autosave.spec.ts | 3 +- tests/e2e/recovery.spec.ts | 47 +++++++++++++++++++ .../app/document/recovery/controller.test.ts | 26 ++++++++++ 6 files changed, 82 insertions(+), 19 deletions(-) create mode 100644 tests/e2e/recovery.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1049eac36..09e890946 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ ### Fixed +- Keep unsaved source-less documents recoverable after their editor tab is closed, matching Figma's retained offline-change behavior. - Decode zstd-compressed FIG containers, reject invalid compressed payloads, and preserve exact fixture byte ranges. (#397) - Compose caller CSS with Tailwind defaults when importing DOM/CSS documents. (#397) - Preserve desktop HTTP timeout, abort, and empty-response semantics. (#397) diff --git a/src/app/document/recovery/controller.ts b/src/app/document/recovery/controller.ts index 3c36cb8b3..005ad9bd4 100644 --- a/src/app/document/recovery/controller.ts +++ b/src/app/document/recovery/controller.ts @@ -65,11 +65,9 @@ export function createDocumentRecovery({ if (requestedVersion === protectedVersion) return if (!writing) { const generation = lifecycleGeneration - writing = runWrites(generation) - .catch((error) => console.warn('[Recovery] Snapshot failed:', error)) - .finally(() => { - writing = null - }) + writing = runWrites(generation).finally(() => { + writing = null + }) } await writing } @@ -77,7 +75,7 @@ export function createDocumentRecovery({ const stop: WatchHandle = watchDebounced( () => state.sceneVersion, () => { - void persistNow() + void persistNow().catch((error) => console.warn('[Recovery] Snapshot failed:', error)) }, { debounce: 3000, maxWait: 10000 } ) diff --git a/src/app/tabs/index.ts b/src/app/tabs/index.ts index 56a6d2243..cd6953b7d 100644 --- a/src/app/tabs/index.ts +++ b/src/app/tabs/index.ts @@ -36,7 +36,6 @@ function generateTabId(): string { } const tabsRef = shallowRef([]) -const pendingRecoveryDeletions = new Set>() const activeTabId = shallowRef('') export const activeTab = computed(() => tabsRef.value.find((t) => t.id === activeTabId.value)) @@ -98,13 +97,7 @@ export async function closeTab(tabId: string): Promise { const closingTab = tabsRef.value[idx] const wasActive = activeTabId.value === tabId - const deletion = closingTab.store.discardRecovery() - pendingRecoveryDeletions.add(deletion) - try { - await deletion - } finally { - pendingRecoveryDeletions.delete(deletion) - } + await closingTab.store.persistRecoveryNow() closingTab.store.dispose() tabsRef.value = tabsRef.value.filter((t) => t.id !== tabId) @@ -304,10 +297,7 @@ export async function restoreRecoverySnapshot(id: string): Promise { } export async function prepareForReload(): Promise { - await Promise.all([ - ...pendingRecoveryDeletions, - ...tabsRef.value.map((tab) => tab.store.persistRecoveryNow()) - ]) + await Promise.all(tabsRef.value.map((tab) => tab.store.persistRecoveryNow())) } export function tabCount(): number { diff --git a/tests/e2e/autosave.spec.ts b/tests/e2e/autosave.spec.ts index 431a3af34..ad933514f 100644 --- a/tests/e2e/autosave.spec.ts +++ b/tests/e2e/autosave.spec.ts @@ -67,8 +67,9 @@ test('autosave triggers after scene changes with a file handle', async () => { expect(writeHappened).toBe(true) }) -test('no autosave without file handle', async ({ browser }) => { +test('no autosave without file handle', async ({ browser, baseURL }) => { const context = await browser.newContext({ + baseURL, viewport: { width: 1280, height: 800 }, deviceScaleFactor: 2 }) diff --git a/tests/e2e/recovery.spec.ts b/tests/e2e/recovery.spec.ts new file mode 100644 index 000000000..1155c87a8 --- /dev/null +++ b/tests/e2e/recovery.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from '#tests/e2e/fixtures' +import { CanvasHelper } from '#tests/helpers/canvas' + +test('keeps an unsaved document recoverable after its tab closes', async ({ browser, baseURL }) => { + const context = await browser.newContext({ baseURL }) + const page = await context.newPage() + await page.goto('/') + const canvas = new CanvasHelper(page) + await canvas.waitForInit() + + await page.evaluate(async () => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + const id = store.createShape('RECTANGLE', 120, 120, 240, 140) + await store.persistRecoveryNow() + store.updateNode(id, { name: 'Retained recovery rectangle' }) + }) + + await page.keyboard.press('ControlOrMeta+t') + await expect(page.getByRole('button', { name: 'New tab' })).toBeVisible() + await page.getByTestId('tabbar-tab').first().getByTestId('tabbar-close').click() + await expect(page.getByRole('button', { name: 'New tab' })).toBeHidden() + await expect + .poll(() => + page.evaluate(async () => { + const request = indexedDB.open('open-pencil-recovery') + const database = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) + const transaction = database.transaction('meta') + const countRequest = transaction.objectStore('meta').count() + return new Promise((resolve, reject) => { + countRequest.onsuccess = () => resolve(countRequest.result) + countRequest.onerror = () => reject(countRequest.error) + }) + }) + ) + .toBe(1) + + await page.reload() + await expect(page.getByRole('alertdialog', { name: 'Recover unsaved work' })).toBeVisible() + await page.getByRole('button', { name: 'Restore' }).click() + await expect(page.getByText('Retained recovery rectangle')).toBeVisible() + + await context.close() +}) diff --git a/tests/engine/app/document/recovery/controller.test.ts b/tests/engine/app/document/recovery/controller.test.ts index 572a072b0..dd06a906e 100644 --- a/tests/engine/app/document/recovery/controller.test.ts +++ b/tests/engine/app/document/recovery/controller.test.ts @@ -86,6 +86,32 @@ describe('document recovery controller', () => { recovery.disposeRecovery() }) + test('propagates persistence failures to close and reload callers', async () => { + const state = reactive({ ...createDefaultEditorState('page-1'), documentName: 'Draft' }) + const store = createMemoryRecoveryStore() + const memoryWrite = store.write.bind(store) + let writeAttempts = 0 + store.write = async (input) => { + writeAttempts++ + if (writeAttempts === 1) throw new Error('recovery storage unavailable') + return memoryWrite(input) + } + const recovery = createDocumentRecovery({ + state, + store, + recoveryId: 'recovery-1', + hasWritableSource: () => false, + buildFigFile: () => new Uint8Array([1]) + }) + state.sceneVersion = 1 + + await expect(recovery.persistNow()).rejects.toThrow('recovery storage unavailable') + await recovery.persistNow() + expect(writeAttempts).toBe(2) + expect((await store.read('recovery-1'))?.sceneVersion).toBe(1) + recovery.disposeRecovery() + }) + test('successful save removes recovery data', async () => { const { state, store, recovery } = setup() state.sceneVersion = 1 From 5190a64017bf4391dc2584042711f235c296f002 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 13 Aug 2026 22:23:37 +0300 Subject: [PATCH 2/7] fix(tauri): transfer FIG exports over binary IPC (#509) - Return native FIG archives as raw IPC responses instead of JSON byte arrays - Decode the response as an ArrayBuffer and cover the binary contract - Prevent large desktop exports from multiplying memory use during save --- CHANGELOG.md | 1 + desktop/src/fig_container.rs | 4 ++-- packages/core/src/io/formats/fig/export.ts | 2 +- tests/helpers/tauri/fig-export-fixture.ts | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09e890946..c3f7c5c85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ ### Fixed +- Transfer native `.fig` exports over binary Tauri IPC instead of JSON byte arrays, preventing large desktop saves from being truncated or exhausting WebView memory. (#484) - Keep unsaved source-less documents recoverable after their editor tab is closed, matching Figma's retained offline-change behavior. - Decode zstd-compressed FIG containers, reject invalid compressed payloads, and preserve exact fixture byte ranges. (#397) - Compose caller CSS with Tailwind defaults when importing DOM/CSS documents. (#397) diff --git a/desktop/src/fig_container.rs b/desktop/src/fig_container.rs index 16e206e8c..b30e7f1f3 100644 --- a/desktop/src/fig_container.rs +++ b/desktop/src/fig_container.rs @@ -15,7 +15,7 @@ pub fn build_fig_file( meta_json: String, images: Option>, fig_kiwi_version: Option, -) -> Result, String> { +) -> Result { let mut encoder = zstd::Encoder::new(Vec::new(), 3).map_err(|e| e.to_string())?; encoder .include_contentsize(true) @@ -63,5 +63,5 @@ pub fn build_fig_file( } let result = zip.finish().map_err(|e| e.to_string())?; - Ok(result.into_inner()) + Ok(tauri::ipc::Response::new(result.into_inner())) } diff --git a/packages/core/src/io/formats/fig/export.ts b/packages/core/src/io/formats/fig/export.ts index d3f0d3d44..dc3f11aa4 100644 --- a/packages/core/src/io/formats/fig/export.ts +++ b/packages/core/src/io/formats/fig/export.ts @@ -554,7 +554,7 @@ export async function exportFigFile( if (IS_TAURI) { const { invoke } = await import('@tauri-apps/api/core') return new Uint8Array( - await invoke('build_fig_file', { + await invoke('build_fig_file', { schemaDeflated: Array.from(schemaDeflated), kiwiData: Array.from(kiwiData), thumbnailPng: Array.from(thumbnailPNG), diff --git a/tests/helpers/tauri/fig-export-fixture.ts b/tests/helpers/tauri/fig-export-fixture.ts index 92b3b132a..1cba85d6e 100644 --- a/tests/helpers/tauri/fig-export-fixture.ts +++ b/tests/helpers/tauri/fig-export-fixture.ts @@ -20,7 +20,7 @@ mockIPC((cmd, args) => { if (payload.thumbnailPng.length === 0) throw new Error('thumbnailPng is empty') if (payload.images.length !== 0) throw new Error('images should be empty') JSON.parse(payload.metaJson) - return [7, 8, 9] + return new Uint8Array([7, 8, 9]).buffer }) const [{ exportFigFile }, { SceneGraph }] = await Promise.all([ From 3ee3708d71cb3d06ea7eee404e4b83085e480bf7 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 14 Aug 2026 12:37:05 +0300 Subject: [PATCH 3/7] perf(clipboard): index pasted node children (#511) - Build parent-to-child indexes once while decoding Figma clipboard data - Reuse the index for hierarchy traversal and internal component discovery - Avoid quadratic scans that blocked large flat pastes --- CHANGELOG.md | 1 + packages/core/src/clipboard.ts | 29 ++++++++++++++++------------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3f7c5c85..b70944f98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Performance +- Index Figma clipboard children once during import instead of rescanning every pasted node, keeping large flat pastes linear. (#500) - Reduce peak memory during `.fig` export by sharing immutable binary resources with the isolated export graph. ### Fixed diff --git a/packages/core/src/clipboard.ts b/packages/core/src/clipboard.ts index e16acc0bb..5ba9a51a9 100644 --- a/packages/core/src/clipboard.ts +++ b/packages/core/src/clipboard.ts @@ -139,25 +139,31 @@ export function figmaNodesBounds( interface ClipboardImportMaps { guidMap: Map parentMap: Map + childMap: Map } function buildClipboardMaps(nodeChanges: KiwiNodeChange[]): ClipboardImportMaps { const guidMap = new Map() const parentMap = new Map() + const childMap = new Map() for (const nc of nodeChanges) { if (!nc.guid) continue const id = `${nc.guid.sessionID}:${nc.guid.localID}` guidMap.set(id, nc) if (nc.parentIndex?.guid) { - parentMap.set(id, `${nc.parentIndex.guid.sessionID}:${nc.parentIndex.guid.localID}`) + const parentId = `${nc.parentIndex.guid.sessionID}:${nc.parentIndex.guid.localID}` + parentMap.set(id, parentId) + const siblings = childMap.get(parentId) + if (siblings) siblings.push(id) + else childMap.set(parentId, [id]) } } - return { guidMap, parentMap } + return { guidMap, parentMap, childMap } } function findInternalNodeIds( guidMap: Map, - parentMap: Map + childMap: Map ): { internalCanvasIds: Set; internalFigmaIds: Set } { const internalCanvasIds = new Set() for (const [id, nc] of guidMap) { @@ -169,8 +175,8 @@ function findInternalNodeIds( const internalFigmaIds = new Set() function markInternal(id: string) { internalFigmaIds.add(id) - for (const [childId, pid] of parentMap) { - if (pid === id && !internalFigmaIds.has(childId)) markInternal(childId) + for (const childId of childMap.get(id) ?? []) { + if (!internalFigmaIds.has(childId)) markInternal(childId) } } for (const canvasId of internalCanvasIds) markInternal(canvasId) @@ -230,8 +236,8 @@ export function importClipboardNodes( offsetY = 0, blobs: Uint8Array[] = [] ): string[] { - const { guidMap, parentMap } = buildClipboardMaps(nodeChanges) - const { internalCanvasIds, internalFigmaIds } = findInternalNodeIds(guidMap, parentMap) + const { guidMap, parentMap, childMap } = buildClipboardMaps(nodeChanges) + const { internalCanvasIds, internalFigmaIds } = findInternalNodeIds(guidMap, childMap) const { topLevel, internalTopLevel } = classifyTopLevelNodes( guidMap, parentMap, @@ -262,12 +268,9 @@ export function importClipboardNodes( created.set(figmaId, node.id) if (ourParentId === targetParentId && !internalFigmaIds.has(figmaId)) createdIds.push(node.id) - const children: string[] = [] - for (const [childId, pid] of parentMap) { - if (pid === figmaId && !NON_VISUAL_TYPES.has(guidMap.get(childId)?.type ?? '')) { - children.push(childId) - } - } + const children = (childMap.get(figmaId) ?? []).filter( + (childId) => !NON_VISUAL_TYPES.has(guidMap.get(childId)?.type ?? '') + ) sortChildren(children, nc, guidMap) for (const childId of children) { createNode(childId, node.id) From 742522a69254271b6518bcf44d848ccc30513eba Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 14 Aug 2026 12:56:13 +0300 Subject: [PATCH 4/7] fix(io): preserve SVG clip paths - Resolve clipPath geometry referenced through use elements - Import clipped paint runs as editable mask groups - Cover clipped multicolor SVG imports with a regression test --- CHANGELOG.md | 1 + packages/core/src/icons/svg.ts | 47 +++++++++++-- packages/core/src/icons/types.ts | 4 ++ .../core/src/vector/vectorize/placement.ts | 69 +++++++++++++++++-- .../src/vector/vectorize/svg/to-vectors.ts | 21 +++++- tests/engine/io/svg/import.test.ts | 26 +++++++ 6 files changed, 158 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1049eac36..0918ec118 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ - Remove the permanent CORS configuration action from cloud-storage settings and report connection results through standard toasts with clear browser-specific guidance. - Complete translated app, accessibility, font, color, collaboration, import, connection-test, and browser fallback text across all supported locales, and keep the document language synchronized with the selected locale. - Preserve effective nested instance text overrides when importing complex Figma component hierarchies. (#102) +- Preserve SVG clip paths, including clip shapes referenced through ``, when importing editable vectors. - Preserve circles, ellipses, rectangles, lines, polylines, and polygons supplied as JSX children of inline SVG elements. (#452) ## 0.14.0 — 2026-08-10 diff --git a/packages/core/src/icons/svg.ts b/packages/core/src/icons/svg.ts index 7ab25ad60..2325eb07b 100644 --- a/packages/core/src/icons/svg.ts +++ b/packages/core/src/icons/svg.ts @@ -12,7 +12,7 @@ import type { Vector } from '@open-pencil/scene-graph/primitives' import { parseSVGFragment } from '#core/io/formats/svg/document' -import type { IconData, IconifyIconEntry, IconPathInfo } from './types' +import type { IconData, IconifyIconEntry, IconPathInfo, SVGClipPathInfo } from './types' interface SVGElementInput { type: string @@ -185,6 +185,7 @@ function appendShapePath( element: Element, presentation: PresentationAttributes, transform: string | null, + clipPaths: SVGClipPathInfo[][], result: IconPathInfo[] ): void { if (!SHAPE_NAMES.has(tagName)) return @@ -199,7 +200,8 @@ function appendShapePath( strokeCap: presentation.strokeCap, strokeJoin: presentation.strokeJoin, fillRule: presentation.fillRule === 'evenodd' ? 'EVENODD' : 'NONZERO', - transform + transform, + clipPaths: clipPaths.length > 0 ? clipPaths : undefined }) } @@ -233,6 +235,28 @@ function collectUsePaths( return true } +function collectClipPaths( + value: string | null, + parentTransform: string | null, + elementsById: ReadonlyMap +): SVGClipPathInfo[] { + const match = value?.trim().match(/^url\(\s*['"]?#([^'")\s]+)['"]?\s*\)$/) + const target = match ? elementsById.get(match[1]) : null + if (!target || (target.localName || target.tagName) !== 'clipPath') return [] + + const paths: IconPathInfo[] = [] + collectPaths( + target, + { ...DEFAULT_PRESENTATION, fill: '#000000' }, + parentTransform, + paths, + elementsById, + new Set([target]), + true + ) + return paths.map(({ d, fillRule, transform }) => ({ d, fillRule, transform })) +} + function collectPaths( element: Element, inherited: PresentationAttributes, @@ -240,19 +264,32 @@ function collectPaths( result: IconPathInfo[], elementsById: ReadonlyMap, useStack: ReadonlySet = new Set(), - referenced = false + referenced = false, + inheritedClipPaths: SVGClipPathInfo[][] = [] ): void { const tagName = element.localName || element.tagName if (NON_RENDERED_CONTAINERS.has(tagName) && !referenced) return const presentation = presentationFor(element, inherited) const transform = combinedTransform(parentTransform, element) + const ownClipPaths = collectClipPaths(element.getAttribute('clip-path'), transform, elementsById) + const clipPaths = + ownClipPaths.length > 0 ? [...inheritedClipPaths, ownClipPaths] : inheritedClipPaths if (collectUsePaths(element, presentation, transform, result, elementsById, useStack)) return - appendShapePath(tagName, element, presentation, transform, result) + appendShapePath(tagName, element, presentation, transform, clipPaths, result) for (const child of Array.from(element.childNodes)) { if (isElement(child)) { - collectPaths(child, presentation, transform, result, elementsById, useStack, referenced) + collectPaths( + child, + presentation, + transform, + result, + elementsById, + useStack, + referenced, + clipPaths + ) } } } diff --git a/packages/core/src/icons/types.ts b/packages/core/src/icons/types.ts index e8df7c89b..299964cab 100644 --- a/packages/core/src/icons/types.ts +++ b/packages/core/src/icons/types.ts @@ -45,6 +45,10 @@ export interface IconPathInfo { strokeCap: string strokeJoin: string fillRule: WindingRule + /** Nested SVG clip regions, ordered from outermost to innermost. */ + clipPaths?: SVGClipPathInfo[][] /** Raw transform attribute from the source SVG element. */ transform?: string | null } + +export type SVGClipPathInfo = Pick diff --git a/packages/core/src/vector/vectorize/placement.ts b/packages/core/src/vector/vectorize/placement.ts index 61750c2c7..4dbc7a6c3 100644 --- a/packages/core/src/vector/vectorize/placement.ts +++ b/packages/core/src/vector/vectorize/placement.ts @@ -27,7 +27,8 @@ interface NormalizedVectorGeometry { bounds: Rect } -type VectorChildPaints = Pick +type VectorChildPaints = Pick & + Partial> function shouldTightenToContent( node: Pick, @@ -206,6 +207,48 @@ export function createVectorFrameChildren( } } +function createClipFrame( + graph: SceneGraph, + frameId: string, + placement: VectorFramePlacement, + index: number +): SceneNode { + return graph.createNode('FRAME', frameId, { + name: `clip ${index + 1}`, + x: 0, + y: 0, + width: placement.width, + height: placement.height, + fills: [] + }) +} + +function createClipMaskChild( + graph: SceneGraph, + frameId: string, + clipNetwork: VectorNetwork, + placement: VectorFramePlacement, + index: number +): void { + const network = offsetVectorNetwork(clipNetwork, placement.offsetX, placement.offsetY) + const normalized = normalizeVectorToNodeBounds(network) + if (!normalized) return + createNormalizedVectorChild(graph, frameId, normalized, index, { + fillGeometry: [], + fills: [ + { + type: 'SOLID', + color: { r: 1, g: 1, b: 1, a: 1 }, + opacity: 1, + visible: true + } + ], + strokes: [], + isMask: true, + maskType: 'VECTOR' + }) +} + function isFlattenableVectorPath(path: VectorizedPath): boolean { return path.fills.length > 0 && path.strokes.length === 0 && path.vectorNetwork.regions.length > 0 } @@ -218,28 +261,46 @@ export function createFlattenedVectorFrameChildren( placement: VectorFramePlacement ): void { let run: { path: VectorizedPath; index: number }[] = [] + let runClipNetwork: VectorNetwork | undefined const flush = () => { + if (run.length === 0) return + const targetFrameId = runClipNetwork + ? createClipFrame(graph, frameId, placement, run[0].index).id + : frameId + if (runClipNetwork) { + createClipMaskChild(graph, targetFrameId, runClipNetwork, placement, run[0].index) + } if (run.length > 1) { createFlattenedVectorChild( graph, - frameId, + targetFrameId, run.map(({ path }) => path), placement, run[0].index ) } else if (run[0]) { - createVectorChild(graph, frameId, run[0].path, placement, run[0].index) + createVectorChild(graph, targetFrameId, run[0].path, placement, run[0].index) } run = [] + runClipNetwork = undefined } for (const [index, path] of vectorized.paths.entries()) { + const clipNetwork = path.clipNetworks?.at(-1) if (isFlattenableVectorPath(path)) { + if (run.length > 0 && runClipNetwork !== clipNetwork) flush() + runClipNetwork = clipNetwork run.push({ path, index }) continue } flush() - createVectorChild(graph, frameId, path, placement, index) + if (clipNetwork) { + const clipFrame = createClipFrame(graph, frameId, placement, index) + createClipMaskChild(graph, clipFrame.id, clipNetwork, placement, index) + createVectorChild(graph, clipFrame.id, path, placement, index) + } else { + createVectorChild(graph, frameId, path, placement, index) + } } flush() } diff --git a/packages/core/src/vector/vectorize/svg/to-vectors.ts b/packages/core/src/vector/vectorize/svg/to-vectors.ts index ae8a18969..fba617445 100644 --- a/packages/core/src/vector/vectorize/svg/to-vectors.ts +++ b/packages/core/src/vector/vectorize/svg/to-vectors.ts @@ -6,6 +6,7 @@ * (viewBox, else width/height) into the target node bounds before parsing. */ import type { Fill, Stroke, VectorNetwork, WindingRule } from '@open-pencil/scene-graph' +import { mergeVectorNetworks } from '@open-pencil/scene-graph' import { computeBounds } from '@open-pencil/scene-graph/geometry' import { parseSVGPath } from '@open-pencil/scene-graph/parse-path' import type { Rect, Size } from '@open-pencil/scene-graph/primitives' @@ -59,6 +60,7 @@ export interface VectorizedPath { vectorNetwork: VectorNetwork fills: Fill[] strokes: Stroke[] + clipNetworks?: VectorNetwork[] } export interface SVGVectorizeResult { @@ -89,6 +91,7 @@ export function svgToVectorPaths( const strokeScale = Math.min(viewport.scaleX, viewport.scaleY) const vectorized: VectorizedPath[] = [] + const clipCache = new WeakMap, VectorNetwork[]>() for (const path of paths) { const fillRule: WindingRule = path.fillRule const transform = path.transform ?? null @@ -105,10 +108,26 @@ export function svgToVectorPaths( computeAccurateBounds(network) ) : null + let clipNetworks: VectorNetwork[] | undefined + if (path.clipPaths) { + clipNetworks = clipCache.get(path.clipPaths) + if (!clipNetworks) { + clipNetworks = path.clipPaths.map((clipRegion) => + mergeVectorNetworks( + clipRegion.map((clipPath) => { + const clipData = applySVGTransformToPath(clipPath.d, clipPath.transform ?? null) + return parseSVGPath(mapSVGPathToViewport(clipData, viewport), clipPath.fillRule) + }) + ) + ) + clipCache.set(path.clipPaths, clipNetworks) + } + } vectorized.push({ vectorNetwork: network, fills: gradientFill ? [gradientFill] : resolveFill(path, defaultColor), - strokes: resolveStrokes(path, defaultColor, strokeScale) + strokes: resolveStrokes(path, defaultColor, strokeScale), + clipNetworks }) } diff --git a/tests/engine/io/svg/import.test.ts b/tests/engine/io/svg/import.test.ts index 448dbedd9..31dc78401 100644 --- a/tests/engine/io/svg/import.test.ts +++ b/tests/engine/io/svg/import.test.ts @@ -263,6 +263,32 @@ describe('import_svg', () => { expect(path.fills[0].color.b).toBeCloseTo(1) }) + test('imports clip paths as masks for clipped paint runs', async () => { + const result = (await importSVG.execute(figma, { + svg: ` + + + + + + + + ` + })) as { id: string } + + const children = graph.getChildren(result.id) + expect(children).toHaveLength(2) + const clippedGroup = expectDefined(children[0]) + expect(clippedGroup.type).toBe('FRAME') + const clippedChildren = graph.getChildren(clippedGroup.id) + expect(clippedChildren).toHaveLength(2) + expect(clippedChildren[0].isMask).toBe(true) + expect(clippedChildren[0].maskType).toBe('VECTOR') + expect(expectDefined(clippedChildren[0].vectorNetwork).regions).toHaveLength(1) + expect(clippedChildren[1].fillGeometry).toHaveLength(2) + expect(children[1].isMask).toBe(false) + }) + test('imports gradient fills through the shared SVG pipeline', async () => { const result = (await importSVG.execute(figma, { svg: `` From 5d66cb68a6b723a6ebf877c6ad00af0dcc385dd8 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 14 Aug 2026 13:19:55 +0300 Subject: [PATCH 5/7] chore: add reproducible Dev Container (#510) Supersedes the Docker Compose development setup proposed in #118. --- .devcontainer/Dockerfile | 34 ++++++++++++++++++++++++++++++ .devcontainer/devcontainer.json | 37 +++++++++++++++++++++++++++++++++ CHANGELOG.md | 1 + README.md | 4 ++++ 4 files changed, 76 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 000000000..ad72b4f60 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,34 @@ +FROM oven/bun@sha256:b86c67b531d87b4db11470d9b2bd0c519b1976eee6fcd71634e73abfa6230d2e + +# Bun 1.3.10, pinned to the multi-platform image digest. + +ARG CA_CERTIFICATES_VERSION=20250419 +ARG GIT_VERSION=1:2.47.3-0+deb13u1 +ARG GIT_LFS_VERSION=3.6.1-1+deb13u1 +ARG OPENSSH_CLIENT_VERSION=1:10.0p1-7+deb13u4 + +USER root + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends \ + ca-certificates=${CA_CERTIFICATES_VERSION} \ + git=${GIT_VERSION} \ + git-lfs=${GIT_LFS_VERSION} \ + openssh-client=${OPENSSH_CLIENT_VERSION} \ + && rm -rf /var/lib/apt/lists/* \ + && git lfs install --system \ + && install -d -o bun -g bun \ + /workspace/open-pencil/node_modules \ + /workspace/open-pencil/packages/scene-graph/node_modules \ + /workspace/open-pencil/packages/pen/node_modules \ + /workspace/open-pencil/packages/kiwi/node_modules \ + /workspace/open-pencil/packages/fig/node_modules \ + /workspace/open-pencil/packages/core/node_modules \ + /workspace/open-pencil/packages/dom-css/node_modules \ + /workspace/open-pencil/packages/vue/node_modules \ + /workspace/open-pencil/packages/cli/node_modules \ + /workspace/open-pencil/packages/mcp/node_modules \ + /workspace/open-pencil/packages/docs/node_modules \ + /workspace/open-pencil/tools/docs/node_modules + +USER bun diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..93fa2a157 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.base.schema.json", + "name": "OpenPencil", + "build": { + "dockerfile": "Dockerfile", + "context": "." + }, + "workspaceMount": "source=${localWorkspaceFolder},target=/workspace/open-pencil,type=bind,consistency=cached", + "workspaceFolder": "/workspace/open-pencil", + "remoteUser": "bun", + "updateRemoteUserUID": true, + "mounts": [ + "source=open-pencil-root-modules-${devcontainerId},target=/workspace/open-pencil/node_modules,type=volume", + "source=open-pencil-scene-graph-modules-${devcontainerId},target=/workspace/open-pencil/packages/scene-graph/node_modules,type=volume", + "source=open-pencil-pen-modules-${devcontainerId},target=/workspace/open-pencil/packages/pen/node_modules,type=volume", + "source=open-pencil-kiwi-modules-${devcontainerId},target=/workspace/open-pencil/packages/kiwi/node_modules,type=volume", + "source=open-pencil-fig-modules-${devcontainerId},target=/workspace/open-pencil/packages/fig/node_modules,type=volume", + "source=open-pencil-core-modules-${devcontainerId},target=/workspace/open-pencil/packages/core/node_modules,type=volume", + "source=open-pencil-dom-css-modules-${devcontainerId},target=/workspace/open-pencil/packages/dom-css/node_modules,type=volume", + "source=open-pencil-vue-modules-${devcontainerId},target=/workspace/open-pencil/packages/vue/node_modules,type=volume", + "source=open-pencil-cli-modules-${devcontainerId},target=/workspace/open-pencil/packages/cli/node_modules,type=volume", + "source=open-pencil-mcp-modules-${devcontainerId},target=/workspace/open-pencil/packages/mcp/node_modules,type=volume", + "source=open-pencil-docs-modules-${devcontainerId},target=/workspace/open-pencil/packages/docs/node_modules,type=volume", + "source=open-pencil-docs-tool-modules-${devcontainerId},target=/workspace/open-pencil/tools/docs/node_modules,type=volume" + ], + "postCreateCommand": "if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then git lfs pull; fi && bun install --frozen-lockfile", + "containerEnv": { + "TAURI_DEV_HOST": "0.0.0.0" + }, + "forwardPorts": [1420], + "portsAttributes": { + "1420": { + "label": "OpenPencil web editor", + "onAutoForward": "notify" + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index b70944f98..bc9aa4ff2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Add a reproducible Dev Container for web, package, CLI, and non-browser test development. - Add local crash recovery for unsaved and pathless documents, including MCP-created documents. (#487) - Add isolated visual inspection that sends bounded selection renders to the configured Vision model and returns text findings without retaining image data in Design chat history. (#232, #471) - Allow supported AI model profiles to set a provider-specific reasoning effort. (#454) diff --git a/README.md b/README.md index c714a37c7..c75e9d115 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,10 @@ bun run dev # Dev server at localhost:1420 bun run tauri dev # Desktop app (requires Rust) ``` +Alternatively, open the repository in any [Dev Container](https://containers.dev/)-compatible tool. The container pins Bun, installs the workspace dependencies, and forwards the web editor on port 1420. Start it with `bun run dev` after the container is ready. + +The Dev Container supports the web editor, packages, CLI, and automated checks. Native Tauri development still requires the host setup described below because desktop windows and platform WebView dependencies are not provided in the container. + ### Quality gates | Command | Description | From bcf361f45dbaa0556abb4893e038978b4eb8f6a5 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 14 Aug 2026 13:13:29 +0300 Subject: [PATCH 6/7] fix(io): handle bounded and nested SVG clips - Carry inherited clip metadata through use expansions - Map objectBoundingBox clips per painted path bounds - Apply nested clip regions as ordered vector mask frames --- packages/core/src/icons/svg.ts | 37 ++++++---- packages/core/src/icons/types.ts | 7 +- .../core/src/vector/vectorize/placement.ts | 40 +++++++---- .../src/vector/vectorize/svg/to-vectors.ts | 21 ++++-- tests/engine/io/svg/import.test.ts | 67 +++++++++++++++++++ 5 files changed, 139 insertions(+), 33 deletions(-) diff --git a/packages/core/src/icons/svg.ts b/packages/core/src/icons/svg.ts index 2325eb07b..c8b3f5bf4 100644 --- a/packages/core/src/icons/svg.ts +++ b/packages/core/src/icons/svg.ts @@ -12,7 +12,7 @@ import type { Vector } from '@open-pencil/scene-graph/primitives' import { parseSVGFragment } from '#core/io/formats/svg/document' -import type { IconData, IconifyIconEntry, IconPathInfo, SVGClipPathInfo } from './types' +import type { IconData, IconifyIconEntry, IconPathInfo, SVGClipPathRegion } from './types' interface SVGElementInput { type: string @@ -185,7 +185,7 @@ function appendShapePath( element: Element, presentation: PresentationAttributes, transform: string | null, - clipPaths: SVGClipPathInfo[][], + clipPaths: SVGClipPathRegion[], result: IconPathInfo[] ): void { if (!SHAPE_NAMES.has(tagName)) return @@ -211,7 +211,8 @@ function collectUsePaths( transform: string | null, result: IconPathInfo[], elementsById: ReadonlyMap, - useStack: ReadonlySet + useStack: ReadonlySet, + clipPaths: SVGClipPathRegion[] ): boolean { const tagName = element.localName || element.tagName if (tagName !== 'use') return false @@ -229,32 +230,40 @@ function collectUsePaths( result, elementsById, new Set([...useStack, target]), - true + true, + clipPaths ) } return true } -function collectClipPaths( +function collectClipPath( value: string | null, parentTransform: string | null, elementsById: ReadonlyMap -): SVGClipPathInfo[] { +): SVGClipPathRegion | null { const match = value?.trim().match(/^url\(\s*['"]?#([^'")\s]+)['"]?\s*\)$/) const target = match ? elementsById.get(match[1]) : null - if (!target || (target.localName || target.tagName) !== 'clipPath') return [] + if (!target || (target.localName || target.tagName) !== 'clipPath') return null + const units = + target.getAttribute('clipPathUnits') === 'objectBoundingBox' + ? 'objectBoundingBox' + : 'userSpaceOnUse' const paths: IconPathInfo[] = [] collectPaths( target, { ...DEFAULT_PRESENTATION, fill: '#000000' }, - parentTransform, + units === 'objectBoundingBox' ? null : parentTransform, paths, elementsById, new Set([target]), true ) - return paths.map(({ d, fillRule, transform }) => ({ d, fillRule, transform })) + return { + paths: paths.map(({ d, fillRule, transform }) => ({ d, fillRule, transform })), + units + } } function collectPaths( @@ -265,17 +274,17 @@ function collectPaths( elementsById: ReadonlyMap, useStack: ReadonlySet = new Set(), referenced = false, - inheritedClipPaths: SVGClipPathInfo[][] = [] + inheritedClipPaths: SVGClipPathRegion[] = [] ): void { const tagName = element.localName || element.tagName if (NON_RENDERED_CONTAINERS.has(tagName) && !referenced) return const presentation = presentationFor(element, inherited) const transform = combinedTransform(parentTransform, element) - const ownClipPaths = collectClipPaths(element.getAttribute('clip-path'), transform, elementsById) - const clipPaths = - ownClipPaths.length > 0 ? [...inheritedClipPaths, ownClipPaths] : inheritedClipPaths - if (collectUsePaths(element, presentation, transform, result, elementsById, useStack)) return + const ownClipPath = collectClipPath(element.getAttribute('clip-path'), transform, elementsById) + const clipPaths = ownClipPath ? [...inheritedClipPaths, ownClipPath] : inheritedClipPaths + if (collectUsePaths(element, presentation, transform, result, elementsById, useStack, clipPaths)) + return appendShapePath(tagName, element, presentation, transform, clipPaths, result) for (const child of Array.from(element.childNodes)) { diff --git a/packages/core/src/icons/types.ts b/packages/core/src/icons/types.ts index 299964cab..e2c1471f5 100644 --- a/packages/core/src/icons/types.ts +++ b/packages/core/src/icons/types.ts @@ -46,9 +46,14 @@ export interface IconPathInfo { strokeJoin: string fillRule: WindingRule /** Nested SVG clip regions, ordered from outermost to innermost. */ - clipPaths?: SVGClipPathInfo[][] + clipPaths?: SVGClipPathRegion[] /** Raw transform attribute from the source SVG element. */ transform?: string | null } export type SVGClipPathInfo = Pick + +export interface SVGClipPathRegion { + paths: SVGClipPathInfo[] + units: 'userSpaceOnUse' | 'objectBoundingBox' +} diff --git a/packages/core/src/vector/vectorize/placement.ts b/packages/core/src/vector/vectorize/placement.ts index 4dbc7a6c3..3aa4069e3 100644 --- a/packages/core/src/vector/vectorize/placement.ts +++ b/packages/core/src/vector/vectorize/placement.ts @@ -249,6 +249,22 @@ function createClipMaskChild( }) } +function createClipFrames( + graph: SceneGraph, + frameId: string, + clipNetworks: VectorNetwork[], + placement: VectorFramePlacement, + index: number +): string { + let targetFrameId = frameId + for (const clipNetwork of clipNetworks) { + const clipFrame = createClipFrame(graph, targetFrameId, placement, index) + createClipMaskChild(graph, clipFrame.id, clipNetwork, placement, index) + targetFrameId = clipFrame.id + } + return targetFrameId +} + function isFlattenableVectorPath(path: VectorizedPath): boolean { return path.fills.length > 0 && path.strokes.length === 0 && path.vectorNetwork.regions.length > 0 } @@ -261,15 +277,12 @@ export function createFlattenedVectorFrameChildren( placement: VectorFramePlacement ): void { let run: { path: VectorizedPath; index: number }[] = [] - let runClipNetwork: VectorNetwork | undefined + let runClipNetworks: VectorNetwork[] | undefined const flush = () => { if (run.length === 0) return - const targetFrameId = runClipNetwork - ? createClipFrame(graph, frameId, placement, run[0].index).id + const targetFrameId = runClipNetworks + ? createClipFrames(graph, frameId, runClipNetworks, placement, run[0].index) : frameId - if (runClipNetwork) { - createClipMaskChild(graph, targetFrameId, runClipNetwork, placement, run[0].index) - } if (run.length > 1) { createFlattenedVectorChild( graph, @@ -282,22 +295,21 @@ export function createFlattenedVectorFrameChildren( createVectorChild(graph, targetFrameId, run[0].path, placement, run[0].index) } run = [] - runClipNetwork = undefined + runClipNetworks = undefined } for (const [index, path] of vectorized.paths.entries()) { - const clipNetwork = path.clipNetworks?.at(-1) + const clipNetworks = path.clipNetworks if (isFlattenableVectorPath(path)) { - if (run.length > 0 && runClipNetwork !== clipNetwork) flush() - runClipNetwork = clipNetwork + if (run.length > 0 && runClipNetworks !== clipNetworks) flush() + runClipNetworks = clipNetworks run.push({ path, index }) continue } flush() - if (clipNetwork) { - const clipFrame = createClipFrame(graph, frameId, placement, index) - createClipMaskChild(graph, clipFrame.id, clipNetwork, placement, index) - createVectorChild(graph, clipFrame.id, path, placement, index) + if (clipNetworks) { + const targetFrameId = createClipFrames(graph, frameId, clipNetworks, placement, index) + createVectorChild(graph, targetFrameId, path, placement, index) } else { createVectorChild(graph, frameId, path, placement, index) } diff --git a/packages/core/src/vector/vectorize/svg/to-vectors.ts b/packages/core/src/vector/vectorize/svg/to-vectors.ts index fba617445..16268ed17 100644 --- a/packages/core/src/vector/vectorize/svg/to-vectors.ts +++ b/packages/core/src/vector/vectorize/svg/to-vectors.ts @@ -5,6 +5,8 @@ * reflect the input pixel size. Scale path data from the SVG coordinate space * (viewBox, else width/height) into the target node bounds before parsing. */ +import svgpath from 'svgpath' + import type { Fill, Stroke, VectorNetwork, WindingRule } from '@open-pencil/scene-graph' import { mergeVectorNetworks } from '@open-pencil/scene-graph' import { computeBounds } from '@open-pencil/scene-graph/geometry' @@ -98,6 +100,7 @@ export function svgToVectorPaths( const pathData = applySVGTransformToPath(path.d, transform) const scaledD = mapSVGPathToViewport(pathData, viewport) const network = parseSVGPath(scaledD, fillRule) + const pathBounds = computeAccurateBounds(network) const gradientFill = gradients.size > 0 ? resolveGradientFill( @@ -110,17 +113,27 @@ export function svgToVectorPaths( : null let clipNetworks: VectorNetwork[] | undefined if (path.clipPaths) { - clipNetworks = clipCache.get(path.clipPaths) + const hasObjectBoundingBoxClip = path.clipPaths.some( + ({ units }) => units === 'objectBoundingBox' + ) + clipNetworks = hasObjectBoundingBoxClip ? undefined : clipCache.get(path.clipPaths) if (!clipNetworks) { clipNetworks = path.clipPaths.map((clipRegion) => mergeVectorNetworks( - clipRegion.map((clipPath) => { - const clipData = applySVGTransformToPath(clipPath.d, clipPath.transform ?? null) + clipRegion.paths.map((clipPath) => { + let clipData = applySVGTransformToPath(clipPath.d, clipPath.transform ?? null) + if (clipRegion.units === 'objectBoundingBox') { + clipData = svgpath(clipData) + .scale(pathBounds.width, pathBounds.height) + .translate(pathBounds.x, pathBounds.y) + .toString() + return parseSVGPath(clipData, clipPath.fillRule) + } return parseSVGPath(mapSVGPathToViewport(clipData, viewport), clipPath.fillRule) }) ) ) - clipCache.set(path.clipPaths, clipNetworks) + if (!hasObjectBoundingBoxClip) clipCache.set(path.clipPaths, clipNetworks) } } vectorized.push({ diff --git a/tests/engine/io/svg/import.test.ts b/tests/engine/io/svg/import.test.ts index 31dc78401..c2a4c1000 100644 --- a/tests/engine/io/svg/import.test.ts +++ b/tests/engine/io/svg/import.test.ts @@ -289,6 +289,73 @@ describe('import_svg', () => { expect(children[1].isMask).toBe(false) }) + test('preserves inherited clips when expanding use elements', async () => { + const result = (await importSVG.execute(figma, { + svg: ` + + + + + + ` + })) as { id: string } + + const clipFrame = expectDefined(graph.getChildren(result.id)[0]) + const clippedChildren = graph.getChildren(clipFrame.id) + expect(clippedChildren).toHaveLength(2) + expect(clippedChildren[0].isMask).toBe(true) + expect(clippedChildren[1].fills[0].color.r).toBeCloseTo(1) + }) + + test('applies nested clip paths from outermost to innermost', async () => { + const result = (await importSVG.execute(figma, { + svg: ` + + + + + + + + ` + })) as { id: string } + + const outerFrame = expectDefined(graph.getChildren(result.id)[0]) + const outerChildren = graph.getChildren(outerFrame.id) + expect(outerChildren[0].isMask).toBe(true) + const innerFrame = expectDefined(outerChildren[1]) + expect(innerFrame.type).toBe('FRAME') + const innerChildren = graph.getChildren(innerFrame.id) + expect(innerChildren[0].isMask).toBe(true) + expect(innerChildren[1].type).toBe('VECTOR') + }) + + test('maps objectBoundingBox clip paths to each painted path bounds', async () => { + const result = (await importSVG.execute(figma, { + svg: ` + + + + + + + + ` + })) as { id: string } + + const [leftFrame, rightFrame] = graph.getChildren(result.id) + const leftMask = expectDefined(graph.getChildren(expectDefined(leftFrame).id)[0]) + const rightMask = expectDefined(graph.getChildren(expectDefined(rightFrame).id)[0]) + expect(leftMask.x).toBeCloseTo(20) + expect(leftMask.y).toBeCloseTo(10) + expect(leftMask.width).toBeCloseTo(30) + expect(leftMask.height).toBeCloseTo(80) + expect(rightMask.x).toBeCloseTo(120) + expect(rightMask.y).toBeCloseTo(20) + expect(rightMask.width).toBeCloseTo(20) + expect(rightMask.height).toBeCloseTo(60) + }) + test('imports gradient fills through the shared SVG pipeline', async () => { const result = (await importSVG.execute(figma, { svg: `` From d90e640e8991cfc03d587b6bb705ae8e1f6f1598 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 14 Aug 2026 14:24:10 +0300 Subject: [PATCH 7/7] perf(app): defer inactive Code panel generation (#514) * perf(app): defer inactive Code panel generation - Skip JSX serialization and syntax highlighting while the Code tab is hidden - Restore code generation when desktop or mobile users activate the tab - Cover large Design-tab selections without hidden Code-panel work * test(app): cover deferred Code panel updates - Keep mobile JSX generation inactive while the drawer is closed - Measure the complete two-frame inactive selection flow - Verify mobile Code output refreshes when the drawer reopens --- CHANGELOG.md | 1 + src/components/CodePanel.vue | 2 ++ src/components/MobileDrawer.vue | 2 +- src/components/PropertiesPanel.vue | 2 +- tests/e2e/code/mobile-panel.spec.ts | 33 +++++++++++++++++++++++++++++ tests/e2e/code/panel.spec.ts | 31 +++++++++++++++++++++++++++ 6 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/code/mobile-panel.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dfd4e0e24..5f5c9a10a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### Performance +- Defer JSX generation and syntax highlighting until the Code panel is active, keeping large canvas selections responsive. (#500) - Index Figma clipboard children once during import instead of rescanning every pasted node, keeping large flat pastes linear. (#500) - Reduce peak memory during `.fig` export by sharing immutable binary resources with the isolated export graph. diff --git a/src/components/CodePanel.vue b/src/components/CodePanel.vue index e43379859..d9557b237 100644 --- a/src/components/CodePanel.vue +++ b/src/components/CodePanel.vue @@ -14,6 +14,7 @@ import Tip from '@/components/ui/Tip.vue' import type { JSXFormat } from '@open-pencil/core/design-jsx' +const { active = true } = defineProps<{ active?: boolean }>() const store = useEditorStore() const { copy, copied } = useClipboard({ copiedDuring: 2000 }) const { dialogs } = useI18n() @@ -29,6 +30,7 @@ function toggleFormat() { } const jsxCode = useSceneComputed(() => { + if (!active) return '' void store.state.sceneVersion const ids = [...store.state.selectedIds] if (ids.length === 0) return '' diff --git a/src/components/MobileDrawer.vue b/src/components/MobileDrawer.vue index af5cd1b1e..518e0a95b 100644 --- a/src/components/MobileDrawer.vue +++ b/src/components/MobileDrawer.vue @@ -190,7 +190,7 @@ const drawerTransition = {
- +
diff --git a/src/components/PropertiesPanel.vue b/src/components/PropertiesPanel.vue index 3ccd96c32..49e837dbd 100644 --- a/src/components/PropertiesPanel.vue +++ b/src/components/PropertiesPanel.vue @@ -62,7 +62,7 @@ const { panels } = useI18n() :force-mount="true" :hidden="activeTab !== 'code'" > - + { + await editor.page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + const frameId = store.createShape('FRAME', 0, 0, 100, 100) + store.select([frameId]) + }) + + await editor.page.getByTestId('mobile-ribbon-code').click() + await expect(editor.page.getByTestId('code-panel')).toBeVisible() + + await editor.page.getByTestId('mobile-ribbon-code').click() + await expect + .poll(() => editor.page.evaluate(() => window.openPencil?.getStore?.().state.mobileDrawerSnap)) + .toBe('closed') + + await editor.page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + store.clearSelection() + const rectangleId = store.createShape('RECTANGLE', 120, 0, 100, 100) + store.select([rectangleId]) + }) + + await editor.page.getByTestId('mobile-ribbon-code').click() + await expect(editor.page.getByTestId('code-panel')).toContainText('Rectangle') +}) diff --git a/tests/e2e/code/panel.spec.ts b/tests/e2e/code/panel.spec.ts index 0d0bdc05c..dc3df10c0 100644 --- a/tests/e2e/code/panel.spec.ts +++ b/tests/e2e/code/panel.spec.ts @@ -26,6 +26,37 @@ function copyButton() { return editor.page.getByTestId('code-panel-copy') } +test('inactive Code tab skips JSX generation for large selections', async () => { + const selectionDuration = await editor.page.evaluate(async () => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + const pageId = store.state.currentPageId + const ids: string[] = [] + for (let frameIndex = 0; frameIndex < 50; frameIndex++) { + const frame = store.graph.createNode('FRAME', pageId, { name: `Frame ${frameIndex}` }) + ids.push(frame.id) + for (let childIndex = 0; childIndex < 100; childIndex++) { + store.graph.createNode('RECTANGLE', frame.id, { name: `Child ${childIndex}` }) + } + } + const startedAt = performance.now() + store.select(ids) + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }) + return performance.now() - startedAt + }) + + expect(selectionDuration).toBeLessThan(1000) + await expect(designTab()).toHaveAttribute('data-state', 'active') + + await codeTab().click() + await expect(codePanel()).toContainText('Frame') + + await editor.page.evaluate(() => window.openPencil?.getStore?.().clearSelection()) + await designTab().click() +}) + test('Code tab shows empty state with no selection', async () => { await codeTab().click() await expect(codePanelEmpty()).toBeVisible()