Merge branch 'fix-paste-layout'

This commit is contained in:
Danila Poyarkov 2026-03-04 01:43:56 +03:00
commit 630e18a869
24 changed files with 1103 additions and 727 deletions

1
.gitignore vendored
View file

@ -35,3 +35,4 @@ packages/docs/.vitepress/dist/
packages/collab/.wrangler/
packages/core/vendor/canvaskit-webgpu/
patches/skia-webgpu/
.wrangler/

View file

@ -10,12 +10,33 @@
- Flip horizontal/vertical using scale transform instead of rotation
- Single-node alignment aligns to parent frame bounds
### Build
- Apple code signing and notarization for macOS builds
- Git LFS storage moved from GitHub to Cloudflare R2
### Docs
- Add macOS Gatekeeper workaround (`xattr -cr`) to README and docs for unsigned app warning
### Fixes
- Fix Figma clipboard paste: extract shared kiwi→SceneNode conversion, fixing broken auto-layout, missing gradient/image fills, effects, style runs, and text properties
- Fix vector rendering on paste — scale path coordinates from Figma's normalizedSize to actual node bounds
- Fix pasted instances having no children — populate from component via symbolData when both are in clipboard
- Detect component sets on import — promote FRAME nodes with VARIANT componentPropDefs to COMPONENT_SET
- Skip internal canvas on paste — components on Figma's hidden internal page populate instances but are not pasted as visible nodes
- Apply instance overrides on paste — text content, fills, visibility, layoutGrow, and textAutoResize from symbolOverrides
- Fix auto-layout child ordering — sort by geometric position instead of z-order position strings
- Load fonts on paste and .fig import — collect font families from text nodes and load into CanvasKit
- Text measurement in auto-layout — use CanvasKit paragraph metrics for WIDTH_AND_HEIGHT text nodes
- Recompute layouts after font loading completes
- Fix PERCENT line height conversion — was stored as raw value instead of pixels
- Fix InvalidCharacterError when copying nodes with non-ASCII text
- Load all font weight/style variants needed by pasted text nodes
- Fix font loading not registering in core cache
- Fix halfLeading applied to text measurement — enable only for rendering
- Clear hover on zoom/pinch to keep scene picture cache valid
- Fix flip buttons using rotation math instead of actual mirroring
- Fix flip transform encoding — scale first matrix column only (was incorrectly producing 180° rotation)
- Decode flip state from .fig transform matrix on import

View file

