Commit graph

549 commits

Author SHA1 Message Date
Danila Poyarkov f741eaa296 Fix open paths rendering as closed
Only close a loop path when the last segment ends at the first
segment's start vertex.
2026-02-28 07:15:07 +03:00
Danila Poyarkov b4c7700f05 Decode/encode Figma vectorNetworkBlob binary format
Reverse-engineered the undocumented binary blob format that Figma uses
to serialize vector networks in both .fig files and clipboard data:

  Header:  [numVertices:u32, numSegments:u32, numRegions:u32]
  Vertex:  [styleOverrideIdx:u32, x:f32, y:f32]
  Segment: [styleOverrideIdx:u32, start:u32, tsX:f32, tsY:f32, end:u32, teX:f32, teY:f32]
  Region:  [windingRule:u32, numLoops:u32, {numSegs:u32, segIdx...}...]

- decodeVectorNetworkBlob() and encodeVectorNetworkBlob() in vector.ts
- Clipboard paste (Figma → OpenPencil) resolves blob references
- Clipboard copy (OpenPencil → Figma) encodes blobs into the message
- .fig file import resolves vectorNetworkBlob references from blobs array
2026-02-28 07:14:17 +03:00
Danila Poyarkov 77d8000b36 Fix pen tool preview line to (0,0) on first click
penCursorX/Y now null until mouse actually moves, preventing
the preview line from drawing to canvas origin on first vertex.
2026-02-28 06:42:14 +03:00
Danila Poyarkov db45e09c76 Fix vector node fills set to undefined on pen commit 2026-02-28 06:38:31 +03:00
Danila Poyarkov bf054e386b Pen tool with vector network model
Full Figma-compatible vector network: vertices, segments (cubic
bezier with tangent offsets), and regions (filled closed paths).

Pen tool interaction:
- Click to place vertices, click+drag to set bezier tangent handles
- Click first vertex to close path (with visual highlight)
- Enter to commit open path, Escape to cancel
- Preview line from last vertex to cursor
- Tangent handle visualization (lines + control points)

Rendering:
- Vector nodes rendered via CanvasKit Path API
- Converts vector network to path: regions as closed fills,
  segments as open strokes
- Proper cubic bezier from tangentStart/tangentEnd offsets

Data model:
- VectorNetwork, VectorVertex, VectorSegment, VectorRegion types
- vectorNetwork field on SceneNode (nullable)
- computeVectorBounds for node sizing
- HandleMirroring enum for future handle behavior
2026-02-28 06:37:25 +03:00
Danila Poyarkov 179acd0edb Wire undo/redo into all editor operations
Undoable operations:
- Create shape (undo deletes it)
- Delete (undo restores with original z-order)
- Move (was already wired, cleaned up)
- Resize (commitResize on mouseUp)
- Rotate (commitRotation on mouseUp)
- Duplicate (undo deletes copies, restores selection)
- Paste (undo deletes pasted nodes)
- Text editing (undo restores previous text)
- Wrap in auto layout (undo unwraps and restores positions)
- Set layout mode (undo restores all layout properties)
- Fill/stroke add/remove/toggle (via updateNodeWithUndo)
- Alignment and padding changes
- Properties panel scrub inputs: emit commit event on
  pointerup/blur with previous value for undo push
- UndoManager.push() added for entries where forward was
  already applied
2026-02-28 06:28:17 +03:00
Danila Poyarkov bd107634d5 Rotation-aware snap guides and selection bounds
Both snap targets and moving selection bounds now compute AABB
from rotated corners, so guides align to the visual edges of
rotated shapes.
2026-02-28 01:24:23 +03:00
Danila Poyarkov 8e6d0a7f2f Fix snap guides using sibling nodes in absolute coordinates
Snap targets are now siblings of the moving node (not root children),
converted to absolute coordinates. This fixes guides not reflecting
resized objects.
2026-02-28 01:21:45 +03:00
Danila Poyarkov 464a3b6e9c Fix handle/rotation hit testing on rotated nodes
Un-rotate mouse position around node center before testing against
axis-aligned handle positions. Fixes resize handles being unclickable
after rotating a shape.
2026-02-28 01:18:17 +03:00
Danila Poyarkov 3fe58c3d44 Use native grab cursor for rotation handle 2026-02-28 01:14:51 +03:00
Danila Poyarkov 946ea74eba Frame name label and size widget on selection
- Blue frame name above top-level frames (root children only)
- Blue pill with W × H below selection bounding box
- Pre-create labelFont/sizeFont once on font load to avoid
  CanvasKit raw-to-smart pointer error
