Commit graph

99 commits

Author SHA1 Message Date
Danila Poyarkov 33389a95ad Remove re-export shims and import types directly from core
Delete src/types.ts (pure re-export of Color, Rect, etc.) — 9 files
now import directly from @open-pencil/core. Remove dead AI_PROVIDERS
re-export from use-chat.ts and dead font function re-exports from
engine/fonts.ts.
2026-03-09 08:56:47 +03:00
Danila Poyarkov 7a334268d0 Fix all type-aware lint warnings
Add void prefix to 23 fire-and-forget promises across 10 files.
Fix no-base-to-string in bridge.ts by using Buffer.from().toString().
Promote no-floating-promises from warn to error.
2026-03-08 23:04:44 +03:00
Danila Poyarkov 31326e8e5e Eliminate as Record<string, unknown> casts
- Add index signature to NodeChange for Kiwi codec fields
- Add blobs field to FigmaMessage
- Use 'in' type guards instead of Record casts in ai-adapter
- Use Object.fromEntries for Partial<SceneNode> property copying
- Use satisfies instead of as for handle position map
- Type node() to accept { children?: unknown; [key: string]: unknown }
- Remove ext() helper in kiwi-convert (direct property access)

37 occurrences → 1 (Kiwi codec boundary only).
2026-03-06 21:43:32 +03:00
Danila Poyarkov 9d73297040 Delete re-export shims, import from @open-pencil/core directly
Remove 14 src/engine/ shims and entire src/kiwi/ directory.
Only src/engine/fonts.ts remains (has real Tauri font logic).
Replace isTauri() with IS_TAURI constant.
2026-03-06 21:42:56 +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 ad96b1c654 Add CJK font fallback for text rendering
Load a system CJK font (PingFang SC, Microsoft YaHei, Noto Sans CJK, etc.)
at startup and pass it as a fallback in CanvasKit's fontFamilies array.
Falls back to Google Fonts (Noto Sans SC) when no system font is available.

Also fixes loadFont to only cache fonts that successfully register with
CanvasKit's TypefaceFontProvider, preventing invalid font data from being
treated as loaded.

Closes #48
2026-03-05 09:48:11 +03:00
Danila Poyarkov 9c82cba672 Fix font loading not registering in core cache, add layout text measurement tests
Tauri's loadFont registered fonts with the CanvasKit provider but
not in core's loadedFamilies cache, so isFontLoaded returned false
and measureTextNode returned null during layout recomputation.

Add markFontLoaded() to core and call it from Tauri font loader.
Add tests verifying text measurement integrates with auto-layout
centering.
2026-03-04 00:16:15 +03:00
Danila Poyarkov d7e2015967 Estimate text width for WIDTH_AND_HEIGHT auto-resize in layout
Text nodes with textAutoResize=WIDTH_AND_HEIGHT now use an estimated
content width instead of the node's current width when computing
auto-layout. Fixes month headers (and similar centered text in
auto-layout) appearing left-aligned after paste.
2026-03-04 00:11:48 +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 8b1547ece7 Center pasted Figma nodes in viewport instead of using original coordinates 2026-03-02 17:32:06 +03:00
Danila Poyarkov c59d5fc397 Add Code tab with JSX export and syntax highlighting
- sceneNodeToJsx() in @open-pencil/core: converts SceneNode subtree to JSX
- CodePanel.vue with Prism.js highlighting, line numbers, copy button
- Third tab in properties panel: Design | Code | AI
- 14 new tests covering shapes, text, layout, effects, multi-selection
2026-03-01 12:12:45 +03:00
Danila Poyarkov 20827a27d3 Deduplicate fig-kiwi parsing, font weight mapping, layout, style runs, text editor
- Extract buildFigKiwi, parseFigKiwiChunks, decompressFigKiwiDataAsync to kiwi-serialize.ts
- Unify styleToWeight across fonts.ts, fig-import.ts, src/engine/fonts.ts
- Merge applyYogaLayout + applyYogaLayoutNested into single recursive fn
- Extract expandRuns helper in style-runs.ts
- Add prepareMove helper to TextEditor for selection extension boilerplate

