Merge branch 'fix-paste-layout'
This commit is contained in:
commit
630e18a869
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -35,3 +35,4 @@ packages/docs/.vitepress/dist/
|
|||
packages/collab/.wrangler/
|
||||
packages/core/vendor/canvaskit-webgpu/
|
||||
patches/skia-webgpu/
|
||||
.wrangler/
|
||||
|
|
|
|||
21
CHANGELOG.md
21
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}|`))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
559
packages/core/src/kiwi/kiwi-convert.ts
Normal file
559
packages/core/src/kiwi/kiwi-convert.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export { computeLayout, computeAllLayouts } from '@open-pencil/core'
|
||||
export { computeLayout, computeAllLayouts, setTextMeasurer } from '@open-pencil/core'
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue