diff --git a/CHANGELOG.md b/CHANGELOG.md index d220c41b8..4b9b2a087 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.14.0 - 2026-08-07 + ### Breaking changes - **Core SDK:** Import scene graph types, geometry, coordinate, matrix, snapping, undo, and path helpers from `@open-pencil/scene-graph`; import `.pen` parsing from `@open-pencil/pen`; and import synchronous Kiwi decompression from `@open-pencil/kiwi` instead of the `@open-pencil/core` compatibility barrel. diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 5da560134..2dfb2c578 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2939,7 +2939,7 @@ dependencies = [ [[package]] name = "open_pencil" -version = "0.13.2" +version = "0.14.0" dependencies = [ "fix-path-env", "font-kit", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 429bf9a1f..8f969b9a4 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "open_pencil" -version = "0.13.2" +version = "0.14.0" description = "OpenPencil desktop app" authors = ["Danila Poyarkov"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 7dd411121..1497a4e28 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenPencil", - "version": "0.13.2", + "version": "0.14.0", "identifier": "net.dannote.open-pencil", "build": { "beforeDevCommand": "bun run generate:tauri-menu && bun run dev", diff --git a/package.json b/package.json index b621f7177..78f2b88ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-pencil-app", - "version": "0.13.2", + "version": "0.14.0", "private": true, "license": "MIT", "workspaces": [ diff --git a/packages/cli/package.json b/packages/cli/package.json index acd5bda91..6ef731530 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@open-pencil/cli", - "version": "0.13.2", + "version": "0.14.0", "license": "MIT", "type": "module", "imports": { diff --git a/packages/core/package.json b/packages/core/package.json index fd46df11a..e5b81f2ca 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@open-pencil/core", - "version": "0.13.2", + "version": "0.14.0", "license": "MIT", "repository": { "type": "git", diff --git a/packages/core/src/kiwi/fig/population/client.ts b/packages/core/src/kiwi/fig/population/client.ts index f806502b9..459a709aa 100644 --- a/packages/core/src/kiwi/fig/population/client.ts +++ b/packages/core/src/kiwi/fig/population/client.ts @@ -44,13 +44,13 @@ export function registerFigPopulationWorker(graph: SceneGraph, worker: Worker): emitTelemetry({ event: 'registered' }) } -function isDevelopmentBuild(meta: { env?: { DEV?: boolean } }): boolean { - return meta.env?.DEV ?? false +function isDevelopmentBuild(env?: { DEV?: boolean }): boolean { + return env?.DEV ?? false } export function canUseFigPopulationWorker(graph: SceneGraph): boolean { return ( - isDevelopmentBuild(import.meta) && + isDevelopmentBuild(import.meta.env) && populationWorkers.has(graph) && getLazyFigImportContext(graph) !== undefined ) @@ -81,7 +81,10 @@ function createPopulationWorkerClient(graph: SceneGraph, worker: Worker): FigPop let disposed = false let applyingDelta = false const invalidate = () => { - if (applyingDelta || stale) return + // Layout recomputation (import-time or after a switch) is derived from the + // same scene graph the worker deltas were built from; it must not count as + // user divergence. Only real user edits invalidate the worker. + if (applyingDelta || stale || graph.isApplyingLayout) return revision++ stale = true emitTelemetry({ event: 'stale', reason: 'graph-mutation' }) diff --git a/packages/core/src/layout.ts b/packages/core/src/layout.ts index 1aae2c2fa..d93476c8c 100644 --- a/packages/core/src/layout.ts +++ b/packages/core/src/layout.ts @@ -37,6 +37,10 @@ import { } from './layout/yoga-helpers' export function computeLayout(graph: SceneGraph, frameId: string): void { + graph.withLayoutMutations(() => computeLayoutInternal(graph, frameId)) +} + +function computeLayoutInternal(graph: SceneGraph, frameId: string): void { const frame = graph.getNode(frameId) if (!frame || frame.layoutMode === 'NONE') return @@ -47,7 +51,7 @@ export function computeLayout(graph: SceneGraph, frameId: string): void { ? buildGridTree(graph, frame, rootDirection) : buildYogaTree(graph, frame, rootDirection) yogaRoot.calculateLayout(undefined, undefined, yogaDirection) - applyYogaLayout(graph, frame, yogaRoot, computeLayout) + applyYogaLayout(graph, frame, yogaRoot, computeLayoutInternal) freeYogaTree(yogaRoot) } function resolveComputedLayoutDirection( @@ -60,12 +64,14 @@ function resolveComputedLayoutDirection( } export function computeAllLayouts(graph: SceneGraph, scopeId?: string): void { - const rootId = scopeId ?? graph.rootId - const visited = new Set() - computeLayoutsBottomUp(graph, rootId, visited) - if (applyEffectiveGeneratedTextLayout(graph, rootId)) { - computeLayoutsBottomUp(graph, rootId, new Set()) - } + graph.withLayoutMutations(() => { + const rootId = scopeId ?? graph.rootId + const visited = new Set() + computeLayoutsBottomUp(graph, rootId, visited) + if (applyEffectiveGeneratedTextLayout(graph, rootId)) { + computeLayoutsBottomUp(graph, rootId, new Set()) + } + }) } function computeLayoutsBottomUp(graph: SceneGraph, nodeId: string, visited: Set): void { diff --git a/packages/dom-css/package.json b/packages/dom-css/package.json index 46cc37c5f..a9b67c56e 100644 --- a/packages/dom-css/package.json +++ b/packages/dom-css/package.json @@ -1,6 +1,6 @@ { "name": "@open-pencil/dom-css", - "version": "0.13.2", + "version": "0.14.0", "license": "MIT", "type": "module", "imports": { diff --git a/packages/fig/package.json b/packages/fig/package.json index 70d280142..3b5787c50 100644 --- a/packages/fig/package.json +++ b/packages/fig/package.json @@ -1,6 +1,6 @@ { "name": "@open-pencil/fig", - "version": "0.13.2", + "version": "0.14.0", "license": "MIT", "type": "module", "exports": { diff --git a/packages/kiwi/package.json b/packages/kiwi/package.json index a0dcc4aee..d0b2c5e5c 100644 --- a/packages/kiwi/package.json +++ b/packages/kiwi/package.json @@ -1,6 +1,6 @@ { "name": "@open-pencil/kiwi", - "version": "0.13.2", + "version": "0.14.0", "license": "MIT", "type": "module", "exports": { diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 8655a7c07..b2952f4c7 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@open-pencil/mcp", - "version": "0.13.2", + "version": "0.14.0", "license": "MIT", "type": "module", "imports": { diff --git a/packages/pen/package.json b/packages/pen/package.json index 5d14d6ce1..462ee3017 100644 --- a/packages/pen/package.json +++ b/packages/pen/package.json @@ -1,6 +1,6 @@ { "name": "@open-pencil/pen", - "version": "0.13.2", + "version": "0.14.0", "license": "MIT", "type": "module", "exports": { diff --git a/packages/scene-graph/package.json b/packages/scene-graph/package.json index 72a8e8b94..f32f0157e 100644 --- a/packages/scene-graph/package.json +++ b/packages/scene-graph/package.json @@ -1,6 +1,6 @@ { "name": "@open-pencil/scene-graph", - "version": "0.13.2", + "version": "0.14.0", "license": "MIT", "type": "module", "exports": { diff --git a/packages/scene-graph/src/index.ts b/packages/scene-graph/src/index.ts index 0b0015bb3..50d8241f2 100644 --- a/packages/scene-graph/src/index.ts +++ b/packages/scene-graph/src/index.ts @@ -78,6 +78,7 @@ export class SceneGraph { private absPosCache = new Map() private previewMutationDepth = 0 private sourceMetadataPreservationDepth = 0 + private layoutMutationDepth = 0 positionPreviewVersion = 0 instanceIndex = new Map>() @@ -366,6 +367,17 @@ export class SceneGraph { this.sourceMetadataPreservationDepth-- } } + withLayoutMutations(fn: () => void): void { + this.layoutMutationDepth++ + try { + fn() + } finally { + this.layoutMutationDepth-- + } + } + get isApplyingLayout(): boolean { + return this.layoutMutationDepth > 0 + } updateNodePositionPreview(id: string, x: number, y: number): void { this.updateNodePreview(id, { x, y }) } diff --git a/packages/vue/package.json b/packages/vue/package.json index 012461515..65983c069 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -1,6 +1,6 @@ { "name": "@open-pencil/vue", - "version": "0.13.2", + "version": "0.14.0", "license": "MIT", "type": "module", "exports": { diff --git a/tests/e2e/pages/fig-population-worker.spec.ts b/tests/e2e/pages/fig-population-worker.spec.ts index a585a1005..dfc1fc095 100644 --- a/tests/e2e/pages/fig-population-worker.spec.ts +++ b/tests/e2e/pages/fig-population-worker.spec.ts @@ -3,6 +3,7 @@ import { expect, test, useEditorSetup } from '#tests/e2e/fixtures' const editor = useEditorSetup() test('populates a real lazy FIG page in the retained parse worker', async () => { + test.setTimeout(90_000) await editor.page.evaluate(() => { const events: unknown[] = [] Object.assign(window, { figPopulationWorkerEvents: events }) @@ -11,7 +12,7 @@ test('populates a real lazy FIG page in the retained parse worker', async () => }) }) const openFile = editor.page.evaluate(() => - window.openPencil?.openFile?.('/tests/fixtures/gold-preview.fig') + window.openPencil?.openFile?.('/tests/fixtures/material3.fig') ) await openFile await editor.canvas.waitForRender() @@ -23,18 +24,28 @@ test('populates a real lazy FIG page in the retained parse worker', async () => }) if (!targetPageId) throw new Error('Target page not found') + const populateEventCount = await editor.page.evaluate( + () => + (Reflect.get(window, 'figPopulationWorkerEvents') as Array<{ event: string }>).filter( + ({ event }) => event === 'populate' + ).length + ) await editor.page.evaluate((pageId) => { const store = window.openPencil?.getStore?.() if (!store) throw new Error('OpenPencil store not initialized') return store.switchPage(pageId) }, targetPageId) await expect - .poll(() => - editor.page.evaluate(() => - (Reflect.get(window, 'figPopulationWorkerEvents') as Array<{ event: string }>).some( - ({ event }) => event === 'populate' - ) - ) + .poll( + () => + editor.page.evaluate( + (previousCount) => + (Reflect.get(window, 'figPopulationWorkerEvents') as Array<{ event: string }>).filter( + ({ event }) => event === 'populate' + ).length > previousCount, + populateEventCount + ), + { timeout: 45_000 } ) .toBe(true) diff --git a/tests/e2e/properties/blend-modes.spec.ts-snapshots/effect-blend-mode-openpencil-darwin.png b/tests/e2e/properties/blend-modes.spec.ts-snapshots/effect-blend-mode-openpencil-darwin.png index 7b26f04dc..a77adbc95 100644 Binary files a/tests/e2e/properties/blend-modes.spec.ts-snapshots/effect-blend-mode-openpencil-darwin.png and b/tests/e2e/properties/blend-modes.spec.ts-snapshots/effect-blend-mode-openpencil-darwin.png differ