Commit graph

951 commits

Author SHA1 Message Date
TKman c52f88e34c feat: add spacebar hold to temporarily activate Hand (pan) tool
Hold Spacebar to temporarily switch to the Hand tool for panning,
release to return to the previously active tool. This matches the
standard Figma UX pattern.

- Uses separate keydown/keyup event listeners for reliable handling
- Guards against text editing mode and key repeat
- References state.activeTool for correct tool state

Fixes #147
2026-03-24 23:47:56 +03:00
Shai Rubinstein 2e723caba0 fix: kiwi serialization bugs causing broken auto-layout in Figma import
- Zero transforms for auto-layout children (Figma computes positions)
- Write bordersTakeSpace from strokesIncludedInLayout
- Always write explicit lineHeight, defaulting to ceil(fontSize * 1.2)
- Normalize font family in derivedTextData fontMetaData and fontName
- Extend normalizeFontFamily to strip optical size suffixes (9pt, 18px)
- Extract computeExportTransform() helper for lint compliance
- Add 20 unit tests covering all fixes

Fixes #134
2026-03-24 23:47:56 +03:00
Shai Rubinstein 5139836816 fix: default TEXT nodes to solid black fill
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
2026-03-24 23:47:56 +03:00
Shai Rubinstein 2efd63ea09 fix: normalize font family names on .fig export
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
2026-03-24 23:47:56 +03:00
myhency 8599750f1d feat(fonts): load multiple CJK Google Fonts for full Han/Japanese/Korean coverage
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
2026-03-24 23:47:55 +03:00
Danila Poyarkov 2956507783 Fix switch_page not persisting across MCP tool calls
Each RPC tool call created a fresh FigmaAPI instance, so switch_page
set currentPageId on a throwaway object. Sync the page change back
to the editor store after tool execution.

Fixes #112
2026-03-24 23:47:55 +03:00
Danila Poyarkov bceddd759f Guard keyboard shortcuts during canvas text editing
Backspace/Delete and tool shortcuts (V, R, F, T, etc.) fired during
canvas text editing because the guard only checked for DOM input/textarea
focus, not the canvas TextEditor state. Add editingTextId check to both
the onEventFired handler and the plain() shortcut helper.
2026-03-24 23:47:55 +03:00
Danila Poyarkov 2ace1d4e49
Merge pull request #137 from open-pencil/refactor/vue-sdk-architecture
Refactor: extract @open-pencil/vue headless SDK, split editor into modules
2026-03-24 23:29:21 +03:00
Danila Poyarkov a743da54b6 Document Vue SDK public APIs 2026-03-24 23:25:06 +03:00
Danila Poyarkov 99e646aaa4 Refine docs structure for SDK and automation 2026-03-24 23:24:08 +03:00
Danila Poyarkov 07fade6abb Use explicit Vue SDK barrel exports 2026-03-24 18:37:08 +03:00
Danila Poyarkov 80a2855bb3 Fix review issues in undo and Vue SDK 2026-03-24 18:15:40 +03:00
Danila Poyarkov bff8895775 Fix CLI tsconfig rootDir mismatch 2026-03-24 17:16:44 +03:00
Danila Poyarkov 936ba6637b Adopt package aliases across Vue SDK 2026-03-24 17:10:43 +03:00
Danila Poyarkov 1da5483fec Format remaining SDK refactor files 2026-03-24 17:02:01 +03:00
Danila Poyarkov 07af72ab24 Fix imported fig rendering and file open regressions 2026-03-24 16:52:45 +03:00
Danila Poyarkov 0a770248a2 Reorganize Vue SDK modules by domain 2026-03-24 09:50:31 +03:00
Danila Poyarkov 74bb119579 Extract Vue SDK editor controls and pickers 2026-03-24 09:50:28 +03:00
Danila Poyarkov 329dd301bd Align app UI primitives with shared ui overrides 2026-03-24 09:50:20 +03:00
Danila Poyarkov 0c30d044b2 Extract Vue SDK commands and menu model 2026-03-24 09:50:17 +03:00
Danila Poyarkov b9b960f6ef Fix broken SVG import path after shared/ restructure 2026-03-17 20:56:35 +03:00
Danila Poyarkov 29b63842fa Integrate SDK composables into all app components
FillPicker: useFillPicker() replaces inline category/switch logic
PagesPanel: usePageList() replaces inline pages computed
VariablesDialog: useVariables() replaces 11 inline CRUD functions
CanvasMenu: inline editor.copySelectionAs* instead of wrapper fns
Toolbar: ToolbarRoot + ToolbarItem wrapping tool buttons

