Commit graph

163 commits

Author SHA1 Message Date
Danila Poyarkov 30cbba22aa Extract shared surface, button, and select styles 2026-03-06 19:36:36 +03:00
Danila Poyarkov bdb95a9d31 Refine breakpoint and rename patterns 2026-03-06 19:32:14 +03:00
Danila Poyarkov 35dc5bd2a5 Extract menu styling with tailwind-variants 2026-03-06 19:01:14 +03:00
Danila Poyarkov a334a7f63c Refactor UI to use Reka & VueUse primitives 2026-03-06 18:28:40 +03:00
Danila Poyarkov 5ae6234e40
Tailwind CSS v4 JSX export (#54)
* Add Tailwind CSS v4 JSX export format

- Add JSXFormat type ('openpencil' | 'tailwind') to sceneNodeToJSX/selectionToJSX
- Tailwind resolver (tailwind.ts): px→spacing (v4 multiplier), hex→color class,
  fontSize/fontWeight/borderRadius named lookups with arbitrary value fallback
- Export: FRAME→div, TEXT→p, SECTION→section, className with TW utility classes
- CLI: export --format jsx --style tailwind
- CodePanel: format toggle button (OpenPencil / Tailwind)
- Rename Jsx→JSX in all public APIs (sceneNodeToJSX, selectionToJSX, renderJSX, etc.)
- 29 new Tailwind export tests, all 43 export tests pass

* Extract escapeJSXText — replace nested ternary with entity map lookup

* Deduplicate export-jsx: extract shared padding, corner radius, and node context helpers

* Update changelog

* Tighten Tailwind JSX export fidelity
2026-03-06 18:04:11 +03:00
Danila Poyarkov bc52095138 Add double-click to rename layers, extract useInlineRename composable
- New useInlineRename composable: shared editing state, commit logic,
  and keyboard handling for inline rename across panels
- LayerTree: double-click a layer name to rename inline
- PagesPanel: refactored to use useInlineRename, removing duplicate code

Closes #57
2026-03-06 09:50:35 +03:00
Danila Poyarkov 6f31af44e9 Fix drawer animation: single spring transition, no jump on close
Use one constant spring (stiffness 800, damping 50) for both drag
tracking and snap animation. Eliminates the transition switch that
caused motion-v to reinitialize mid-animation. Extract spring
constants to constants.ts. Fix selectTab not updating targetHeight.
2026-03-06 09:22:03 +03:00
Danila Poyarkov a2d7b89709 Adopt motion-v for mobile drawer and toolbar animations
- MobileDrawer: spring-animated height via motion.div :animate + @pan,
  replaces useSwipe + manual rAF. Tab state always set (never null),
  content stays rendered when drawer is closed.
- Toolbar: AnimatePresence with directional slide variants and
  layout-animated width via motion.div layout, replaces manual
  scrollWidth measuring + inline CSS transitions.
- Add SWIPE_VELOCITY_THRESHOLD constant.
- activeRibbonTab type narrowed from union | null to union.
2026-03-06 08:50:38 +03:00
Danila Poyarkov 724e632251 Fix mobile drawer jump on swipe down
Reset dragOffset after snap change and a rAF so the height
transition starts before the translate snaps back to 0.
2026-03-06 00:14:28 +03:00
Danila Poyarkov 49b891d3f9 Refactor mobile drawer: use VueUse composables, unify panels
- Replace hand-rolled touch handlers with useSwipe
- Replace manual offsetHeight measurement with useElementSize
- Replace window.innerHeight with useWindowSize
- Merge MobileRibbon into MobileDrawer (unified bottom sheet)
- Match desktop layout: PagesPanel + Layers in same panel
- Add viewport-fit=cover for safe area insets
- Glassy HUD buttons with backdrop-blur
- Fix toolbar overflow on narrow viewports
- Remove MobileRibbon.vue, SWIPE_MAX_DURATION constant
2026-03-05 22:13:24 +03:00
Anton Soldatov 168c25c189
feat: mobile layout, PWA support (#27) (#27)
- Mobile-responsive editor with bottom drawer, ribbon nav, HUD overlay
- Touch support: single-finger tools, two-finger pinch-zoom
- PWA: manifest, service worker, installability
- Extract LayerTree from LayersPanel, shared utils (colorToCSS, initials, toolIcons)
- Constants moved to src/constants.ts, clipboard state to editor store
- reka-ui Popover/DropdownMenu in MobileHud

Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-03-05 19:51:51 +03:00
Danila Poyarkov ed61445e2d
SVG export (#52)
* Add SVG export

Scene graph → SVG serializer with support for rectangles, ellipses,
lines, stars, polygons, vectors (fill/stroke geometry and vector
networks), text with style runs, gradients (linear, radial, angular),
image fills, effects (drop shadow, inner shadow, blur), opacity,
rotation, flips, blend modes, clip paths, and nested groups.

- packages/core: svg-node.ts (minimal XML builder), svg-export.ts
  (serializer), ExportFormat now includes 'SVG', computeContentBounds
  extracted for reuse
- packages/cli: export command accepts --format svg
- UI: SVG added to ExportSection format picker (scale hidden for SVG),
  context menu 'Export as SVG' option
- 47 new tests covering SVGNode builder, geometry blob → path, vector
  network → path, and full node export for all shape types

* Update changelog

* Add export_svg tool to ToolDefs

Available via MCP, AI chat, and CLI eval command.

* Replace standalone export items with Copy/Paste as submenu

Figma-style submenu: Copy as text, Copy as SVG, Copy as PNG (⇧⌘C),
Copy as JSX. Keeps Export as PNG (⇧⌘E) as a separate item.

* Remove standalone Export as PNG from context menu

* Update changelog
2026-03-05 10:31:02 +03:00
Danila Poyarkov 29096fa56b Merge remote-tracking branch 'origin/master' into reka-color-picker
# Conflicts:
#	src/components/HsvColorArea.vue
2026-03-05 09:05:06 +03:00
Danila Poyarkov d060e32784 Merge origin/master (PR #41 data-test-ids) 2026-03-05 09:03:26 +03:00
Danila Poyarkov b3e1bbdb09 Replace custom color picker with reka-ui Color components
Upgrade reka-ui 2.8.2 → 2.9.0 and replace the hand-rolled HSV color
area, hue slider, alpha slider, and hex input with reka-ui's new
ColorArea, ColorSlider, and ColorField primitives.

- ColorAreaRoot + ColorAreaArea handle the 2D saturation/brightness
  picker with proper gradient backgrounds and keyboard navigation
- ColorSliderRoot + ColorSliderTrack for hue and alpha channels with
  automatic gradient rendering
- ColorFieldRoot + ColorFieldInput for hex color text input with
  arrow key increment/decrement support
- Bridge between our Color type (0–1 range) and reka-ui's Color
  (0–255 RGB) via hex string serialization

Removes ~110 lines of custom HSV↔RGB conversion math, pointer capture
handling, and CSS gradient computation.
2026-03-04 20:52:30 +03:00
Anton Soldatov 3ac016b806
Add data-test-id attributes to all Vue components (#41) 2026-03-04 17:18:26 +03:00
Danila Poyarkov 2a86ea5ce5 Add renderer profiler with HUD overlay, GPU timing, and phase instrumentation
Profiler modules in packages/core/src/profiler/:
- FrameStats: rolling 120-frame buffer tracking FPS, CPU/GPU times, node counts
- GpuTimer: EXT_disjoint_timer_query_webgl2 wrapper for GPU-side timing
- DrawCallCounter: WebGL context proxy counting draw calls per frame
- PhaseTimer: Chrome DevTools Performance panel custom tracks via User Timing API
- HudRenderer: in-canvas overlay with stats text and frame time bar graph
- CaptureStack: per-node profiling with speedscope JSON export
- RenderProfiler: orchestrates all layers with zero cost when disabled

Integration:
- SkiaRenderer.render() instrumented with phase markers for scene, picture
  recording/replay, section titles, component labels, selection, rulers, flush
- Node counting and viewport culling tracking in renderNode()
- WebGL2 context passed through for GPU timer and draw call counter
- Shift+P keyboard shortcut to toggle HUD overlay
- toggleProfiler() added to editor store
2026-03-04 14:13:17 +03:00
Danila Poyarkov 942d3be059 Add auto-save toggle to File menu
Checkbox item in File > Auto-save to local file. Enabled by default.
When disabled, the 3s debounced auto-save to the opened .fig file
is skipped — user can still save manually with ⌘S.
2026-03-04 10:43:20 +03:00
Danila Poyarkov a92315149a Always show visibility toggle on fill, stroke, and effect rows 2026-03-04 09:56:16 +03:00
Danila Poyarkov 1ca96094ab Fix stroke color row overlap
- Remove opacity ScrubInput from color row (matches Figma layout)
- Constrain ColorInput with min-w-0 flex-1 so it doesn't push buttons off
- Add shrink-0 to eye/remove buttons to prevent squishing
2026-03-04 09:54:09 +03:00
Danila Poyarkov e7426f3c58 Fix stroke section layout to match Figma
- Move side selector from header to details row (right of weight input)
- Use 4-dot grid icon for the side selector button (matches Figma)
- Highlight button border with accent color when independent weights active
- Use 3-line stroke weight icon instead of filled rect
- Header now only has section label + add button
2026-03-04 09:51:39 +03:00
Danila Poyarkov 45cab3e5fb Merge branch 'individual-strokes' into compatibility-fixes 2026-03-04 09:49:23 +03:00
Danila Poyarkov c412b78ed8 Add stroke align, individual side strokes, and side selector UI
- Renderer: stroke align (INSIDE/CENTER/OUTSIDE) using clip-based
  approach for all shape types including sections
- Renderer: individual side strokes when independentStrokeWeights is
  enabled, drawing per-side lines with correct align offsets
- StrokeSection: align dropdown, side selector dropdown (All/Top/Bottom/
  Left/Right/Custom), individual weight inputs with side-specific icons
- Kiwi serialize: export correct strokeAlign and border weight fields
- Scene graph: sync border weight props through instance sync
- 19 tests covering align, individual weights, instance sync, kiwi
  export, and FigmaAPI properties
2026-03-04 09:47:14 +03:00
Danila Poyarkov c3e0810620 Fix scrollbar: use utility class instead of global selector
Global * scrollbar styles caused scrollbars to appear on the
menu bar and other elements. Replace with scrollbar-thin utility
applied only to panel scroll containers.
2026-03-04 09:35:30 +03:00
Danila Poyarkov 038c876bc6 Fix horizontal scrollbar on pages panel 2026-03-04 02:27:09 +03:00
Danila Poyarkov c40c6c78a8 Fix horizontal scrollbar on right panel, style scrollbars for Tauri 2026-03-04 02:21:06 +03:00
Danila Poyarkov 7e06969ab5 Multi-selection properties panel with tests
Show position, appearance, fill, stroke, and effects sections
when multiple nodes are selected. Shared values display normally,
differing values show 'Mixed'. Editing applies to all selected.

- Shared composable (use-multi-props) for isMulti, active, activeNode
- ScrubInput supports MIXED symbol as model-value
- Per-node previous values for correct undo on multi-edit
- Fix flip transform encoding (scale first column only)
- Fix npm publish auth for MCP package
- 22 new tests: flip, alignment, multi-node merging, kiwi roundtrip
2026-03-03 20:35:51 +03:00
Danila Poyarkov 5a77f2c1cc Fix flip and single-node alignment
Flip horizontal/vertical now toggles flipX/flipY with scale
transform instead of manipulating rotation. Import detects flip
from negative transform determinant. Single-node alignment uses
parent frame bounds instead of bailing out.
2026-03-03 18:35:30 +03:00
Danila Poyarkov 299d9b73b8 Auto-expand layers tree to reveal selected node 2026-03-03 14:48:51 +03:00
Danila Poyarkov 0c0be3f28b Hide internal pages and render page dividers
Decode internalOnly from kiwi field 142, filter internal pages
in SceneGraph.getPages() by default (pass true to include them).
Pages with names of only dashes/asterisks/spaces and no children
render as horizontal divider lines.
2026-03-03 14:37:56 +03:00
Danila Poyarkov 3e054f0885 Resizable pages/layers split in left panel 2026-03-03 14:22:32 +03:00
Danila Poyarkov 93c549bad0 Reuse GL context on panel resize
Panel resizing now creates a new surface from the existing
GrDirectContext instead of destroying and recreating the entire
renderer. All caches (paints, fonts, node pictures, scene picture,
filters) are preserved across resizes.
2026-03-03 12:48:51 +03:00
Danila Poyarkov 921f28e9f9 Show loading overlay on canvas while opening .fig files 2026-03-03 12:35:54 +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 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 3e81c15689 Center empty chat placeholder text 2026-03-01 21:15:32 +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