2026-02-28 01:11:20 +03:00
Danila Poyarkov d60045ab16 Only show dashed parent outline for deeply nested frames
Top-level frames (direct children of root) don't get the dashed
outline — matching Figma where it only appears for nested hierarchy.
2026-02-28 01:05:20 +03:00
Danila Poyarkov 362e5d2cee Dashed parent frame outline on child selection
When selecting a node inside a frame, the parent frame gets a
dashed blue outline (50% opacity, 4px dash) showing hierarchy
context — matching Figma's behavior.
2026-02-28 01:04:34 +03:00
Danila Poyarkov 423647dd67 Replace text arrow with SVG chevron icon in layers panel 2026-02-28 01:02:22 +03:00
Danila Poyarkov 9afabfe009 Layers panel: Reka UI Tree with expand/collapse
- TreeRoot/TreeItem from reka-ui for proper tree structure
- Click arrow to expand/collapse frames with children
- Leaf nodes (no children) show no arrow
- Drag reorder preserved with drop indicator
- Bigger arrow icons (text-xs) with hover highlight
2026-02-28 01:01:30 +03:00
Danila Poyarkov 2abd50733f Layer panel drag reordering
Drag layers to reorder within parent or reparent into frames.
Blue indicator line shows insertion point, ring highlight on
drop-into containers. 4px dead zone before drag starts.
Prevents dropping into self or descendants.
2026-02-28 00:57:19 +03:00
Danila Poyarkov 509dd4fab4 Migrate to Tailwind CSS 4.2
- @tailwindcss/vite plugin for zero-config build
- @theme tokens: panel, canvas, border, hover, accent, surface, muted, input
- All scoped CSS replaced with Tailwind utility classes
- Only remaining scoped CSS: ColorPicker range slider thumbs
- Removed conflicting * { padding: 0 } reset (Tailwind preflight handles it)
2026-02-28 00:54:38 +03:00
Danila Poyarkov 20da5830cd Prevent browser pinch-to-zoom on panels
Ctrl/Meta+wheel on document is now preventDefault'd globally.
Canvas zoom still works because its own wheel handler fires first.
2026-02-28 00:34:47 +03:00
Danila Poyarkov a78a43e717 Fix scrub label hit area and clean up cursor handling
Label now stretches to full input height (24px vs 11px).
Removed global scrubbing class hack, use body.style.cursor for drag.
2026-02-28 00:32:06 +03:00
Danila Poyarkov 98104cee58 Add ScrubInput component with drag-to-scrub values
Figma-style scrubby inputs: hover icon shows ew-resize cursor,
drag left/right to change value. Click the number to type.
Replaces all raw number inputs in PropertiesPanel.
Uses VueUse useEventListener for automatic cleanup.
2026-02-28 00:27:38 +03:00
Danila Poyarkov b6ae7d40d0 Replace opacity slider with number input
Slider was overlapping the label text. Figma uses a number input
with % suffix for opacity, not a slider.
2026-02-28 00:22:18 +03:00
Danila Poyarkov bc68d660b9 Replace native range input with Reka UI Slider
Accessible, unstyled slider with custom dark theme styling:
thin 3px track, blue range fill, 10px white circular thumb with
hover/focus ring.
2026-02-28 00:17:25 +03:00
Danila Poyarkov 2e91959cc5 Style opacity range slider to match dark theme 2026-02-28 00:15:16 +03:00
Danila Poyarkov 639a2a943e Fix properties panel reactivity: spread node objects in computed
Vue's computed caching compares array contents by reference. Since
graph.getNode() returns the same mutable object, the computed saw
no change even when properties were mutated. Spreading creates new
objects so Vue detects the difference.
2026-02-28 00:14:25 +03:00
Danila Poyarkov 3c7ff3ecd1 Fix properties panel not updating on node property changes
selectedNodes computed didn't depend on renderVersion, so mutations
to node objects via graph.updateNode() weren't triggering reactivity.
Adding void state.renderVersion ensures the computed recomputes
whenever requestRender() is called.
2026-02-28 00:12:46 +03:00
Danila Poyarkov 759940194e Figma-style auto-layout properties panel
- 9-dot alignment grid for simultaneous primary + counter axis
- Direction buttons with SVG icons (vertical/horizontal/wrap)
- W/H sizing mode dropdowns (Fixed/Hug/Fill) with click-outside
- Uniform padding input with expand toggle for per-side padding
- Gap input with visual icon
- + / − to add/remove auto layout instead of triple toggle
- Sizing mode awareness: shows Fill option when inside auto-layout
2026-02-28 00:11:01 +03:00
Danila Poyarkov f6cee76d22 Auto-layout drag reordering with insertion indicator
Dragging inside auto-layout frames:
- 8px dead zone before breaking out of auto-layout flow
- Blue insertion indicator line between children, computed from
  cursor position relative to child midpoints
