Commit graph

66 commits

Author SHA1 Message Date
Danila Poyarkov de83a716cd Remove prop drilling from property sections
All sections now get selected node from useNodeProps() composable
instead of receiving it as a prop from PropertiesPanel.
2026-02-28 09:27:03 +03:00
Danila Poyarkov 2af2a280c6 Appearance section: opacity + corner radius + visibility toggle
- Opacity with blend icon, corner radius with radius icon
- Eye/eye-off toggle in section header
- Layers panel: hidden layers dimmed (opacity-50) with permanent
  eye-off indicator
- Removed duplicate corner radius from LayoutSection
2026-02-28 09:20:31 +03:00
Danila Poyarkov bfe9726035 ScrubInput: entire field is draggable, click-to-edit, suffix styling
- Value + suffix area is now the drag handle (cursor: ew-resize)
- Click without drag enters edit mode (2px dead zone)
- Suffix rendered as separate muted span (right-aligned)
- No more tiny icon-only drag area
2026-02-28 09:16:02 +03:00
Danila Poyarkov c4a3ee6b4a Replace plain number inputs with ScrubInput for drag-to-adjust
Fill opacity, stroke opacity, and stroke weight now use ScrubInput
for consistent drag-to-adjust behavior matching W/H/position inputs.
2026-02-28 09:11:50 +03:00
Danila Poyarkov 8d35d4dbaf Deduplicate hex formatting: colorToHexRaw() in engine/color.ts
Removes duplicate hexString() wrappers from FillSection, StrokeSection,
and ColorPicker — all now use culori-based colorToHexRaw().
2026-02-28 09:08:40 +03:00
Danila Poyarkov babeecc76e Polish fill/stroke sections: editable hex, opacity %, eye toggle, minus
- Hex input is now editable (was read-only label)
- Per-fill/stroke opacity percentage input
- Eye/eye-off icon for visibility toggle (on hover)
- Minus button for removal (replaces ×)
- Stroke weight shown below stroke list
2026-02-28 09:06:04 +03:00
Danila Poyarkov 7b0f6c10f7 Fix text alignment icons: use direct template tags for auto-import 2026-02-28 09:01:54 +03:00
Danila Poyarkov 7e9cd46e0b Split PropertiesPanel into sections, add Typography panel
- PropertiesPanel.vue: 513 → 67 lines (composition only)
- Extracted: PositionSection, LayoutSection, AppearanceSection,
  FillSection, StrokeSection, TypographySection
- Shared useNodeProps() composable for update/commit logic
- Typography panel: font family picker with search (queryLocalFonts),
  weight select, font size, line height, letter spacing, text alignment
- Font picker shows each family in its own typeface
- Loads selected font into both CanvasKit and browser on pick
2026-02-28 09:00:25 +03:00
Danila Poyarkov e3c80fc790 System font loading via Local Font Access API
- Font service (src/engine/fonts.ts) loads system fonts on demand
- Same font bytes registered in both CanvasKit and browser (FontFace API)
- Eliminates rendering mismatch between canvas and text editing overlay
- queryLocalFonts() for enumeration and blob() for font data
- Bundled Inter-Regular.ttf as fallback when API unavailable
- Renderer uses font service instead of hardcoded fetch
2026-02-28 08:54:53 +03:00
Danila Poyarkov 5c0afd4d06 Pass fontWeight to both CanvasKit text style and textarea overlay 2026-02-28 08:49:28 +03:00
Danila Poyarkov 2715f0d8be Text tool: start empty with cursor, blue editing outline
- Create text nodes with empty text instead of 'Text' placeholder
- Immediately enter editing mode on creation (blinking cursor)
- Blue border on textarea during editing (matches Figma)
2026-02-28 08:47:42 +03:00
Danila Poyarkov 98eeb9582c Fix text editing: hide canvas text/selection during edit, load Inter font
- Skip rendering text node on canvas while textarea overlay is active
- Skip selection handles for the node being edited
- Remove border from textarea (was causing double outline)
- Add @font-face for Inter-Regular.ttf so textarea matches CanvasKit
2026-02-28 08:45:37 +03:00
Danila Poyarkov e37d2b216a Fix silent rendering crash, no-chrome test mode, error detection
- Fix this.this.selColor() typo in renderer (6 occurrences) — broke
  selection/handles rendering since the constants extraction refactor
- Add preserveDrawingBuffer to WebGL surface for reliable screenshots
- Add ?no-chrome URL param to hide panels/toolbar in tests
- CanvasHelper collects pageerror + console.error, assertNoErrors()
  fails tests on any browser-side exception
2026-02-28 08:39:19 +03:00
Danila Poyarkov bcd8bf6bca Add IconsResolver for auto-import, explicit imports only for dynamic maps 2026-02-28 08:29:39 +03:00
Danila Poyarkov 3ddc671bc5 Replace emoji icons with Lucide via Iconify (unplugin-icons)
- unplugin-icons + @iconify-json/lucide for compile-time icon bundling
- Toolbar: proper cursor, frame, square, pen, type, hand icons
- Layers panel: per-type node icons + chevron-right for expand/collapse
- Remove icon field from ToolDef (icons mapped in components)
2026-02-28 08:24:42 +03:00
Danila Poyarkov f0d817c747 Refactor: vendor kiwi-schema, extract types/constants, culori colors
- Fork kiwi-schema from source TypeScript (evanw/kiwi), drop npm dep + patch
- Deduplicate GUID/Color types into src/types.ts
- Extract UI colors and default fills into src/constants.ts
- Replace hand-rolled hex parsing with culori library
- Extract demo shapes from App.vue into src/demo.ts
- useUrlSearchParams from VueUse for test mode detection
- Move fig-file/fig-import into kiwi/ (format layer)
- Delete PoC files (poc-yoga.ts, poc-test.ts)
2026-02-28 08:08:04 +03:00
Danila Poyarkov b5eb7f751f Faster E2E: data-ready attr + rAF wait, 4.6s → 2.1s 2026-02-28 07:43:05 +03:00
Danila Poyarkov 40f7b3a648 Playwright visual regression testing with Figma CDP reference
- Playwright E2E tests for OpenPencil: draw, move, delete, undo shapes
- Visual screenshot baselines with toHaveScreenshot()
- SwiftShader for headless WebGL/CanvasKit rendering
- Figma CDP helper for reference tests (connect to Figma Desktop)
- ?test URL param for clean canvas without demo shapes
- Scripts: test, test:update, test:figma, figma:debug
2026-02-28 07:32:15 +03:00
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