28/42 app components now use SDK. Remaining 14 are legitimately
app-specific (chat, collab, mobile layout) or generic UI widgets
(select, toast, tip — no editor logic needed).
2026-03-17 20:39:45 +03:00
Danila Poyarkov fb2bfb5827 SDK rewrite: delete 15 useless wrappers, replace with composables
Deleted 15 fake headless component folders (slot-prop wrappers with
no provide/inject, no DOM, no ARIA). Replaced with 9 composables:
- usePosition, useLayout, useAppearance, useTypography
- useExport, useVariables, useFillPicker, useGradientStops, usePageList

Kept 6 real headless component families with provide/inject + DOM:
- Canvas (WebGL surface lifecycle)
- ScrubInput (pointer capture + input + display)
- Toolbar (wraps Reka UI Toolbar)
- LayerTree (wraps Reka UI Tree + Atlaskit DnD)
- PropertyList (array CRUD via inject)
- GradientEditor (pointer capture for stop drag)

App components updated: use composables directly instead of wrapping
in Root components. -1047 net lines, cleaner separation.
2026-03-17 20:22:41 +03:00
Danila Poyarkov 30ae92483e Restructure SDK: shared/ replaces composables/+input/+utils/+context
Reka UI source structure: component folders + shared/ for utilities.
- composables/ → shared/ (camelCase names: useCanvas, useNodeProps, etc)
- input/ → shared/input/ (internal to useCanvasInput)
- utils/ → shared/ (toolCursor)
- context.ts → shared/editorContext.ts
- assets/ → shared/assets/
- components/ deleted (legacy wrappers)
- Added shared/createContext.ts (Reka UI pattern)
2026-03-17 20:04:14 +03:00
Danila Poyarkov 920af0a14e Fix scrub lag: simplify useSceneComputed to synchronous sceneVersion
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.
2026-03-17 19:45:22 +03:00
Danila Poyarkov c36456d820 Fix loader not removed — add onReady callback to dismiss #loader
The loader div from index.html was never dismissed after the SDK
migration because use-canvas.ts moved to the SDK (which correctly
doesn't touch app-specific DOM). Added onReady callback in
EditorCanvas.vue to fade out and remove the loader.
2026-03-17 19:25:06 +03:00
Danila Poyarkov 43694aaeb3 Fix runtime errors: TooltipProvider scope, provideEditor in EditorView
Browser validation found 2 runtime issues:

1. TooltipProvider only wrapped EditorView, but <Tip> components in
   AppToast rendered outside it → moved TooltipProvider to App.vue
   to wrap everything including toasts.

2. SDK components call useEditor() but the app never provided the
   editor via the SDK's injection system → added provideEditor(store)
   in EditorView.vue so SDK primitives can access the editor.

Validated: zero console errors, canvas renders, demo shapes visible.
2026-03-17 17:33:04 +03:00
Danila Poyarkov 92c49f80f4 Self-review fixes: CanvasContext types, toast out of SDK, last graph access
- 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.
2026-03-17 17:13:33 +03:00
Danila Poyarkov c01e5ec141 Fix review round 2 remaining: duplicate children, JSON.stringify perf
- 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.
2026-03-17 16:58:25 +03:00
Danila Poyarkov 4d5b3482d4 Fix review round 2: emitter leaks, snapshot restore, undo ID safety
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
2026-03-17 16:54:18 +03:00
Danila Poyarkov d3aa511056 Self-review fixes: VueUse adoption, tailwind-variants recipes
VueUse:
- useClipboard({ copiedDuring }) in CodePanel, CollabPanel \u2014 replaces
  manual copied ref + setTimeout pattern
- useDebounceFn in stores/editor.ts \u2014 replaces manual clearTimeout +
  setTimeout for autosave
- onScopeDispose in ExportSection \u2014 replaces onUnmounted for URL cleanup

Tailwind-variants:
- New iconButton() recipe (sm/md) \u2014 replaces 15+ repeated button class strings
- New sectionWrapper() + sectionLabel() \u2014 replaces 12 repeated section classes
- Applied across all 10 property section components
2026-03-17 16:48:12 +03:00
Danila Poyarkov ced083439e Complete SDK plan: all 21 component families built
New SDK components (7):
- ColorPickerRoot — color state, swatch CSS, update handler
- ColorInputRoot — hex string, updateFromHex handler
- FontPickerRoot — font families, search/filter, selection
- ImageFillControlsRoot — image preview, file picker, scale mode
- GradientEditorBar — pointer capture drag for stop bar
- GradientEditorStop — per-stop data + update handlers
- PageControlsRoot — page background color
- VariablesIndicatorRoot — collection/variable counts
- VariablesEditorRoot — collections, variables, CRUD, search/filter

App components rewritten to compose from SDK:
- ColorPicker, ColorInput, FontPicker, ImageFillPicker
- GradientEditor (script: ~100 → imports only)
- PageSection, VariablesSection

Full SDK inventory: 21 component families, 35 Vue files, 48 TS files.
Every item from SDK-PLAN.md is now implemented.
2026-03-17 16:21:26 +03:00
Danila Poyarkov 2a69ef956f SDK: AppearanceControls, TypographyControls, EffectsSection via PropertyListRoot
New SDK components:
- AppearanceControlsRoot — corner radius, visibility, opacity, blend mode
- TypographyControlsRoot — font family/weight/size, alignment, formatting
  toggles, missing font detection, font loading

EffectsSection rewritten to compose from PropertyListRoot \u2014 effect CRUD
via SDK, scrub tracking stays in app component.

All property sections now use SDK headless primitives:
- PositionSection → PositionControlsRoot
- LayoutSection → LayoutControlsRoot
- AppearanceSection → AppearanceControlsRoot
- TypographySection → TypographyControlsRoot
- FillSection → PropertyListRoot
- StrokeSection → PropertyListRoot
- EffectsSection → PropertyListRoot
- ExportSection → ExportControlsRoot
2026-03-17 15:59:43 +03:00
Danila Poyarkov 35e78e276a Fix all deferred code quality items
1. childIds direct mutation bypasses emitter (CRITICAL):
   Added SceneGraph.insertChildAt() — removes from old parent, inserts
   at index, emits node:reordered. Replaced all direct childIds
   filter+splice in structure.ts (wrapSelectionInContainer, ungroup,
   bringToFront, sendToBack) with insertChildAt().

2. Canvas input orchestrator 815→461 lines:
   Extracted input/move.ts (215 lines) — move drag, snap, reparent
   Extracted input/select.ts (172 lines) — hit resolve, select, hover
   Extracted input/draw.ts (32 lines) — shape drawing

3. PropertyListContext generic typing:
   PropertyListContext<T> — items, add, update, patch all typed.
   usePropertyList<Fill>() gives Fill-typed context.
   Added missing patch to context and PropertyListItem.
2026-03-17 15:51:42 +03:00
Danila Poyarkov 2757e9e6ec Fix PR review: critical bugs, package config, remaining title attrs
Critical fixes:
- useSceneComputed: watchEffect on state.sceneVersion as fallback
  when replaceGraph() swaps the emitter (bridge stayed on old graph)
- useLayerDrag: capture monitorForElements() cleanup, onScopeDispose
  (was leaking global listeners on tab switch)

Package fixes:
- packages/vue: sideEffects: false for tree-shaking
- Build: --noEmit → --emitDeclarationOnly (was skipping .d.ts)
- canvaskit-wasm peerDep: optional via peerDependenciesMeta
- use-canvas.ts: onUnmounted → onScopeDispose

Remaining title= attrs migrated to <Tip>:
- AppMenu toggle UI, TypographySection missing font, ProviderSettings
2026-03-17 15:30:28 +03:00
Danila Poyarkov 9ad69f459d Fix Homebrew workflow: authenticated clone for tap push 2026-03-17 15:28:53 +03:00
Danila Poyarkov d1d0e9c513 Add workflow_dispatch trigger to Homebrew workflow 2026-03-17 15:12:06 +03:00
Danila Poyarkov 6ffe201bec StrokeSection: compose from PropertyListRoot (388→180 lines)
Stroke CRUD (add/remove/toggle/update color+weight) now uses
PropertyListRoot from SDK. Stroke-specific logic (sides, align,
border weights) stays in app component as functions taking activeNode.
2026-03-17 15:08:34 +03:00
Danila Poyarkov 8ba8c48a4c Migrate all title= attributes to Reka UI Tooltip via <Tip>
Replace 30 title= attributes across 15 component files with <Tip>
wrappers for proper Reka UI Tooltip behavior (positioning, delay,
animation, accessibility).

Remaining 2: TypographySection missing-font icon (non-interactive),
AppMenu toggle-UI button (browser menu context).
2026-03-17 12:22:25 +03:00
Danila Poyarkov 92857aed45 Add LayoutControlsRoot SDK primitive, LayoutSection 556→119 lines
New SDK component LayoutControls/LayoutControlsRoot.vue (189 lines):
- Layout sizing logic (width/height sizing, flex vs auto-layout vs grid)
- Alignment grid computation (3x3 for flex)
- Grid track CRUD (add/remove/update columns and rows)
- Padding management (uniform + per-side toggle)
- Sizing options derived from layout context

App LayoutSection.vue is now template-only (119 lines) — composes
LayoutControlsRoot with Tailwind styling + Lucide icons.
2026-03-17 12:10:27 +03:00
Danila Poyarkov 25b7d8afa4 useSceneComputed: bridge nanoevents emitter into Vue reactivity
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.
2026-03-17 11:55:13 +03:00
Danila Poyarkov a8e8e63a55 Replace void sceneVersion hacks with useSceneComputed composable
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.
2026-03-17 11:48:03 +03:00
Danila Poyarkov 513b16983f Clean remaining component leaks: requestRender, graph access, variable queries
Editor API additions:
- bindVariable/unbindVariable in nodes.ts (with undo + requestRender)
- setPageColor in pages.ts (state + requestRender)
- getVariablesByType, getVariable, getCollection, resolveColorVariable,
  getVariablesForCollection, getCollections, getCollectionCount,
  getVariableCount in variables.ts (read-through to graph)
- getNode, getChildren, getPages in create.ts (read-through to graph)

Removed:
- 5 redundant requestRender() after updateNodeWithUndo
- All store.graph.* access from components (was in 7 files, now 0)

Remaining requestRender (1 file): EffectsSection uses updateNode
(without undo) which correctly needs manual render trigger.
2026-03-17 11:38:32 +03:00
Danila Poyarkov 0fbaf1c24e Adopt Reka UI Tooltip/ToggleGroup, VueUse useClipboard
Reka UI:
- Tip.vue — reusable tooltip wrapping Reka UI Tooltip primitives
- TooltipProvider in EditorView.vue with 400ms delay
- PositionSection: 9 title= attrs → <Tip> wrappers
- TypographySection: alignment → ToggleGroup (single),
  formatting (bold/italic/underline/strikethrough) → ToggleGroup
  (multiple). Keyboard nav + ARIA for free.

VueUse:
- useClipboard replaces navigator.clipboard.writeText in
  CodePanel, CollabPanel, MobileHud, CanvasMenu
2026-03-17 11:30:25 +03:00
Danila Poyarkov fd44d7b853 Deduplicate: 47 → 28 clones, 1.37% → 0.61% duplication
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.
2026-03-17 11:10:11 +03:00
Danila Poyarkov 267f251f8a Phase 7: Migrate 10 components from useEditorStore to useEditor
Components now using useEditor() from SDK instead of app's useEditorStore():
- CodePanel, DesignPanel, ImageFillPicker, LayerTree, PagesPanel,
  PropertiesPanel, VariablesDialog, FillSection, PageSection,
  VariablesSection

7 components remain on useEditorStore for app-specific methods:
- AppMenu, Toolbar, MobileHud (saveFigFile, exportSelection, mobile*)
- CanvasMenu (renderExportImage)
- EditorCanvas (cursorCanvasX/Y app state)
- MobileDrawer (mobileDrawerSnap app state)
- ExportSection (exportSelection, renderExportImage)
2026-03-17 10:38:40 +03:00
Danila Poyarkov 6dd2f96790 Phase 7: App migration — Toolbar, LayerTree, CanvasMenu, ExportSection
App components now compose from SDK primitives:

Toolbar.vue — imports EDITOR_TOOLS, Tool, EditorToolDef from SDK
  instead of @/stores/editor. Tool data comes from SDK, actions
  still wire to store methods.

LayerTree.vue — rewritten to compose LayerTreeRoot + LayerTreeItem
  from SDK. Tree building, expand/collapse, selection sync,
  scroll-into-view all in SDK. App adds icons, Tailwind styling,
  context menu, DnD indicators. Deleted LayerRow.vue.

CanvasMenu.vue — composes EditorMenuRoot from SDK. Selection state,
  copy-as-* functions from SDK slot props. App adds Reka UI
  ContextMenu composition + Tailwind styling.

ExportSection.vue — composes ExportControlsRoot from SDK. Settings
  management in SDK. Export execution + preview rendering stay
  app-specific (need store.exportSelection/renderExportImage).

FillSection.vue — already composed from PropertyListRoot (prior commit).
2026-03-17 10:29:33 +03:00
Danila Poyarkov 579492649d Phase 6: FillPicker, GradientEditor, ExportControls headless primitives
New SDK components:
- FillPickerRoot — fill type category (solid/gradient/image), swatch
  background, category switching. Events via emit, not callback props.
- GradientEditorRoot — stop state, subtype, add/remove/drag/select stops,
  active color, bar background. All stop mutations + color updates as
  action slot props.
- ExportControlsRoot — export settings list, scale/format options,
  selected node name.

Fixed callback props passed as :on-xxx across all SDK components:
- ScrubInputRoot: :on-keydown → :keydown
- FillPickerRoot: removed :on-update (consumers use @update on the
  component itself)
- GradientEditorRoot: :on-active-color-update → :update-active-color,
  :on-stop-drag → :drag-stop
2026-03-17 10:16:34 +03:00
Danila Poyarkov 72876f8a3e Phase 4-5: LayerTree and EditorMenu headless primitives
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.
2026-03-17 10:06:24 +03:00
Danila Poyarkov 638db38589 Phase 3: Property primitives — PropertyList, PositionControls
New SDK components:
- PropertyListRoot / PropertyListItem — headless array property editor
  for fills, strokes, effects. Provides add/remove/update/patch/
  toggleVisibility via slots. Handles multi-selection, mixed state.
- PositionControlsRoot — headless position/alignment/flip/rotate.
  Provides x/y/w/h/rotation + updateProp/commitProp/align/flip/rotate.

App FillSection.vue rewritten to compose from PropertyListRoot —
no more useNodeProps() destructuring, CRUD logic in SDK.

SkiaRenderer.renderFromEditorState() — single-call render that
takes EditorState directly (was 30 lines of manual property sync).
2026-03-17 09:43:26 +03:00