- Drop reorders children in the parent's childIds array and
  recomputes layout via Yoga
- Dragging from outside into an auto-layout frame shows the
  indicator and inserts at the correct index

SceneGraph.reorderChild() added for index-based child insertion.
Renderer draws the indicator as a 2px blue line (horizontal for
vertical layout, vertical for horizontal layout).
2026-02-28 00:00:45 +03:00
Danila Poyarkov ecfc6dd0c2 Shift+A wraps selected nodes in auto-layout frame
Single frame selected: toggles auto-layout on/off.
Any other selection: creates a new frame around the selected nodes,
reparents them into it, and enables vertical auto-layout with HUG
sizing.
2026-02-27 23:55:51 +03:00
Danila Poyarkov 10c0a39ebe Add Shift+A shortcut to toggle auto-layout 2026-02-27 23:53:55 +03:00
Danila Poyarkov 4de83542d3 Auto-layout with Yoga WASM integration
Layout engine (src/engine/layout.ts):
- Yoga WASM adapter mapping SceneNode layout props to Yoga nodes
- Supports vertical/horizontal direction, padding (per-side), gap,
  primary/counter axis alignment, HUG/FIXED/FILL sizing, wrap,
  absolute positioning, flex grow, stretch
- Bottom-up computation: nested auto-layout frames resolve correctly
- Layout runs automatically on any property change to affected frames

Scene graph:
- New layout properties on SceneNode: layoutMode, itemSpacing,
  padding (4 sides), primaryAxisAlign, counterAxisAlign,
  primaryAxisSizing, counterAxisSizing, layoutWrap, layoutGrow,
  layoutPositioning, layoutAlignSelf, counterAxisSpacing

Properties panel:
- Auto-layout section for frames: direction toggles (none/vertical/
  horizontal), gap, padding (4 inputs), primary and counter axis
  alignment dropdowns

Import/export:
- .fig import maps stackMode/stackSpacing/stackPadding* fields
- Clipboard paste maps all Figma stack* properties
- Clipboard copy exports layout as Kiwi stackMode/stackSpacing/etc.
2026-02-27 23:51:21 +03:00
Danila Poyarkov 49e7514dcf Fix copy: build fig-kiwi binary synchronously in copy event handler
navigator.clipboard.write() inside .then() loses user gesture context
and gets blocked by the browser. The schema is prefetched on app init
and cached, so encoding is done synchronously in clipboardData.setData
within the native copy event.
2026-02-27 23:32:56 +03:00
Danila Poyarkov 155f7b3af1 Encode fig-kiwi binary on copy for Figma paste compatibility
Copy now produces actual fig-kiwi binary (Kiwi schema + encoded
Message with NodeChanges) matching Figma's clipboard format. Schema
is bundled as public/figma-schema.bin (28KB deflated, 540 defs)
and prefetched on app init.

Dual clipboard: synchronous OpenPencil internal format written via
clipboardData.setData, then navigator.clipboard.write overwrites
with combined HTML containing both (figma) and (openpencil) markers.
Paste checks our format first, falls back to Figma decode.
2026-02-27 23:31:01 +03:00
Danila Poyarkov b1808acac8 Fix clipboard: use native copy/paste events instead of async Clipboard API
navigator.clipboard.read() requires clipboard-read permission and
silently fails on localhost. Use copy/cut/paste DOM events instead —
the browser provides clipboardData synchronously, no permissions needed.
2026-02-27 23:25:27 +03:00
Danila Poyarkov 9fb410811b Figma-compatible clipboard: paste from Figma, copy/cut between instances
- Decode Figma's fig-kiwi clipboard format: magic header + version +
  deflate-compressed schema chunk + deflate/zstd data chunk
- Parse Kiwi binary schema (540 defs) and decode NodeChange messages
- Import nodes preserving hierarchy, transforms, fills, strokes, corner
  radii, text properties, and child ordering (parentIndex.position)
- Map Figma node types to OpenPencil types (COMPONENT→FRAME, etc.)
- Internal clipboard format (openpencil/v1) for copy/paste within app
- Cmd+C/X/V keyboard shortcuts wired up
- Uses ClipboardItem API with text/html + text/plain MIME types
2026-02-27 23:23:44 +03:00
Danila Poyarkov ede6cf27d7 Add full keyboard shortcut reference to PLAN.md, fix zoom speed
Shortcut table covers all Figma shortcuts organized by category
(tools, file, edit, view, object, text, arrange, canvas interaction)
with implementation status tracking.

