Commit graph

251 commits

Author SHA1 Message Date
Danila Poyarkov a32b134eba
Improve zoom smoothness, add absolute position cache (#21)
* Improve trackpad pinch-zoom smoothness

- Replace Math.pow(0.99, delta) with Math.exp(-delta/100) zoom curve
  for natural, symmetric scaling that handles both tiny trackpad deltas
  and large mouse wheel jumps gracefully
- Normalize wheel deltaMode (LINE → 40px, PAGE → 800px) so external
  mice on Firefox produce consistent zoom behavior
- Clamp per-flush scale factor to 0.75–1.25 to prevent jarring jumps
  from discrete mouse wheel ticks

* Add per-frame absolute position cache for SceneGraph
2026-03-03 11:44:31 +03:00
Danila Poyarkov a9a1f9ac1f
Shadow rendering performance: per-node SkPicture cache (#25)
* Replace saveLayer with MaskFilter for drop shadows

Drop shadows now draw directly with MaskFilter.MakeBlur instead of
saveLayer + ImageFilter. Eliminates per-shadow offscreen buffer
allocation, significantly improving render throughput for scenes
with many shadowed nodes.

Text shadows still use saveLayer (MaskFilter can't be applied to
text glyphs individually).

Benchmark: 50 shadow nodes render at 1.55ms/frame (scene change).

* Cache per-node SkPictures for effect rendering

Nodes with visible effects (shadows, blurs) are recorded into
individual SkPictures and replayed on subsequent scene redraws.
Only the modified node re-records; unchanged shadow nodes replay
from cache.

- MaskFilter.MakeBlur for drop shadows instead of saveLayer
- Reusable effectLayerPaint and cached ImageFilters/MaskFilters
- invalidateNodePicture() called on node update
- invalidateAllPictures() on font load; invalidateScenePicture()
  preserves node cache for scene-only invalidation
- Shadow benchmark: 50 nodes at 1.47ms/frame (scene change)

* Update changelog with shadow rendering performance improvements
2026-03-03 11:37:01 +03:00
Danila Poyarkov c618d486e4 Remove effects showcase section from demo 2026-03-03 10:17:43 +03:00
Danila Poyarkov 8ced3201cf Cache effect ImageFilters and reuse layer paint
Eliminates per-frame WASM allocations for shadows and blurs:
- Reusable effectLayerPaint instead of new Paint() per effect
- ImageFilter cache keyed by params (drop shadow, blur, decal blur)
- Zero allocations during SkPicture recording for cached effects
2026-03-03 10:12:02 +03:00
Danila Poyarkov 8ab7148294
Fix effects rendering: shadows, blur, spread (#24)
* Fix shadow rendering, implement blur effects, add spread support

- Fix drop shadow rendering order: draw shadows before fills so they
  appear behind opaque shapes instead of on top (#23.1)
- Implement shadow spread: expand/contract shadow shape by spread value
  for both drop shadow and inner shadow (#23.2)
- Implement background blur: clip to node shape and apply blur filter
  to content behind the node (#23.3)
- Fix text shadows: use saveLayer with ColorFilter.MakeBlend to apply
  shadows to text glyphs instead of the bounding box (#23.4)
- Implement layer blur with actual saveLayer + blur filter instead of
  the no-op comment (#23.3)
- Implement foreground blur: clip to node shape and blur content in
  front (#23.7)
- Add inner shadow spread support with rounded rect handling
- Add makeRRectWithSpread and makeRRectWithOffset helpers

Closes #23

* Add Effects section to demo showcasing shadow and blur fixes

Drop shadows (subtle, medium, heavy, with spread), inner shadows
(inset, with spread, on ellipse, combined), text shadows on glyphs,
layer blur, and glassmorphism background blur card.

* Fix drop shadow, layer blur, and background blur rendering

Drop shadow: use MakeDropShadowOnly to produce shadow-only output
that doesn't bleed through opaque fills. The sigma is radius/2 to
match Figma's blur convention.

Layer blur: wrap entire node content in a saveLayer with blur filter
at the renderNode level instead of trying to blur after-the-fact
in renderEffects.

Background/foreground blur: extract clipNodeShape helper to reduce
duplication in blur effect rendering.

Update tests to match new rendering approach.

* Add visual regression tests for effects rendering

9 snapshot tests covering drop shadow, inner shadow, spread,
ellipse shapes, text glyphs, layer blur, and combined effects.

* Make Effects demo section background white

* Clean up renderer code and tests

- Extract applyClippedBlur to deduplicate background/foreground blur
- Remove comments that repeat what code does
- Format with oxfmt

* Add undo/redo support for effect property changes

ScrubInput controls (offset, radius, spread, opacity) now use
scrubEffect for live preview and commitEffect on release to create
a single undo entry per interaction.
2026-03-03 09:53:39 +03:00
Danila Poyarkov a5fe5b0cf4 Optimize zoom/pan smoothness with shallowReactive, useRafFn, and input coalescing
- Switch editor state from reactive() to shallowReactive() to avoid deep
  Proxy wrapping of Set, arrays, and nested objects
- Replace Vue watch on renderVersion with useRafFn dirty-flag loop that
  polls for changes without going through Vue's watcher scheduler
- Coalesce wheel events: accumulate deltaX/deltaY/zoomDelta between
  frames and flush once per rAF instead of per event
- Coalesce Safari gesturechange events: buffer the latest scale/position
  and apply once per rAF
- Fix Set.delete() on shallowReactive state to use replace pattern
2026-03-02 23:43:39 +03:00
Danila Poyarkov 25233294a1 Replace native <select> with reka-ui AppSelect component
- New AppSelect.vue: generic wrapper around reka-ui Select primitives
- Replace all 4 native <select> elements (font weight, effect type,
  export scale, export format)
- Fix font picker dropdown truncating names (min-w-56)
- Show explanation when local fonts unavailable (Safari/Firefox)
2026-03-02 21:33:04 +03:00
Danila Poyarkov 412e614d07 Fix font picker dropdown truncating font names
Set min-w-56 on popover so it doesn't shrink below 224px
when the trigger button is narrow.
2026-03-02 21:18:57 +03:00
Danila Poyarkov da28a0ed93 Merge branch 'multi-file-tabs' 2026-03-02 19:52:03 +03:00
Danila Poyarkov 90e29f13a2 Add context menu to layers panel
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.
2026-03-02 19:45:09 +03:00
Danila Poyarkov 7534c46583 Truncate long node names in export button 2026-03-02 19:43:42 +03:00
Danila Poyarkov ef78ffd2e4 Render text from SkPicture cache when fonts are missing
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
2026-03-02 19:35:04 +03:00
Danila Poyarkov ce55af6171 Fix pasteID int overflow and make error toasts copyable
- Use Int32Array instead of Uint32Array for Kiwi pasteID (signed int field)
- Error toasts: don't auto-dismiss, show copy button, text is selectable
- Add clipboard roundtrip tests (encode → decode → verify)
2026-03-02 18:55:49 +03:00
Danila Poyarkov af9b9fd0fa Replace store thunks with a late-binding proxy
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.
2026-03-02 18:36:27 +03:00
Danila Poyarkov a6634b9543 Wire native Tauri menu to tab actions
Map File → New to createTab() and File → Close Tab to closeTab().
Rename menu labels from 'New File'/'Close Window' to 'New'/'Close Tab'.
2026-03-02 18:36:27 +03:00
Danila Poyarkov 1f652b5e90 Use reka-ui Tabs for tab bar accessibility
Arrow key navigation, role=tablist/tab, aria-selected,
focus management, and screen reader support.
2026-03-02 18:36:27 +03:00
Danila Poyarkov b3438cd272 Fix active tab text invisible against same-color background 2026-03-02 18:36:27 +03:00
Danila Poyarkov 1b4dc08c54 Add multi-file tabs
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
2026-03-02 18:36:27 +03:00
Danila Poyarkov db7a019385 Fix Figma paste redo + add clipboard import tests
Redo was creating duplicate childIds because snapshots captured after
tree construction had populated childIds, and createNode appends to
parent.childIds again. Fix: clear childIds in redo snapshots.

New tests (7):
- layoutAlignSelf from stackChildAlignSelf
- clipsContent from frameMaskDisabled
- fontWeight from fontName.style via styleToWeight
- letterSpacing object → pixels conversion
- undo removes full subtree
- redo recreates correct parent-child tree
- redo without childIds:[] demonstrates the duplicate bug
2026-03-02 18:05:22 +03:00
Danila Poyarkov 8850625a6f Fix text not rendering + undo for Figma clipboard paste
- letterSpacing is an object {value, units} not a number — was NaN,
  which silently breaks CanvasKit paragraph layout
- Add mapLetterSpacing() to handle PIXELS and PERCENT units
- Force textAutoResize to NONE (Yoga has no text measurement callback)
- Record undo entry for Figma paste with full subtree snapshots
- Add stackChildAlignSelf, fontWeight to NodeChange interface
2026-03-02 17:59:18 +03:00
Danila Poyarkov f54871fae2 Compute layouts after clipboard paste 2026-03-02 17:34:53 +03:00
Danila Poyarkov 8b1547ece7 Center pasted Figma nodes in viewport instead of using original coordinates 2026-03-02 17:32:06 +03:00
Danila Poyarkov 518ddb0c5b Release v0.3.2 2026-03-02 11:36:18 +03:00
Danila Poyarkov 527be98f2a Re-apply SkPicture scene caching with visual regression tests 2026-03-02 11:33:45 +03:00
Danila Poyarkov 38aeade46e Revert SkPicture scene caching (breaks text rendering on hover) 2026-03-02 11:13:46 +03:00
Danila Poyarkov 1ee8e8e5af Add render performance benchmark test 2026-03-01 22:35:16 +03:00
Danila Poyarkov 9431918634 SkPicture scene caching for fast pan/zoom 2026-03-01 22:28:03 +03:00
Danila Poyarkov 80beba3348 Cache vector paths and pen paints across frames 2026-03-01 22:21:20 +03:00
Danila Poyarkov 97f4727e26 Cache ruler paints, disable preserveDrawingBuffer outside tests 2026-03-01 22:16:13 +03:00
Danila Poyarkov 5e209eca1c Fix trackpad pinch-to-zoom on Safari macOS 2026-03-01 22:07:47 +03:00
Danila Poyarkov 3e81c15689 Center empty chat placeholder text 2026-03-01 21:15:32 +03:00
Danila Poyarkov 0ba1f00e74 Hide panels by default on mobile 2026-03-01 21:13:51 +03:00
Danila Poyarkov d4b8a2a310 Always show local user avatar in collab header 2026-03-01 21:12:22 +03:00
Danila Poyarkov ab658ac33f Add ⌘\ to toggle side panels, header with logo and editable filename
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.
2026-03-01 21:07:02 +03:00
Danila Poyarkov 575914b194 Add touch support for pan and pinch-zoom on iOS 2026-03-01 20:13:21 +03:00
Danila Poyarkov c3844db2bd Stub shiki to eliminate 9MB of unused language grammars
vue-stream-markdown bundles all 359 shiki grammar chunks by default.
We only need plain text code blocks in chat. Alias shiki to an empty
module — build drops from 20MB/377 files to 11MB/103 files.
2026-03-01 20:05:52 +03:00
Danila Poyarkov 56aa194785 Toast notifications via Reka UI Toast
- 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)
2026-03-01 18:25:04 +03:00
Danila Poyarkov f90e1400b7 P2P collaboration: cursors, follow mode, cleanup
- Broadcast cursor position from canvas mouse move via awareness
- Broadcast selection changes via reactive watcher on selectedIds
- Follow mode: click peer avatar to track their viewport (pan + zoom)
- Zoom broadcast via watcher on store.state.zoom, not just mouse move
- Figma-style cursor arrows: colored fill, white border, name pill
- Stale cursor cleanup: removeAwarenessStates on peer leave
- MQTT signaling (replace Nostr), STUN + TURN ICE servers
- Collab constants extracted to src/constants.ts
- crypto.getRandomValues() replaces Math.random() everywhere
- Provide/inject for collab composable (COLLAB_KEY)
- Update README (collab section, tech stack), CHANGELOG, AGENTS.md
- AGENTS.md: release process, CI workflows, documentation guidelines
2026-03-01 18:11:07 +03:00
Danila Poyarkov 3427b7fd3c Replace Durable Object relay with Trystero P2P + y-indexeddb
- Remove packages/collab/ (CF Worker + Durable Object server)
- Replace WebSocket relay with Trystero (serverless WebRTC P2P)
  - Peers connect via Nostr relays for signaling (free, public)
  - All sync traffic flows directly peer-to-peer
  - Automatic chunking for large Yjs updates
- Add y-indexeddb for local persistence
  - Each client persists Y.Doc to IndexedDB
  - Room survives browser refresh, loads instantly
- Yjs sync protocol over Trystero actions:
  - yjs-update: incremental doc updates
  - sync-step1/sync-reply: full state sync on peer join
  - awareness: cursor, selection, presence
- Zero server cost at any scale
2026-03-01 17:25:46 +03:00
Danila Poyarkov 76134ccc1a Collab UI polish, vue-router, extract HsvColorArea
- Move CollabPanel to right panel header (like Figma)
- /share/:roomId shows join dialog with name input before connecting
- Add vue-router with / and /share/:roomId routes
- Extract EditorView from App.vue
- Extract HsvColorArea component from ColorPicker + FillPicker
  (eliminates ~160 lines of duplicated HSV logic)
- Extract syncNodePropsToYMap in use-collab.ts
- Register Yjs update listener before opening WebSocket (fix race)
- Scrollable AppMenu without scrollbar (scrollbar-none utility)
- Overflow-x hidden on LayersPanel
- Gitignore .wrangler build artifacts
- SPA catch-all redirect for Cloudflare Pages
2026-03-01 17:15:08 +03:00
Danila Poyarkov 2add3ac4c0 Real-time collaboration with Yjs CRDT over Cloudflare Durable Objects
- 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)
2026-03-01 16:44:37 +03:00
Danila Poyarkov fb8891c431 Fix case-sensitive filename: ApiKeySetup.vue → APIKeySetup.vue 2026-03-01 16:29:54 +03:00
Danila Poyarkov c4e6514ea2 Update repo references to open-pencil/open-pencil 2026-03-01 16:18:11 +03:00
Danila Poyarkov f8f85970e1 Add favicon, persist banner dismissal in localStorage 2026-03-01 15:44:25 +03:00
Danila Poyarkov c8af1c35e7 Improve Save for browsers without File System Access API
- Save As prompts for filename via prompt() before downloading
- Save reuses the filename from last Save As (no repeated prompts)
- Banner in Safari/Firefox explaining the limitation with Chrome link
- Banner is dismissible
2026-03-01 15:41:27 +03:00
Danila Poyarkov 22f8599408 Fix Save in Safari: append <a> to DOM, defer revokeObjectURL
Safari requires the <a> element to be in the document for .click()
downloads to work. Also defers URL.revokeObjectURL by 100ms so the
browser has time to start the download. Extracted shared downloadBlob
helper used by both file save and image export fallbacks.
2026-03-01 15:31:56 +03:00
Danila Poyarkov 90e47e07ab Add autosave: debounced write 3s after last scene change
Triggers only when a file handle (browser File System Access API) or
file path (Tauri) exists. Tracks savedVersion to skip redundant writes.
Silent failure — user can always save manually.
2026-03-01 15:17:03 +03:00
Danila Poyarkov 7809fb60f9 Add app menu bar for browser mode (File, Edit, View, Object, Text, Arrange)
Reka-ui Menubar in the left panel header, hidden when running in Tauri
(native menu handles it). Includes Open, Save, Save As, Export, Undo/Redo,
Zoom, Group/Ungroup, Component creation, Bring to front/Send to back,
Auto layout, Text formatting, Alignment.
2026-03-01 15:09:08 +03:00
Danila Poyarkov 3849872b32 Unify tool definitions: define once, adapt for AI/CLI/MCP
Move tool logic to @open-pencil/core/tools/schema.ts as framework-agnostic
ToolDef objects. AI adapter generates valibot schemas + Vercel AI tool()
wrappers automatically.

26 tools (was 10): create_shape, render (JSX), set_fill, set_stroke,
set_effects, set_layout, set_constraints, update_node, delete, clone,
rename, reparent, group/ungroup, find_nodes, get_node, get_page_tree,
get_selection, select, list_pages, switch_page, list_variables,
list_collections, create_component, create_instance, eval.

src/ai/tools.ts: 269→28 lines (adapter only).

Tests for all 3 interfaces:
- 26 core tool tests (FigmaAPI directly)
- 11 AI adapter tests (valibot + Vercel AI SDK tool())
- 12 CLI integration tests (eval command on .fig fixture)
323 total, all passing.
2026-03-01 15:02:05 +03:00
Danila Poyarkov f22bd2e133 Merge eval-command: Figma Plugin API + eval CLI command
FigmaAPI wraps SceneGraph with Figma-compatible interface (Symbol-hidden internals).
CLI eval command runs Plugin API scripts headless on .fig files.
106 new tests (60 figma-api + 17 cli integration + 29 gap-filling).
2026-03-01 13:55:39 +03:00