jscpd: 18 → 9 clones, 1.7% → 0.62%
2026-03-01 11:29:26 +03:00
Danila Poyarkov 88bfacdc9e Rich text style runs with per-selection bold/italic/underline
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.
2026-03-01 09:03:38 +03:00
Danila Poyarkov 33d3defcef Canvas-native text editing with phantom textarea
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.
2026-03-01 03:46:43 +03:00
Danila Poyarkov 4e03d262c0 Register system font faces for CSS preview, fix picker highlight width 2026-03-01 03:25:50 +03:00
Danila Poyarkov 326f5d194d Preload system fonts on app startup
Cache font list in Rust via OnceLock, fire preload request from
main.ts so the font list is ready before user opens the picker.
2026-03-01 02:57:58 +03:00
Danila Poyarkov 5ccae709a0 Add system font enumeration via font-kit
Rust: list_system_fonts and load_system_font Tauri commands using
font-kit crate for cross-platform system font access.

TS: font shim detects Tauri runtime and uses invoke() for font
listing/loading instead of queryLocalFonts (unavailable in WKWebView).
2026-03-01 02:56:12 +03:00
Danila Poyarkov d30ab4c5d5 Add variables support: collections, modes, bindings, UI panel
Core:
- Variable, VariableCollection types on SceneGraph
- resolveVariable with alias chain resolution + cycle detection
- Mode switching (activeMode per collection)
- Bind/unbind variables to node properties (fills/strokes colors)
- Renderer resolves variable bindings before painting

Import:
- Parse VARIABLE type NodeChanges from .fig files
- Extract paint variable bindings (fills/strokes)

UI:
- VariablesPanel with reka-ui Tabs (collections) + Editable (names/values)
- FillSection: variable picker (reka-ui Popover + Combobox)
- Bound fills show purple variable name badge with detach button
- Toggle variables panel from bottom of layers sidebar

7 new unit tests (83 total)
2026-03-01 01:47:52 +03:00
Danila Poyarkov 6d1cec1606 Extract @open-pencil/core package
Move engine (scene-graph, renderer, layout, codec, kiwi) into
packages/core/ as a Bun workspace package. The app's src/engine/
files become thin re-export shims so all existing imports still work.

- Bun workspace with packages/core
- Zero DOM dependencies in core
- Headless-friendly CanvasKit init (locateFile option)
- Constants split: engine constants in core, UI constants in app
- All 76 unit tests pass, lint + typecheck clean, vite build works
2026-03-01 01:03:07 +03:00
Danila Poyarkov 103c5617cf Re-encode JPG/WEBP via OffscreenCanvas instead of CanvasKit
The default CanvasKit WASM build lacks JPEG/WEBP encoders. Instead of
switching to the full build (+1MB), render PNG via CanvasKit then
re-encode to the target format using OffscreenCanvas.convertToBlob().
2026-03-01 00:11:48 +03:00
Danila Poyarkov 7d4b821e77 Use full CanvasKit build for JPEG/WEBP encoding support
The default canvaskit.wasm doesn't include JPEG/WEBP encoders
(encodeToBytes returns null). The full build is only ~1MB larger.
2026-03-01 00:09:35 +03:00
Danila Poyarkov 55b0b37789 Add image export (PNG/JPG/WEBP) with shared rendering
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
2026-02-28 23:59:39 +03:00
Danila Poyarkov 70c88dd72b Replace relative imports with @/ alias
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.
2026-02-28 23:08:45 +03:00
Danila Poyarkov 6eca2f8432 Consolidate shared types and remove inline duplicates
- 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
2026-02-28 23:06:47 +03:00
Danila Poyarkov 18609e00c3 Fix all lint, typecheck, and formatting issues
- 0 lint warnings (was 66), 0 type errors (was 30+)
- Add typed interfaces for Paint.stops/transform, Effect.blendMode,
  NodeChange.strokeCap/strokeJoin/dashPattern in kiwi codec
