TEXT nodes created via SceneGraph.createNode() defaulted to empty
fills, causing text to be invisible when .fig files are opened in
Figma. Now defaults to a solid black fill matching Figma's behavior.
Closes#133
Strip optical size suffixes (e.g. "DM Sans 9pt" → "DM Sans") and
"Variable" suffixes when writing fontName.family to .fig files.
This ensures Figma recognizes the font instead of showing a
"Missing font" dialog.
Closes#131
Previously, the CJK fallback loaded only a single Google Font
(Noto Sans SC) when local system fonts were unavailable. This meant
Korean and Japanese text would not render correctly in browsers that
block the Local Font Access API.
Changes:
- Replace CJK_GOOGLE_FONT (single string) with CJK_GOOGLE_FONTS array
containing Noto Sans SC, Noto Sans JP, and Noto Sans KR
- Load all three fonts in parallel via Promise.allSettled so each
script has proper glyph coverage
- Update text renderer to pass all loaded CJK families as fallbacks
- Maintain backward compatibility: getCJKFallbackFamily() still works,
new getCJKFallbackFamilies() returns the full list
Drop the emitter-to-shallowRef bridge (rAF batched, 60 lines).
state.sceneVersion on shallowReactive IS already Vue-reactive.
useSceneComputed is now 15 lines. Zero latency during scrub.
- CanvasContext hit test return types: unknown → SceneNode | null
- Move toast.ts from SDK to app (src/utils/toast.ts) — singleton
with unguarded window access doesn't belong in headless SDK
- ExportSection: store.graph.getNode → store.getNode
- ImageFillPicker: store.graph.images.get → store.getImage (new method)
- Add getImage(hash) to core editor create.ts
Zero store.graph access from components. Zero toast in SDK.
- duplicateSelected: recursive subtree cloning via graph.cloneTree()
Filters to top-level selection to avoid double-cloning. Children
get new IDs wired to new parent. Single undo entry.
- PropertyListRoot isMixed: compare array lengths first (fast path),
only JSON.stringify when lengths match (rare).
- glContext in use-canvas.ts: ownership verified correct, no change.
Critical:
- restorePageFromSnapshot: emit node:reordered after bulk restore
so component sync and useSceneComputed bridge detect changes
- subscribeToGraph: capture and cleanup unbind functions on
replaceGraph() — prevents listener leak on old graph emitter
Important:
- useNodeProps: node/nodes use useSceneComputed instead of plain
computed — re-evaluate on any scene mutation, not just selection
- Undo forward closures: explicit id: containerId/frameId/groupId
in createNode spreads — no more implicit ID preservation contract
Moderate:
- Extract usePropScrub composable — deduplicate previousValues +
updateProp + commitProp from AppearanceControls + PositionControls
Replace the sceneVersion counter hack with proper Vue pattern:
- shallowRef + triggerRef bridged from SceneGraph's nanoevents emitter
- Ref-counted listeners — subscribe on first useSceneComputed, unsubscribe
when last scope is disposed. WeakMap per editor instance.
- Emitter fires on node:updated/created/deleted/reparented/reordered
sceneVersion counter stays in EditorState for:
- Renderer cache invalidation (label-cache, scene picture recording)
- watch() dependencies in LayerTreeRoot, ExportSection, autosave
These are legitimate uses of the counter as a cache key.
Add useSceneComputed() to SDK — wraps a function in a computed()
that depends on sceneVersion internally. Eliminates the anti-pattern
of 'void editor.state.sceneVersion' at the top of computed blocks.
Updated 8 files: EditorMenuRoot, ExportControlsRoot, PageListRoot,
useSelectionState, PagesPanel, CodePanel, VariablesDialog,
VariablesSection.
Zero void-sceneVersion hacks remaining in components.
Extracted shared helpers to eliminate copy-pasted code:
Core:
- computeAbsoluteBounds() in geometry.ts — replaces 4 bounding box loops
- makeDocumentNodeChange/makeCanvasNodeChange in kiwi-serialize.ts
- parseFigBuffer/parseFigKiwiContainer in kiwi/fig-parse-core.ts
(fig-parse-worker.ts: 103 → 11 lines)
- createDefaultEditorState() — shared between core + app store
- calcClusterConfidence() in tools/analyze.ts
- retryWithNormalizedFamily() in fonts.ts
- getVectorNode() in tools/vector.ts
- renderFills() helper in renderer/scene.ts
- withNodeBounds() helper in renderer/overlays.ts
- drawNodeHighlightRect() in renderer/highlight-rect.ts
- isInViewport() in renderer/label-cache.ts
App:
- decodeTauriStderr() in utils/tauri.ts
- printNodeResults() in cli format.ts
Remaining 28 clones are: text-editor cursor movement (inherent),
xpath DOM traversal (inherent), small 5-line patterns too
context-specific to extract.
LayerTree SDK components:
- LayerTreeRoot — tree data from editor graph, expand/collapse with
auto-expand on selection, scroll-into-view, scope sync
- LayerTreeItem — per-node slot with isSelected, padLeft, select,
toggleExpand, toggleVisibility, toggleLock, rename
App LayerTree.vue now composes LayerTreeRoot + LayerTreeItem + TreeRoot/
TreeItem from Reka UI. Deleted LayerRow.vue — its logic is in the SDK.
EditorMenu SDK component:
- EditorMenuRoot — selection-aware state (hasSelection, isInstance,
isComponent, isGroup, canCreateComponentSet, otherPages, visibility,
lock) + copyAsText/SVG/JSX via slots
App CanvasMenu.vue now composes EditorMenuRoot — pure template with
Reka UI ContextMenu + Tailwind styling, zero business logic.
Removed useSelectionState composable — EditorMenuRoot replaces it.
New headless components:
- CanvasRoot — provides canvas context, initializes surface + render loop
- CanvasSurface — bare <canvas> element, receives ref from CanvasRoot
SkiaRenderer.renderFromEditorState() — single method replaces 30-line
state sync in use-canvas.ts. Takes EditorState + graph + textEditor
directly, sets all renderer properties and calls render().
Example app updated to use CanvasRoot/CanvasSurface, ToolbarRoot/
ToolbarItem, PageListRoot instead of old monolithic components.
New Reka UI-style headless components following the pattern:
ComponentName/context.ts — InjectionKey, provide/inject
ComponentName/Root.vue — state management, slot props
ComponentName/Item.vue — per-item logic, slot props
ComponentName/index.ts — barrel export
ScrubInput (new headless):
ScrubInputRoot — scrub state, pointer capture, edit/commit logic
ScrubInputField — input element for direct editing
ScrubInputDisplay — read-only display with slot for value/suffix
App ScrubInput.vue composes these with Tailwind styling
Toolbar (new headless):
ToolbarRoot — active tool, tool list, flyout state
ToolbarItem — per-tool active state and select handler
PageList (refactored):
PageListRoot — pages, currentPageId, CRUD actions via slots
Old components kept as backward-compatible aliases.
Add hitTestAtPoint() and selectAtPoint() to core editor/selection.ts.
CanvasContextMenu is now a pure Reka UI wrapper — receives onContextMenu
callback from EditorCanvas which owns the canvas ref and coordinates.
- VariablesDialog: replace 7 manual undo.push + graph mutation + requestRender
patterns with editor.renameCollection/addVariable/removeVariable etc.
- PositionSection: replace inline alignment/flip/rotate geometry with
editor.alignNodes/flipNodes/rotateNodes
- Move kiwi-serialize.ts into kiwi/ where it belongs
- Remove dead createPropertyChange export from undo.ts
- Un-export internal queryFonts/FontInfo from fonts.ts
- Deduplicate font weight maps: single FONT_WEIGHT_NAMES in fonts.ts,
figma-api-proxy.ts imports from there
- Add interaction constants to core barrel export (were in constants.ts
but not reachable via '@open-pencil/core')
- Fix variable shadowing in use-text-edit.ts: rename inner 'editor'
(textEditor) to 'textEd' to avoid shadowing the Editor parameter
- Fix unused params: prefix with _ in scene.ts, describe-issues.ts, xpath.ts
- Remove unused getCoords param from setupPanZoom
- Move cursorCanvasX/Y assignment to app callback (not on EditorState)
- Simplify EditorStore return: spread editor instead of listing 80+ methods
individually — new Editor methods auto-forward, no more manual sync
- Add tsgo to check script, add check:vue script for vue-tsc
- Add vue-tsc as dev dependency
- Reduces vue-tsc errors from 172 (pre-existing) to 135 (37 fixed)
Renderless components (Reka UI-style) exposing editor logic via slots:
- OpenPencilProvider — injects editor via Vue provide/inject
- OpenPencilCanvas — CanvasKit/Skia surface with render loop
- PageList, LayerTree, ToolSelector, NodeProperties — slot-based
Composables:
- useEditor() — access editor from any descendant
- useCanvas() — low-level surface lifecycle (moved from app)
The app's use-canvas.ts is now a 2-line re-export from the SDK.
Includes example app in packages/vue/example/.
Reuse safeColor() (which defaults a ?? 1) in the variable export
path. The Kiwi schema requires all Color struct fields — variables
created without explicit alpha caused 'Missing required field a'.
Fixes#128