Zoom: use continuous exponential scaling (0.99^deltaY) instead of
fixed 1.1/0.9 factor per event.
2026-02-27 23:10:27 +03:00
Danila Poyarkov b809521a8d Patch kiwi-schema ESM and CLI builds for sparse field IDs 2026-02-27 23:04:52 +03:00
Danila Poyarkov 859afdce45 Add .fig file import (Kiwi codec → scene graph)
File parser:
- .fig files are ZIP archives with Kiwi-encoded canvas data
- Detects canvas blob by name (canvas.fig) or largest binary entry
- Decodes Kiwi Message → NodeChange[] → SceneGraph

Node conversion:
- Maps Figma types to OpenPencil types (COMPONENT → FRAME, etc.)
- Extracts position from transform matrix (m02, m12)
- Extracts rotation from transform matrix (atan2)
- Converts fillPaints/strokePaints to Fill[]/Stroke[]
- Converts effects (shadows, blurs)
- Handles independent corner radii
- Preserves text properties (font, size, alignment, spacing)
- Builds parent-child tree from parentIndex GUIDs
- Sorts children by parentIndex.position

Browser compatibility:
- fzstd for Zstd decompression (replaces Bun.zstd*)
- fflate for ZIP extraction
- Cmd+O opens file dialog for .fig files
- UndoManager.clear() for resetting on file open
2026-02-27 23:01:35 +03:00
Danila Poyarkov 7139546cb5 Add text editing with Paragraph API and inline overlay
Font loading:
- Inter Regular font bundled (342KB TTF)
- TypefaceFontProvider for Paragraph API
- MakeFreeTypeFaceFromData for Font object
- Fonts load async, fallback to drawText before ready

Text rendering:
- ParagraphBuilder with font family, size, letter spacing,
  line height, text alignment
- Multi-line text wraps to node width
- Text nodes render at correct position in local space

Inline editing:
- Double-click text node → HTML textarea overlay
- Positioned/sized to match node on canvas (zoom-aware)
- Live updates to scene graph on input
- Escape or blur commits edit
- Keyboard events stop propagation during editing
- Text cursor for TEXT tool
2026-02-27 22:57:39 +03:00
Danila Poyarkov 7d483f340b Add nested frames with reparenting and relative coordinates
Scene graph:
- Nodes use relative coordinates (x,y relative to parent)
- getAbsolutePosition() walks up the parent chain
- reparentNode() preserves visual position when moving between frames
- Deep hit testing traverses into container children
- hitTestFrame() finds deepest frame under cursor (for drop targeting)
- flattenTree() returns indented layer list for the panel
- isDescendant() prevents circular reparenting

Renderer:
- canvas.translate(node.x, node.y) per node — children render relative
- All shapes draw at (0,0) in their local space
- Drop target highlight (blue outline on frame during drag-over)
- Selection UI uses getAbsolutePosition() for screen-space handles

Input:
- Drag onto frame → blue highlight → drop reparents nodes
- Handle hit testing uses absolute positions
- Snap guides work with absolute coordinates

Layers panel:
- Tree view with depth-based indentation (16px per level)
- Expand arrow for containers with children
- Shift+click for additive selection
2026-02-27 22:34:15 +03:00
Danila Poyarkov 94c4635907 Add rotation, multi-select bbox, snap guides, text tool, color picker
Rotation:
- Rotation handle above selection (line + circle)
- Drag to rotate, Shift snaps to 15° increments
- Live preview during drag, applied on release
- Rotated nodes render correctly with canvas.rotate()

Multi-select bounding box:
- Individual outlines per node + unified group bbox
- Group resize handles on the unified bounds
- Handles rotated AABB for rotated nodes

Snap guides:
- Magenta guide lines when moving nodes near edges/centers
- Snaps to left/right/top/bottom/center of other nodes
- 5px threshold, guides clear on mouse up

Text tool:
- T shortcut or toolbar to activate
- Click to place text node (200×24, default 'Text')
- CanvasKit drawText with default font (Inter to come)

Color picker (Reka UI Popover):
- HSV saturation/brightness area with pointer drag
- Hue slider (rainbow gradient)
- Alpha slider
- Hex input + alpha percentage
- Live color updates to fill/stroke

Properties panel:
- Add/remove fills and strokes with +/× buttons
- Toggle fill visibility
- Stroke weight editing
- Multi-select shows layer count + shared opacity
- ColorPicker integrated for fill and stroke colors