- Add CompiledSchema.encodePaint/encodeNodeChange methods
- Remove all no-explicit-any: use Window declaration merging for
  queryLocalFonts, showOpenFilePicker, showSaveFilePicker
- Remove all non-null assertions: add guards, use ?. operator,
  ?? fallbacks throughout
- Add SceneGraph.copyProp typed helper for instance sync
- Suppress kiwi-schema lint warnings via oxlint override
- Remove unused imports, variables, parameters
- Use proper CanvasKit StrokeCap/StrokeJoin enums in renderer
- Run oxfmt with import sorting
2026-02-28 22:57:33 +03:00
Danila Poyarkov 046f23b138 Format codebase with oxfmt 2026-02-28 22:41:34 +03:00
Danila Poyarkov 2ab75ad25a Add Polygon and Star tools with rendering and flyout shortcuts
- POLYGON (triangle icon) and STAR (star icon) in the shapes flyout
- Renderer draws regular polygons and stars using pointCount and
  starInnerRadius properties on SceneNode
- Flyout dropdown now shows keyboard shortcuts (R, L, O) aligned
  right like Figma's context menu
- Polygon defaults to 3 sides, Star to 5 points with 0.38 inner ratio
- Fill, stroke, hover highlight, and selection outline all work
2026-02-28 21:04:02 +03:00
Danila Poyarkov f77ad708b8 Live component-instance sync with override support
Editing a main component now propagates to all instances:
- Instance children are mapped to component children via componentId
- syncInstances() copies properties from component to instances,
  skipping any property marked in the overrides record
- New children added to a component appear in existing instances
- Store calls syncIfInsideComponent after updateNode, commitMove,
  commitResize — changes propagate automatically
- Demo creates real instances (Button, Badge) in the App Preview

Tests added: instance creation with child mapping, sync propagation,
override preservation, new child addition, detach.
2026-02-28 19:57:52 +03:00
Danila Poyarkov 86a5edfab5 Reuse Paint objects instead of allocating per frame
Pre-allocate auxFill, auxStroke, and opacityPaint on the renderer.
Reconfigure them before each use instead of new/delete cycles.
Reduces per-frame WASM Paint allocations from 29 to 10 (the
remaining 10 are cold-path: pen tool, rulers, one-off overlays).

Hot paths covered: renderNode opacity layers, hover/drop highlights,
selection handles (called 8-16× per frame), section title pills,
component label icons+text, size pill, marquee, layout indicator,
rotation handle, component set dashed border, drop shadows.
2026-02-28 19:19:30 +03:00
Danila Poyarkov bb5b4b57e5 Viewport culling: skip rendering off-screen nodes
Compute the world-space viewport from pan/zoom and skip nodes
whose bounds don't intersect it. Only culls leaf nodes and
clipped containers — unclipped parents are still traversed since
their children may extend beyond the parent bounds.

Handles rotation by expanding the bounding box to the diagonal.
Section titles and component labels also culled during collection.
Export path (renderSceneToCanvas) uses infinite viewport to render all.
2026-02-28 19:14:00 +03:00
Danila Poyarkov e39f81078f Fix text disappearing on window resize
On resize the renderer is recreated with a fresh TypefaceFontProvider.
loadFont() had an early return for cached fonts that skipped registering
with the new provider, so ParagraphBuilder had no fonts to render with.
2026-02-28 19:08:16 +03:00
Danila Poyarkov 33fa850573 Fix component interaction: click selects component, drag by label, no white fill
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.
2026-02-28 18:28:55 +03:00
Danila Poyarkov 058373123f Always-visible purple labels for components, component sets, instances
Like Figma: component/instance names render as permanent purple text
with a diamond icon prefix, at fixed screen-space size (doesn't scale
with zoom). Drawn in the screen-coordinate pass alongside section titles.

