Extract menu items into shared NodeContextMenuContent component,
used by both the canvas and layers panel context menus.
Right-clicking a layer selects it and shows the same menu.
On copy: serialize each text node's paragraph into an SkPicture and
embed the bytes (base64) in the OpenPencil clipboard payload.
On paste: if the font isn't available, render from the cached SkPicture
instead of the broken fallback. Text looks pixel-perfect without the
font installed — same behavior as Figma.
Missing font indicator:
- Orange ⚠ badge on canvas over text nodes with unavailable fonts
- Amber warning banner in the Typography panel listing missing families
- isFontLoaded() check in @open-pencil/core for querying font status
useEditorStore() now returns a Proxy that delegates to the current
active store at access time. No more store() wrapper functions,
try/catch guards, or passing stores through function parameters.
Open multiple documents in tabs within a single window, like Figma.
- Tab bar appears when 2+ documents are open, with close (×) and new (+) buttons
- ⌘N / ⌘T create a new empty tab
- ⌘W closes the active tab (last tab closes and reopens as Untitled)
- ⌘O opens .fig files in a new tab (reuses current tab if Untitled with no changes)
- Middle-click closes a tab
- Each tab has its own SceneGraph, UndoManager, viewport, and selection state
- Switching tabs remounts the canvas to rebind the Skia renderer to the active store
- Keyboard shortcuts, menu actions, and AI tools all operate on the active tab
Header row with app icon, double-click-to-rename document name, and
sidebar toggle button. Shows in both Tauri and browser.
When panels are hidden, a floating bar on the canvas provides the same
controls to restore the UI.
- AppToast component: top-center blue pill (info), red pill (errors)
- Global error handler catches window.error + unhandledrejection
- Link copied toast on share and copy link button
- use-toast composable: toast.show(message, variant)
- Cloudflare Worker + Durable Object server (packages/collab/)
- One DO per room with WebSocket Hibernation for near-zero idle cost
- Yjs sync protocol for document state (CRDT merge)
- Yjs awareness protocol for cursors, selection, presence
- Persists doc state to DO storage
- CORS headers for cross-origin WebSocket upgrade
- Client composable (src/composables/use-collab.ts)
- Y.Doc ↔ SceneGraph bidirectional binding
- Awareness: broadcasts cursor position, selection, name, color
- Auto-join from /share/:roomId URL
- Suppresses echo loops between Yjs and graph updates
- CollabPanel component (src/components/CollabPanel.vue)
- Share button generates room link, copies to clipboard
- Join flow: paste link or room ID
- Avatar stack with colored initials and tooltips
- Name input persisted to localStorage
- Remote cursor rendering in CanvasKit
- Colored arrow cursor + name label pill per remote peer
- Remote selection outlines in peer's color
- Only shows cursors on the same page
- SPA redirect for /share/* routes (Cloudflare Pages _redirects)
- Track click count with timing/proximity in onMouseDown
- Double-click in text: select word (was only handled in dblclick)
- Triple-click in text: select all
- Double-click to enter editing: select word at click position
- Add selectLine/selectLineAt to TextEditor
Use markRaw to prevent Vue from deep-proxying the Chat class
instance, which broke its internal Vue refs. Add dedent for
multiline prompts.
Tests use mock transport by default. Set TEST_REAL_LLM=1 and
OPENROUTER_API_KEY to run against real OpenRouter.
StyleRun model: array of {start, length, style} with
CharacterStyleOverride (fontWeight, italic, textDecoration, etc.).
Renderer uses ParagraphBuilder.pushStyle/pop for mixed-style text.
TextEditor delegates paragraph building to renderer.buildParagraph().
⌘B/I/U apply to selection range when text is selected, or toggle
whole-node style when no selection. Style runs adjust on
insert/delete to preserve formatting across edits.
Fig roundtrip: import characterStyleIDs + styleOverrideTable from
TextData, export back with deduped style table.
B/I/U/S toggle buttons in TypographySection.
TextEditor class in core — cursor positioning, selection, word
boundaries, line navigation via CanvasKit Paragraph API
(getGlyphPositionAtCoordinate, getRectsForRange).
Renderer draws selection highlights and blinking caret directly
on the canvas. Blue outline around text node during editing.
Phantom textarea captures input, IME composition, clipboard,
arrow keys, Home/End, word/line movement (⌥/⌘ modifiers).
Replaces the old visible <textarea> overlay.
Extract renderThumbnail and renderNodesToImage into render-image.ts,
reusing the same offscreen CanvasKit surface approach that fig-export
used for thumbnails.
- ExportSection in properties panel with scale/format selectors,
multi-export support, and live preview with checkerboard background
- Export via context menu and ⇧⌘E shortcut
- Tauri save dialog, File System Access API, and download fallback
- JPG renders with white background, PNG/WEBP with transparency
Add @/ → src/ alias in vite.config.ts and tsconfig.json.
Rewrite all ../ imports across 32 files to use @/ paths.
Same-directory ./ imports kept as-is.
- Move Vector, Matrix, Rect to src/types.ts as shared primitives
- GradientTransform is now a type alias for Matrix
- Consolidate Window API declarations (File System Access,
queryLocalFonts) in src/global.d.ts
- Remove duplicate KiwiNodeChange from fig-export.ts and
clipboard.ts, reuse NodeChange from kiwi/codec.ts
- Add missing fields to codec NodeChange: frameMaskDisabled,
stackWrap, stackPositioning, stackChildPrimaryGrow, vectorData
- Widen codec Paint.type and Effect.type unions to match scene-graph
- Replace inline Rect definitions across 5 files with shared type
- fig-export.ts fillPaints now constructs typed Paint objects
ResizeObserver was recreating the surface on every tick. Now
coalesced via rAF so it fires at most once per frame — surface
always matches the canvas size so no skew.
Option key on Mac transforms e.key into special characters
(e.g. ⌥K → ˚), so modifier+letter shortcuts must check
e.code (physical key) instead of e.key (character produced).
Multiple state changes between display refreshes (e.g. rapid
trackpad wheel events during zoom) now produce a single render
per frame instead of one per event. Zoom feels identical but
CPU usage drops since redundant intermediate renders are skipped.
1. Hit test: components/instances are opaque containers — clicking
their children selects the component itself. Double-click enters
and selects the child inside (like Figma).
2. Component label hit testing: clicking the purple name label
selects the component, enabling drag-by-title (same as sections).
3. Components created from wrapping have no fill (transparent) —
was incorrectly adding a white background rect.
reka-ui's ContextMenuTrigger calls event.preventDefault() after
await nextTick(), which is too late to suppress the native context
menu in Tauri's webview. Replace with a custom implementation using
@floating-ui/vue for positioning (already a transitive dependency)
and proper ARIA roles (role=menu/menuitem).
- e.preventDefault() called synchronously in our handler
- VirtualElement anchor at click coordinates
- flip/shift middleware for viewport-aware positioning
- onClickOutside + Escape to dismiss
- Submenu hover for 'Move to page'
- Remove redundant contextmenu handler from use-canvas-input
- CanvasContextMenu: reka-ui ContextMenu with dark theme matching editor
- Clipboard: Copy, Cut, Paste here, Duplicate, Delete
- Z-order: Bring to front (]), Send to back ([)
- Move to page: submenu listing all other pages
- Grouping: Group (⌘G), Ungroup (⇧⌘G), Add auto layout (⇧A)
- Components: Create component (⌥⌘K), Create component set (⇧⌘K),
Create instance, Go to main component, Detach instance (⌥⌘B)
— component items styled in purple
- Visibility: Show/Hide (⇧⌘H), Lock/Unlock (⇧⌘L)
- Context-aware: items show/hide based on selection type and count
- Right-click selects node under cursor before showing menu
- Store: bringToFront, sendToBack, toggleVisibility, toggleLock,
moveToPage, renameNode operations
- Keyboard: ], [, ⇧⌘H, ⇧⌘L shortcuts
Render the current page to a 400×225 offscreen CanvasKit surface,
fit content with padding, and include as thumbnail.png in the ZIP.
Falls back to a 1×1 placeholder when renderer is unavailable (tests).
- Replace deflate with Zstd compression via zstd-wasm for .fig export
(Figma expects Zstd, not zlib deflate)
- Extract IS_TAURI constant from repeated window check
- Fix Tauri fs permissions: use allow-write-file / allow-read-file
with glob scope instead of bare allow-write / allow-read