Frame clipping:
- Frame children now clip to parent bounds
2026-02-27 22:15:35 +03:00
Danila Poyarkov 0b563ed621 Recreate Skia surface on window resize
The WebGL surface is bound to the framebuffer size at creation time.
Resizing the canvas element without recreating the surface left dark
empty space. Now createSurface() is called on every resize.
2026-02-27 22:03:18 +03:00
Danila Poyarkov a3ad587652 Move all event listeners to useEventListener 2026-02-27 22:01:12 +03:00
Danila Poyarkov 93e1837282 Use useEventListener from VueUse for wheel handler 2026-02-27 22:00:21 +03:00
Danila Poyarkov 1009d6dc12 Add resize handles, marquee selection, duplicate, constrained drawing
- Resize by dragging selection handles (8 positions: corners + edges)
- Shift+resize maintains aspect ratio
- Marquee selection: drag on empty canvas to select nodes in rectangle
- Alt+drag to duplicate and move selected nodes
- Cmd+D to duplicate in place (+20px offset)
- Cmd+A to select all
- Shift+draw constrains to square/circle
- Cursor changes to resize arrows on handle hover
- Marquee drawn as blue transparent rect with selection stroke
2026-02-27 21:59:36 +03:00
Danila Poyarkov b7f51579ef Fix selection outline scaling with zoom
Draw selection outlines and resize handles in screen space (CSS pixels)
instead of canvas coordinates. Skia renders via dpr-scaled transform
so strokes stay 1px regardless of zoom level.
2026-02-27 21:55:19 +03:00
Danila Poyarkov 323a1f90c4 Migrate React → Vue 3 + VueUse + Reka UI
- Vue 3 SFC components with <script setup>
- Reactive store (Vue reactivity) replacing React refs/state
- Composables: useCanvas, useCanvasInput, useKeyboard
- Components: EditorCanvas, LayersPanel, PropertiesPanel, Toolbar
- Floating toolbar (Figma UI3 style) with pill shape + shadow
- Properties panel with Design/Prototype tabs + zoom display
- Sections: Position, Rotation, Layout, Appearance, Fill, Stroke, Effects, Export
- CSS custom properties for theming (--panel-bg, --border, --accent, etc.)
- Add Figma UI3 CDP inspection findings (toolbar, panels, sections)
2026-02-27 21:25:31 +03:00
Danila Poyarkov ec90f35df8 PoC 2 & 3: Yoga WASM layout + Kiwi codec
Yoga WASM (yoga-layout v3.2.1):
- Vertical/horizontal auto-layout with gap, padding, alignment 
- Nested frames with flex-grow 
- Space-between distribution 
- 1000-node layout in 3ms 

Kiwi binary codec (from figma-use multiplayer module):
- 194-definition Figma schema (2178 lines) compiles in 6ms
- Encode 4 nodes → 237 bytes Zstd-compressed
- Full round-trip: encode → Zstd compress → decompress → decode 
- Patch kiwi-schema to allow sparse field IDs (Figma uses IDs > 312)

Extracted files: schema.ts, codec.ts, protocol.ts, client.ts, index.ts
2026-02-27 17:16:21 +03:00
Danila Poyarkov c123dad50c Add oxlint, oxfmt, typescript-go
- oxlint with react, react-hooks, typescript, import, unicorn plugins
- oxfmt (no trailing commas, single quotes, sorted imports)
- tsgo (typescript-go native preview) for fast typechecking
- Fix all lint warnings: non-null assertions, unused expressions, stale closures
- Scripts: lint, format, typecheck, check
2026-02-27 15:52:55 +03:00
Danila Poyarkov 68b54ad6b7 Initial commit: Tauri + CanvasKit + React editor scaffold
- Tauri v2 desktop shell with 1280x800 window
- CanvasKit WASM (Skia) rendering via WebGL2
- Scene graph with typed nodes (Frame, Rectangle, Ellipse, Line, etc.)
- Skia renderer with fills, strokes, rounded corners, opacity, rotation
- Selection system with resize handles
- Undo/redo manager (inverse command pattern with batching)
- Bottom toolbar (Select, Frame, Rect, Ellipse, Line) matching Figma UI3
- Left layers panel, right properties panel
- Pan (scroll), zoom (Ctrl+scroll toward cursor)
- Keyboard shortcuts: V/F/R/O/L for tools, Cmd+Z undo, Backspace delete
- 5 demo shapes rendered on canvas
2026-02-27 15:48:29 +03:00