- COMPONENT: single diamond ◆ + name, above top-left
- COMPONENT_SET: 4-diamond grid ◆◆◆◆ + name, above top-left
- INSTANCE: single diamond ◆ + name, above top-left
- Variants inside a set: label inside top-left instead of above
- Remove component names from selection labels (no longer needed)
2026-02-28 18:23:46 +03:00
Danila Poyarkov 3c5795f90b Add components, component sets, and instances
- SceneNode: componentId and overrides fields for instance tracking
- SceneGraph: cloneTree, createInstance, detachInstance, getMainComponent,
  getInstances methods
- Editor store: createComponentFromSelection (converts frame/group or wraps
  selection), createComponentSetFromComponents (groups multiple components),
  createInstanceFromComponent, detachInstance, goToMainComponent
- Renderer: purple (#9747ff) selection outlines, hover highlights, size pills,
  and name labels for component types; dashed purple border for component sets
- Layers panel: diamond icons for components/instances with purple tint
- Properties panel: component type indicator, Go to Main Component and
  Detach Instance buttons for instances, Create Instance for components
- Keyboard shortcuts: Ctrl+Alt+K (create component), Ctrl+Shift+K
  (create component set), Ctrl+Alt+B (detach instance)
- Clipboard: preserve COMPONENT/COMPONENT_SET/INSTANCE types
- Fig export: emit proper Kiwi node types instead of FRAME
2026-02-28 17:46:28 +03:00
Danila Poyarkov cf454a855e Fill type picker with solid/gradient/image tabs and gradient stop editing
- FillPicker component: 3 category tabs (Solid, Gradient, Image), gradient
  subtype dropdown (Linear/Radial/Angular/Diamond), draggable gradient stop
  bar, per-stop color/position/opacity editing with ScrubInput
- FillSection uses FillPicker instead of ColorPicker for full Fill editing
- Default gradient transforms for each subtype so renderer picks them up
- colorToRgba255/rgba255ToColor helpers in color.ts
- Custom SVG icons for fill type tabs (solid square, gradient fade)
2026-02-28 17:26:50 +03:00
Danila Poyarkov e2e0e75173 Tier 1 rendering parity: gradients, images, effects, strokes, arcs
Data model:
- Gradient fills (LINEAR, RADIAL, ANGULAR, DIAMOND) with stops + transform
- Image fills with hash reference, scale mode, transform
- Blend modes on fills, nodes, effects
- Stroke cap, join, dash pattern, independent border weights
- Arc data for partial ellipses/donuts
- Text: vertical align, auto-resize, case, decoration, max lines
- Constraints: horizontal/vertical
- New node types: ROUNDED_RECTANGLE, COMPONENT, COMPONENT_SET,
  INSTANCE, CONNECTOR, SHAPE_WITH_TEXT

Import:
- All paint types imported (not just SOLID)
- Image hashes extracted from .fig ZIP images/ directory
- Font weight mapping (Thin→100 through Black→900)
- Arc data, stroke options, constraints, text properties

Renderer:
- Gradient shaders via CanvasKit (linear, radial, sweep)
- Image fills with FILL/FIT scale modes and shader matrix
- Drop shadow and inner shadow effects
- Arc/donut rendering for ellipses with arcData
- Stroke cap/join/dash pattern support
- Image cache with cleanup on destroy

Export:
- Gradient/image fills round-trip through Kiwi
- New node types mapped to Figma equivalents

UI:
- Fill section shows gradient/image type labels for non-SOLID fills
2026-02-28 16:40:34 +03:00
Danila Poyarkov edf6da6738 Build .fig ZIP in Rust instead of fflate
fflate's zipSync produces ZIP files that Figma rejects. Move the entire
fig-kiwi container + Zstd compression + ZIP packaging into a single
Rust command (build_fig_file). Zstd now includes content size via
set_pledged_src_size. Web fallback still uses fflate/deflate.
2026-02-28 16:16:49 +03:00
Danila Poyarkov 2b91f51de8 Generate thumbnail for .fig export
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).
2026-02-28 15:57:15 +03:00
Danila Poyarkov f31ded3c08 Fix .fig export: include thumbnail.png required by Figma
Figma's importer requires thumbnail.png to exist in the ZIP archive.
Without it, import fails with 'Internal error during import' regardless
of how correct the Kiwi data is.

