Figma distributes leading equally above and below text. CanvasKit
defaults to ascent-biased leading. halfLeading: true fixes this but
must only apply to rendering (drawParagraph), not measureTextNode —
otherwise measured sizes change and break grid layouts.
loadFontsForNodes only loaded the Regular style for each family.
Now collects fontWeight and italic from each text node and style
run, converts to style name via weightToStyle, and loads each
variant separately (e.g. Medium, Bold, Bold Italic).
btoa() only handles Latin-1. Use TextEncoder + binaryToBase64 for
encoding and TextDecoder + base64ToBinary for decoding the internal
OpenPencil clipboard format.
Figma's lineHeight with units=PERCENT stores e.g. 100 meaning 100%
of fontSize. We stored the raw value (100) which the renderer
interpreted as 100px, producing ~6x line height. Now properly
converts: PERCENT → value/100 * fontSize, PIXELS → value as-is.
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.
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.
Replace all 'Inter' string literals with DEFAULT_FONT_FAMILY from
constants. Add loadFontsForNodes() that collects font families from
text nodes and loads them into CanvasKit, called after clipboard
paste and .fig file import.
Figma's parentIndex.position strings represent z-order, not visual
layout order. In auto-layout frames, children must be sorted by their
x (horizontal) or y (vertical) coordinate to match the visual order.
Fixes scrambled calendar dates and other auto-layout child ordering.
Each instance in Figma's clipboard carries symbolData.symbolOverrides
with per-child property changes (text, fills, visibility). The override
targets a child via guidPath which matches the overrideKey field on the
component's child node.
After cloning component children into instances, overrides are resolved
through the overrideKey→figmaId→internalId→clonedChild chain and applied
to the instance's children.
FRAME nodes with VARIANT componentPropDefs are now promoted to
COMPONENT_SET on import (both .fig files and clipboard paste).
Clipboard paste now respects the Internal Only Canvas: components
on it are used to populate instance children but are not pasted
as visible nodes, matching Figma's behavior.
When pasting from Figma, INSTANCE nodes reference their component via
symbolData.symbolID but have no children in the clipboard. If the
component was also pasted, clone its children into the instance using
the componentId mapping (cloneChildrenWithMapping).
Figma vectors use a normalized coordinate space (vectorData.normalizedSize)
that can differ from the node's actual size. The decoded path vertices and
tangents must be scaled to fit the node bounds, otherwise vectors render
at the wrong size (e.g. 16×16 path in a 12×12 node).
Extract shared kiwi→SceneNode conversion into kiwi-convert.ts, used by
both fig-import.ts (.fig file opening) and clipboard.ts (Figma paste).
The clipboard import was a simplified copy that diverged over time:
- Missing SYMBOL→COMPONENT type mapping (Figma's kiwi name for components)
- textAutoResize hardcoded to NONE instead of using clipboard value
- No gradient/image fill support, no effects, no style runs
- Missing flipX/flipY, constraints, text decorations, arc data, etc.
- Percent letter spacing not converted to pixels
Now both paths use nodeChangeToProps() with identical conversion logic.
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.
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.
* 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
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
* 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.