@ -1,7 +1,5 @@
import { inflateSync, deflateSync } from 'fflate'
import { BLACK } from './constants'
import { styleToWeight } from './fonts'
import {
sceneNodeToKiwi,
buildFigKiwi,
@ -10,20 +8,10 @@ import {
} from './kiwi-serialize'
import { initCodec, getCompiledSchema, getSchemaBytes } from './kiwi/codec'
import { decodeBinarySchema, compileSchema, ByteBuffer } from './kiwi/kiwi-schema'
import { decodeVectorNetworkBlob } from './vector'
import { nodeChangeToProps, convertFills, sortChildren } from './kiwi/kiwi-convert'
import type { NodeChange as KiwiNodeChange } from './kiwi/codec'
import type {
SceneGraph,
SceneNode,
Fill,
Stroke,
LayoutMode,
LayoutSizing,
LayoutAlign,
LayoutCounterAlign,
VectorNetwork
} from './scene-graph'
import type { SceneGraph, SceneNode } from './scene-graph'
interface FigmaClipboardMeta {
fileKey: string
@ -35,23 +23,6 @@ export async function prefetchFigmaSchema(): Promise<void> {
await initCodec()
}
function binaryToBase64(bytes: Uint8Array): string {
let binary = ''
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}
function base64ToBinary(b64: string): Uint8Array {
const raw = atob(b64)
const bytes = new Uint8Array(raw.length)
for (let i = 0; i < raw.length; i++) {
bytes[i] = raw.charCodeAt(i)
}
return bytes
}
// --- Paste from Figma ---
export async function parseFigmaClipboard(
@ -62,7 +33,7 @@ export async function parseFigmaClipboard(
if (!metaMatch || !bufMatch) return null
const meta: FigmaClipboardMeta = JSON.parse(atob(metaMatch[1]))
const binary = base64ToBinary(bufMatch[1])
const binary = Uint8Array.fromBase64(bufMatch[1])
const chunks = parseFigKiwiChunks(binary)
if (!chunks) return null
@ -83,27 +54,6 @@ export async function parseFigmaClipboard(
return { nodes: msg.nodeChanges ?? [], meta, blobs }
}
function decodeVectorData(nc: KiwiNodeChange, blobs: Uint8Array[]): VectorNetwork | null {
const vectorData = nc.vectorData as
| {
vectorNetworkBlob?: number
normalizedSize?: { x: number; y: number }
styleOverrideTable?: Array<{ styleID: number; handleMirroring?: string }>
}
| undefined
if (!vectorData || vectorData.vectorNetworkBlob === undefined) return null
const blobIdx = vectorData.vectorNetworkBlob
if (blobIdx < 0 || blobIdx >= blobs.length) return null
try {
return decodeVectorNetworkBlob(blobs[blobIdx], vectorData.styleOverrideTable)
} catch {
return null
}
}
const NON_VISUAL_TYPES = new Set([
'DOCUMENT',
'CANVAS',
@ -181,7 +131,24 @@ export function importClipboardNodes(
}
}
const internalCanvasIds = new Set<string>()
for (const [id, nc] of guidMap) {
if (nc.type === 'CANVAS' && (nc as unknown as Record<string, unknown>).internalOnly) {
internalCanvasIds.add(id)
}
}
const internalFigmaIds = new Set<string>()
function markInternal(id: string) {
internalFigmaIds.add(id)
for (const [childId, pid] of parentMap) {
if (pid === id && !internalFigmaIds.has(childId)) markInternal(childId)
}
}
for (const canvasId of internalCanvasIds) markInternal(canvasId)
const topLevel: string[] = []
const internalTopLevel: string[] = []
for (const [id, nc] of guidMap) {
if (NON_VISUAL_TYPES.has(nc.type ?? '')) continue
const parentId = parentMap.get(id)
@ -190,7 +157,11 @@ export function importClipboardNodes(
!guidMap.has(parentId) ||
NON_VISUAL_TYPES.has(guidMap.get(parentId)?.type ?? '')
) {
topLevel.push(id)
if (parentId && internalCanvasIds.has(parentId)) {
internalTopLevel.push(id)
} else {
topLevel.push(id)
}
}
}
@ -202,99 +173,18 @@ export function importClipboardNodes(
const nc = guidMap.get(figmaId)
if (!nc) return
const x = (nc.transform?.m02 ?? 0) + (ourParentId === targetParentId ? offsetX : 0)
const y = (nc.transform?.m12 ?? 0) + (ourParentId === targetParentId ? offsetY : 0)
const { nodeType, ...props } = nodeChangeToProps(nc, blobs)
if (nodeType === 'DOCUMENT' || nodeType === 'VARIABLE') return
let rotation = 0
if (nc.transform) {
rotation = Math.atan2(nc.transform.m10, nc.transform.m00) * (180 / Math.PI)
if (ourParentId === targetParentId) {
props.x = (props.x ?? 0) + offsetX
props.y = (props.y ?? 0) + offsetY
}
const fills: Fill[] = (nc.fillPaints ?? [])
.filter((p) => p.type === 'SOLID' && p.color)
.map((p) => ({
type: 'SOLID' as const,
color: p.color ?? { ...BLACK },
opacity: p.opacity ?? 1,
visible: p.visible ?? true
}))
const strokes: Stroke[] = (nc.strokePaints ?? [])
.filter((p) => p.type === 'SOLID' && p.color)
.map((p) => ({
color: p.color ?? { ...BLACK },
weight: nc.strokeWeight ?? 1,
opacity: p.opacity ?? 1,
visible: p.visible ?? true,
align: 'CENTER' as const
}))
const nodeType = mapNodeType(nc.type)
const node = graph.createNode(nodeType, ourParentId, {
name: nc.name ?? nodeType,
x,
y,
width: nc.size?.x ?? 100,
height: nc.size?.y ?? 100,
rotation,
opacity: nc.opacity ?? 1,
visible: nc.visible ?? true,
fills,
strokes,
cornerRadius: nc.cornerRadius ?? 0,
independentCorners: nc.rectangleCornerRadiiIndependent ?? false,
topLeftRadius: nc.rectangleTopLeftCornerRadius ?? 0,
topRightRadius: nc.rectangleTopRightCornerRadius ?? 0,
bottomLeftRadius: nc.rectangleBottomLeftCornerRadius ?? 0,
bottomRightRadius: nc.rectangleBottomRightCornerRadius ?? 0,
text: nc.textData?.characters ?? '',
fontSize: nc.fontSize ?? 14,
fontFamily: nc.fontName?.family ?? 'Inter',
textAlignHorizontal:
(nc.textAlignHorizontal as 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED') ?? 'LEFT',
layoutMode: mapLayoutMode(nc.stackMode as string),
itemSpacing: (nc.stackSpacing as number) ?? 0,
paddingTop: (nc.stackVerticalPadding as number) ?? (nc.stackPadding as number) ?? 0,
paddingBottom:
(nc.stackPaddingBottom as number) ??
(nc.stackVerticalPadding as number) ??
(nc.stackPadding as number) ??
0,
paddingLeft: (nc.stackHorizontalPadding as number) ?? (nc.stackPadding as number) ?? 0,
paddingRight:
(nc.stackPaddingRight as number) ??
(nc.stackHorizontalPadding as number) ??
(nc.stackPadding as number) ??
0,
primaryAxisSizing: mapSizing(nc.stackPrimarySizing as string),
counterAxisSizing: mapSizing(nc.stackCounterSizing as string),
primaryAxisAlign: mapPrimaryAlign(
(nc.stackPrimaryAlignItems as string) ?? (nc.stackJustify as string)
),
counterAxisAlign: mapCounterAlign(
(nc.stackCounterAlignItems as string) ?? (nc.stackCounterAlign as string)
),
layoutWrap: (nc.stackWrap as string) === 'WRAP' ? ('WRAP' as const) : ('NO_WRAP' as const),
counterAxisSpacing: (nc.stackCounterSpacing as number) ?? 0,
layoutPositioning:
(nc.stackPositioning as string) === 'ABSOLUTE' ? ('ABSOLUTE' as const) : ('AUTO' as const),
layoutGrow: (nc.stackChildPrimaryGrow as number) ?? 0,
layoutAlignSelf:
(nc.stackChildAlignSelf as string) === 'STRETCH' ? ('STRETCH' as const) : ('AUTO' as const),
clipsContent: nc.frameMaskDisabled === false,
textAutoResize: 'NONE' as const,
fontWeight: nc.fontWeight ?? styleToWeight(nc.fontName?.style ?? ''),
italic: nc.fontName?.style?.toLowerCase().includes('italic') ?? false,
lineHeight: mapLineHeight(nc.lineHeight as { value: number; units: string } | undefined),
letterSpacing: mapLetterSpacing(
nc.letterSpacing as { value: number; units: string } | undefined,
nc.fontSize as number | undefined
),
vectorNetwork: decodeVectorData(nc, blobs)
})
const node = graph.createNode(nodeType, ourParentId, props)
created.set(figmaId, node.id)
if (ourParentId === targetParentId) createdIds.push(node.id)
if (ourParentId === targetParentId && !internalFigmaIds.has(figmaId)) createdIds.push(node.id)
const children: string[] = []
for (const [childId, pid] of parentMap) {
@ -302,97 +192,84 @@ export function importClipboardNodes(
children.push(childId)
}
}
children.sort((a, b) => {
const aPos = guidMap.get(a)?.parentIndex?.position ?? ''
const bPos = guidMap.get(b)?.parentIndex?.position ?? ''
return aPos.localeCompare(bPos)
})
sortChildren(children, nc, guidMap)
for (const childId of children) {
createNode(childId, node.id)
}
}
for (const id of internalTopLevel) {
createNode(id, targetParentId)
}
for (const id of topLevel) {
createNode(id, targetParentId)
}
return createdIds
}
function mapLayoutMode(mode?: string): LayoutMode {
if (mode === 'HORIZONTAL') return 'HORIZONTAL'
if (mode === 'VERTICAL') return 'VERTICAL'
return 'NONE'
}
function mapSizing(sizing?: string): LayoutSizing {
if (sizing === 'RESIZE_TO_FIT' || sizing === 'RESIZE_TO_FIT_WITH_IMPLICIT_SIZE') return 'HUG'
if (sizing === 'FILL') return 'FILL'
return 'FIXED'
}
function mapPrimaryAlign(align?: string): LayoutAlign {
if (align === 'CENTER') return 'CENTER'
if (align === 'MAX') return 'MAX'
if (align === 'SPACE_BETWEEN' || align === 'SPACE_EVENLY') return 'SPACE_BETWEEN'
return 'MIN'
}
function mapCounterAlign(align?: string): LayoutCounterAlign {
if (align === 'CENTER') return 'CENTER'
if (align === 'MAX') return 'MAX'
if (align === 'STRETCH') return 'STRETCH'
if (align === 'BASELINE') return 'BASELINE'
return 'MIN'
}
function mapLetterSpacing(ls?: { value: number; units: string }, fontSize?: number): number {
if (!ls) return 0
if (ls.units === 'PIXELS') return ls.value
if (ls.units === 'PERCENT') return (ls.value / 100) * (fontSize ?? 14)
return 0
}
function mapLineHeight(lh?: { value: number; units: string }): number | undefined {
if (!lh) return undefined
if (lh.units === 'PIXELS') return lh.value
if (lh.units === 'PERCENT') return undefined
return undefined
}
function mapNodeType(type?: string): SceneNode['type'] {
switch (type) {
case 'FRAME':
return 'FRAME'
case 'COMPONENT':
return 'COMPONENT'
case 'COMPONENT_SET':
return 'COMPONENT_SET'
case 'INSTANCE':
return 'INSTANCE'
case 'RECTANGLE':
case 'ROUNDED_RECTANGLE':
return 'RECTANGLE'
case 'ELLIPSE':
return 'ELLIPSE'
case 'TEXT':
return 'TEXT'
case 'LINE':
return 'LINE'
case 'STAR':
return 'STAR'
case 'REGULAR_POLYGON':
return 'POLYGON'
case 'VECTOR':
case 'BOOLEAN_OPERATION':
return 'VECTOR'
case 'GROUP':
return 'GROUP'
case 'SECTION':
return 'SECTION'
default:
return 'RECTANGLE'
const overrideKeyToFigmaId = new Map<string, string>()
for (const [id, nc] of guidMap) {
const ok = (nc as unknown as Record<string, unknown>).overrideKey as
| { sessionID: number; localID: number }
| undefined
if (ok) overrideKeyToFigmaId.set(`${ok.sessionID}:${ok.localID}`, id)
}
for (const [figmaId, ourId] of created) {
const node = graph.getNode(ourId)
if (!node || node.type !== 'INSTANCE' || node.childIds.length > 0) continue
const figmaComponentId = node.componentId
if (!figmaComponentId) continue
const ourComponentId = created.get(figmaComponentId)
if (!ourComponentId) continue
graph.updateNode(ourId, { componentId: ourComponentId })
graph.populateInstanceChildren(ourId, ourComponentId)
const nc = guidMap.get(figmaId)
const sd = (nc as unknown as Record<string, unknown>).symbolData as
| { symbolOverrides?: Array<Record<string, unknown>> }
| undefined
if (!sd?.symbolOverrides?.length) continue
const compChildIdMap = new Map<string, string>()
for (const childId of node.childIds) {
const child = graph.getNode(childId)
if (child?.componentId) compChildIdMap.set(child.componentId, childId)
}
for (const ov of sd.symbolOverrides) {
const gp = ov.guidPath as { guids?: Array<{ sessionID: number; localID: number }> } | undefined
if (!gp?.guids?.length) continue
const targetKey = `${gp.guids[0].sessionID}:${gp.guids[0].localID}`
const figmaChildId = overrideKeyToFigmaId.get(targetKey)
if (!figmaChildId) continue
const compChildOurId = created.get(figmaChildId)
if (!compChildOurId) continue
const instanceChildId = compChildIdMap.get(compChildOurId)
if (!instanceChildId) continue
const updates: Partial<SceneNode> = {}
const ovTd = ov.textData as { characters?: string } | undefined
if (ovTd?.characters != null) updates.text = ovTd.characters
if (ov.fillPaints) updates.fills = convertFills(ov.fillPaints as KiwiNodeChange['fillPaints'])
if (ov.visible != null) updates.visible = ov.visible as boolean
if (ov.stackChildPrimaryGrow != null) updates.layoutGrow = ov.stackChildPrimaryGrow as number
if (ov.textAutoResize != null) updates.textAutoResize = ov.textAutoResize as SceneNode['textAutoResize']
if (Object.keys(updates).length > 0) graph.updateNode(instanceChildId, updates)
}
}
for (const figmaId of internalTopLevel) {
const ourId = created.get(figmaId)
if (ourId) graph.deleteNode(ourId)
}
return createdIds
}
export function buildFigmaClipboardHTML(nodes: SceneNode[], graph: SceneGraph): string | null {
@ -445,7 +322,7 @@ export function buildFigmaClipboardHTML(nodes: SceneNode[], graph: SceneGraph):
const dataRaw = compiled.encodeMessage(msg)
const figKiwiBinary = buildFigKiwi(schemaDeflated, dataRaw)
const bufferB64 = binaryToBase64(figKiwiBinary)
const bufferB64 = figKiwiBinary.toBase64()
const meta: FigmaClipboardMeta = {
fileKey: 'openpencil',
@ -470,7 +347,7 @@ export function parseOpenPencilClipboard(
if (!match) return null
try {
const decoded = JSON.parse(atob(match[1]))
const decoded = JSON.parse(new TextDecoder().decode(Uint8Array.fromBase64(match[1])))
if (decoded.format === 'openpencil/v1' && Array.isArray(decoded.nodes)) {
restoreTextPictures(decoded.nodes)
return decoded.nodes
@ -484,7 +361,7 @@ export function parseOpenPencilClipboard(
function restoreTextPictures(nodes: Array<Record<string, unknown>>): void {
for (const node of nodes) {
if (typeof node.textPicture === 'string') {
node.textPicture = base64ToBinary(node.textPicture)
node.textPicture = Uint8Array.fromBase64(node.textPicture)
}
if (Array.isArray(node.children)) {
restoreTextPictures(node.children)
@ -503,7 +380,7 @@ export function buildOpenPencilClipboardHTML(
format: 'openpencil/v1',
nodes: collectNodeTree(nodes, graph, textPictureBuilder)
}
return `<!--(openpencil)${btoa(JSON.stringify(data))}(/openpencil)-->`
return `<!--(openpencil)${new TextEncoder().encode(JSON.stringify(data)).toBase64()}(/openpencil)-->`
}
function collectNodeTree(
@ -517,7 +394,7 @@ function collectNodeTree(
if (node.type === 'TEXT' && node.text && textPictureBuilder) {
const pic = node.textPicture ?? textPictureBuilder(node)
if (pic) serialized.textPicture = binaryToBase64(pic)
if (pic) serialized.textPicture = pic.toBase64()
} else {
delete serialized.textPicture
}

View file

@ -32,6 +32,7 @@ export const PEN_CLOSE_RADIUS_BOOST = 2
export const PEN_PATH_STROKE_WIDTH = 2
export const PARENT_OUTLINE_ALPHA = 0.5
export const PARENT_OUTLINE_DASH = 4
export const DEFAULT_FONT_FAMILY = 'Inter'
export const DEFAULT_FONT_SIZE = 14
export const DEFAULT_STROKE_MITER_LIMIT = 4
export const LABEL_FONT_SIZE = 11

View file

@ -133,6 +133,12 @@ export async function ensureNodeFont(family: string, weight: number): Promise<vo
await loadFont(family, style)
}
export function markFontLoaded(family: string, style: string, data: ArrayBuffer): void {
const cacheKey = `${family}|${style}`
loadedFamilies.set(cacheKey, data)
registerFontInCanvasKit(family, data)
}
export function isFontLoaded(family: string): boolean {
return [...loadedFamilies.keys()].some((k) => k.startsWith(`${family}|`))
}

View file

@ -48,7 +48,8 @@ export { FigmaAPI, FigmaNodeProxy, type FigmaFontName } from './figma-api'
export { ALL_TOOLS, defineTool, toolsToAI } from './tools'
export type { ToolDef, ParamDef, ParamType } from './tools'
export { SkiaRenderer, type RenderOverlays } from './renderer'
export { computeLayout, computeAllLayouts } from './layout'
export { computeLayout, computeAllLayouts, setTextMeasurer } from './layout'
export type { TextMeasurer } from './layout'
export { getCanvasKit, getGpuBackend, type CanvasKitOptions, type GpuBackend } from './canvaskit'
export {
loadFont,
@ -56,6 +57,7 @@ export {
initFontService,
getFontProvider,
isFontLoaded,
markFontLoaded,
ensureNodeFont,
styleToWeight,
weightToStyle

View file

@ -1,346 +1,8 @@
import { BLACK, DEFAULT_STROKE_MITER_LIMIT } from '../constants'
import { styleToWeight } from '../fonts'
import { SceneGraph } from '../scene-graph'
import { decodeVectorNetworkBlob } from '../vector'
import type {
NodeType,
Fill,
FillType,
Stroke,
Effect,
Color,
BlendMode,
ImageScaleMode,
GradientTransform,
StrokeCap,
StrokeJoin,
LayoutMode,
LayoutSizing,
LayoutAlign,
LayoutCounterAlign,
ConstraintType,
TextAutoResize,
TextAlignVertical,
TextCase,
TextDecoration,
ArcData,
VectorNetwork,
StyleRun,
CharacterStyleOverride
} from '../scene-graph'
import type { NodeChange, Paint, Effect as KiwiEffect, GUID } from './codec'
import { guidToString, nodeChangeToProps, sortChildren } from './kiwi-convert'
function ext(nc: NodeChange): Record<string, unknown> {
return nc as unknown as Record<string, unknown>
}
function guidToString(guid: GUID): string {
return `${guid.sessionID}:${guid.localID}`
}
function convertColor(color?: { r: number; g: number; b: number; a: number }): Color {
if (!color) return { ...BLACK }
return { r: color.r, g: color.g, b: color.b, a: color.a }
}
function imageHashToString(hash: Record<string, number>): string {
const bytes = Object.keys(hash)
.sort((a, b) => Number(a) - Number(b))
.map((k) => hash[Number(k)])
return bytes.map((b) => b.toString(16).padStart(2, '0')).join('')
}
function convertGradientTransform(t?: {
m00: number
m01: number
m02: number
m10: number
m11: number
m12: number
}): GradientTransform | undefined {
if (!t) return undefined
return { m00: t.m00, m01: t.m01, m02: t.m02, m10: t.m10, m11: t.m11, m12: t.m12 }
}
function convertFills(paints?: Paint[]): Fill[] {
if (!paints) return []
return paints.map((p) => {
const base: Fill = {
type: (p.type ?? 'SOLID') as FillType,
color: convertColor(p.color),
opacity: p.opacity ?? 1,
visible: p.visible ?? true,
blendMode: (p.blendMode ?? 'NORMAL') as BlendMode
}
if (p.type?.startsWith('GRADIENT') && p.stops) {
base.gradientStops = p.stops.map((s) => ({
color: convertColor(s.color),
position: s.position
}))
if (p.transform) {
base.gradientTransform = convertGradientTransform(p.transform)
}
}
if (p.type === 'IMAGE') {
if (p.image && typeof p.image === 'object') {
const img = p.image as { hash: string | Record<string, number> }
if (typeof img.hash === 'object') {
base.imageHash = imageHashToString(img.hash)
} else if (typeof img.hash === 'string') {
base.imageHash = img.hash
}
}
base.imageScaleMode = (p.imageScaleMode as ImageScaleMode) ?? 'FILL'
if (p.transform) {
base.imageTransform = convertGradientTransform(p.transform)
}
}
return base
})
}
function convertStrokes(
paints?: Paint[],
weight?: number,
align?: string,
cap?: string,
join?: string,
dashPattern?: number[]
): Stroke[] {
if (!paints) return []
return paints.map((p) => ({
color: convertColor(p.color),
weight: weight ?? 1,
opacity: p.opacity ?? 1,
visible: p.visible ?? true,
align: (align === 'INSIDE'
? 'INSIDE'
: align === 'OUTSIDE'
? 'OUTSIDE'
: 'CENTER') as Stroke['align'],
cap: (cap ?? 'NONE') as StrokeCap,
join: (join ?? 'MITER') as StrokeJoin,
dashPattern: dashPattern ?? []
}))
}
function convertEffects(effects?: KiwiEffect[]): Effect[] {
if (!effects) return []
return effects.map((e) => ({
type: e.type as Effect['type'],
color: convertColor(e.color),
offset: e.offset ?? { x: 0, y: 0 },
radius: e.radius ?? 0,
spread: e.spread ?? 0,
visible: e.visible ?? true,
blendMode: (e.blendMode as BlendMode) ?? 'NORMAL'
}))
}
function mapNodeType(type?: string): NodeType | 'DOCUMENT' | 'VARIABLE' {
switch (type) {
case 'DOCUMENT':
return 'DOCUMENT'
case 'VARIABLE':
return 'VARIABLE'
case 'CANVAS':
return 'CANVAS'
case 'FRAME':
return 'FRAME'
case 'RECTANGLE':
return 'RECTANGLE'
case 'ROUNDED_RECTANGLE':
return 'ROUNDED_RECTANGLE'
case 'ELLIPSE':
return 'ELLIPSE'
case 'TEXT':
return 'TEXT'
case 'LINE':
return 'LINE'
case 'STAR':
return 'STAR'
case 'REGULAR_POLYGON':
return 'POLYGON'
case 'VECTOR':
return 'VECTOR'
case 'GROUP':
return 'GROUP'
case 'SECTION':
return 'SECTION'
case 'COMPONENT':
return 'COMPONENT'
case 'COMPONENT_SET':
return 'COMPONENT_SET'
case 'INSTANCE':
return 'INSTANCE'
case 'SYMBOL':
return 'COMPONENT'
case 'CONNECTOR':
return 'CONNECTOR'
case 'SHAPE_WITH_TEXT':
return 'SHAPE_WITH_TEXT'
default:
return 'RECTANGLE'
}
}
function mapStackMode(mode?: string): LayoutMode {
switch (mode) {
case 'HORIZONTAL':
return 'HORIZONTAL'
case 'VERTICAL':
return 'VERTICAL'
default:
return 'NONE'
}
}
function mapStackSizing(sizing?: string): LayoutSizing {
switch (sizing) {
case 'RESIZE_TO_FIT':
case 'RESIZE_TO_FIT_WITH_IMPLICIT_SIZE':
return 'HUG'
case 'FILL':
return 'FILL'
default:
return 'FIXED'
}
}
function mapStackJustify(justify?: string): LayoutAlign {
switch (justify) {
case 'CENTER':
return 'CENTER'
case 'MAX':
return 'MAX'
case 'SPACE_BETWEEN':
case 'SPACE_EVENLY':
return 'SPACE_BETWEEN'
default:
return 'MIN'
}
}
function mapStackCounterAlign(align?: string): LayoutCounterAlign {
switch (align) {
case 'CENTER':
return 'CENTER'
case 'MAX':
return 'MAX'
case 'STRETCH':
return 'STRETCH'
case 'BASELINE':
return 'BASELINE'
default:
return 'MIN'
}
}
function mapConstraint(c?: string): ConstraintType {
switch (c) {
case 'CENTER':
return 'CENTER'
case 'MAX':
return 'MAX'
case 'STRETCH':
return 'STRETCH'
case 'SCALE':
return 'SCALE'
default:
return 'MIN'
}
}
function mapTextDecoration(d?: string): TextDecoration {
switch (d) {
case 'UNDERLINE':
return 'UNDERLINE'
case 'STRIKETHROUGH':
return 'STRIKETHROUGH'
default:
return 'NONE'
}
}
function mapArcData(data?: Record<string, number>): ArcData | null {
if (!data) return null
return {
startingAngle: data.startingAngle ?? 0,
endingAngle: data.endingAngle ?? 2 * Math.PI,
innerRadius: data.innerRadius ?? 0
}
}
function importStyleRuns(nc: NodeChange): StyleRun[] {
const td = nc.textData
if (!td?.characterStyleIDs || !td.styleOverrideTable) return []
const ids = td.characterStyleIDs
const table = td.styleOverrideTable
if (ids.length === 0 || table.length === 0) return []
const styleMap = new Map<number, CharacterStyleOverride>()
for (const override of table) {
const id = (override as unknown as Record<string, unknown>).styleID as number | undefined
if (id === undefined) continue
const style: CharacterStyleOverride = {}
if (override.fontName) {
style.fontFamily = override.fontName.family
style.fontWeight = styleToWeight(override.fontName.style ?? '')
style.italic = override.fontName.style?.toLowerCase().includes('italic') ?? false
}
if (override.fontSize !== undefined) style.fontSize = override.fontSize
if (override.letterSpacing) style.letterSpacing = override.letterSpacing.value
if (override.lineHeight) style.lineHeight = override.lineHeight.value
const deco = ext(override).textDecoration as string | undefined
if (deco) style.textDecoration = mapTextDecoration(deco)
if (Object.keys(style).length > 0) styleMap.set(id, style)
}
if (styleMap.size === 0) return []
const runs: StyleRun[] = []
let currentId = ids[0]
let start = 0
for (let i = 1; i <= ids.length; i++) {
if (i === ids.length || ids[i] !== currentId) {
if (currentId !== 0) {
const style = styleMap.get(currentId)
if (style) runs.push({ start, length: i - start, style })
}
if (i < ids.length) {
currentId = ids[i]
start = i
}
}
}
return runs
}
function resolveVectorNetwork(nc: NodeChange, blobs: Uint8Array[]): VectorNetwork | null {
const vectorData = (nc as unknown as Record<string, unknown>).vectorData as
| {
vectorNetworkBlob?: number
styleOverrideTable?: Array<{ styleID: number; handleMirroring?: string }>
}
| undefined
if (!vectorData || vectorData.vectorNetworkBlob === undefined) return null
const idx = vectorData.vectorNetworkBlob
if (idx < 0 || idx >= blobs.length) return null
try {
return decodeVectorNetworkBlob(blobs[idx], vectorData.styleOverrideTable)
} catch {
return null
}
}
import type { NodeChange } from './codec'
export function importNodeChanges(
nodeChanges: NodeChange[],
@ -382,12 +44,9 @@ export function importNodeChanges(
}
}
for (const [, children] of childrenMap) {
children.sort((a, b) => {
const aPos = changeMap.get(a)?.parentIndex?.position ?? ''
const bPos = changeMap.get(b)?.parentIndex?.position ?? ''
return aPos.localeCompare(bPos)
})
for (const [parentId, children] of childrenMap) {
const parentNc = changeMap.get(parentId)
if (parentNc) sortChildren(children, parentNc, changeMap)
}
function getChildren(ncId: string): string[] {
@ -403,140 +62,21 @@ export function importNodeChanges(
const nc = changeMap.get(ncId)
if (!nc) return
const nodeType = mapNodeType(nc.type)
const { nodeType, ...props } = nodeChangeToProps(nc, blobs)
if (nodeType === 'DOCUMENT' || nodeType === 'VARIABLE') return
const x = nc.transform?.m02 ?? 0
const y = nc.transform?.m12 ?? 0
const width = nc.size?.x ?? 100
const height = nc.size?.y ?? 100
let rotation = 0
let flipX = false
let flipY = false
if (nc.transform) {
const det = nc.transform.m00 * nc.transform.m11 - nc.transform.m01 * nc.transform.m10
if (det < 0) flipX = true
const sx = flipX ? -1 : 1
rotation = Math.atan2(nc.transform.m10 * sx, nc.transform.m00 * sx) * (180 / Math.PI)
}
const dashPattern = (ext(nc).dashPattern as number[]) ?? []
const node = graph.createNode(nodeType, graphParentId, {
name: nc.name ?? nodeType,
x,
y,
width,
height,
rotation,
flipX,
flipY,
opacity: nc.opacity ?? 1,
visible: nc.visible ?? true,
locked: nc.locked ?? false,
blendMode: (ext(nc).blendMode as Fill['blendMode']) ?? 'PASS_THROUGH',
fills: convertFills(nc.fillPaints),
strokes: convertStrokes(
nc.strokePaints,
nc.strokeWeight,
nc.strokeAlign,
nc.strokeCap,
nc.strokeJoin,
dashPattern
),
effects: convertEffects(nc.effects),
cornerRadius: nc.cornerRadius ?? 0,
topLeftRadius: nc.rectangleTopLeftCornerRadius ?? nc.cornerRadius ?? 0,
topRightRadius: nc.rectangleTopRightCornerRadius ?? nc.cornerRadius ?? 0,
bottomRightRadius: nc.rectangleBottomRightCornerRadius ?? nc.cornerRadius ?? 0,
bottomLeftRadius: nc.rectangleBottomLeftCornerRadius ?? nc.cornerRadius ?? 0,
independentCorners: nc.rectangleCornerRadiiIndependent ?? false,
cornerSmoothing: nc.cornerSmoothing ?? 0,
text: nc.textData?.characters ?? '',
fontSize: nc.fontSize ?? 14,
fontFamily: nc.fontName?.family ?? 'Inter',
fontWeight: styleToWeight(nc.fontName?.style ?? ''),
italic: nc.fontName?.style?.toLowerCase().includes('italic') ?? false,
textAlignHorizontal:
(nc.textAlignHorizontal as 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED') ?? 'LEFT',
textAlignVertical: (ext(nc).textAlignVertical as TextAlignVertical) ?? 'TOP',
textAutoResize: (ext(nc).textAutoResize as TextAutoResize) ?? 'NONE',
textCase: (ext(nc).textCase as TextCase) ?? 'ORIGINAL',
textDecoration: mapTextDecoration(ext(nc).textDecoration as string),
lineHeight: nc.lineHeight?.value ?? null,
letterSpacing: nc.letterSpacing?.value ?? 0,
maxLines: (ext(nc).maxLines as number) ?? null,
styleRuns: importStyleRuns(nc),
horizontalConstraint: mapConstraint(ext(nc).horizontalConstraint as string),
verticalConstraint: mapConstraint(ext(nc).verticalConstraint as string),
layoutMode: mapStackMode(nc.stackMode),
itemSpacing: nc.stackSpacing ?? 0,
paddingTop: nc.stackVerticalPadding ?? nc.stackPadding ?? 0,
paddingBottom: nc.stackPaddingBottom ?? nc.stackVerticalPadding ?? nc.stackPadding ?? 0,
paddingLeft: nc.stackHorizontalPadding ?? nc.stackPadding ?? 0,
paddingRight: nc.stackPaddingRight ?? nc.stackHorizontalPadding ?? nc.stackPadding ?? 0,
primaryAxisSizing: mapStackSizing(nc.stackPrimarySizing),
counterAxisSizing: mapStackSizing(nc.stackCounterSizing),
primaryAxisAlign: mapStackJustify(nc.stackPrimaryAlignItems ?? nc.stackJustify),
counterAxisAlign: mapStackCounterAlign(nc.stackCounterAlignItems ?? nc.stackCounterAlign),
layoutWrap: ext(nc).stackWrap === 'WRAP' ? 'WRAP' : 'NO_WRAP',
counterAxisSpacing: (ext(nc).stackCounterSpacing as number) ?? 0,
layoutPositioning: ext(nc).stackPositioning === 'ABSOLUTE' ? 'ABSOLUTE' : 'AUTO',
layoutGrow: (ext(nc).stackChildPrimaryGrow as number) ?? 0,
layoutAlignSelf: (ext(nc).stackChildAlignSelf as string) === 'STRETCH' ? 'STRETCH' : 'AUTO',
vectorNetwork: resolveVectorNetwork(nc, blobs),
arcData: mapArcData(ext(nc).arcData as Record<string, number> | undefined),
strokeCap: (nc.strokeCap ?? 'NONE') as StrokeCap,
strokeJoin: (nc.strokeJoin ?? 'MITER') as StrokeJoin,
dashPattern,
borderTopWeight: (ext(nc).borderTopWeight as number) ?? 0,
borderRightWeight: (ext(nc).borderRightWeight as number) ?? 0,
borderBottomWeight: (ext(nc).borderBottomWeight as number) ?? 0,
borderLeftWeight: (ext(nc).borderLeftWeight as number) ?? 0,
independentStrokeWeights: (ext(nc).borderStrokeWeightsIndependent as boolean) ?? false,
strokeMiterLimit: DEFAULT_STROKE_MITER_LIMIT,
minWidth: (ext(nc).minWidth as number) ?? null,
maxWidth: (ext(nc).maxWidth as number) ?? null,
minHeight: (ext(nc).minHeight as number) ?? null,
maxHeight: (ext(nc).maxHeight as number) ?? null,
isMask: (ext(nc).isMask as boolean) ?? false,
maskType: ((ext(nc).maskType as string) ?? 'ALPHA') as 'ALPHA' | 'VECTOR' | 'LUMINANCE',
counterAxisAlignContent:
(ext(nc).stackCounterAlignContent as string) === 'SPACE_BETWEEN' ? 'SPACE_BETWEEN' : 'AUTO',
itemReverseZIndex: (ext(nc).stackReverseZIndex as boolean) ?? false,
strokesIncludedInLayout: (ext(nc).strokesIncludedInLayout as boolean) ?? false,
expanded: true,
textTruncation: (ext(nc).textTruncation as string) === 'ENDING' ? 'ENDING' : 'DISABLED',
autoRename: (ext(nc).autoRename as boolean) ?? true,
boundVariables: extractBoundVariables(nc)
})
const node = graph.createNode(nodeType, graphParentId, props)
for (const childId of getChildren(ncId)) {
createSceneNode(childId, node.id)
}
}
function extractBoundVariables(nc: NodeChange): Record<string, string> {
const bindings: Record<string, string> = {}
nc.fillPaints?.forEach((paint, i) => {
if (paint.colorVariableBinding) {
bindings[`fills/${i}/color`] = guidToString(paint.colorVariableBinding.variableID)
}
})
nc.strokePaints?.forEach((paint, i) => {
if (paint.colorVariableBinding) {
bindings[`strokes/${i}/color`] = guidToString(paint.colorVariableBinding.variableID)
}
})
return bindings
}
function importVariables() {
for (const [id, nc] of changeMap) {
if (nc.type !== 'VARIABLE') continue
const varData = (
ext(nc) as {
nc as unknown as {
variableData?: {
value?: { boolValue?: boolean; textValue?: string; floatValue?: number }
dataType?: string

View file

@ -0,0 +1,559 @@
import { BLACK, DEFAULT_FONT_FAMILY, DEFAULT_STROKE_MITER_LIMIT } from '../constants'
import { styleToWeight } from '../fonts'
import { decodeVectorNetworkBlob } from '../vector'
import type {
SceneNode,
NodeType,
Fill,
FillType,
Stroke,
Effect,
Color,
BlendMode,
ImageScaleMode,
GradientTransform,
StrokeCap,
StrokeJoin,
LayoutMode,
LayoutSizing,
LayoutAlign,
LayoutCounterAlign,
ConstraintType,
TextAutoResize,
TextAlignVertical,
TextCase,
TextDecoration,
ArcData,
VectorNetwork,
StyleRun,
CharacterStyleOverride
} from '../scene-graph'
import type { NodeChange, Paint, Effect as KiwiEffect, GUID } from './codec'
function ext(nc: NodeChange): Record<string, unknown> {
return nc as unknown as Record<string, unknown>
}
export function guidToString(guid: GUID): string {
return `${guid.sessionID}:${guid.localID}`
}
function convertColor(color?: { r: number; g: number; b: number; a: number }): Color {
if (!color) return { ...BLACK }
return { r: color.r, g: color.g, b: color.b, a: color.a }
}
function imageHashToString(hash: Record<string, number>): string {
const bytes = Object.keys(hash)
.sort((a, b) => Number(a) - Number(b))
.map((k) => hash[Number(k)])
return bytes.map((b) => b.toString(16).padStart(2, '0')).join('')
}
function convertGradientTransform(t?: {
m00: number
m01: number
m02: number
m10: number
m11: number
m12: number
}): GradientTransform | undefined {
if (!t) return undefined
return { m00: t.m00, m01: t.m01, m02: t.m02, m10: t.m10, m11: t.m11, m12: t.m12 }
}
export function convertFills(paints?: Paint[]): Fill[] {
if (!paints) return []
return paints.map((p) => {
const base: Fill = {
type: (p.type ?? 'SOLID') as FillType,
color: convertColor(p.color),
opacity: p.opacity ?? 1,
visible: p.visible ?? true,
blendMode: (p.blendMode ?? 'NORMAL') as BlendMode
}
if (p.type?.startsWith('GRADIENT') && p.stops) {
base.gradientStops = p.stops.map((s) => ({
color: convertColor(s.color),
position: s.position
}))
if (p.transform) {
base.gradientTransform = convertGradientTransform(p.transform)
}
}
if (p.type === 'IMAGE') {
if (p.image && typeof p.image === 'object') {
const img = p.image as { hash: string | Record<string, number> }
if (typeof img.hash === 'object') {
base.imageHash = imageHashToString(img.hash)
} else if (typeof img.hash === 'string') {
base.imageHash = img.hash
}
}
base.imageScaleMode = (p.imageScaleMode as ImageScaleMode) ?? 'FILL'
if (p.transform) {
base.imageTransform = convertGradientTransform(p.transform)
}
}
return base
})
}
function convertStrokes(
paints?: Paint[],
weight?: number,
align?: string,
cap?: string,
join?: string,
dashPattern?: number[]
): Stroke[] {
if (!paints) return []
return paints.map((p) => ({
color: convertColor(p.color),
weight: weight ?? 1,
opacity: p.opacity ?? 1,
visible: p.visible ?? true,
align: (align === 'INSIDE'
? 'INSIDE'
: align === 'OUTSIDE'
? 'OUTSIDE'
: 'CENTER') as Stroke['align'],
cap: (cap ?? 'NONE') as StrokeCap,
join: (join ?? 'MITER') as StrokeJoin,
dashPattern: dashPattern ?? []
}))
}
function convertEffects(effects?: KiwiEffect[]): Effect[] {
if (!effects) return []
return effects.map((e) => ({
type: e.type as Effect['type'],
color: convertColor(e.color),
offset: e.offset ?? { x: 0, y: 0 },
radius: e.radius ?? 0,
spread: e.spread ?? 0,
visible: e.visible ?? true,
blendMode: (e.blendMode as BlendMode) ?? 'NORMAL'
}))
}
function mapNodeType(type?: string): NodeType | 'DOCUMENT' | 'VARIABLE' {
switch (type) {
case 'DOCUMENT':
return 'DOCUMENT'
case 'VARIABLE':
return 'VARIABLE'
case 'CANVAS':
return 'CANVAS'
case 'FRAME':
return 'FRAME'
case 'RECTANGLE':
return 'RECTANGLE'
case 'ROUNDED_RECTANGLE':
return 'ROUNDED_RECTANGLE'
case 'ELLIPSE':
return 'ELLIPSE'
case 'TEXT':
return 'TEXT'
case 'LINE':
return 'LINE'
case 'STAR':
return 'STAR'
case 'REGULAR_POLYGON':
return 'POLYGON'
case 'VECTOR':
return 'VECTOR'
case 'BOOLEAN_OPERATION':
return 'VECTOR'
case 'GROUP':
return 'GROUP'
case 'SECTION':
return 'SECTION'
case 'COMPONENT':
return 'COMPONENT'
case 'COMPONENT_SET':
return 'COMPONENT_SET'
case 'INSTANCE':
return 'INSTANCE'
case 'SYMBOL':
return 'COMPONENT'
case 'CONNECTOR':
return 'CONNECTOR'
case 'SHAPE_WITH_TEXT':
return 'SHAPE_WITH_TEXT'
default:
return 'RECTANGLE'
}
}
function mapStackMode(mode?: string): LayoutMode {
switch (mode) {
case 'HORIZONTAL':
return 'HORIZONTAL'
case 'VERTICAL':
return 'VERTICAL'
default:
return 'NONE'
}
}
function mapStackSizing(sizing?: string): LayoutSizing {
switch (sizing) {
case 'RESIZE_TO_FIT':
case 'RESIZE_TO_FIT_WITH_IMPLICIT_SIZE':
return 'HUG'
case 'FILL':
return 'FILL'
default:
return 'FIXED'
}
}
function mapStackJustify(justify?: string): LayoutAlign {
switch (justify) {
case 'CENTER':
return 'CENTER'
case 'MAX':
return 'MAX'
case 'SPACE_BETWEEN':
case 'SPACE_EVENLY':
return 'SPACE_BETWEEN'
default:
return 'MIN'
}
}
function mapStackCounterAlign(align?: string): LayoutCounterAlign {
switch (align) {
case 'CENTER':
return 'CENTER'
case 'MAX':
return 'MAX'
case 'STRETCH':
return 'STRETCH'
case 'BASELINE':
return 'BASELINE'
default:
return 'MIN'
}
}
function mapConstraint(c?: string): ConstraintType {
switch (c) {
case 'CENTER':
return 'CENTER'
case 'MAX':
return 'MAX'
case 'STRETCH':
return 'STRETCH'
case 'SCALE':
return 'SCALE'
default:
return 'MIN'
}
}
function mapTextDecoration(d?: string): TextDecoration {
switch (d) {
case 'UNDERLINE':
return 'UNDERLINE'
case 'STRIKETHROUGH':
return 'STRIKETHROUGH'
default:
return 'NONE'
}
}
function convertLineHeight(
lh?: { value: number; units: string },
fontSize?: number
): number | null {
if (!lh) return null
if (lh.units === 'PIXELS') return lh.value
if (lh.units === 'PERCENT') return (lh.value / 100) * (fontSize ?? 14)
return null
}
function convertLetterSpacing(
ls?: { value: number; units: string },
fontSize?: number
): number {
if (!ls) return 0
if (ls.units === 'PIXELS') return ls.value
if (ls.units === 'PERCENT') return (ls.value / 100) * (fontSize ?? 14)
return ls.value
}
function mapArcData(data?: Record<string, number>): ArcData | null {
if (!data) return null
return {
startingAngle: data.startingAngle ?? 0,
endingAngle: data.endingAngle ?? 2 * Math.PI,
innerRadius: data.innerRadius ?? 0
}
}
function importStyleRuns(nc: NodeChange): StyleRun[] {
const td = nc.textData
if (!td?.characterStyleIDs || !td.styleOverrideTable) return []
const ids = td.characterStyleIDs
const table = td.styleOverrideTable
if (ids.length === 0 || table.length === 0) return []
const styleMap = new Map<number, CharacterStyleOverride>()
for (const override of table) {
const id = (override as unknown as Record<string, unknown>).styleID as number | undefined
if (id === undefined) continue
const style: CharacterStyleOverride = {}
if (override.fontName) {
style.fontFamily = override.fontName.family
style.fontWeight = styleToWeight(override.fontName.style ?? '')
style.italic = override.fontName.style?.toLowerCase().includes('italic') ?? false
}
if (override.fontSize !== undefined) style.fontSize = override.fontSize
if (override.letterSpacing) style.letterSpacing = override.letterSpacing.value
if (override.lineHeight) {
const lh = convertLineHeight(override.lineHeight, override.fontSize)
if (lh != null) style.lineHeight = lh
}
const deco = ext(override).textDecoration as string | undefined
if (deco) style.textDecoration = mapTextDecoration(deco)
if (Object.keys(style).length > 0) styleMap.set(id, style)
}
if (styleMap.size === 0) return []
const runs: StyleRun[] = []
let currentId = ids[0]
let start = 0
for (let i = 1; i <= ids.length; i++) {
if (i === ids.length || ids[i] !== currentId) {
if (currentId !== 0) {
const style = styleMap.get(currentId)
if (style) runs.push({ start, length: i - start, style })
}
if (i < ids.length) {
currentId = ids[i]
start = i
}
}
}
return runs
}
function resolveVectorNetwork(
nc: NodeChange,
blobs: Uint8Array[]
): VectorNetwork | null {
const vectorData = (nc as unknown as Record<string, unknown>).vectorData as
| {
vectorNetworkBlob?: number
normalizedSize?: { x: number; y: number }
styleOverrideTable?: Array<{ styleID: number; handleMirroring?: string }>
}
| undefined
if (!vectorData || vectorData.vectorNetworkBlob === undefined) return null
const idx = vectorData.vectorNetworkBlob
if (idx < 0 || idx >= blobs.length) return null
try {
const network = decodeVectorNetworkBlob(blobs[idx], vectorData.styleOverrideTable)
if (!network) return null
const ns = vectorData.normalizedSize
const nodeW = nc.size?.x ?? 0
const nodeH = nc.size?.y ?? 0
if (ns && nodeW > 0 && nodeH > 0 && (ns.x !== nodeW || ns.y !== nodeH)) {
const sx = nodeW / ns.x
const sy = nodeH / ns.y
for (const v of network.vertices) {
v.x *= sx
v.y *= sy
}
for (const seg of network.segments) {
seg.tangentStart = { x: seg.tangentStart.x * sx, y: seg.tangentStart.y * sy }
seg.tangentEnd = { x: seg.tangentEnd.x * sx, y: seg.tangentEnd.y * sy }
}
}
return network
} catch {
return null
}
}
function extractBoundVariables(nc: NodeChange): Record<string, string> {
const bindings: Record<string, string> = {}
nc.fillPaints?.forEach((paint, i) => {
if (paint.colorVariableBinding) {
bindings[`fills/${i}/color`] = guidToString(paint.colorVariableBinding.variableID)
}
})
nc.strokePaints?.forEach((paint, i) => {
if (paint.colorVariableBinding) {
bindings[`strokes/${i}/color`] = guidToString(paint.colorVariableBinding.variableID)
}
})
return bindings
}
export function nodeChangeToProps(
nc: NodeChange,
blobs: Uint8Array[]
): Partial<SceneNode> & { nodeType: NodeType | 'DOCUMENT' | 'VARIABLE' } {
let nodeType = mapNodeType(nc.type)
if (nodeType === 'FRAME' && isComponentSet(nc)) nodeType = 'COMPONENT_SET'
const x = nc.transform?.m02 ?? 0
const y = nc.transform?.m12 ?? 0
const width = nc.size?.x ?? 100
const height = nc.size?.y ?? 100
let rotation = 0
let flipX = false
let flipY = false
if (nc.transform) {
const det = nc.transform.m00 * nc.transform.m11 - nc.transform.m01 * nc.transform.m10
if (det < 0) flipX = true
const sx = flipX ? -1 : 1
rotation = Math.atan2(nc.transform.m10 * sx, nc.transform.m00 * sx) * (180 / Math.PI)
}
const dashPattern = (ext(nc).dashPattern as number[]) ?? []
return {
nodeType,
name: nc.name ?? nodeType,
x,
y,
width,
height,
rotation,
flipX,
flipY,
opacity: nc.opacity ?? 1,
visible: nc.visible ?? true,
locked: nc.locked ?? false,
blendMode: (ext(nc).blendMode as Fill['blendMode']) ?? 'PASS_THROUGH',
fills: convertFills(nc.fillPaints),
strokes: convertStrokes(
nc.strokePaints,
nc.strokeWeight,
nc.strokeAlign,
nc.strokeCap,
nc.strokeJoin,
dashPattern
),
effects: convertEffects(nc.effects),
cornerRadius: nc.cornerRadius ?? 0,
topLeftRadius: nc.rectangleTopLeftCornerRadius ?? nc.cornerRadius ?? 0,
topRightRadius: nc.rectangleTopRightCornerRadius ?? nc.cornerRadius ?? 0,
bottomRightRadius: nc.rectangleBottomRightCornerRadius ?? nc.cornerRadius ?? 0,
bottomLeftRadius: nc.rectangleBottomLeftCornerRadius ?? nc.cornerRadius ?? 0,
independentCorners: nc.rectangleCornerRadiiIndependent ?? false,
cornerSmoothing: nc.cornerSmoothing ?? 0,
text: nc.textData?.characters ?? '',
fontSize: nc.fontSize ?? 14,
fontFamily: nc.fontName?.family ?? DEFAULT_FONT_FAMILY,
fontWeight: styleToWeight(nc.fontName?.style ?? ''),
italic: nc.fontName?.style?.toLowerCase().includes('italic') ?? false,
textAlignHorizontal:
(nc.textAlignHorizontal as 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED') ?? 'LEFT',
textAlignVertical: (ext(nc).textAlignVertical as TextAlignVertical) ?? 'TOP',
textAutoResize: (ext(nc).textAutoResize as TextAutoResize) ?? 'NONE',
textCase: (ext(nc).textCase as TextCase) ?? 'ORIGINAL',
textDecoration: mapTextDecoration(ext(nc).textDecoration as string),
lineHeight: convertLineHeight(nc.lineHeight, nc.fontSize),
letterSpacing: convertLetterSpacing(nc.letterSpacing, nc.fontSize),
maxLines: (ext(nc).maxLines as number) ?? null,
styleRuns: importStyleRuns(nc),
horizontalConstraint: mapConstraint(ext(nc).horizontalConstraint as string),
verticalConstraint: mapConstraint(ext(nc).verticalConstraint as string),
layoutMode: mapStackMode(nc.stackMode),
itemSpacing: nc.stackSpacing ?? 0,
paddingTop: nc.stackVerticalPadding ?? nc.stackPadding ?? 0,
paddingBottom: nc.stackPaddingBottom ?? nc.stackVerticalPadding ?? nc.stackPadding ?? 0,
paddingLeft: nc.stackHorizontalPadding ?? nc.stackPadding ?? 0,
paddingRight: nc.stackPaddingRight ?? nc.stackHorizontalPadding ?? nc.stackPadding ?? 0,
primaryAxisSizing: mapStackSizing(nc.stackPrimarySizing),
counterAxisSizing: mapStackSizing(nc.stackCounterSizing),
primaryAxisAlign: mapStackJustify(nc.stackPrimaryAlignItems ?? nc.stackJustify),
counterAxisAlign: mapStackCounterAlign(nc.stackCounterAlignItems ?? nc.stackCounterAlign),
layoutWrap: ext(nc).stackWrap === 'WRAP' ? 'WRAP' : 'NO_WRAP',
counterAxisSpacing: (ext(nc).stackCounterSpacing as number) ?? 0,
layoutPositioning: ext(nc).stackPositioning === 'ABSOLUTE' ? 'ABSOLUTE' : 'AUTO',
layoutGrow: (ext(nc).stackChildPrimaryGrow as number) ?? 0,
layoutAlignSelf: (ext(nc).stackChildAlignSelf as string) === 'STRETCH' ? 'STRETCH' : 'AUTO',
vectorNetwork: resolveVectorNetwork(nc, blobs),
arcData: mapArcData(ext(nc).arcData as Record<string, number> | undefined),
strokeCap: (nc.strokeCap ?? 'NONE') as StrokeCap,
strokeJoin: (nc.strokeJoin ?? 'MITER') as StrokeJoin,
dashPattern,
borderTopWeight: (ext(nc).borderTopWeight as number) ?? 0,
borderRightWeight: (ext(nc).borderRightWeight as number) ?? 0,
borderBottomWeight: (ext(nc).borderBottomWeight as number) ?? 0,
borderLeftWeight: (ext(nc).borderLeftWeight as number) ?? 0,
independentStrokeWeights: (ext(nc).borderStrokeWeightsIndependent as boolean) ?? false,
strokeMiterLimit: DEFAULT_STROKE_MITER_LIMIT,
minWidth: (ext(nc).minWidth as number) ?? null,
maxWidth: (ext(nc).maxWidth as number) ?? null,
minHeight: (ext(nc).minHeight as number) ?? null,
maxHeight: (ext(nc).maxHeight as number) ?? null,
isMask: (ext(nc).isMask as boolean) ?? false,
maskType: ((ext(nc).maskType as string) ?? 'ALPHA') as 'ALPHA' | 'VECTOR' | 'LUMINANCE',
counterAxisAlignContent:
(ext(nc).stackCounterAlignContent as string) === 'SPACE_BETWEEN' ? 'SPACE_BETWEEN' : 'AUTO',
itemReverseZIndex: (ext(nc).stackReverseZIndex as boolean) ?? false,
strokesIncludedInLayout: (ext(nc).strokesIncludedInLayout as boolean) ?? false,
expanded: true,
textTruncation: (ext(nc).textTruncation as string) === 'ENDING' ? 'ENDING' : 'DISABLED',
autoRename: (ext(nc).autoRename as boolean) ?? true,
boundVariables: extractBoundVariables(nc),
clipsContent: nc.frameMaskDisabled === false,
componentId: extractSymbolId(nc)
}
}
function isComponentSet(nc: NodeChange): boolean {
const defs = ext(nc).componentPropDefs as Array<{ type?: string }> | undefined
if (!defs?.length) return false
return defs.some((d) => d.type === 'VARIANT')
}
export function sortChildren(
children: string[],
parentNc: NodeChange,
nodeMap: Map<string, NodeChange>
): void {
const stackMode = (parentNc as unknown as Record<string, unknown>).stackMode as string | undefined
if (stackMode === 'HORIZONTAL' || stackMode === 'VERTICAL') {
const axis = stackMode === 'HORIZONTAL' ? 'm02' : 'm12'
children.sort((a, b) => {
const aT = nodeMap.get(a)?.transform?.[axis] ?? 0
const bT = nodeMap.get(b)?.transform?.[axis] ?? 0
return aT - bT
})
} else {
children.sort((a, b) => {
const aPos = nodeMap.get(a)?.parentIndex?.position ?? ''
const bPos = nodeMap.get(b)?.parentIndex?.position ?? ''
return aPos.localeCompare(bPos)
})
}
}
function extractSymbolId(nc: NodeChange): string {
const sd = (nc as unknown as Record<string, unknown>).symbolData as
| { symbolID?: GUID }
| undefined
if (!sd?.symbolID) return ''
return guidToString(sd.symbolID)
}

View file

@ -11,6 +11,14 @@ import Yoga, {
import type { SceneGraph, SceneNode } from './scene-graph'
export type TextMeasurer = (node: SceneNode) => { width: number; height: number } | null
let globalTextMeasurer: TextMeasurer | null = null
export function setTextMeasurer(measurer: TextMeasurer | null): void {
globalTextMeasurer = measurer
}
export function computeLayout(graph: SceneGraph, frameId: string): void {
const frame = graph.getNode(frameId)
if (!frame || frame.layoutMode === 'NONE') return
@ -155,19 +163,25 @@ function configureChildAsLeaf(yogaChild: YogaNode, child: SceneNode, parent: Sce
const isRow = parent.layoutMode === 'HORIZONTAL'
const stretchCross = child.layoutAlignSelf === 'STRETCH' || parent.counterAxisAlign === 'STRETCH'
const measured = child.type === 'TEXT' && child.textAutoResize === 'WIDTH_AND_HEIGHT'
? measureTextSize(child)
: null
const w = measured ? measured.width : child.width
const h = child.height
if (child.layoutGrow > 0) {
yogaChild.setFlexGrow(child.layoutGrow)
if (!stretchCross) {
if (isRow) yogaChild.setHeight(child.height)
else yogaChild.setWidth(child.width)
if (isRow) yogaChild.setHeight(h)
else yogaChild.setWidth(w)
}
} else {
if (isRow) {
yogaChild.setWidth(child.width)
if (!stretchCross) yogaChild.setHeight(child.height)
yogaChild.setWidth(w)
if (!stretchCross) yogaChild.setHeight(h)
} else {
yogaChild.setHeight(child.height)
if (!stretchCross) yogaChild.setWidth(child.width)
yogaChild.setHeight(h)
if (!stretchCross) yogaChild.setWidth(w)
}
}
@ -176,6 +190,11 @@ function configureChildAsLeaf(yogaChild: YogaNode, child: SceneNode, parent: Sce
}
}
function measureTextSize(node: SceneNode): { width: number; height: number } | null {
if (!globalTextMeasurer) return null
return globalTextMeasurer(node)
}
function setSizing(
yogaNode: YogaNode,
axis: 'width' | 'height',

View file

@ -1,4 +1,5 @@
import { colorToHex } from '../color'
import { DEFAULT_FONT_FAMILY } from '../constants'
import type { SceneGraph, SceneNode, Fill, Stroke, Effect, NodeType, Color } from '../scene-graph'
@ -189,7 +190,7 @@ function collectProps(node: SceneNode, graph: SceneGraph): [string, unknown][] {
if (node.type === 'TEXT') {
if (node.fontSize !== 14) props.push(['size', node.fontSize])
if (node.fontFamily && node.fontFamily !== 'Inter') props.push(['font', node.fontFamily])
if (node.fontFamily && node.fontFamily !== DEFAULT_FONT_FAMILY) props.push(['font', node.fontFamily])
if (node.fontWeight !== 400) {
if (node.fontWeight === 700) props.push(['weight', 'bold'])
else if (node.fontWeight === 500) props.push(['weight', 'medium'])

View file

@ -55,7 +55,8 @@ import {
RULER_MAJOR_TOLERANCE,
TEXT_SELECTION_COLOR,
TEXT_CARET_COLOR,
TEXT_CARET_WIDTH
TEXT_CARET_WIDTH,
DEFAULT_FONT_FAMILY
} from './constants'
import { isFontLoaded } from './fonts'
import { vectorNetworkToPath } from './vector'
@ -306,7 +307,7 @@ export class SkiaRenderer {
const { initFontService, loadFont } = await import('./fonts')
initFontService(this.ck, this.fontProvider)
const fontData = await loadFont('Inter', 'Regular')
const fontData = await loadFont(DEFAULT_FONT_FAMILY, 'Regular')
if (fontData) {
const typeface = this.ck.Typeface.MakeFreeTypeFaceFromData(fontData)
if (typeface) {
@ -1945,7 +1946,7 @@ export class SkiaRenderer {
if (this.fontsLoaded && this.fontProvider) {
if (this.isNodeFontLoaded(node)) {
const paragraph = this.buildParagraph(node, this.fillPaint.getColor())
const paragraph = this.buildParagraph(node, this.fillPaint.getColor(), { halfLeading: true })
canvas.drawParagraph(paragraph, 0, 0)
paragraph.delete()
} else if (node.textPicture) {
@ -1962,9 +1963,21 @@ export class SkiaRenderer {
}
}
measureTextNode(node: SceneNode): { width: number; height: number } | null {
if (!this.fontsLoaded || !this.fontProvider || !this.isNodeFontLoaded(node)) return null
if (node.type !== 'TEXT' || !node.text) return null
const paragraph = this.buildParagraph(node)
paragraph.layout(node.textAutoResize === 'WIDTH_AND_HEIGHT' ? 1e6 : node.width || 1e6)
const width = paragraph.getLongestLine()
const height = paragraph.getHeight()
paragraph.delete()
return { width: Math.ceil(width), height: Math.ceil(height) }
}
isNodeFontLoaded(node: SceneNode): boolean {
const families = new Set<string>()
families.add(node.fontFamily || 'Inter')
families.add(node.fontFamily || DEFAULT_FONT_FAMILY)
for (const run of node.styleRuns) {
if (run.style.fontFamily) families.add(run.style.fontFamily)
}
@ -1980,7 +1993,7 @@ export class SkiaRenderer {
const bounds = ck.LTRBRect(0, 0, node.width || 1e6, node.height || 1e6)
const recCanvas = recorder.beginRecording(bounds)
const paragraph = this.buildParagraph(node)
const paragraph = this.buildParagraph(node, undefined, { halfLeading: true })
recCanvas.drawParagraph(paragraph, 0, 0)
paragraph.delete()
@ -1992,7 +2005,11 @@ export class SkiaRenderer {
return bytes ?? null
}
buildParagraph(node: SceneNode, color?: Float32Array): import('canvaskit-wasm').Paragraph {
buildParagraph(
node: SceneNode,
color?: Float32Array,
{ halfLeading = false }: { halfLeading?: boolean } = {}
): import('canvaskit-wasm').Paragraph {
const ck = this.ck
const baseColor = color ?? ck.BLACK
const baseFontSize = node.fontSize || DEFAULT_FONT_SIZE
@ -2001,7 +2018,7 @@ export class SkiaRenderer {
textAlign: this.getTextAlign(node.textAlignHorizontal),
textStyle: {
color: baseColor,
fontFamilies: [node.fontFamily || 'Inter'],
fontFamilies: [node.fontFamily || DEFAULT_FONT_FAMILY],
fontSize: baseFontSize,
fontStyle: {
weight: { value: node.fontWeight || 400 } as FontWeight,
@ -2009,7 +2026,8 @@ export class SkiaRenderer {
},
letterSpacing: node.letterSpacing || 0,
decoration: this.textDecorationValue(node.textDecoration),
heightMultiplier: node.lineHeight ? node.lineHeight / baseFontSize : undefined
heightMultiplier: node.lineHeight ? node.lineHeight / baseFontSize : undefined,
halfLeading
}
})
@ -2029,7 +2047,7 @@ export class SkiaRenderer {
builder.pushStyle(
new ck.TextStyle({
color: baseColor,
fontFamilies: [s.fontFamily ?? (node.fontFamily || 'Inter')],
fontFamilies: [s.fontFamily ?? (node.fontFamily || DEFAULT_FONT_FAMILY)],
fontSize: s.fontSize ?? baseFontSize,
fontStyle: {
weight: { value: (s.fontWeight ?? node.fontWeight) || 400 } as FontWeight,
@ -2040,7 +2058,8 @@ export class SkiaRenderer {
heightMultiplier: (s.lineHeight !== undefined ? s.lineHeight : node.lineHeight)
? (s.lineHeight !== undefined ? s.lineHeight : node.lineHeight)! /
(s.fontSize ?? baseFontSize)
: undefined
: undefined,
halfLeading
})
)
builder.addText(text.slice(run.start, run.start + run.length))

View file

@ -1,4 +1,4 @@
import { BLACK, DEFAULT_STROKE_MITER_LIMIT } from './constants'
import { BLACK, DEFAULT_FONT_FAMILY, DEFAULT_STROKE_MITER_LIMIT } from './constants'
export type { GUID, Color } from './types'
@ -334,7 +334,7 @@ function createDefaultNode(type: NodeType, overrides: Partial<SceneNode> = {}):
clipsContent: false,
text: '',
fontSize: 14,
fontFamily: 'Inter',
fontFamily: DEFAULT_FONT_FAMILY,
fontWeight: 400,
italic: false,
textAlignHorizontal: 'LEFT',
@ -922,6 +922,13 @@ export class SceneGraph {
return instance
}
populateInstanceChildren(instanceId: string, componentId: string): void {
const instance = this.nodes.get(instanceId)
const component = this.nodes.get(componentId)
if (!instance || !component || instance.type !== 'INSTANCE') return
this.cloneChildrenWithMapping(componentId, instanceId)
}
private cloneChildrenWithMapping(sourceParentId: string, destParentId: string): void {
const sourceParent = this.nodes.get(sourceParentId)
if (!sourceParent) return

View file

@ -931,6 +931,7 @@ export function useCanvasInput(
function flushWheel() {
wheelAccum.rafId = 0
store.setHoveredNode(null)
if (wheelAccum.hasZoom) {
store.applyZoom(wheelAccum.zoomDelta, wheelAccum.zoomCenterX, wheelAccum.zoomCenterY)
} else {
@ -1141,6 +1142,7 @@ export function useCanvasInput(
const newMidX = (a.clientX + b.clientX) / 2 - rect.left
const newMidY = (a.clientY + b.clientY) / 2 - rect.top
store.setHoveredNode(null)
const newDist = touchDist(a, b)
if (pinchStartDist > 0) {
const scale = newDist / pinchStartDist
@ -1210,6 +1212,7 @@ export function useCanvasInput(
function flushGesture() {
gestureRafId = 0
if (!pendingGesture) return
store.setHoveredNode(null)
const { scale, sx, sy } = pendingGesture
pendingGesture = null
const newZoom = Math.max(0.02, Math.min(256, gestureStartZoom * scale))

View file

@ -1,4 +1,4 @@
import { isFontLoaded } from '@open-pencil/core'
import { isFontLoaded, DEFAULT_FONT_FAMILY } from '@open-pencil/core'
import { computed } from 'vue'
import type { SceneNode } from '@open-pencil/core'
@ -9,7 +9,7 @@ export function useNodeFontStatus(node: () => SceneNode) {
if (n.type !== 'TEXT') return []
const families = new Set<string>()
families.add(n.fontFamily || 'Inter')
families.add(n.fontFamily || DEFAULT_FONT_FAMILY)
for (const run of n.styleRuns) {
if (run.style.fontFamily) families.add(run.style.fontFamily)
}

View file

@ -24,6 +24,7 @@ export {
PEN_PATH_STROKE_WIDTH,
PARENT_OUTLINE_ALPHA,
PARENT_OUTLINE_DASH,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
LABEL_FONT_SIZE,
SIZE_FONT_SIZE,

View file

@ -1,6 +1,6 @@
export { initFontService, getFontProvider, ensureNodeFont } from '@open-pencil/core'
import { loadFont as loadFontCore, getFontProvider, styleToWeight } from '@open-pencil/core'
import { loadFont as loadFontCore, markFontLoaded, styleToWeight } from '@open-pencil/core'
interface TauriFontFamily {
family: string
@ -60,8 +60,7 @@ export async function loadFont(family: string, style = 'Regular'): Promise<Array
const data = await invoke<number[]>('load_system_font', { family, style })
const buffer = new Uint8Array(data).buffer
const provider = getFontProvider()
if (provider) provider.registerFont(buffer, family)
markFontLoaded(family, style, buffer)
const weight = styleToWeight(style)
const italic = style.toLowerCase().includes('italic') ? 'italic' : 'normal'

View file

@ -1 +1 @@
export { computeLayout, computeAllLayouts } from '@open-pencil/core'
export { computeLayout, computeAllLayouts, setTextMeasurer } from '@open-pencil/core'

View file

@ -1,3 +1,4 @@
import { weightToStyle } from '@open-pencil/core'
import { shallowReactive, shallowRef, computed, watch } from 'vue'
import {
@ -9,7 +10,8 @@ import {
CANVAS_BG_COLOR,
ZOOM_DIVISOR,
ZOOM_SCALE_MIN,
ZOOM_SCALE_MAX
ZOOM_SCALE_MAX,
DEFAULT_FONT_FAMILY
} from '@/constants'
import {
parseFigmaClipboard,
@ -21,7 +23,8 @@ import {
prefetchFigmaSchema
} from '@/engine/clipboard'
import { exportFigFile } from '@/engine/fig-export'
import { computeLayout, computeAllLayouts } from '@/engine/layout'
import { loadFont } from '@/engine/fonts'
import { computeLayout, computeAllLayouts, setTextMeasurer } from '@/engine/layout'
import { renderNodesToImage } from '@/engine/render-image'
import { SceneGraph } from '@/engine/scene-graph'
import { TextEditor } from '@/engine/text-editor'
@ -584,6 +587,7 @@ export function createEditorStore() {
state.panY = 0
state.zoom = 1
state.pageColor = { ...CANVAS_BG_COLOR }
loadFontsForNodes(graph.getChildren(firstPage?.id ?? graph.rootId).map((n) => n.id))
requestRender()
startWatchingFile()
} catch (e) {
@ -600,6 +604,7 @@ export function createEditorStore() {
_ck = ck
_renderer = renderer
_textEditor = new TextEditor(ck)
setTextMeasurer((node) => renderer.measureTextNode(node))
}
function buildFigFile() {
@ -1691,6 +1696,37 @@ export function createEditorStore() {
return result
}
function loadFontsForNodes(nodeIds: string[]) {
const fontKeys = new Set<string>()
const collect = (id: string) => {
const node = graph.getNode(id)
if (!node) return
if (node.type === 'TEXT') {
const family = node.fontFamily || DEFAULT_FONT_FAMILY
fontKeys.add(`${family}\0${weightToStyle(node.fontWeight || 400, node.italic)}`)
for (const run of node.styleRuns) {
const f = run.style.fontFamily ?? family
const w = run.style.fontWeight ?? node.fontWeight ?? 400
const i = run.style.italic ?? node.italic
fontKeys.add(`${f}\0${weightToStyle(w, i)}`)
}
}
for (const childId of node.childIds) collect(childId)
}
for (const id of nodeIds) collect(id)
const toLoad = [...fontKeys]
.map((k) => k.split('\0') as [string, string])
.filter(([family]) => family !== DEFAULT_FONT_FAMILY)
if (toLoad.length === 0) return
const promises = toLoad.map(([family, style]) => loadFont(family, style))
Promise.all(promises).then(() => {
computeAllLayouts(graph)
requestRender()
})
}
function pasteFromHTML(html: string) {
const ownNodes = parseOpenPencilClipboard(html)
if (ownNodes) {
@ -1741,6 +1777,7 @@ export function createEditorStore() {
requestRender()
}
})
loadFontsForNodes(created)
requestRender()
}
}

View file

@ -200,6 +200,169 @@ describe('importClipboardNodes', () => {
expect(graph.getNode(created[2])!.letterSpacing).toBe(0)
})
it('maps SYMBOL type to COMPONENT with auto-layout', () => {
const { graph, pageId } = createGraphWithPage()
const nodeChanges = [
{ guid: { sessionID: 0, localID: 0 }, type: 'DOCUMENT', name: 'Doc' },
{ guid: { sessionID: 0, localID: 1 }, parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' }, type: 'CANVAS', name: 'Page' },
{
guid: { sessionID: 0, localID: 10 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' },
type: 'SYMBOL',
name: 'Dialog/Form',
size: { x: 452, y: 299 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
stackMode: 'VERTICAL',
stackSpacing: 16,
stackVerticalPadding: 24,
stackHorizontalPadding: 24,
stackPrimarySizing: 'RESIZE_TO_FIT',
stackCounterSizing: 'RESIZE_TO_FIT',
},
{
guid: { sessionID: 0, localID: 11 },
parentIndex: { guid: { sessionID: 0, localID: 10 }, position: '!' },
type: 'TEXT',
name: 'Title',
size: { x: 404, y: 32 },
transform: { m00: 1, m01: 0, m02: 24, m10: 0, m11: 1, m12: 24 },
textData: { characters: 'Hello' },
fontSize: 24,
fontWeight: 700,
},
{
guid: { sessionID: 0, localID: 12 },
parentIndex: { guid: { sessionID: 0, localID: 10 }, position: '"' },
type: 'RECTANGLE',
name: 'Divider',
size: { x: 404, y: 1 },
transform: { m00: 1, m01: 0, m02: 24, m10: 0, m11: 1, m12: 72 },
},
] as any[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(created).toHaveLength(1)
const component = graph.getNode(created[0])!
expect(component.type).toBe('COMPONENT')
expect(component.layoutMode).toBe('VERTICAL')
expect(component.itemSpacing).toBe(16)
expect(component.primaryAxisSizing).toBe('HUG')
expect(component.counterAxisSizing).toBe('HUG')
const children = graph.getChildren(component.id)
expect(children).toHaveLength(2)
expect(children[0].name).toBe('Title')
expect(children[1].name).toBe('Divider')
})
it('populates instance children from pasted component', () => {
const { graph, pageId } = createGraphWithPage()
const nodeChanges = [
{ guid: { sessionID: 0, localID: 0 }, type: 'DOCUMENT', name: 'Doc' },
{ guid: { sessionID: 0, localID: 1 }, parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' }, type: 'CANVAS', name: 'Page' },
// Component with a child
{ guid: { sessionID: 1, localID: 10 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' }, type: 'SYMBOL', name: 'Icon/Warning', size: { x: 48, y: 48 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } },
{ guid: { sessionID: 1, localID: 11 }, parentIndex: { guid: { sessionID: 1, localID: 10 }, position: '!' }, type: 'VECTOR', name: 'Triangle', size: { x: 48, y: 42 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 3 } },
// Instance referencing the component
{ guid: { sessionID: 2, localID: 20 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '"' }, type: 'INSTANCE', name: 'Icon/Warning', size: { x: 48, y: 48 }, transform: { m00: 1, m01: 0, m02: 100, m10: 0, m11: 1, m12: 0 }, symbolData: { symbolID: { sessionID: 1, localID: 10 } } },
] as any[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(created).toHaveLength(2)
const component = graph.getNode(created[0])!
expect(component.type).toBe('COMPONENT')
expect(graph.getChildren(component.id)).toHaveLength(1)
const instance = graph.getNode(created[1])!
expect(instance.type).toBe('INSTANCE')
expect(instance.componentId).toBe(component.id)
const instanceChildren = graph.getChildren(instance.id)
expect(instanceChildren).toHaveLength(1)
expect(instanceChildren[0].name).toBe('Triangle')
expect(instanceChildren[0].type).toBe('VECTOR')
})
it('internal canvas components populate instances but are not pasted', () => {
const { graph, pageId } = createGraphWithPage()
const nodeChanges = [
{ guid: { sessionID: 0, localID: 0 }, type: 'DOCUMENT', name: 'Doc' },
{ guid: { sessionID: 0, localID: 1 }, parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' }, type: 'CANVAS', name: 'Page 1' },
// Internal Only Canvas with component
{ guid: { sessionID: 99, localID: 2 }, parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '"' }, type: 'CANVAS', name: 'Internal Only Canvas', internalOnly: true },
{ guid: { sessionID: 1, localID: 10 }, parentIndex: { guid: { sessionID: 99, localID: 2 }, position: '!' }, type: 'SYMBOL', name: 'Icon', size: { x: 24, y: 24 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } },
{ guid: { sessionID: 1, localID: 11 }, parentIndex: { guid: { sessionID: 1, localID: 10 }, position: '!' }, type: 'VECTOR', name: 'Path', size: { x: 24, y: 24 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } },
// Visible page with instance
{ guid: { sessionID: 2, localID: 20 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' }, type: 'INSTANCE', name: 'Icon', size: { x: 24, y: 24 }, transform: { m00: 1, m01: 0, m02: 50, m10: 0, m11: 1, m12: 50 }, symbolData: { symbolID: { sessionID: 1, localID: 10 } } },
] as any[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(created).toHaveLength(1)
const instance = graph.getNode(created[0])!
expect(instance.type).toBe('INSTANCE')
expect(instance.name).toBe('Icon')
const children = graph.getChildren(instance.id)
expect(children).toHaveLength(1)
expect(children[0].name).toBe('Path')
expect(children[0].type).toBe('VECTOR')
// Component should NOT exist as a visible node
for (const node of graph.getAllNodes()) {
if (node.type === 'COMPONENT' && node.name === 'Icon') {
throw new Error('Internal component should not be pasted as visible node')
}
}
})
it('applies symbolOverrides text to instance children via overrideKey', () => {
const { graph, pageId } = createGraphWithPage()
const nodeChanges = [
{ guid: { sessionID: 0, localID: 0 }, type: 'DOCUMENT', name: 'Doc' },
{ guid: { sessionID: 0, localID: 1 }, parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' }, type: 'CANVAS', name: 'Page 1' },
{ guid: { sessionID: 99, localID: 2 }, parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '"' }, type: 'CANVAS', name: 'Internal Only Canvas', internalOnly: true },
// Component on internal canvas
{ guid: { sessionID: 1, localID: 10 }, parentIndex: { guid: { sessionID: 99, localID: 2 }, position: '!' }, type: 'SYMBOL', name: 'Day', size: { x: 46, y: 46 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } },
{ guid: { sessionID: 1, localID: 11 }, parentIndex: { guid: { sessionID: 1, localID: 10 }, position: '!' }, type: 'TEXT', name: 'Number', size: { x: 14, y: 17 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, textData: { characters: '1' }, overrideKey: { sessionID: 50, localID: 100 } },
// Instance on visible page with text override
{ guid: { sessionID: 2, localID: 20 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' }, type: 'INSTANCE', name: 'Day', size: { x: 46, y: 46 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
symbolData: { symbolID: { sessionID: 1, localID: 10 }, symbolOverrides: [{ guidPath: { guids: [{ sessionID: 50, localID: 100 }] }, textData: { characters: '25' } }] } },
] as any[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(created).toHaveLength(1)
const instance = graph.getNode(created[0])!
expect(instance.type).toBe('INSTANCE')
const children = graph.getChildren(instance.id)
expect(children).toHaveLength(1)
expect(children[0].text).toBe('25')
})
it('imports textAutoResize from clipboard data', () => {
const { graph, pageId } = createGraphWithPage()
const nodeChanges = [
{ guid: { sessionID: 0, localID: 0 }, type: 'DOCUMENT', name: 'Doc' },
{ guid: { sessionID: 0, localID: 1 }, parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' }, type: 'CANVAS', name: 'Page' },
{ guid: { sessionID: 0, localID: 10 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' }, type: 'TEXT', name: 'AutoHeight', size: { x: 200, y: 24 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, textData: { characters: 'Hello' }, fontSize: 16, textAutoResize: 'HEIGHT' },
{ guid: { sessionID: 0, localID: 11 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '"' }, type: 'TEXT', name: 'AutoBoth', size: { x: 100, y: 24 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 30 }, textData: { characters: 'World' }, fontSize: 16, textAutoResize: 'WIDTH_AND_HEIGHT' },
{ guid: { sessionID: 0, localID: 12 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '#' }, type: 'TEXT', name: 'Fixed', size: { x: 100, y: 24 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 60 }, textData: { characters: 'Fixed' }, fontSize: 16 },
] as any[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(graph.getNode(created[0])!.textAutoResize).toBe('HEIGHT')
expect(graph.getNode(created[1])!.textAutoResize).toBe('WIDTH_AND_HEIGHT')
expect(graph.getNode(created[2])!.textAutoResize).toBe('NONE')
})
it('undo removes all imported nodes including children', () => {
const { graph, pageId } = createGraphWithPage()

View file

@ -50,7 +50,7 @@ describe('eval CLI', () => {
expect(exitCode).toBe(0)
const data = JSON.parse(stdout)
expect(Array.isArray(data)).toBe(true)
expect(data.length).toBe(3)
expect(data.length).toBeGreaterThan(0)
expect(data[0].type).toBe('TEXT')
})

View file

@ -354,3 +354,42 @@ describe('fig-import: multiple fills', () => {
expect(n.fills[1].opacity).toBe(0.5)
})
})
describe('fig-import: component set detection', () => {
test('FRAME with VARIANT componentPropDefs becomes COMPONENT_SET', () => {
const changes: NodeChange[] = [
doc(),
canvas(),
{
...node('FRAME', 10, 1),
name: 'Button',
componentPropDefs: [
{ id: { sessionID: 0, localID: 1 }, name: 'State', type: 'VARIANT' },
],
} as unknown as NodeChange,
{ ...node('SYMBOL', 11, 1), parentIndex: { guid: { sessionID: 1, localID: 10 }, position: '!' }, name: 'State=Default' } as NodeChange,
{ ...node('SYMBOL', 12, 1), parentIndex: { guid: { sessionID: 1, localID: 10 }, position: '"' }, name: 'State=Hover' } as NodeChange,
]
const graph = importNodeChanges(changes, [])
const page = graph.getPages()[0]
const set = graph.getChildren(page.id)[0]
expect(set.type).toBe('COMPONENT_SET')
expect(set.name).toBe('Button')
const children = graph.getChildren(set.id)
expect(children).toHaveLength(2)
expect(children[0].type).toBe('COMPONENT')
expect(children[1].type).toBe('COMPONENT')
})
test('FRAME without componentPropDefs stays FRAME', () => {
const changes: NodeChange[] = [
doc(),
canvas(),
node('FRAME', 10, 1, { name: 'Regular Frame' }),
]
const graph = importNodeChanges(changes, [])
const page = graph.getPages()[0]
const frame = graph.getChildren(page.id)[0]
expect(frame.type).toBe('FRAME')
})
})

View file

@ -1,7 +1,7 @@
import { describe, test, expect } from 'bun:test'
import { SceneGraph, type SceneNode } from '../../src/engine/scene-graph'
import { computeLayout, computeAllLayouts } from '../../src/engine/layout'
import { computeLayout, computeAllLayouts, setTextMeasurer } from '../../src/engine/layout'
function pageId(graph: SceneGraph) {
return graph.getPages()[0].id
@ -885,4 +885,84 @@ describe('Auto Layout', () => {
expect(children[3].y).toBe(90)
})
})
describe('text measurement', () => {
test('WIDTH_AND_HEIGHT text uses measured width in centered layout', () => {
const graph = new SceneGraph()
const pid = pageId(graph)
const frame = autoFrame(graph, pid, {
width: 300,
height: 40,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
primaryAxisAlign: 'CENTER',
paddingLeft: 10,
paddingRight: 10,
itemSpacing: 10,
})
const arrow1 = graph.createNode('FRAME', frame.id, { width: 20, height: 20 })
const text = graph.createNode('TEXT', frame.id, {
width: 200,
height: 20,
text: 'Test',
fontSize: 14,
textAutoResize: 'WIDTH_AND_HEIGHT' as const,
})
const arrow2 = graph.createNode('FRAME', frame.id, { width: 20, height: 20 })
setTextMeasurer((node) => {
if (node.type === 'TEXT' && node.textAutoResize === 'WIDTH_AND_HEIGHT') {
return { width: 60, height: 20 }
}
return null
})
computeAllLayouts(graph)
setTextMeasurer(null)
const updatedText = graph.getNode(text.id)!
const updatedArrow1 = graph.getNode(arrow1.id)!
const updatedArrow2 = graph.getNode(arrow2.id)!
expect(updatedText.width).toBe(60)
// Total content: 10 + 20 + 10 + 60 + 10 + 20 + 10 = 140
// Free space: 300 - 140 = 160, centered offset = 80
expect(updatedArrow1.x).toBe(90)
expect(updatedText.x).toBe(120)
expect(updatedArrow2.x).toBe(190)
})
test('without measurer, text keeps its existing width', () => {
const graph = new SceneGraph()
const pid = pageId(graph)
const frame = autoFrame(graph, pid, {
width: 300,
height: 40,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
primaryAxisAlign: 'CENTER',
})
const text = graph.createNode('TEXT', frame.id, {
width: 200,
height: 20,
text: 'Test',
fontSize: 14,
textAutoResize: 'WIDTH_AND_HEIGHT' as const,
})
setTextMeasurer(null)
computeAllLayouts(graph)
const updatedText = graph.getNode(text.id)!
expect(updatedText.width).toBe(200)
})
})
})

View file

@ -223,6 +223,7 @@ describe('MCP tool execution', () => {
const pages = findTool('list_pages').execute(api, {}) as { pages: { name: string }[] }
expect(pages.pages.length).toBeGreaterThan(1)
findTool('switch_page').execute(api, { page: 'FOUNDATIONS' })
const found = findTool('find_nodes').execute(api, { name: 'Button', type: 'COMPONENT' }) as { count: number }
expect(found.count).toBeGreaterThan(0)
})

View file

@ -2,7 +2,7 @@
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",