Also: STORE compression for canvas.fig (already internally compressed),
and match Figma's default DOCUMENT/CANVAS fields.
2026-02-28 15:52:35 +03:00
Danila Poyarkov dc3c5a47b1 Fix .fig import/export round-trip
- Parse fig-kiwi container format properly: strip header, extract
  schema and data chunks, decompress (Zstd or deflate)
- Import uses the schema embedded in the fig-kiwi container (supports
  newer Figma files with unknown fields)
- Export uses the codec's schema via getCompiledSchema/getSchemaBytes
- Consolidate schema loading: remove duplicate figma-schema.bin fetch
  from clipboard and export; both now use codec's initCodec()
- Fix IS_TAURI for non-browser environments (SSR/Bun)
2026-02-28 15:12:24 +03:00
Danila Poyarkov 495e419e79 Zstd compression via Tauri Rust command, deflate fallback for browser
- Added zstd crate to Rust deps and zstd_compress Tauri command
- Export uses native Zstd in Tauri, deflate in browser (our reader
  handles both)
- Removed zstd-wasm dependency (decompress-only, didn't work)
2026-02-28 14:56:06 +03:00
Danila Poyarkov 197323162f Fix .fig export: Zstd compression, IS_TAURI constant, fs permissions
- 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
2026-02-28 14:48:22 +03:00
Danila Poyarkov 150bd7a256 Merge branch 'save-fig' 2026-02-28 14:26:47 +03:00
Danila Poyarkov 0524092f3e Merge branch 'autolayout-tests' 2026-02-28 14:04:30 +03:00
Danila Poyarkov 55c3ea35dc Section title text inverts based on pill background luminance 2026-02-28 14:03:57 +03:00
Danila Poyarkov 27f57da340 Improved demo: section with Desktop/Mobile frames, color swatches
- 'Design System' section containing Desktop and Mobile frames
- Desktop: header, hero card, avatar, two content cards
- Mobile: status bar, banner, list items, FAB button
- Color palette swatches (Primary, Success, Warning, Danger, Purple)
- Updated E2E tests for new demo structure
- Reverted section title pill to always use fill color (not blue)
2026-02-28 14:02:46 +03:00
Danila Poyarkov 27f83c721e Add layout tests, fix cross-axis sizing bugs
- Fix axis mapping for nested frames with different layout directions
  (e.g. vertical child inside horizontal parent had swapped sizing)
- Fix stretch alignment not applying to leaf children when set on parent
- Fix layoutGrow being ignored on children with FIXED primaryAxisSizing
- Add 42 unit tests covering all layout scenarios
2026-02-28 13:59:39 +03:00
Danila Poyarkov 59a98f9186 Show frame name label for direct children of sections 2026-02-28 13:51:18 +03:00
Danila Poyarkov 584661315f Section title hit test uses exact pill width from font metrics
Moved section title hit testing from scene-graph (no font access) to
renderer where exact pill width is computed from glyph widths. Only
the title pill area is clickable, not the full width above the section.
Hit test passed through use-canvas → use-canvas-input via callback.
2026-02-28 13:46:39 +03:00
Danila Poyarkov 60ed0f195d Fix missing import for section title hit testing 2026-02-28 13:43:02 +03:00