From 59981709ba4bfc2f26bbb6d12936ce4fc869cbf0 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 9 Mar 2026 13:40:57 +0300 Subject: [PATCH] Reduce cyclomatic complexity below 20 in all core functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor 24 complex functions across 14 files by extracting dispatch branches and property-group handlers into focused helpers. kiwi/kiwi-convert.ts (4 functions, was 22-81): - nodeChangeToProps → 6 property-group converters - convertOverrideToProps → 5 override applicators - importStyleRuns → 3 style-run helpers - mapNodeType → NODE_TYPE_MAP lookup table kiwi/instance-overrides.ts (3 functions, was 28-44): - applyDerivedSymbolData → resolveDsd* helpers - applyComponentProperties → assignment/override applicators - propagateOverridesTransitively → clone/sync helpers kiwi/fig-import.ts (2 functions, was 34-40): - importNodeChanges → buildChangeMaps, importPages, etc. - importVariables → importCollections, resolveVariableType, etc. render/export-jsx.ts (2 functions, was 70-85): - collectProps → property-category extractors - collectTailwindClasses → Tailwind class-group extractors render/renderer.ts: propsToOverrides (78) → style-category helpers renderer/scene.ts: renderNode (46), renderShapeUncached (30) → phase extractors (transforms, content, children, strokes) renderer/renderer.ts: buildParagraph (34) → buildTruncateOpts, addStyledRuns svg-export.ts: split into svg-export.ts + svg-export-defs.ts + svg-export-paths.ts; renderNode/renderTextNode/nodeShapeElements all reduced via helper extraction kiwi-serialize.ts: sceneNodeToKiwi (44) → 6 property serializers clipboard.ts: importClipboardNodes (36) → 5 focused helpers scene-graph.ts: hitTestChildren (31) → containsPoint, hitTestOpaqueContainer, hitTestTransparentContainer tools/analyze.ts: serializeNodeProps (23) → 4 prop serializers --- oxlint.json | 7 +- packages/core/src/clipboard.ts | 93 ++- packages/core/src/kiwi-serialize.ts | 266 ++++--- packages/core/src/kiwi/fig-import.ts | 380 +++++----- packages/core/src/kiwi/instance-overrides.ts | 244 +++--- packages/core/src/kiwi/kiwi-convert.ts | 284 +++---- packages/core/src/render/export-jsx.ts | 431 ++++++----- packages/core/src/render/renderer.ts | 126 ++-- packages/core/src/renderer/overlays.ts | 75 +- packages/core/src/renderer/renderer.ts | 110 +-- packages/core/src/renderer/rulers.ts | 199 +++-- packages/core/src/renderer/scene.ts | 317 ++++---- packages/core/src/scene-graph.ts | 71 +- packages/core/src/svg-export-defs.ts | 255 +++++++ packages/core/src/svg-export-paths.ts | 212 ++++++ packages/core/src/svg-export.ts | 748 ++++--------------- packages/core/src/tools/analyze.ts | 50 +- 17 files changed, 2116 insertions(+), 1752 deletions(-) create mode 100644 packages/core/src/svg-export-defs.ts create mode 100644 packages/core/src/svg-export-paths.ts diff --git a/oxlint.json b/oxlint.json index 9dd0839ca..f284e793f 100644 --- a/oxlint.json +++ b/oxlint.json @@ -88,6 +88,7 @@ "typescript/no-non-null-assertion": "off" } }, + { "files": ["**/kiwi/kiwi-schema/**"], "rules": { @@ -116,12 +117,6 @@ "rules": { "open-pencil/no-raw-console-format": "error" } - }, - { - "files": ["packages/core/src/**"], - "rules": { - "complexity": "warn" - } } ], "ignorePatterns": ["node_modules", "dist", "desktop", "*.config.*"] diff --git a/packages/core/src/clipboard.ts b/packages/core/src/clipboard.ts index d0e47ff2d..1e9760605 100644 --- a/packages/core/src/clipboard.ts +++ b/packages/core/src/clipboard.ts @@ -77,6 +77,13 @@ const NON_VISUAL_TYPES = new Set([ 'SLIDE' ]) +function isChildOfVisualNode(nc: KiwiNodeChange, parentTypes: Map): boolean { + const parentId = nc.parentIndex?.guid + ? `${nc.parentIndex.guid.sessionID}:${nc.parentIndex.guid.localID}` + : null + return !!parentId && parentTypes.has(parentId) && !NON_VISUAL_TYPES.has(parentTypes.get(parentId) ?? '') +} + export function figmaNodesBounds( nodeChanges: KiwiNodeChange[] ): { x: number; y: number; w: number; h: number } | null { @@ -93,11 +100,7 @@ export function figmaNodesBounds( for (const nc of nodeChanges) { if (!nc.type || NON_VISUAL_TYPES.has(nc.type)) continue - const parentId = nc.parentIndex?.guid - ? `${nc.parentIndex.guid.sessionID}:${nc.parentIndex.guid.localID}` - : null - if (parentId && parentTypes.has(parentId) && !NON_VISUAL_TYPES.has(parentTypes.get(parentId)!)) - continue + if (isChildOfVisualNode(nc, parentTypes)) continue const x = nc.transform?.m02 ?? 0 const y = nc.transform?.m12 ?? 0 @@ -113,14 +116,12 @@ export function figmaNodesBounds( return { x: minX, y: minY, w: maxX - minX, h: maxY - minY } } -export function importClipboardNodes( - nodeChanges: KiwiNodeChange[], - graph: SceneGraph, - targetParentId: string, - offsetX = 0, - offsetY = 0, - blobs: Uint8Array[] = [] -): string[] { +interface ClipboardImportMaps { + guidMap: Map + parentMap: Map +} + +function buildClipboardMaps(nodeChanges: KiwiNodeChange[]): ClipboardImportMaps { const guidMap = new Map() const parentMap = new Map() for (const nc of nodeChanges) { @@ -130,7 +131,13 @@ export function importClipboardNodes( parentMap.set(id, `${nc.parentIndex.guid.sessionID}:${nc.parentIndex.guid.localID}`) } } + return { guidMap, parentMap } +} +function findInternalNodeIds( + guidMap: Map, + parentMap: Map +): { internalCanvasIds: Set; internalFigmaIds: Set } { const internalCanvasIds = new Set() for (const [id, nc] of guidMap) { if (nc.type === 'CANVAS' && nc.internalOnly) { @@ -147,6 +154,14 @@ export function importClipboardNodes( } for (const canvasId of internalCanvasIds) markInternal(canvasId) + return { internalCanvasIds, internalFigmaIds } +} + +function classifyTopLevelNodes( + guidMap: Map, + parentMap: Map, + internalCanvasIds: Set +): { topLevel: string[]; internalTopLevel: string[] } { const topLevel: string[] = [] const internalTopLevel: string[] = [] for (const [id, nc] of guidMap) { @@ -164,6 +179,39 @@ export function importClipboardNodes( } } } + return { topLevel, internalTopLevel } +} + +function remapComponentIds(created: Map, graph: SceneGraph): void { + for (const [, ourId] of created) { + const node = graph.getNode(ourId) + if (node?.type !== 'INSTANCE' || !node.componentId) continue + const ourComponentId = created.get(node.componentId) + if (ourComponentId) graph.updateNode(ourId, { componentId: ourComponentId }) + } +} + +function detachOrphanedInstances(created: Map, graph: SceneGraph): void { + for (const [, ourId] of created) { + const node = graph.getNode(ourId) + if (node?.type !== 'INSTANCE') continue + if (node.childIds.length === 0 && (!node.componentId || !graph.getNode(node.componentId))) { + graph.updateNode(ourId, { type: 'FRAME', componentId: '' }) + } + } +} + +export function importClipboardNodes( + nodeChanges: KiwiNodeChange[], + graph: SceneGraph, + targetParentId: string, + offsetX = 0, + offsetY = 0, + blobs: Uint8Array[] = [] +): string[] { + const { guidMap, parentMap } = buildClipboardMaps(nodeChanges) + const { internalCanvasIds, internalFigmaIds } = findInternalNodeIds(guidMap, parentMap) + const { topLevel, internalTopLevel } = classifyTopLevelNodes(guidMap, parentMap, internalCanvasIds) const created = new Map() const createdIds: string[] = [] @@ -205,13 +253,7 @@ export function importClipboardNodes( createNode(id, targetParentId) } - // Remap componentId from original Figma GUIDs to our node IDs - for (const [, ourId] of created) { - const node = graph.getNode(ourId) - if (node?.type !== 'INSTANCE' || !node.componentId) continue - const ourComponentId = created.get(node.componentId) - if (ourComponentId) graph.updateNode(ourId, { componentId: ourComponentId }) - } + remapComponentIds(created, graph) populateAndApplyOverrides( graph, @@ -225,16 +267,7 @@ export function importClipboardNodes( if (ourId) graph.deleteNode(ourId) } - // Detach orphaned instances whose components weren't in the paste data. - // Without the component, children can't be populated — convert to FRAME - // so the node at least renders its own fills/strokes/layout. - for (const [, ourId] of created) { - const node = graph.getNode(ourId) - if (node?.type !== 'INSTANCE') continue - if (node.childIds.length === 0 && (!node.componentId || !graph.getNode(node.componentId))) { - graph.updateNode(ourId, { type: 'FRAME', componentId: '' }) - } - } + detachOrphanedInstances(created, graph) return createdIds } diff --git a/packages/core/src/kiwi-serialize.ts b/packages/core/src/kiwi-serialize.ts index be9cae26d..666527edf 100644 --- a/packages/core/src/kiwi-serialize.ts +++ b/packages/core/src/kiwi-serialize.ts @@ -2,7 +2,7 @@ export const FIG_KIWI_VERSION = 106 import { deflateSync, inflateSync } from 'fflate' -import { weightToStyle, getLoadedFontData, styleToWeight } from './fonts' +import { weightToStyle, getLoadedFontData } from './fonts' import { encodeVectorNetworkBlob } from './vector' import { stringToGuid, VARIABLE_BINDING_FIELDS } from './kiwi/kiwi-convert' @@ -22,7 +22,8 @@ async function computeFontDigest(data: ArrayBuffer): Promise { async function getFontDigest(family: string, style: string): Promise { const key = `${family}|${style}` - if (fontDigestCache.has(key)) return fontDigestCache.get(key)! + const cached = fontDigestCache.get(key) + if (cached) return cached const data = getLoadedFontData(family, style) if (!data) return null const digest = await computeFontDigest(data) @@ -202,7 +203,7 @@ function exportTextData(node: SceneNode): NodeChange['textData'] { return { characters: node.text, lines: textLines(node.text) } } - const charIds = new Array(node.text.length).fill(0) + const charIds = Array.from({ length: node.text.length }).fill(0) const styleMap = new Map() let nextId = 1 @@ -247,6 +248,140 @@ function exportTextData(node: SceneNode): NodeChange['textData'] { } } +function fillToKiwiPaint(f: SceneNode['fills'][number]): Paint { + const paint: Paint = { + type: f.type, + color: f.color, + opacity: f.opacity, + visible: f.visible, + blendMode: f.blendMode ?? 'NORMAL' + } + if (f.gradientStops) { + paint.stops = f.gradientStops.map((s) => ({ color: s.color, position: s.position })) + } + if (f.gradientTransform) paint.transform = f.gradientTransform + if (f.imageHash) paint.image = { hash: f.imageHash } + if (f.imageScaleMode) paint.imageScaleMode = f.imageScaleMode + if (f.imageTransform) paint.transform = f.imageTransform + return paint +} + +function serializeCornerRadii(node: SceneNode, nc: KiwiNodeChange): void { + if (node.cornerRadius > 0 || node.independentCorners) { + nc.cornerRadius = node.cornerRadius + nc.rectangleCornerRadiiIndependent = node.independentCorners + nc.rectangleTopLeftCornerRadius = node.independentCorners + ? node.topLeftRadius + : node.cornerRadius + nc.rectangleTopRightCornerRadius = node.independentCorners + ? node.topRightRadius + : node.cornerRadius + nc.rectangleBottomLeftCornerRadius = node.independentCorners + ? node.bottomLeftRadius + : node.cornerRadius + nc.rectangleBottomRightCornerRadius = node.independentCorners + ? node.bottomRightRadius + : node.cornerRadius + } + if (node.cornerSmoothing > 0) { + nc.cornerSmoothing = node.cornerSmoothing + } +} + +function serializeTextProps( + node: SceneNode, + nc: KiwiNodeChange, + fontDigestMap?: Map +): void { + nc.fontSize = node.fontSize + nc.fontName = { + family: node.fontFamily, + style: weightToStyle(node.fontWeight, node.italic), + postscript: '' + } + nc.textData = exportTextData(node) + nc.textAutoResize = 'WIDTH_AND_HEIGHT' + nc.textAlignHorizontal = node.textAlignHorizontal + nc.textUserLayoutVersion = 3 + if (fontDigestMap) nc.derivedTextData = buildDerivedTextData(node, fontDigestMap) + if (node.lineHeight != null) nc.lineHeight = { value: node.lineHeight, units: 'PIXELS' } + if (node.letterSpacing !== 0) nc.letterSpacing = { value: node.letterSpacing, units: 'PIXELS' } + if (node.textDecoration !== 'NONE') { + nc.textDecoration = node.textDecoration === 'UNDERLINE' ? 'UNDERLINE' : 'STRIKETHROUGH' + } +} + +function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange): void { + if (node.layoutMode !== 'NONE' && node.layoutMode !== 'GRID') { + nc.stackMode = node.layoutMode + nc.stackSpacing = node.itemSpacing + nc.stackVerticalPadding = node.paddingTop + nc.stackHorizontalPadding = node.paddingLeft + nc.stackPaddingBottom = node.paddingBottom + nc.stackPaddingRight = node.paddingRight + nc.stackPrimarySizing = node.primaryAxisSizing === 'HUG' ? 'RESIZE_TO_FIT' : 'FIXED' + nc.stackCounterSizing = node.counterAxisSizing === 'HUG' ? 'RESIZE_TO_FIT' : 'FIXED' + nc.stackPrimaryAlignItems = node.primaryAxisAlign + nc.stackCounterAlignItems = node.counterAxisAlign + if (node.layoutWrap === 'WRAP') nc.stackWrap = 'WRAP' + if (node.counterAxisSpacing > 0) nc.stackCounterSpacing = node.counterAxisSpacing + } + if (node.layoutPositioning === 'ABSOLUTE') nc.stackPositioning = 'ABSOLUTE' + if (node.layoutGrow > 0) nc.stackChildPrimaryGrow = node.layoutGrow +} + +function serializeGeometry(node: SceneNode, nc: KiwiNodeChange, blobs: Uint8Array[]): void { + if (node.vectorNetwork && node.type === 'VECTOR') { + const blobIdx = blobs.length + blobs.push(encodeVectorNetworkBlob(node.vectorNetwork)) + nc.vectorData = { + vectorNetworkBlob: blobIdx, + normalizedSize: { x: node.width, y: node.height } + } + } + if (node.fillGeometry.length > 0) { + nc.fillGeometry = node.fillGeometry.map((g) => { + const blobIdx = blobs.length + blobs.push(g.commandsBlob) + return { windingRule: g.windingRule, commandsBlob: blobIdx } + }) + } + if (node.strokeGeometry.length > 0) { + nc.strokeGeometry = node.strokeGeometry.map((g) => { + const blobIdx = blobs.length + blobs.push(g.commandsBlob) + return { windingRule: g.windingRule, commandsBlob: blobIdx } + }) + } +} + +function serializeVariableBindings( + node: SceneNode, + nc: KiwiNodeChange, + graph: SceneGraph +): void { + if (Object.keys(node.boundVariables).length === 0) return + const entries: VariableConsumptionEntry[] = [] + const typeMap: Record = { COLOR: 'COLOR', BOOLEAN: 'BOOLEAN', STRING: 'STRING' } + for (const [field, varId] of Object.entries(node.boundVariables)) { + const kiwiField = VARIABLE_BINDING_FIELDS[field] + if (!kiwiField) continue + const variable = graph.variables.get(varId) + if (!variable) continue + const varGuid = stringToGuid(varId) + const resolvedType = typeMap[variable.type] ?? 'FLOAT' + entries.push({ + variableData: { + value: { alias: { guid: varGuid } }, + dataType: 'ALIAS', + resolvedDataType: resolvedType + }, + variableField: kiwiField + }) + } + if (entries.length > 0) nc.variableConsumptionMap = { entries } +} + export function sceneNodeToKiwi( node: SceneNode, parentGuid: GUID, @@ -264,24 +399,7 @@ export function sceneNodeToKiwi( const cos = Math.cos((node.rotation * Math.PI) / 180) const sin = Math.sin((node.rotation * Math.PI) / 180) - const fillPaints = node.fills.map((f) => { - const paint: Paint = { - type: f.type, - color: f.color, - opacity: f.opacity, - visible: f.visible, - blendMode: f.blendMode ?? 'NORMAL' - } - if (f.gradientStops) { - paint.stops = f.gradientStops.map((s) => ({ color: s.color, position: s.position })) - } - if (f.gradientTransform) paint.transform = f.gradientTransform - if (f.imageHash) paint.image = { hash: f.imageHash } - if (f.imageScaleMode) paint.imageScaleMode = f.imageScaleMode - if (f.imageTransform) paint.transform = f.imageTransform - return paint - }) - + const fillPaints = node.fills.map(fillToKiwiPaint) const strokePaints = node.strokes.map((s) => ({ type: 'SOLID' as const, color: s.color, @@ -315,26 +433,7 @@ export function sceneNodeToKiwi( if (fillPaints.length > 0) nc.fillPaints = fillPaints if (strokePaints.length > 0) nc.strokePaints = strokePaints - if (node.cornerRadius > 0 || node.independentCorners) { - nc.cornerRadius = node.cornerRadius - nc.rectangleCornerRadiiIndependent = node.independentCorners - nc.rectangleTopLeftCornerRadius = node.independentCorners - ? node.topLeftRadius - : node.cornerRadius - nc.rectangleTopRightCornerRadius = node.independentCorners - ? node.topRightRadius - : node.cornerRadius - nc.rectangleBottomLeftCornerRadius = node.independentCorners - ? node.bottomLeftRadius - : node.cornerRadius - nc.rectangleBottomRightCornerRadius = node.independentCorners - ? node.bottomRightRadius - : node.cornerRadius - } - - if (node.cornerSmoothing > 0) { - nc.cornerSmoothing = node.cornerSmoothing - } + serializeCornerRadii(node, nc) if (node.effects.length > 0) { nc.effects = node.effects.map((e) => ({ @@ -347,93 +446,16 @@ export function sceneNodeToKiwi( })) } - if (node.type === 'TEXT') { - nc.fontSize = node.fontSize - nc.fontName = { - family: node.fontFamily, - style: weightToStyle(node.fontWeight, node.italic), - postscript: '' - } - nc.textData = exportTextData(node) - nc.textAutoResize = 'WIDTH_AND_HEIGHT' - nc.textAlignHorizontal = node.textAlignHorizontal - nc.textUserLayoutVersion = 3 - if (fontDigestMap) nc.derivedTextData = buildDerivedTextData(node, fontDigestMap) - if (node.lineHeight != null) nc.lineHeight = { value: node.lineHeight, units: 'PIXELS' } - if (node.letterSpacing !== 0) nc.letterSpacing = { value: node.letterSpacing, units: 'PIXELS' } - if (node.textDecoration !== 'NONE') { - nc.textDecoration = node.textDecoration === 'UNDERLINE' ? 'UNDERLINE' : 'STRIKETHROUGH' - } - } + if (node.type === 'TEXT') serializeTextProps(node, nc, fontDigestMap) if (node.type === 'FRAME' || node.type === 'GROUP') { nc.frameMaskDisabled = node.type === 'GROUP' if (node.clipsContent) nc.clipsContent = true } - if (node.layoutMode !== 'NONE' && node.layoutMode !== 'GRID') { - nc.stackMode = node.layoutMode - nc.stackSpacing = node.itemSpacing - nc.stackVerticalPadding = node.paddingTop - nc.stackHorizontalPadding = node.paddingLeft - nc.stackPaddingBottom = node.paddingBottom - nc.stackPaddingRight = node.paddingRight - nc.stackPrimarySizing = node.primaryAxisSizing === 'HUG' ? 'RESIZE_TO_FIT' : 'FIXED' - nc.stackCounterSizing = node.counterAxisSizing === 'HUG' ? 'RESIZE_TO_FIT' : 'FIXED' - nc.stackPrimaryAlignItems = node.primaryAxisAlign - nc.stackCounterAlignItems = node.counterAxisAlign - if (node.layoutWrap === 'WRAP') nc.stackWrap = 'WRAP' - if (node.counterAxisSpacing > 0) nc.stackCounterSpacing = node.counterAxisSpacing - } - - if (node.layoutPositioning === 'ABSOLUTE') nc.stackPositioning = 'ABSOLUTE' - if (node.layoutGrow > 0) nc.stackChildPrimaryGrow = node.layoutGrow - - if (node.vectorNetwork && node.type === 'VECTOR') { - const blobIdx = blobs.length - blobs.push(encodeVectorNetworkBlob(node.vectorNetwork)) - nc.vectorData = { - vectorNetworkBlob: blobIdx, - normalizedSize: { x: node.width, y: node.height } - } - } - - if (node.fillGeometry.length > 0) { - nc.fillGeometry = node.fillGeometry.map((g) => { - const blobIdx = blobs.length - blobs.push(g.commandsBlob) - return { windingRule: g.windingRule, commandsBlob: blobIdx } - }) - } - if (node.strokeGeometry.length > 0) { - nc.strokeGeometry = node.strokeGeometry.map((g) => { - const blobIdx = blobs.length - blobs.push(g.commandsBlob) - return { windingRule: g.windingRule, commandsBlob: blobIdx } - }) - } - - if (Object.keys(node.boundVariables).length > 0) { - const entries: VariableConsumptionEntry[] = [] - for (const [field, varId] of Object.entries(node.boundVariables)) { - const kiwiField = VARIABLE_BINDING_FIELDS[field] - if (!kiwiField) continue - const variable = graph.variables.get(varId) - if (!variable) continue - const varGuid = stringToGuid(varId) - const typeMap: Record = { COLOR: 'COLOR', BOOLEAN: 'BOOLEAN', STRING: 'STRING' } - const resolvedType = typeMap[variable.type] ?? 'FLOAT' - entries.push({ - variableData: { - value: { alias: { guid: varGuid } }, - dataType: 'ALIAS', - resolvedDataType: resolvedType - }, - variableField: kiwiField - }) - } - if (entries.length > 0) nc.variableConsumptionMap = { entries } - } + serializeLayoutProps(node, nc) + serializeGeometry(node, nc, blobs) + serializeVariableBindings(node, nc, graph) const result: KiwiNodeChange[] = [nc] const children = graph.getChildren(node.id) diff --git a/packages/core/src/kiwi/fig-import.ts b/packages/core/src/kiwi/fig-import.ts index ad09b9fb6..4b73e698a 100644 --- a/packages/core/src/kiwi/fig-import.ts +++ b/packages/core/src/kiwi/fig-import.ts @@ -10,26 +10,15 @@ import { import { populateAndApplyOverrides } from './instance-overrides' import type { InstanceNodeChange } from './instance-overrides' -import type { NodeChange } from './codec' +import type { NodeChange, VariableDataValuesEntry } from './codec' -export function importNodeChanges( - nodeChanges: NodeChange[], - blobs: Uint8Array[] = [], - images?: Map -): SceneGraph { - const graph = new SceneGraph() - - if (images) { - for (const [hash, data] of images) { - graph.images.set(hash, data) - } - } - - // Remove the default page created by constructor — we'll create pages from the file - for (const page of graph.getPages(true)) { - graph.deleteNode(page.id) - } +interface ChangeMaps { + changeMap: Map + parentMap: Map + childrenMap: Map +} +function buildChangeMaps(nodeChanges: NodeChange[]): ChangeMaps { const changeMap = new Map() const parentMap = new Map() const childrenMap = new Map() @@ -56,12 +45,211 @@ export function importNodeChanges( if (parentNc) sortChildren(children, parentNc, changeMap) } - function getChildren(ncId: string): string[] { - return childrenMap.get(ncId) ?? [] + return { changeMap, parentMap, childrenMap } +} + +function resolveVariableType(resolvedType: string | undefined): VariableType { + if (resolvedType === 'COLOR') return 'COLOR' + if (resolvedType === 'BOOLEAN') return 'BOOLEAN' + if (resolvedType === 'STRING') return 'STRING' + return 'FLOAT' +} + +function resolveVariableValue(entry: VariableDataValuesEntry): VariableValue | undefined { + const vd = entry.variableData + if (!vd.value) return undefined + + const dt = vd.dataType ?? vd.resolvedDataType + if (dt === 'COLOR' && vd.value.colorValue) { + const c = vd.value.colorValue + return { r: c.r, g: c.g, b: c.b, a: c.a } } + if (dt === 'BOOLEAN') return vd.value.boolValue ?? false + if (dt === 'STRING') return vd.value.textValue ?? '' + if (dt === 'ALIAS' && vd.value.alias?.guid) { + return { aliasId: guidToString(vd.value.alias.guid) } + } + return vd.value.floatValue ?? 0 +} + +function resolveDefaultValue(type: VariableType): VariableValue { + if (type === 'BOOLEAN') return false + if (type === 'STRING') return '' + if (type === 'COLOR') return { r: 0, g: 0, b: 0, a: 1 } + return 0 +} + +function importCollections( + changeMap: Map, + graph: SceneGraph +): void { + for (const [id, nc] of changeMap) { + if (nc.type !== 'VARIABLE_SET') continue + + const modes = (nc.variableSetModes ?? []).map((m) => { + const modeId = guidToString(m.id) + return { modeId, name: m.name } + }) + if (modes.length === 0) modes.push({ modeId: 'default', name: 'Default' }) + + graph.addCollection({ + id, + name: nc.name ?? 'Variables', + modes, + defaultModeId: modes[0].modeId, + variableIds: [] + }) + } +} + +function importVariableEntries( + changeMap: Map, + parentMap: Map, + graph: SceneGraph +): void { + for (const [id, nc] of changeMap) { + if (nc.type !== 'VARIABLE') continue + + const collectionId = nc.variableSetID?.guid ? guidToString(nc.variableSetID.guid) : (parentMap.get(id) ?? '') + + if (!graph.variableCollections.has(collectionId)) { + const parentNc = changeMap.get(collectionId) + graph.addCollection({ + id: collectionId, + name: parentNc?.name ?? 'Variables', + modes: [{ modeId: 'default', name: 'Default' }], + defaultModeId: 'default', + variableIds: [] + }) + } + + const type = resolveVariableType(nc.variableResolvedType) + const valuesByMode: Record = {} + + if (nc.variableDataValues?.entries) { + for (const entry of nc.variableDataValues.entries) { + const val = resolveVariableValue(entry) + if (val !== undefined) { + valuesByMode[guidToString(entry.modeID)] = val + } + } + } + + if (Object.keys(valuesByMode).length === 0) { + const col = graph.variableCollections.get(collectionId) + const defaultMode = col?.defaultModeId ?? 'default' + valuesByMode[defaultMode] = resolveDefaultValue(type) + } + + graph.addVariable({ + id, + name: nc.name ?? 'Variable', + type, + collectionId, + valuesByMode, + description: '', + hiddenFromPublishing: false + }) + } +} + +function importPages( + graph: SceneGraph, + changeMap: Map, + parentMap: Map, + childrenMap: Map, + created: Set, + createSceneNode: (ncId: string, graphParentId: string) => void +): void { + const getChildren = (ncId: string): string[] => childrenMap.get(ncId) ?? [] + + let docId: string | null = null + for (const [id, nc] of changeMap) { + if (nc.type === 'DOCUMENT' || id === '0:0') { + docId = id + break + } + } + + if (docId) { + for (const canvasId of getChildren(docId)) { + const canvasNc = changeMap.get(canvasId) + if (!canvasNc) continue + if (canvasNc.type === 'CANVAS') { + const page = graph.addPage(canvasNc.name ?? 'Page') + if (canvasNc.internalOnly) page.internalOnly = true + created.add(canvasId) + for (const childId of getChildren(canvasId)) { + createSceneNode(childId, page.id) + } + } else { + createSceneNode(canvasId, graph.getPages()[0]?.id ?? graph.rootId) + } + } + } else { + const roots: string[] = [] + for (const [id] of changeMap) { + const pid = parentMap.get(id) + if (!pid || !changeMap.has(pid)) roots.push(id) + } + const page = graph.getPages()[0] ?? graph.addPage('Page 1') + for (const rootId of roots) { + createSceneNode(rootId, page.id) + } + } +} + +function importVariableBindings( + changeMap: Map, + guidToNodeId: Map, + graph: SceneGraph +): void { + for (const [ncId, nc] of changeMap) { + if (!nc.variableConsumptionMap?.entries?.length) continue + const nodeId = guidToNodeId.get(ncId) + if (!nodeId) continue + for (const entry of nc.variableConsumptionMap.entries) { + const varGuid = entry.variableData?.value?.alias?.guid + if (!varGuid) continue + const field = VARIABLE_BINDING_FIELDS_INVERSE[entry.variableField ?? ''] + if (field) graph.bindVariable(nodeId, field, guidToString(varGuid)) + } + } +} + +function remapComponentIds( + graph: SceneGraph, + guidToNodeId: Map +): void { + for (const node of graph.getAllNodes()) { + if (node.type !== 'INSTANCE' || !node.componentId) continue + const remapped = guidToNodeId.get(node.componentId) + if (remapped) node.componentId = remapped + } +} + +export function importNodeChanges( + nodeChanges: NodeChange[], + blobs: Uint8Array[] = [], + images?: Map +): SceneGraph { + const graph = new SceneGraph() + + if (images) { + for (const [hash, data] of images) { + graph.images.set(hash, data) + } + } + + for (const page of graph.getPages(true)) { + graph.deleteNode(page.id) + } + + const { changeMap, parentMap, childrenMap } = buildChangeMaps(nodeChanges) const created = new Set() const guidToNodeId = new Map() + const getChildren = (ncId: string): string[] => childrenMap.get(ncId) ?? [] function createSceneNode(ncId: string, graphParentId: string) { if (created.has(ncId)) return @@ -81,153 +269,12 @@ export function importNodeChanges( } } - function importVariables() { - for (const [id, nc] of changeMap) { - if (nc.type !== 'VARIABLE_SET') continue + importPages(graph, changeMap, parentMap, childrenMap, created, createSceneNode) - const modes = (nc.variableSetModes ?? []).map((m) => { - const modeId = guidToString(m.id) - return { modeId, name: m.name } - }) - if (modes.length === 0) modes.push({ modeId: 'default', name: 'Default' }) - - graph.addCollection({ - id, - name: nc.name ?? 'Variables', - modes, - defaultModeId: modes[0].modeId, - variableIds: [] - }) - } - - for (const [id, nc] of changeMap) { - if (nc.type !== 'VARIABLE') continue - - const collectionId = nc.variableSetID?.guid ? guidToString(nc.variableSetID.guid) : (parentMap.get(id) ?? '') - - if (!graph.variableCollections.has(collectionId)) { - const parentNc = changeMap.get(collectionId) - graph.addCollection({ - id: collectionId, - name: parentNc?.name ?? 'Variables', - modes: [{ modeId: 'default', name: 'Default' }], - defaultModeId: 'default', - variableIds: [] - }) - } - - let type: VariableType = 'FLOAT' - const resolvedType = nc.variableResolvedType - if (resolvedType === 'COLOR') type = 'COLOR' - else if (resolvedType === 'BOOLEAN') type = 'BOOLEAN' - else if (resolvedType === 'STRING') type = 'STRING' - - const valuesByMode: Record = {} - - if (nc.variableDataValues?.entries) { - for (const entry of nc.variableDataValues.entries) { - const modeId = guidToString(entry.modeID) - const vd = entry.variableData - if (!vd.value) continue - - const dt = vd.dataType ?? vd.resolvedDataType - if (dt === 'COLOR' && vd.value.colorValue) { - const c = vd.value.colorValue - valuesByMode[modeId] = { r: c.r, g: c.g, b: c.b, a: c.a } - } else if (dt === 'BOOLEAN') { - valuesByMode[modeId] = vd.value.boolValue ?? false - } else if (dt === 'STRING') { - valuesByMode[modeId] = vd.value.textValue ?? '' - } else if (dt === 'ALIAS' && vd.value.alias?.guid) { - valuesByMode[modeId] = { aliasId: guidToString(vd.value.alias.guid) } - } else { - valuesByMode[modeId] = vd.value.floatValue ?? 0 - } - } - } - - if (Object.keys(valuesByMode).length === 0) { - const col = graph.variableCollections.get(collectionId) - const defaultMode = col?.defaultModeId ?? 'default' - const defaultValue = type === 'BOOLEAN' ? false : (type === 'STRING' ? '' : null) - valuesByMode[defaultMode] = defaultValue ?? (type === 'COLOR' ? { r: 0, g: 0, b: 0, a: 1 } : 0) - } - - graph.addVariable({ - id, - name: nc.name ?? 'Variable', - type, - collectionId, - valuesByMode, - description: '', - hiddenFromPublishing: false - }) - } - } - - function importVariableBindings() { - for (const [ncId, nc] of changeMap) { - if (!nc.variableConsumptionMap?.entries?.length) continue - - const nodeId = guidToNodeId.get(ncId) - if (!nodeId) continue - - for (const entry of nc.variableConsumptionMap.entries) { - const varGuid = entry.variableData?.value?.alias?.guid - if (!varGuid) continue - const field = VARIABLE_BINDING_FIELDS_INVERSE[entry.variableField ?? ''] - if (field) graph.bindVariable(nodeId, field, guidToString(varGuid)) - } - } - } - - // Find the document node (type=DOCUMENT or guid 0:0) - let docId: string | null = null - for (const [id, nc] of changeMap) { - if (nc.type === 'DOCUMENT' || id === '0:0') { - docId = id - break - } - } - - if (docId) { - // Import pages (CANVAS nodes) and their children - for (const canvasId of getChildren(docId)) { - const canvasNc = changeMap.get(canvasId) - if (!canvasNc) continue - if (canvasNc.type === 'CANVAS') { - const page = graph.addPage(canvasNc.name ?? 'Page') - if (canvasNc.internalOnly) page.internalOnly = true - created.add(canvasId) - for (const childId of getChildren(canvasId)) { - createSceneNode(childId, page.id) - } - } else { - createSceneNode(canvasId, graph.getPages()[0]?.id ?? graph.rootId) - } - } - } else { - // No document structure — treat all roots as children of the first page - const roots: string[] = [] - for (const [id] of changeMap) { - const pid = parentMap.get(id) - if (!pid || !changeMap.has(pid)) roots.push(id) - } - const page = graph.getPages()[0] ?? graph.addPage('Page 1') - for (const rootId of roots) { - createSceneNode(rootId, page.id) - } - } - - importVariables() - importVariableBindings() - - // Remap componentId from original Figma GUIDs to imported node IDs - for (const node of graph.getAllNodes()) { - if (node.type !== 'INSTANCE' || !node.componentId) continue - const remapped = guidToNodeId.get(node.componentId) - if (remapped) node.componentId = remapped - } + importCollections(changeMap, graph) + importVariableEntries(changeMap, parentMap, graph) + importVariableBindings(changeMap, guidToNodeId, graph) + remapComponentIds(graph, guidToNodeId) populateAndApplyOverrides( graph, @@ -236,7 +283,6 @@ export function importNodeChanges( blobs ) - // Ensure at least one page exists if (graph.getPages(true).length === 0) { graph.addPage('Page 1') } diff --git a/packages/core/src/kiwi/instance-overrides.ts b/packages/core/src/kiwi/instance-overrides.ts index 198c7ef6a..1596dac7a 100644 --- a/packages/core/src/kiwi/instance-overrides.ts +++ b/packages/core/src/kiwi/instance-overrides.ts @@ -256,61 +256,39 @@ export function populateAndApplyOverrides( componentIdRoot.clear() } - function applyComponentProperties() { - const propRefsMap = new Map() - for (const [figmaId, nc] of changeMap) { - if (nc.componentPropRefs?.length) { - propRefsMap.set(figmaId, nc.componentPropRefs) - } + function assignmentsToValueMap(assignments: ComponentPropAssignment[]): Map { + const valueByDef = new Map() + for (const a of assignments) { + valueByDef.set(guidToString(a.defID), a.value) } - if (propRefsMap.size === 0) return + return valueByDef + } - // Collect all assignment sources: figmaGuid → assignments[] - // Sources: top-level on instance nodes, and inside symbolOverrides - const assignmentSources = new Map() - for (const [figmaId, nc] of changeMap) { - if (nc.componentPropAssignments?.length) { - assignmentSources.set(figmaId, nc.componentPropAssignments) - } - } - - // Apply assignments from the instance's own kiwi data first. The graph - // node for a kiwi INSTANCE has componentPropAssignments that control - // which children are visible, swapped, etc. + function applyInstanceDirectAssignments( + assignmentSources: Map, + propRefsMap: Map + ) { for (const node of graph.getAllNodes()) { if (node.type !== 'INSTANCE') continue const ownFigmaId = nodeIdToGuid.get(node.id) if (ownFigmaId) { const ownAssignments = assignmentSources.get(ownFigmaId) if (ownAssignments) { - const valueByDef = new Map() - for (const a of ownAssignments) { - valueByDef.set(guidToString(a.defID), a.value) - } - applyPropAssignments(node.id, valueByDef, propRefsMap) + applyPropAssignments(node.id, assignmentsToValueMap(ownAssignments), propRefsMap) } } - // Also apply assignments from cloned instance sources. After - // population, cloned instances have componentId pointing to - // the original kiwi node. If that node had assignments, apply - // them to the clone (defaults for nested instances). if (!node.componentId) continue const sourceFigmaId = nodeIdToGuid.get(node.componentId) if (!sourceFigmaId) continue const assignments = assignmentSources.get(sourceFigmaId) if (!assignments) continue - const valueByDef = new Map() - for (const a of assignments) { - valueByDef.set(guidToString(a.defID), a.value) - } - applyPropAssignments(node.id, valueByDef, propRefsMap) + applyPropAssignments(node.id, assignmentsToValueMap(assignments), propRefsMap) } + } - // Apply assignments from symbolOverrides, scoped to the nested - // instance their guidPath resolves to. These override the defaults - // set above. + function applySymbolOverrideAssignments(propRefsMap: Map) { for (const [figmaId, nc] of changeMap) { const instanceNodeId = guidToNodeId.get(figmaId) if (!instanceNodeId) continue @@ -327,15 +305,31 @@ export function populateAndApplyOverrides( const targetId = resolveOverrideTarget(instanceNodeId, guids) if (!targetId) continue - const valueByDef = new Map() - for (const a of ov.componentPropAssignments) { - valueByDef.set(guidToString(a.defID), a.value) - } - applyPropAssignments(targetId, valueByDef, propRefsMap) + applyPropAssignments(targetId, assignmentsToValueMap(ov.componentPropAssignments), propRefsMap) } } } + function applyComponentProperties() { + const propRefsMap = new Map() + for (const [figmaId, nc] of changeMap) { + if (nc.componentPropRefs?.length) { + propRefsMap.set(figmaId, nc.componentPropRefs) + } + } + if (propRefsMap.size === 0) return + + const assignmentSources = new Map() + for (const [figmaId, nc] of changeMap) { + if (nc.componentPropAssignments?.length) { + assignmentSources.set(figmaId, nc.componentPropAssignments) + } + } + + applyInstanceDirectAssignments(assignmentSources, propRefsMap) + applySymbolOverrideAssignments(propRefsMap) + } + function applyPropAssignments( parentId: string, valueByDef: Map, @@ -401,7 +395,27 @@ export function populateAndApplyOverrides( }) } - function applyDerivedSymbolData() { + function resolveDsdGeometry( + d: DerivedSymbolOverride, + target: SceneNode + ): Pick, 'fillGeometry' | 'strokeGeometry'> { + const result: Pick, 'fillGeometry' | 'strokeGeometry'> = {} + const fg = resolveGeometryPaths(d.fillGeometry, blobs) + const sg = resolveGeometryPaths(d.strokeGeometry, blobs) + if (fg.length > 0) { + result.fillGeometry = fg + } else if (d.size && target.fillGeometry.length > 0 && target.width > 0 && target.height > 0) { + result.fillGeometry = scaleGeometryBlobs(target.fillGeometry, d.size.x / target.width, d.size.y / target.height) + } + if (sg.length > 0) { + result.strokeGeometry = sg + } else if (d.size && target.strokeGeometry.length > 0 && target.width > 0 && target.height > 0) { + result.strokeGeometry = scaleGeometryBlobs(target.strokeGeometry, d.size.x / target.width, d.size.y / target.height) + } + return result + } + + function resolveDsdUpdates(): { dsdModified: Set; dsdSizeSet: Set } { const dsdModified = new Set() const dsdSizeSet = new Set() @@ -432,18 +446,7 @@ export function populateAndApplyOverrides( updates.x = d.transform.m02 updates.y = d.transform.m12 } - const fg = resolveGeometryPaths(d.fillGeometry, blobs) - const sg = resolveGeometryPaths(d.strokeGeometry, blobs) - if (fg.length > 0) { - updates.fillGeometry = fg - } else if (d.size && target.fillGeometry.length > 0 && target.width > 0 && target.height > 0) { - updates.fillGeometry = scaleGeometryBlobs(target.fillGeometry, d.size.x / target.width, d.size.y / target.height) - } - if (sg.length > 0) { - updates.strokeGeometry = sg - } else if (d.size && target.strokeGeometry.length > 0 && target.width > 0 && target.height > 0) { - updates.strokeGeometry = scaleGeometryBlobs(target.strokeGeometry, d.size.x / target.width, d.size.y / target.height) - } + Object.assign(updates, resolveDsdGeometry(d, target)) if (Object.keys(updates).length > 0) { graph.updateNode(targetId, updates) @@ -453,60 +456,46 @@ export function populateAndApplyOverrides( } } - // Propagate DSD changes through clone chains. Each clone should match - // its source (componentId) for size/position/geometry. Iterate until - // convergence so deeper clone levels receive updates even when - // intermediate clones were also directly DSD-targeted (e.g., a DSD - // entry set only geometry on an intermediate clone — its size must - // still be inherited from the source). - // - // Nodes whose size was explicitly set by DSD (in dsdSizeSet) keep - // their own values; nodes only touched for position/geometry inherit - // size from their source. - if (dsdModified.size > 0) { - const clonesOf = new Map() - for (const node of graph.getAllNodes()) { - if (!node.componentId) continue - let arr = clonesOf.get(node.componentId) - if (!arr) { - arr = [] - clonesOf.set(node.componentId, arr) - } - arr.push(node.id) - } + return { dsdModified, dsdSizeSet } + } - // BFS from DSD-modified nodes. Unlike the old version, intermediate - // clones that are also in dsdModified are NOT skipped — they act as - // chain links. Nodes in dsdSizeSet keep their explicit size but still - // propagate to their clones. - const queue = [...dsdModified] - const visited = new Set() - for (let sourceId = queue.shift(); sourceId !== undefined; sourceId = queue.shift()) { - const source = graph.getNode(sourceId) - if (!source) continue - const clones = clonesOf.get(sourceId) - if (!clones) continue - for (const cloneId of clones) { - if (visited.has(cloneId)) continue - visited.add(cloneId) - const clone = graph.getNode(cloneId) - if (!clone) continue - if (!dsdSizeSet.has(cloneId)) { - const cu: Partial = {} - if (source.width !== clone.width) cu.width = source.width - if (source.height !== clone.height) cu.height = source.height - if (source.x !== clone.x) cu.x = source.x - if (source.y !== clone.y) cu.y = source.y - if (source.fillGeometry !== clone.fillGeometry) cu.fillGeometry = copyGeometryPaths(source.fillGeometry) - if (source.strokeGeometry !== clone.strokeGeometry) cu.strokeGeometry = copyGeometryPaths(source.strokeGeometry) - if (Object.keys(cu).length > 0) graph.updateNode(cloneId, cu) - } - queue.push(cloneId) + function propagateDsdChanges(dsdModified: Set, dsdSizeSet: Set) { + if (dsdModified.size === 0) return + + const clonesOf = buildClonesMap() + + const queue = [...dsdModified] + const visited = new Set() + for (let sourceId = queue.shift(); sourceId !== undefined; sourceId = queue.shift()) { + const source = graph.getNode(sourceId) + if (!source) continue + const clones = clonesOf.get(sourceId) + if (!clones) continue + for (const cloneId of clones) { + if (visited.has(cloneId)) continue + visited.add(cloneId) + const clone = graph.getNode(cloneId) + if (!clone) continue + if (!dsdSizeSet.has(cloneId)) { + const cu: Partial = {} + if (source.width !== clone.width) cu.width = source.width + if (source.height !== clone.height) cu.height = source.height + if (source.x !== clone.x) cu.x = source.x + if (source.y !== clone.y) cu.y = source.y + if (source.fillGeometry !== clone.fillGeometry) cu.fillGeometry = copyGeometryPaths(source.fillGeometry) + if (source.strokeGeometry !== clone.strokeGeometry) cu.strokeGeometry = copyGeometryPaths(source.strokeGeometry) + if (Object.keys(cu).length > 0) graph.updateNode(cloneId, cu) } + queue.push(cloneId) } } } + function applyDerivedSymbolData() { + const { dsdModified, dsdSizeSet } = resolveDsdUpdates() + propagateDsdChanges(dsdModified, dsdSizeSet) + } + function applySymbolOverrides(): Set { const overriddenNodes = new Set() componentIdRoot.clear() @@ -575,9 +564,7 @@ export function populateAndApplyOverrides( } } - function propagateOverridesTransitively(seeds: Set) { - if (seeds.size === 0) return - + function buildClonesMap(): Map { const clonesOf = new Map() for (const node of graph.getAllNodes()) { if (!node.componentId) continue @@ -588,11 +575,10 @@ export function populateAndApplyOverrides( } arr.push(node.id) } + return clonesOf + } - // Also seed parent INSTANCE nodes of overridden children so their - // clones are visited by the BFS — deep overrides (e.g., stroke color - // on a Vector nested inside a check inside an _icon-xs) need the - // instance-level clone to be visited for syncChildrenDeep to propagate. + function expandSeedsToParents(seeds: Set): Set { const expandedSeeds = new Set(seeds) for (const seedId of seeds) { let cur = graph.getNode(seedId) @@ -605,7 +591,10 @@ export function populateAndApplyOverrides( cur = parent } } + return expandedSeeds + } + function buildNeedsSyncSet(expandedSeeds: Set, clonesOf: Map): Set { const needsSync = new Set() const queue = [...expandedSeeds] for (let id = queue.pop(); id !== undefined; id = queue.pop()) { @@ -617,6 +606,28 @@ export function populateAndApplyOverrides( queue.push(cloneId) } } + return needsSync + } + + function syncCloneFromSource(sourceId: string, source: SceneNode, node: SceneNode, seeds: Set) { + syncNodeProps(source, node) + if (source.childIds.length !== node.childIds.length) { + const childIds = [...node.childIds] + for (const childId of childIds) graph.deleteNode(childId) + if (source.childIds.length > 0) { + graph.populateInstanceChildren(node.id, sourceId) + } + } else if (source.childIds.length > 0 && node.childIds.length > 0) { + syncChildrenDeep(sourceId, node.id, seeds) + } + } + + function propagateOverridesTransitively(seeds: Set) { + if (seeds.size === 0) return + + const clonesOf = buildClonesMap() + const expandedSeeds = expandSeedsToParents(seeds) + const needsSync = buildNeedsSyncSet(expandedSeeds, clonesOf) const visited = new Set() const syncQueue = [...expandedSeeds] @@ -632,25 +643,12 @@ export function populateAndApplyOverrides( const node = graph.getNode(cloneId) if (!node) continue - // Don't overwrite nodes that were directly targeted by symbolOverrides if (seeds.has(cloneId)) { syncQueue.push(cloneId) continue } - syncNodeProps(source, node) - // For structural changes (instance swaps change child count/type), - // re-clone from the SOURCE (not the component) to preserve the - // componentId chain for DSD propagation. - if (source.childIds.length !== node.childIds.length) { - for (const childId of [...node.childIds]) graph.deleteNode(childId) - if (source.childIds.length > 0) { - graph.populateInstanceChildren(node.id, sourceId) - } - } else if (source.childIds.length > 0 && node.childIds.length > 0) { - syncChildrenDeep(sourceId, cloneId, seeds) - } - + syncCloneFromSource(sourceId, source, node, seeds) syncQueue.push(cloneId) } } diff --git a/packages/core/src/kiwi/kiwi-convert.ts b/packages/core/src/kiwi/kiwi-convert.ts index 9ffa0cd5b..b342be0b0 100644 --- a/packages/core/src/kiwi/kiwi-convert.ts +++ b/packages/core/src/kiwi/kiwi-convert.ts @@ -163,53 +163,33 @@ function convertEffects(effects?: KiwiEffect[]): Effect[] { })) } +const NODE_TYPE_MAP: Record = { + DOCUMENT: 'DOCUMENT', + VARIABLE: 'VARIABLE', + CANVAS: 'CANVAS', + FRAME: 'FRAME', + RECTANGLE: 'RECTANGLE', + ROUNDED_RECTANGLE: 'ROUNDED_RECTANGLE', + ELLIPSE: 'ELLIPSE', + TEXT: 'TEXT', + LINE: 'LINE', + STAR: 'STAR', + REGULAR_POLYGON: 'POLYGON', + VECTOR: 'VECTOR', + BOOLEAN_OPERATION: 'VECTOR', + GROUP: 'GROUP', + SECTION: 'SECTION', + COMPONENT: 'COMPONENT', + COMPONENT_SET: 'COMPONENT_SET', + INSTANCE: 'INSTANCE', + SYMBOL: 'COMPONENT', + CONNECTOR: 'CONNECTOR', + SHAPE_WITH_TEXT: 'SHAPE_WITH_TEXT' +} + 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' - } + if (type) return NODE_TYPE_MAP[type] ?? 'RECTANGLE' + return 'RECTANGLE' } function mapStackMode(mode?: string): LayoutMode { @@ -320,39 +300,47 @@ function mapArcData(data?: Partial): ArcData | null { } } -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 [] +function convertStyleOverride( + override: NodeChange, + fallbackFontSize: number | undefined +): CharacterStyleOverride { + 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') + } + if (override.fontSize !== undefined) style.fontSize = override.fontSize + if (override.letterSpacing) { + style.letterSpacing = convertLetterSpacing(override.letterSpacing, override.fontSize ?? fallbackFontSize) + } + if (override.lineHeight) { + const lh = convertLineHeight(override.lineHeight, override.fontSize ?? fallbackFontSize) + if (lh != null) style.lineHeight = lh + } + const deco = override.textDecoration + if (deco) style.textDecoration = mapTextDecoration(deco) + return style +} +function buildStyleMap( + table: NodeChange[], + fallbackFontSize: number | undefined +): Map { const styleMap = new Map() for (const override of table) { const id = override.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') - } - if (override.fontSize !== undefined) style.fontSize = override.fontSize - if (override.letterSpacing) { - style.letterSpacing = convertLetterSpacing(override.letterSpacing, override.fontSize ?? nc.fontSize) - } - if (override.lineHeight) { - const lh = convertLineHeight(override.lineHeight, override.fontSize ?? nc.fontSize) - if (lh != null) style.lineHeight = lh - } - const deco = override.textDecoration - if (deco) style.textDecoration = mapTextDecoration(deco) + const style = convertStyleOverride(override, fallbackFontSize) if (Object.keys(style).length > 0) styleMap.set(id, style) } + return styleMap +} - if (styleMap.size === 0) return [] - +function collectStyleRuns( + ids: number[], + styleMap: Map +): StyleRun[] { const runs: StyleRun[] = [] let currentId = ids[0] let start = 0 @@ -369,10 +357,22 @@ function importStyleRuns(nc: NodeChange): StyleRun[] { } } } - return runs } +function importStyleRuns(nc: NodeChange): StyleRun[] { + const td = nc.textData + if (!td?.characterStyleIDs || !td.styleOverrideTable) return [] + + const ids = td.characterStyleIDs + if (ids.length === 0 || td.styleOverrideTable.length === 0) return [] + + const styleMap = buildStyleMap(td.styleOverrideTable, nc.fontSize) + if (styleMap.size === 0) return [] + + return collectStyleRuns(ids, styleMap) +} + function resolveVectorNetwork( nc: NodeChange, blobs: Uint8Array[] @@ -453,13 +453,7 @@ function extractBoundVariables(nc: NodeChange): Record { return bindings } -export function nodeChangeToProps( - nc: NodeChange, - blobs: Uint8Array[] -): Partial & { nodeType: NodeType | 'DOCUMENT' | 'VARIABLE' } { - let nodeType = mapNodeType(nc.type) - if (nodeType === 'FRAME' && isComponentSet(nc)) nodeType = 'COMPONENT_SET' - +function convertTransformProps(nc: NodeChange): Pick { const x = nc.transform?.m02 ?? 0 const y = nc.transform?.m12 ?? 0 const width = nc.size?.x ?? 100 @@ -467,7 +461,6 @@ export function nodeChangeToProps( 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 @@ -475,46 +468,29 @@ export function nodeChangeToProps( rotation = Math.atan2(nc.transform.m10 * sx, nc.transform.m00 * sx) * (180 / Math.PI) } - const dashPattern = nc.dashPattern ?? [] + return { x, y, width, height, rotation, flipX, flipY: false } +} +function convertCornerProps(nc: NodeChange): Pick { 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: (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, + cornerSmoothing: nc.cornerSmoothing ?? 0 + } +} + +function convertTextProps(nc: NodeChange): Pick { + return { 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 ?? 'LEFT') as 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED', + textAlignHorizontal: (nc.textAlignHorizontal ?? 'LEFT') as 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED', textAlignVertical: (nc.textAlignVertical ?? 'TOP') as TextAlignVertical, textAutoResize: (nc.textAutoResize ?? 'NONE') as TextAutoResize, textCase: (nc.textCase ?? 'ORIGINAL') as TextCase, @@ -523,14 +499,24 @@ export function nodeChangeToProps( letterSpacing: convertLetterSpacing(nc.letterSpacing, nc.fontSize), maxLines: (nc.maxLines ?? null) as number | null, styleRuns: importStyleRuns(nc), - horizontalConstraint: mapConstraint(nc.horizontalConstraint as string), - verticalConstraint: mapConstraint(nc.verticalConstraint as string), - layoutMode: mapStackMode(nc.stackMode), - itemSpacing: nc.stackSpacing ?? 0, + textTruncation: (nc.textTruncation as string) === 'ENDING' ? 'ENDING' : 'DISABLED' + } +} + +function convertLayoutPadding(nc: NodeChange): Pick { + return { 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, + paddingRight: nc.stackPaddingRight ?? nc.stackHorizontalPadding ?? nc.stackPadding ?? 0 + } +} + +function convertLayoutProps(nc: NodeChange): Pick { + return { + layoutMode: mapStackMode(nc.stackMode), + itemSpacing: nc.stackSpacing ?? 0, + ...convertLayoutPadding(nc), primaryAxisSizing: mapStackSizing(nc.stackPrimarySizing), counterAxisSizing: mapStackSizing(nc.stackCounterSizing), primaryAxisAlign: mapStackJustify(nc.stackPrimaryAlignItems ?? nc.stackJustify), @@ -540,31 +526,63 @@ export function nodeChangeToProps( layoutPositioning: nc.stackPositioning === 'ABSOLUTE' ? 'ABSOLUTE' : 'AUTO', layoutGrow: nc.stackChildPrimaryGrow ?? 0, layoutAlignSelf: (nc.stackChildAlignSelf as string) === 'STRETCH' ? 'STRETCH' : 'AUTO', + counterAxisAlignContent: (nc.stackCounterAlignContent as string) === 'SPACE_BETWEEN' ? 'SPACE_BETWEEN' : 'AUTO', + itemReverseZIndex: (nc.stackReverseZIndex ?? false) as boolean, + strokesIncludedInLayout: (nc.strokesIncludedInLayout ?? false) as boolean + } +} + +function convertVectorAndStrokeProps(nc: NodeChange, blobs: Uint8Array[]): Pick { + return { vectorNetwork: resolveVectorNetwork(nc, blobs), fillGeometry: resolveGeometryPaths(nc.fillGeometry, blobs), strokeGeometry: resolveGeometryPaths(nc.strokeGeometry, blobs), arcData: mapArcData(nc.arcData as Partial | undefined), strokeCap: (nc.strokeCap ?? 'NONE') as StrokeCap, strokeJoin: (nc.strokeJoin ?? 'MITER') as StrokeJoin, - dashPattern, + dashPattern: nc.dashPattern ?? [], borderTopWeight: (nc.borderTopWeight ?? 0) as number, borderRightWeight: (nc.borderRightWeight ?? 0) as number, borderBottomWeight: (nc.borderBottomWeight ?? 0) as number, borderLeftWeight: (nc.borderLeftWeight ?? 0) as number, independentStrokeWeights: (nc.borderStrokeWeightsIndependent ?? false) as boolean, - strokeMiterLimit: DEFAULT_STROKE_MITER_LIMIT, + strokeMiterLimit: DEFAULT_STROKE_MITER_LIMIT + } +} + +export function nodeChangeToProps( + nc: NodeChange, + blobs: Uint8Array[] +): Partial & { nodeType: NodeType | 'DOCUMENT' | 'VARIABLE' } { + let nodeType = mapNodeType(nc.type) + if (nodeType === 'FRAME' && isComponentSet(nc)) nodeType = 'COMPONENT_SET' + + const dashPattern = nc.dashPattern ?? [] + + return { + nodeType, + name: nc.name ?? nodeType, + ...convertTransformProps(nc), + opacity: nc.opacity ?? 1, + visible: nc.visible ?? true, + locked: nc.locked ?? false, + blendMode: (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), + ...convertCornerProps(nc), + ...convertTextProps(nc), + horizontalConstraint: mapConstraint(nc.horizontalConstraint as string), + verticalConstraint: mapConstraint(nc.verticalConstraint as string), + ...convertLayoutProps(nc), + ...convertVectorAndStrokeProps(nc, blobs), minWidth: (nc.minWidth ?? null) as number | null, maxWidth: (nc.maxWidth ?? null) as number | null, minHeight: (nc.minHeight ?? null) as number | null, maxHeight: (nc.maxHeight ?? null) as number | null, isMask: (nc.isMask ?? false) as boolean, maskType: (nc.maskType ?? 'ALPHA') as 'ALPHA' | 'VECTOR' | 'LUMINANCE', - counterAxisAlignContent: - (nc.stackCounterAlignContent as string) === 'SPACE_BETWEEN' ? 'SPACE_BETWEEN' : 'AUTO', - itemReverseZIndex: (nc.stackReverseZIndex ?? false) as boolean, - strokesIncludedInLayout: (nc.strokesIncludedInLayout ?? false) as boolean, expanded: true, - textTruncation: (nc.textTruncation as string) === 'ENDING' ? 'ENDING' : 'DISABLED', autoRename: (nc.autoRename ?? true) as boolean, boundVariables: extractBoundVariables(nc), clipsContent: nc.frameMaskDisabled === false, @@ -608,9 +626,7 @@ function extractSymbolId(nc: NodeChange): string { return guidToString(sd.symbolID) } -export function convertOverrideToProps(ov: Record): Partial { - const updates: Partial = {} - +function applyOverridePaints(ov: Record, updates: Partial): void { if (ov.textData != null) { const td = ov.textData as { characters?: string } if (td.characters != null) updates.text = td.characters @@ -629,13 +645,14 @@ export function convertOverrideToProps(ov: Record): Partial, updates: Partial): void { if (ov.size != null) { const sz = ov.size as { x?: number; y?: number } if (sz.x != null) updates.width = sz.x if (sz.y != null) updates.height = sz.y } - if (ov.cornerRadius != null) updates.cornerRadius = ov.cornerRadius as number if (ov.rectangleTopLeftCornerRadius != null) updates.topLeftRadius = ov.rectangleTopLeftCornerRadius as number @@ -647,7 +664,12 @@ export function convertOverrideToProps(ov: Record): Partial | undefined) + if (ov.frameMaskDisabled != null) updates.clipsContent = ov.frameMaskDisabled === false +} +function applyOverrideLayout(ov: Record, updates: Partial): void { if (ov.stackSpacing != null) updates.itemSpacing = ov.stackSpacing as number if (ov.stackPrimarySizing != null) updates.primaryAxisSizing = mapStackSizing(ov.stackPrimarySizing as string) @@ -674,7 +696,9 @@ export function convertOverrideToProps(ov: Record): Partial, updates: Partial): void { if (ov.strokeWeight != null && !ov.strokePaints) { updates.strokes = updates.strokes ?? [] } @@ -689,7 +713,9 @@ export function convertOverrideToProps(ov: Record): Partial, updates: Partial): void { if (ov.fontName != null) { const fn = ov.fontName as { family?: string; style?: string } if (fn.family) updates.fontFamily = fn.family @@ -718,10 +744,14 @@ export function convertOverrideToProps(ov: Record): Partial | undefined) - if (ov.frameMaskDisabled != null) updates.clipsContent = ov.frameMaskDisabled === false - +export function convertOverrideToProps(ov: Record): Partial { + const updates: Partial = {} + applyOverridePaints(ov, updates) + applyOverrideGeometry(ov, updates) + applyOverrideLayout(ov, updates) + applyOverrideStrokes(ov, updates) + applyOverrideText(ov, updates) return updates } diff --git a/packages/core/src/render/export-jsx.ts b/packages/core/src/render/export-jsx.ts index 048d694b6..8335b664e 100644 --- a/packages/core/src/render/export-jsx.ts +++ b/packages/core/src/render/export-jsx.ts @@ -182,96 +182,97 @@ function formatTracks(tracks: GridTrack[]): string { return tracks.map(formatTrack).join(' ') } -// --- OpenPencil format --- +// --- OpenPencil format helpers --- -function collectProps(node: SceneNode, graph: SceneGraph): [string, unknown][] { - const props: [string, unknown][] = [] - const { isAutoLayout, isGrid, isFlex, parentIsAutoLayout, parentIsGrid } = getNodeContext( - node, - graph - ) +function collectGridSizingProps(node: SceneNode, props: [string, unknown][]): void { + props.push(['grid', true]) + if (node.gridTemplateColumns.length > 0) + props.push(['columns', formatTracks(node.gridTemplateColumns)]) + if (node.gridTemplateRows.length > 0) + props.push(['rows', formatTracks(node.gridTemplateRows)]) + if (node.width > 0) props.push(['w', node.width]) + if (node.height > 0) props.push(['h', node.height]) + if (node.gridColumnGap > 0) props.push(['columnGap', node.gridColumnGap]) + if (node.gridRowGap > 0) props.push(['rowGap', node.gridRowGap]) +} - if (node.name && node.name !== node.type) { - props.push(['name', node.name]) - } +function collectFlexSizingProps(node: SceneNode, props: [string, unknown][]): void { + props.push(['flex', node.layoutMode === 'HORIZONTAL' ? 'row' : 'col']) + const primaryAxis = node.layoutMode === 'HORIZONTAL' ? 'width' : 'height' + const crossAxis = node.layoutMode === 'HORIZONTAL' ? 'height' : 'width' - if (isGrid) { - props.push(['grid', true]) - if (node.gridTemplateColumns.length > 0) - props.push(['columns', formatTracks(node.gridTemplateColumns)]) - if (node.gridTemplateRows.length > 0) - props.push(['rows', formatTracks(node.gridTemplateRows)]) - if (node.width > 0) props.push(['w', node.width]) - if (node.height > 0) props.push(['h', node.height]) - if (node.gridColumnGap > 0) props.push(['columnGap', node.gridColumnGap]) - if (node.gridRowGap > 0) props.push(['rowGap', node.gridRowGap]) - } else if (isFlex) { - props.push(['flex', node.layoutMode === 'HORIZONTAL' ? 'row' : 'col']) - const primaryAxis = node.layoutMode === 'HORIZONTAL' ? 'width' : 'height' - const crossAxis = node.layoutMode === 'HORIZONTAL' ? 'height' : 'width' + if (node.primaryAxisSizing === 'FILL') + props.push([primaryAxis === 'width' ? 'w' : 'h', 'fill']) + else if (node.primaryAxisSizing !== 'HUG') + props.push([primaryAxis === 'width' ? 'w' : 'h', node[primaryAxis]]) - if (node.primaryAxisSizing === 'FILL') - props.push([primaryAxis === 'width' ? 'w' : 'h', 'fill']) - else if (node.primaryAxisSizing !== 'HUG') - props.push([primaryAxis === 'width' ? 'w' : 'h', node[primaryAxis]]) + if (node.counterAxisSizing === 'FILL') + props.push([crossAxis === 'width' ? 'w' : 'h', 'fill']) + else if (node.counterAxisSizing !== 'HUG') + props.push([crossAxis === 'width' ? 'w' : 'h', node[crossAxis]]) +} - if (node.counterAxisSizing === 'FILL') - props.push([crossAxis === 'width' ? 'w' : 'h', 'fill']) - else if (node.counterAxisSizing !== 'HUG') - props.push([crossAxis === 'width' ? 'w' : 'h', node[crossAxis]]) - } else { - if (node.width > 0) props.push(['w', node.width]) - if (node.height > 0) props.push(['h', node.height]) - } +function collectGridPositionProps(node: SceneNode, props: [string, unknown][]): void { + if (!node.gridPosition) return + const pos = node.gridPosition + if (pos.column > 0) props.push(['colStart', pos.column]) + if (pos.row > 0) props.push(['rowStart', pos.row]) + if (pos.columnSpan > 1) props.push(['colSpan', pos.columnSpan]) + if (pos.rowSpan > 1) props.push(['rowSpan', pos.rowSpan]) +} - if (parentIsAutoLayout && node.layoutGrow > 0) props.push(['grow', node.layoutGrow]) +function collectFlexAlignmentProps(node: SceneNode, props: [string, unknown][]): void { + if (node.itemSpacing > 0) props.push(['gap', node.itemSpacing]) - if (parentIsGrid && node.gridPosition) { - const pos = node.gridPosition - if (pos.column > 0) props.push(['colStart', pos.column]) - if (pos.row > 0) props.push(['rowStart', pos.row]) - if (pos.columnSpan > 1) props.push(['colSpan', pos.columnSpan]) - if (pos.rowSpan > 1) props.push(['rowSpan', pos.rowSpan]) - } - - if (isFlex && node.itemSpacing > 0) props.push(['gap', node.itemSpacing]) - - if (isFlex && node.layoutWrap === 'WRAP') { + if (node.layoutWrap === 'WRAP') { props.push(['wrap', true]) if (node.counterAxisSpacing > 0) props.push(['rowGap', node.counterAxisSpacing]) } - if (isFlex) { - if (node.primaryAxisAlign === 'CENTER') props.push(['justify', 'center']) - else if (node.primaryAxisAlign === 'MAX') props.push(['justify', 'end']) - else if (node.primaryAxisAlign === 'SPACE_BETWEEN') props.push(['justify', 'between']) + if (node.primaryAxisAlign === 'CENTER') props.push(['justify', 'center']) + else if (node.primaryAxisAlign === 'MAX') props.push(['justify', 'end']) + else if (node.primaryAxisAlign === 'SPACE_BETWEEN') props.push(['justify', 'between']) - if (node.counterAxisAlign === 'CENTER') props.push(['items', 'center']) - else if (node.counterAxisAlign === 'MAX') props.push(['items', 'end']) - else if (node.counterAxisAlign === 'STRETCH') props.push(['items', 'stretch']) - } - - if (isAutoLayout) { - const pad = collectPadding(node) - if (pad) { - props.push( - ...emitPadding( - pad, - (v) => ['p', v] as [string, unknown], - (y, x) => [['py', y], ['px', x]] as [string, unknown][], - ({ pt, pr, pb, pl }) => { - const r: [string, unknown][] = [] - if (pt > 0) r.push(['pt', pt]) - if (pr > 0) r.push(['pr', pr]) - if (pb > 0) r.push(['pb', pb]) - if (pl > 0) r.push(['pl', pl]) - return r - } - ) - ) - } + if (node.counterAxisAlign === 'CENTER') props.push(['items', 'center']) + else if (node.counterAxisAlign === 'MAX') props.push(['items', 'end']) + else if (node.counterAxisAlign === 'STRETCH') props.push(['items', 'stretch']) +} + +function collectAutoLayoutPaddingProps(node: SceneNode, props: [string, unknown][]): void { + const pad = collectPadding(node) + if (!pad) return + props.push( + ...emitPadding( + pad, + (v) => ['p', v] as [string, unknown], + (y, x) => [['py', y], ['px', x]] as [string, unknown][], + ({ pt, pr, pb, pl }) => { + const r: [string, unknown][] = [] + if (pt > 0) r.push(['pt', pt]) + if (pr > 0) r.push(['pr', pr]) + if (pb > 0) r.push(['pb', pb]) + if (pl > 0) r.push(['pl', pl]) + return r + } + ) + ) +} + +function collectCornerRadiiProps(node: SceneNode, props: [string, unknown][]): void { + const corners = collectCornerRadii(node) + if (!corners) return + const { tl, tr, br, bl } = corners + if (tl === tr && tr === br && br === bl) { + props.push(['rounded', tl]) + } else { + if (tl > 0) props.push(['roundedTL', tl]) + if (tr > 0) props.push(['roundedTR', tr]) + if (br > 0) props.push(['roundedBR', br]) + if (bl > 0) props.push(['roundedBL', bl]) } +} +function collectAppearanceProps(node: SceneNode, props: [string, unknown][]): void { const bg = solidFillColor(node.fills) if (bg) props.push(['bg', bg]) @@ -281,18 +282,7 @@ function collectProps(node: SceneNode, graph: SceneGraph): [string, unknown][] { if (stroke.weight !== 1) props.push(['strokeWidth', stroke.weight]) } - const corners = collectCornerRadii(node) - if (corners) { - const { tl, tr, br, bl } = corners - if (tl === tr && tr === br && br === bl) { - props.push(['rounded', tl]) - } else { - if (tl > 0) props.push(['roundedTL', tl]) - if (tr > 0) props.push(['roundedTR', tr]) - if (br > 0) props.push(['roundedBR', br]) - if (bl > 0) props.push(['roundedBL', bl]) - } - } + collectCornerRadiiProps(node, props) if (node.cornerSmoothing > 0) props.push(['cornerSmoothing', node.cornerSmoothing]) if (node.opacity < 1) props.push(['opacity', Math.round(node.opacity * 100) / 100]) @@ -311,27 +301,29 @@ function collectProps(node: SceneNode, graph: SceneGraph): [string, unknown][] { props.push(['blur', effect.radius]) } } +} - if (node.type === 'TEXT') { - if (node.fontSize !== 14) props.push(['size', node.fontSize]) - 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']) - else props.push(['weight', node.fontWeight]) - } - if (node.textAlignHorizontal !== 'LEFT') { - props.push(['textAlign', node.textAlignHorizontal.toLowerCase()]) - } - const textColor = solidFillColor(node.fills) - if (textColor) { - const bgIdx = props.findIndex(([k]) => k === 'bg') - if (bgIdx !== -1) props.splice(bgIdx, 1) - props.push(['color', textColor]) - } +function collectTextNodeProps(node: SceneNode, props: [string, unknown][]): void { + if (node.fontSize !== 14) props.push(['size', node.fontSize]) + 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']) + else props.push(['weight', node.fontWeight]) } + if (node.textAlignHorizontal !== 'LEFT') { + props.push(['textAlign', node.textAlignHorizontal.toLowerCase()]) + } + const textColor = solidFillColor(node.fills) + if (textColor) { + const bgIdx = props.findIndex(([k]) => k === 'bg') + if (bgIdx !== -1) props.splice(bgIdx, 1) + props.push(['color', textColor]) + } +} +function collectShapeNodeProps(node: SceneNode, props: [string, unknown][]): void { if (node.type === 'STAR') { if (node.pointCount !== 5) props.push(['points', node.pointCount]) if (node.starInnerRadius !== 0.382) props.push(['innerRadius', node.starInnerRadius]) @@ -339,11 +331,33 @@ function collectProps(node: SceneNode, graph: SceneGraph): [string, unknown][] { if (node.type === 'POLYGON' && node.pointCount !== 3) { props.push(['points', node.pointCount]) } +} + +function collectProps(node: SceneNode, graph: SceneGraph): [string, unknown][] { + const props: [string, unknown][] = [] + const ctx = getNodeContext(node, graph) + + if (node.name && node.name !== node.type) props.push(['name', node.name]) + + if (ctx.isGrid) collectGridSizingProps(node, props) + else if (ctx.isFlex) collectFlexSizingProps(node, props) + else { + if (node.width > 0) props.push(['w', node.width]) + if (node.height > 0) props.push(['h', node.height]) + } + + if (ctx.parentIsAutoLayout && node.layoutGrow > 0) props.push(['grow', node.layoutGrow]) + if (ctx.parentIsGrid) collectGridPositionProps(node, props) + if (ctx.isFlex) collectFlexAlignmentProps(node, props) + if (ctx.isAutoLayout) collectAutoLayoutPaddingProps(node, props) + collectAppearanceProps(node, props) + if (node.type === 'TEXT') collectTextNodeProps(node, props) + collectShapeNodeProps(node, props) return props } -// --- Tailwind CSS v4 format --- +// --- Tailwind CSS v4 format helpers --- function twRounded(prefix: string, px: number): string { const r = borderRadiusToTw(px) @@ -356,92 +370,97 @@ function gridTemplateTw(tracks: GridTrack[]): string { return `[${tracks.map(formatTrack).join('_')}]` } -function collectTailwindClasses(node: SceneNode, graph: SceneGraph): string[] { - const classes: string[] = [] - const { isAutoLayout, isGrid, isFlex, parentIsAutoLayout, parentIsGrid } = getNodeContext( - node, - graph - ) +function collectTwGridClasses(node: SceneNode, classes: string[]): void { + classes.push('grid') + if (node.gridTemplateColumns.length > 0) + classes.push(`grid-cols-${gridTemplateTw(node.gridTemplateColumns)}`) + if (node.gridTemplateRows.length > 0) + classes.push(`grid-rows-${gridTemplateTw(node.gridTemplateRows)}`) + if (node.width > 0) classes.push(`w-${pxToSpacing(node.width)}`) + if (node.height > 0) classes.push(`h-${pxToSpacing(node.height)}`) + if (node.gridColumnGap > 0) classes.push(`gap-x-${pxToSpacing(node.gridColumnGap)}`) + if (node.gridRowGap > 0) classes.push(`gap-y-${pxToSpacing(node.gridRowGap)}`) +} - if (isGrid) { - classes.push('grid') - if (node.gridTemplateColumns.length > 0) - classes.push(`grid-cols-${gridTemplateTw(node.gridTemplateColumns)}`) - if (node.gridTemplateRows.length > 0) - classes.push(`grid-rows-${gridTemplateTw(node.gridTemplateRows)}`) - if (node.width > 0) classes.push(`w-${pxToSpacing(node.width)}`) - if (node.height > 0) classes.push(`h-${pxToSpacing(node.height)}`) - if (node.gridColumnGap > 0) classes.push(`gap-x-${pxToSpacing(node.gridColumnGap)}`) - if (node.gridRowGap > 0) classes.push(`gap-y-${pxToSpacing(node.gridRowGap)}`) - } else if (isFlex) { - classes.push('flex') - if (node.layoutMode === 'VERTICAL') classes.push('flex-col') +function collectTwFlexSizingClasses(node: SceneNode, classes: string[]): void { + classes.push('flex') + if (node.layoutMode === 'VERTICAL') classes.push('flex-col') - const primaryAxis = node.layoutMode === 'HORIZONTAL' ? 'width' : 'height' - const crossAxis = node.layoutMode === 'HORIZONTAL' ? 'height' : 'width' - const wProp = primaryAxis === 'width' ? 'w' : 'h' - const hProp = crossAxis === 'width' ? 'w' : 'h' + const primaryAxis = node.layoutMode === 'HORIZONTAL' ? 'width' : 'height' + const crossAxis = node.layoutMode === 'HORIZONTAL' ? 'height' : 'width' + const wProp = primaryAxis === 'width' ? 'w' : 'h' + const hProp = crossAxis === 'width' ? 'w' : 'h' - if (node.primaryAxisSizing === 'FILL') classes.push(`${wProp}-full`) - else if (node.primaryAxisSizing !== 'HUG') - classes.push(`${wProp}-${pxToSpacing(node[primaryAxis])}`) + if (node.primaryAxisSizing === 'FILL') classes.push(`${wProp}-full`) + else if (node.primaryAxisSizing !== 'HUG') + classes.push(`${wProp}-${pxToSpacing(node[primaryAxis])}`) - if (node.counterAxisSizing === 'FILL') classes.push(`${hProp}-full`) - else if (node.counterAxisSizing !== 'HUG') - classes.push(`${hProp}-${pxToSpacing(node[crossAxis])}`) - } else { - if (node.width > 0) classes.push(`w-${pxToSpacing(node.width)}`) - if (node.height > 0) classes.push(`h-${pxToSpacing(node.height)}`) - } + if (node.counterAxisSizing === 'FILL') classes.push(`${hProp}-full`) + else if (node.counterAxisSizing !== 'HUG') + classes.push(`${hProp}-${pxToSpacing(node[crossAxis])}`) +} - if (parentIsAutoLayout && node.layoutGrow > 0) classes.push('grow') +function collectTwGridPositionClasses(node: SceneNode, classes: string[]): void { + if (!node.gridPosition) return + const pos = node.gridPosition + if (pos.column > 0) classes.push(`col-start-${pos.column}`) + if (pos.row > 0) classes.push(`row-start-${pos.row}`) + if (pos.columnSpan > 1) classes.push(`col-span-${pos.columnSpan}`) + if (pos.rowSpan > 1) classes.push(`row-span-${pos.rowSpan}`) +} - if (parentIsGrid && node.gridPosition) { - const pos = node.gridPosition - if (pos.column > 0) classes.push(`col-start-${pos.column}`) - if (pos.row > 0) classes.push(`row-start-${pos.row}`) - if (pos.columnSpan > 1) classes.push(`col-span-${pos.columnSpan}`) - if (pos.rowSpan > 1) classes.push(`row-span-${pos.rowSpan}`) - } +function collectTwFlexAlignmentClasses(node: SceneNode, classes: string[]): void { + if (node.itemSpacing > 0) classes.push(`gap-${pxToSpacing(node.itemSpacing)}`) - if (isFlex && node.itemSpacing > 0) classes.push(`gap-${pxToSpacing(node.itemSpacing)}`) - - if (isFlex && node.layoutWrap === 'WRAP') { + if (node.layoutWrap === 'WRAP') { classes.push('flex-wrap') if (node.counterAxisSpacing > 0) classes.push(`gap-y-${pxToSpacing(node.counterAxisSpacing)}`) } - if (isFlex) { - if (node.primaryAxisAlign === 'CENTER') classes.push('justify-center') - else if (node.primaryAxisAlign === 'MAX') classes.push('justify-end') - else if (node.primaryAxisAlign === 'SPACE_BETWEEN') classes.push('justify-between') + if (node.primaryAxisAlign === 'CENTER') classes.push('justify-center') + else if (node.primaryAxisAlign === 'MAX') classes.push('justify-end') + else if (node.primaryAxisAlign === 'SPACE_BETWEEN') classes.push('justify-between') - if (node.counterAxisAlign === 'CENTER') classes.push('items-center') - else if (node.counterAxisAlign === 'MAX') classes.push('items-end') - else if (node.counterAxisAlign === 'STRETCH') classes.push('items-stretch') - } - - if (isAutoLayout) { - const pad = collectPadding(node) - if (pad) { - classes.push( - ...emitPadding( - pad, - (v) => `p-${pxToSpacing(v)}`, - (y, x) => [`py-${pxToSpacing(y)}`, `px-${pxToSpacing(x)}`], - ({ pt, pr, pb, pl }) => { - const r: string[] = [] - if (pt > 0) r.push(`pt-${pxToSpacing(pt)}`) - if (pr > 0) r.push(`pr-${pxToSpacing(pr)}`) - if (pb > 0) r.push(`pb-${pxToSpacing(pb)}`) - if (pl > 0) r.push(`pl-${pxToSpacing(pl)}`) - return r - } - ) - ) - } + if (node.counterAxisAlign === 'CENTER') classes.push('items-center') + else if (node.counterAxisAlign === 'MAX') classes.push('items-end') + else if (node.counterAxisAlign === 'STRETCH') classes.push('items-stretch') +} + +function collectTwPaddingClasses(node: SceneNode, classes: string[]): void { + const pad = collectPadding(node) + if (!pad) return + classes.push( + ...emitPadding( + pad, + (v) => `p-${pxToSpacing(v)}`, + (y, x) => [`py-${pxToSpacing(y)}`, `px-${pxToSpacing(x)}`], + ({ pt, pr, pb, pl }) => { + const r: string[] = [] + if (pt > 0) r.push(`pt-${pxToSpacing(pt)}`) + if (pr > 0) r.push(`pr-${pxToSpacing(pr)}`) + if (pb > 0) r.push(`pb-${pxToSpacing(pb)}`) + if (pl > 0) r.push(`pl-${pxToSpacing(pl)}`) + return r + } + ) + ) +} + +function collectTwCornerRadiiClasses(node: SceneNode, classes: string[]): void { + const corners = collectCornerRadii(node) + if (!corners) return + const { tl, tr, br, bl } = corners + if (tl === tr && tr === br && br === bl) { + classes.push(twRounded('rounded', tl)) + } else { + if (tl > 0) classes.push(twRounded('rounded-tl', tl)) + if (tr > 0) classes.push(twRounded('rounded-tr', tr)) + if (br > 0) classes.push(twRounded('rounded-br', br)) + if (bl > 0) classes.push(twRounded('rounded-bl', bl)) } +} +function collectTwAppearanceClasses(node: SceneNode, classes: string[]): void { const bg = solidFillColor(node.fills) if (bg && node.type !== 'TEXT') classes.push(`bg-${colorToTwClass(bg)}`) @@ -452,18 +471,7 @@ function collectTailwindClasses(node: SceneNode, graph: SceneGraph): string[] { classes.push(`border-${colorToTwClass(stroke.color)}`) } - const corners = collectCornerRadii(node) - if (corners) { - const { tl, tr, br, bl } = corners - if (tl === tr && tr === br && br === bl) { - classes.push(twRounded('rounded', tl)) - } else { - if (tl > 0) classes.push(twRounded('rounded-tl', tl)) - if (tr > 0) classes.push(twRounded('rounded-tr', tr)) - if (br > 0) classes.push(twRounded('rounded-br', br)) - if (bl > 0) classes.push(twRounded('rounded-bl', bl)) - } - } + collectTwCornerRadiiClasses(node, classes) if (node.opacity < 1) classes.push(`opacity-${opacityToTw(node.opacity)}`) if (node.rotation !== 0) classes.push(`rotate-${formatTailwindAngle(node.rotation)}`) @@ -480,19 +488,38 @@ function collectTailwindClasses(node: SceneNode, graph: SceneGraph): string[] { classes.push(`backdrop-blur-[${effect.radius}px]`) } } +} - if (node.type === 'TEXT') { - classes.push(`text-${fontSizeToTw(node.fontSize)}`) - if (node.fontFamily && node.fontFamily !== DEFAULT_FONT_FAMILY) { - classes.push(`font-${formatTailwindFontFamily(node.fontFamily)}`) - } - if (node.fontWeight !== 400) classes.push(`font-${fontWeightToTw(node.fontWeight)}`) - if (node.textAlignHorizontal !== 'LEFT') { - classes.push(`text-${node.textAlignHorizontal.toLowerCase()}`) - } - const textColor = solidFillColor(node.fills) - if (textColor) classes.push(`text-${colorToTwClass(textColor)}`) +function collectTwTextClasses(node: SceneNode, classes: string[]): void { + classes.push(`text-${fontSizeToTw(node.fontSize)}`) + if (node.fontFamily && node.fontFamily !== DEFAULT_FONT_FAMILY) { + classes.push(`font-${formatTailwindFontFamily(node.fontFamily)}`) } + if (node.fontWeight !== 400) classes.push(`font-${fontWeightToTw(node.fontWeight)}`) + if (node.textAlignHorizontal !== 'LEFT') { + classes.push(`text-${node.textAlignHorizontal.toLowerCase()}`) + } + const textColor = solidFillColor(node.fills) + if (textColor) classes.push(`text-${colorToTwClass(textColor)}`) +} + +function collectTailwindClasses(node: SceneNode, graph: SceneGraph): string[] { + const classes: string[] = [] + const ctx = getNodeContext(node, graph) + + if (ctx.isGrid) collectTwGridClasses(node, classes) + else if (ctx.isFlex) collectTwFlexSizingClasses(node, classes) + else { + if (node.width > 0) classes.push(`w-${pxToSpacing(node.width)}`) + if (node.height > 0) classes.push(`h-${pxToSpacing(node.height)}`) + } + + if (ctx.parentIsAutoLayout && node.layoutGrow > 0) classes.push('grow') + if (ctx.parentIsGrid) collectTwGridPositionClasses(node, classes) + if (ctx.isFlex) collectTwFlexAlignmentClasses(node, classes) + if (ctx.isAutoLayout) collectTwPaddingClasses(node, classes) + collectTwAppearanceClasses(node, classes) + if (node.type === 'TEXT') collectTwTextClasses(node, classes) return classes } diff --git a/packages/core/src/render/renderer.ts b/packages/core/src/render/renderer.ts index 7b80bf9fd..ef1877063 100644 --- a/packages/core/src/render/renderer.ts +++ b/packages/core/src/render/renderer.ts @@ -122,11 +122,10 @@ function renderNode(graph: SceneGraph, tree: TreeNode, parentId: string): SceneN return node } -function propsToOverrides(props: Record, isText: boolean): Partial { - const o: Partial = {} - - if (props.name) o.name = props.name as string - +function applySizeOverrides( + props: Record, + o: Partial +): { w: unknown; h: unknown } { const w = props.w ?? props.width const h = props.h ?? props.height if (typeof w === 'number') o.width = w @@ -143,6 +142,10 @@ function propsToOverrides(props: Record, isText: boolean): Part if (props.x !== undefined) o.x = props.x as number if (props.y !== undefined) o.y = props.y as number + return { w, h } +} + +function applyVisualOverrides(props: Record, o: Partial): void { const bg = props.bg ?? props.fill if (typeof bg === 'string') { o.fills = [colorToFill(bg)] @@ -177,7 +180,38 @@ function propsToOverrides(props: Record, isText: boolean): Part o.blendMode = (props.blendMode as string).toUpperCase() as SceneNode['blendMode'] } if (props.overflow === 'hidden') o.clipsContent = true +} +function applyPaddingOverrides(props: Record, o: Partial): void { + const p = props.p ?? props.padding + if (typeof p === 'number') { + o.paddingTop = p + o.paddingRight = p + o.paddingBottom = p + o.paddingLeft = p + } + const px = props.px as number | undefined + const py = props.py as number | undefined + if (px !== undefined) { + o.paddingLeft = px + o.paddingRight = px + } + if (py !== undefined) { + o.paddingTop = py + o.paddingBottom = py + } + if (props.pt !== undefined) o.paddingTop = props.pt as number + if (props.pr !== undefined) o.paddingRight = props.pr as number + if (props.pb !== undefined) o.paddingBottom = props.pb as number + if (props.pl !== undefined) o.paddingLeft = props.pl as number +} + +function applyLayoutOverrides( + props: Record, + o: Partial, + w: unknown, + h: unknown +): void { if (props.flex !== undefined) { const dir = props.flex as string o.layoutMode = (dir === 'col' || dir === 'column' ? 'VERTICAL' : 'HORIZONTAL') as LayoutMode @@ -205,60 +239,42 @@ function propsToOverrides(props: Record, isText: boolean): Part o.counterAxisAlign = COUNTER_ALIGN_MAP[props.items as string] ?? 'MIN' } - const p = props.p ?? props.padding - if (typeof p === 'number') { - o.paddingTop = p - o.paddingRight = p - o.paddingBottom = p - o.paddingLeft = p - } - const px = props.px as number | undefined - const py = props.py as number | undefined - if (px !== undefined) { - o.paddingLeft = px - o.paddingRight = px - } - if (py !== undefined) { - o.paddingTop = py - o.paddingBottom = py - } - if (props.pt !== undefined) o.paddingTop = props.pt as number - if (props.pr !== undefined) o.paddingRight = props.pr as number - if (props.pb !== undefined) o.paddingBottom = props.pb as number - if (props.pl !== undefined) o.paddingLeft = props.pl as number + applyPaddingOverrides(props, o) if (props.grow !== undefined) o.layoutGrow = props.grow as number if (props.minW !== undefined) o.width = Math.max(o.width ?? 0, props.minW as number) if (props.maxW !== undefined) o.width = Math.min(o.width ?? Infinity, props.maxW as number) +} - if (isText) { - const fontSize = props.size ?? props.fontSize - if (typeof fontSize === 'number') o.fontSize = fontSize +function applyTextOverrides(props: Record, o: Partial): void { + const fontSize = props.size ?? props.fontSize + if (typeof fontSize === 'number') o.fontSize = fontSize - const fontFamily = props.font ?? props.fontFamily - if (typeof fontFamily === 'string') o.fontFamily = fontFamily + const fontFamily = props.font ?? props.fontFamily + if (typeof fontFamily === 'string') o.fontFamily = fontFamily - const weight = props.weight ?? props.fontWeight - if (typeof weight === 'number') { - o.fontWeight = weight - } else if (typeof weight === 'string') { - o.fontWeight = WEIGHT_MAP[weight] ?? 400 - } - - if (typeof props.color === 'string') { - o.fills = [colorToFill(props.color)] - } - - if (props.textAlign) { - o.textAlignHorizontal = TEXT_ALIGN_MAP[props.textAlign as string] ?? 'LEFT' - } - - o.textAutoResize = props.textAutoResize - ? (TEXT_AUTO_RESIZE_MAP[props.textAutoResize as string] ?? 'NONE') - : 'HEIGHT' + const weight = props.weight ?? props.fontWeight + if (typeof weight === 'number') { + o.fontWeight = weight + } else if (typeof weight === 'string') { + o.fontWeight = WEIGHT_MAP[weight] ?? 400 } + if (typeof props.color === 'string') { + o.fills = [colorToFill(props.color)] + } + + if (props.textAlign) { + o.textAlignHorizontal = TEXT_ALIGN_MAP[props.textAlign as string] ?? 'LEFT' + } + + o.textAutoResize = props.textAutoResize + ? (TEXT_AUTO_RESIZE_MAP[props.textAutoResize as string] ?? 'NONE') + : 'HEIGHT' +} + +function applyEffectOverrides(props: Record, o: Partial): void { if (props.points !== undefined) o.pointCount = props.points as number if (props.innerRadius !== undefined) o.starInnerRadius = props.innerRadius as number if (props.pointCount !== undefined) o.pointCount = props.pointCount as number @@ -294,6 +310,18 @@ function propsToOverrides(props: Record, isText: boolean): Part } ] } +} + +function propsToOverrides(props: Record, isText: boolean): Partial { + const o: Partial = {} + + if (props.name) o.name = props.name as string + + const { w, h } = applySizeOverrides(props, o) + applyVisualOverrides(props, o) + applyLayoutOverrides(props, o, w, h) + if (isText) applyTextOverrides(props, o) + applyEffectOverrides(props, o) return o } diff --git a/packages/core/src/renderer/overlays.ts b/packages/core/src/renderer/overlays.ts index 53e2d1db6..8c9ca3328 100644 --- a/packages/core/src/renderer/overlays.ts +++ b/packages/core/src/renderer/overlays.ts @@ -30,7 +30,7 @@ import type { SceneNode, SceneGraph } from '../scene-graph' import type { SnapGuide } from '../snap' import type { TextEditor } from '../text-editor' import type { Rect, Vector } from '../types' -import type { Canvas } from 'canvaskit-wasm' +import type { Canvas, Paint } from 'canvaskit-wasm' import type { SkiaRenderer, RenderOverlays } from './renderer' export function drawHoverHighlight( @@ -568,23 +568,15 @@ export function drawTextEditOverlay( } } -export function drawPenOverlay( +type ToScreenFn = (x: number, y: number) => Vector + +function buildPenPath( r: SkiaRenderer, canvas: Canvas, - penState: RenderOverlays['penState'] + penState: NonNullable, + toScreen: ToScreenFn ): void { - if (!penState || penState.vertices.length === 0) return - const { vertices, segments, dragTangent, cursorX, cursorY } = penState - const pathPaint = r.penPathPaint - const handlePaint = r.penHandlePaint - const vertexFill = r.penVertexFill - const vertexStroke = r.penVertexStroke - - const toScreen = (x: number, y: number) => ({ - x: x * r.zoom + r.panX, - y: y * r.zoom + r.panY - }) const path = new r.ck.Path() for (const seg of segments) { @@ -627,8 +619,29 @@ export function drawPenOverlay( } } - canvas.drawPath(path, pathPaint) + canvas.drawPath(path, r.penPathPaint) path.delete() +} + +function drawPenHandlePoint( + canvas: Canvas, + x: number, + y: number, + vertexFill: Paint, + handlePaint: Paint +): void { + canvas.drawCircle(x, y, PEN_HANDLE_RADIUS, vertexFill) + canvas.drawCircle(x, y, PEN_HANDLE_RADIUS, handlePaint) +} + +function drawPenTangentHandles( + canvas: Canvas, + penState: NonNullable, + toScreen: ToScreenFn, + handlePaint: Paint, + vertexFill: Paint +): void { + const { vertices, segments, dragTangent } = penState for (const seg of segments) { const ts = seg.tangentStart @@ -637,15 +650,13 @@ export function drawPenOverlay( const s = toScreen(vertices[seg.start].x, vertices[seg.start].y) const cp = toScreen(vertices[seg.start].x + ts.x, vertices[seg.start].y + ts.y) canvas.drawLine(s.x, s.y, cp.x, cp.y, handlePaint) - canvas.drawCircle(cp.x, cp.y, PEN_HANDLE_RADIUS, vertexFill) - canvas.drawCircle(cp.x, cp.y, PEN_HANDLE_RADIUS, handlePaint) + drawPenHandlePoint(canvas, cp.x, cp.y, vertexFill, handlePaint) } if (te.x !== 0 || te.y !== 0) { const e = toScreen(vertices[seg.end].x, vertices[seg.end].y) const cp = toScreen(vertices[seg.end].x + te.x, vertices[seg.end].y + te.y) canvas.drawLine(e.x, e.y, cp.x, cp.y, handlePaint) - canvas.drawCircle(cp.x, cp.y, PEN_HANDLE_RADIUS, vertexFill) - canvas.drawCircle(cp.x, cp.y, PEN_HANDLE_RADIUS, handlePaint) + drawPenHandlePoint(canvas, cp.x, cp.y, vertexFill, handlePaint) } } @@ -654,11 +665,29 @@ export function drawPenOverlay( const cp1 = toScreen(last.x + dragTangent.x, last.y + dragTangent.y) const cp2 = toScreen(last.x - dragTangent.x, last.y - dragTangent.y) canvas.drawLine(cp2.x, cp2.y, cp1.x, cp1.y, handlePaint) - canvas.drawCircle(cp1.x, cp1.y, PEN_HANDLE_RADIUS, vertexFill) - canvas.drawCircle(cp1.x, cp1.y, PEN_HANDLE_RADIUS, handlePaint) - canvas.drawCircle(cp2.x, cp2.y, PEN_HANDLE_RADIUS, vertexFill) - canvas.drawCircle(cp2.x, cp2.y, PEN_HANDLE_RADIUS, handlePaint) + drawPenHandlePoint(canvas, cp1.x, cp1.y, vertexFill, handlePaint) + drawPenHandlePoint(canvas, cp2.x, cp2.y, vertexFill, handlePaint) } +} + +export function drawPenOverlay( + r: SkiaRenderer, + canvas: Canvas, + penState: RenderOverlays['penState'] +): void { + if (!penState || penState.vertices.length === 0) return + + const { vertices } = penState + const vertexFill = r.penVertexFill + const vertexStroke = r.penVertexStroke + + const toScreen: ToScreenFn = (x, y) => ({ + x: x * r.zoom + r.panX, + y: y * r.zoom + r.panY + }) + + buildPenPath(r, canvas, penState, toScreen) + drawPenTangentHandles(canvas, penState, toScreen, r.penHandlePaint, vertexFill) for (let i = 0; i < vertices.length; i++) { const v = toScreen(vertices[i].x, vertices[i].y) diff --git a/packages/core/src/renderer/renderer.ts b/packages/core/src/renderer/renderer.ts index 8d65e911b..22ea6c24b 100644 --- a/packages/core/src/renderer/renderer.ts +++ b/packages/core/src/renderer/renderer.ts @@ -802,16 +802,7 @@ export class SkiaRenderer { const baseFontSize = node.fontSize || DEFAULT_FONT_SIZE const cjkFallback = getCJKFallbackFamily() - const truncateOpts: { maxLines?: number; ellipsis?: string } = {} - if (node.textTruncation === 'ENDING') { - if (node.maxLines != null && node.maxLines > 0) { - truncateOpts.maxLines = node.maxLines - } else if (node.height > 0) { - const lineH = node.lineHeight || baseFontSize * 1.2 - truncateOpts.maxLines = Math.max(1, Math.floor(node.height / lineH)) - } - truncateOpts.ellipsis = '…' - } + const truncateOpts = this.buildTruncateOpts(node, baseFontSize) const fontFamilies = (primary: string) => cjkFallback ? [primary, cjkFallback] : [primary] @@ -835,43 +826,11 @@ export class SkiaRenderer { }) const builder = ck.ParagraphBuilder.MakeFromFontProvider(paraStyle, this.fontProvider!) - const runs = node.styleRuns - const text = node.text - if (runs.length === 0) { - builder.addText(text) + if (node.styleRuns.length === 0) { + builder.addText(node.text) } else { - let pos = 0 - for (const run of runs) { - if (pos < run.start) { - builder.addText(text.slice(pos, run.start)) - } - const s = run.style - builder.pushStyle( - new ck.TextStyle({ - color: baseColor, - fontFamilies: fontFamilies(s.fontFamily ?? (node.fontFamily || DEFAULT_FONT_FAMILY)), - fontSize: s.fontSize ?? baseFontSize, - fontStyle: { - weight: { value: (s.fontWeight ?? node.fontWeight) || 400 } as FontWeight, - slant: (s.italic ?? node.italic) ? ck.FontSlant.Italic : ck.FontSlant.Upright - }, - letterSpacing: s.letterSpacing ?? (node.letterSpacing || 0), - decoration: this.textDecorationValue(s.textDecoration ?? node.textDecoration), - heightMultiplier: (s.lineHeight !== undefined ? s.lineHeight : node.lineHeight) - ? (s.lineHeight !== undefined ? s.lineHeight : node.lineHeight)! / - (s.fontSize ?? baseFontSize) - : undefined, - halfLeading - }) - ) - builder.addText(text.slice(run.start, run.start + run.length)) - builder.pop() - pos = run.start + run.length - } - if (pos < text.length) { - builder.addText(text.slice(pos)) - } + this.addStyledRuns(builder, node, baseColor, baseFontSize, fontFamilies, halfLeading) } const paragraph = builder.build() @@ -880,6 +839,67 @@ export class SkiaRenderer { return paragraph } + private buildTruncateOpts( + node: SceneNode, + baseFontSize: number + ): { maxLines?: number; ellipsis?: string } { + if (node.textTruncation !== 'ENDING') return {} + + const opts: { maxLines?: number; ellipsis: string } = { ellipsis: '…' } + if (node.maxLines != null && node.maxLines > 0) { + opts.maxLines = node.maxLines + } else if (node.height > 0) { + const lineH = node.lineHeight || baseFontSize * 1.2 + opts.maxLines = Math.max(1, Math.floor(node.height / lineH)) + } + return opts + } + + private addStyledRuns( + builder: ReturnType, + node: SceneNode, + baseColor: Float32Array, + baseFontSize: number, + fontFamilies: (primary: string) => string[], + halfLeading: boolean + ): void { + const ck = this.ck + const text = node.text + let pos = 0 + + for (const run of node.styleRuns) { + if (pos < run.start) { + builder.addText(text.slice(pos, run.start)) + } + const s = run.style + const runLineHeight = s.lineHeight !== undefined ? s.lineHeight : node.lineHeight + const runFontSize = s.fontSize ?? baseFontSize + + builder.pushStyle( + new ck.TextStyle({ + color: baseColor, + fontFamilies: fontFamilies(s.fontFamily ?? (node.fontFamily || DEFAULT_FONT_FAMILY)), + fontSize: runFontSize, + fontStyle: { + weight: { value: (s.fontWeight ?? node.fontWeight) || 400 } as FontWeight, + slant: (s.italic ?? node.italic) ? ck.FontSlant.Italic : ck.FontSlant.Upright + }, + letterSpacing: s.letterSpacing ?? (node.letterSpacing || 0), + decoration: this.textDecorationValue(s.textDecoration ?? node.textDecoration), + heightMultiplier: runLineHeight ? runLineHeight / runFontSize : undefined, + halfLeading + }) + ) + builder.addText(text.slice(run.start, run.start + run.length)) + builder.pop() + pos = run.start + run.length + } + + if (pos < text.length) { + builder.addText(text.slice(pos)) + } + } + private textDecorationValue(decoration: string): number { switch (decoration) { case 'UNDERLINE': diff --git a/packages/core/src/renderer/rulers.ts b/packages/core/src/renderer/rulers.ts index cad552ae5..9d8434e33 100644 --- a/packages/core/src/renderer/rulers.ts +++ b/packages/core/src/renderer/rulers.ts @@ -15,56 +15,47 @@ import type { SceneNode, SceneGraph } from '../scene-graph' import type { Canvas, CanvasKit } from 'canvaskit-wasm' import type { SkiaRenderer } from './renderer' -export function drawRulers( +interface SelectionScreenBounds { + sx1: number + sx2: number + sy1: number + sy2: number +} + +function getSelectionScreenBounds( + r: SkiaRenderer, + graph: SceneGraph, + selNodes: SceneNode[] +): SelectionScreenBounds { + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity + for (const n of selNodes) { + const abs = graph.getAbsolutePosition(n.id) + minX = Math.min(minX, abs.x) + minY = Math.min(minY, abs.y) + maxX = Math.max(maxX, abs.x + n.width) + maxY = Math.max(maxY, abs.y + n.height) + } + return { + sx1: minX * r.zoom + r.panX, + sx2: maxX * r.zoom + r.panX, + sy1: minY * r.zoom + r.panY, + sy2: maxY * r.zoom + r.panY + } +} + +function drawHorizontalRulerTicks( r: SkiaRenderer, canvas: Canvas, - graph: SceneGraph, - selectedIds: Set + font: InstanceType, + step: number, + selBounds: SelectionScreenBounds | null ): void { const R = RULER_SIZE const vw = r.viewportWidth - const vh = r.viewportHeight - if (vw === 0 || vh === 0) return - - const bgPaint = r.rulerBgPaint - const tickPaint = r.rulerTickPaint - const textPaint = r.rulerTextPaint - - canvas.drawRect(r.ck.LTRBRect(0, 0, vw, R), bgPaint) - canvas.drawRect(r.ck.LTRBRect(0, R, R, vh), bgPaint) - canvas.drawRect(r.ck.LTRBRect(0, 0, R, R), bgPaint) - - const font = r.sizeFont ?? r.textFont - if (!font) return - - const step = rulerStep(r) const minorStep = step / 5 - - let sx1 = -Infinity, - sx2 = -Infinity, - sy1 = -Infinity, - sy2 = -Infinity - const selNodes = [...selectedIds] - .map((id) => graph.getNode(id)) - .filter((n): n is SceneNode => n !== undefined) - if (selNodes.length > 0) { - let minX = Infinity, - minY = Infinity, - maxX = -Infinity, - maxY = -Infinity - for (const n of selNodes) { - const abs = graph.getAbsolutePosition(n.id) - minX = Math.min(minX, abs.x) - minY = Math.min(minY, abs.y) - maxX = Math.max(maxX, abs.x + n.width) - maxY = Math.max(maxY, abs.y + n.height) - } - sx1 = minX * r.zoom + r.panX - sx2 = maxX * r.zoom + r.panX - sy1 = minY * r.zoom + r.panY - sy2 = maxY * r.zoom + r.panY - } - const badgeW = RULER_BADGE_EXCLUSION canvas.save() @@ -78,18 +69,31 @@ export function drawRulers( if (sx < R) continue const isMajor = Math.abs(wx % step) < RULER_MAJOR_TOLERANCE const tickLen = isMajor ? R * RULER_MAJOR_TICK : R * RULER_MINOR_TICK - canvas.drawLine(sx, R - tickLen, sx, R, tickPaint) + canvas.drawLine(sx, R - tickLen, sx, R, r.rulerTickPaint) - if (isMajor && selNodes.length > 0) { - const tooClose = Math.abs(sx - sx1) < badgeW || Math.abs(sx - sx2) < badgeW - if (!tooClose) { - canvas.drawText(rulerLabel(wx), sx + 2, R * RULER_TEXT_BASELINE, textPaint, font) + if (isMajor) { + const skipForBadge = + selBounds != null && + (Math.abs(sx - selBounds.sx1) < badgeW || Math.abs(sx - selBounds.sx2) < badgeW) + if (!skipForBadge) { + canvas.drawText(rulerLabel(wx), sx + 2, R * RULER_TEXT_BASELINE, r.rulerTextPaint, font) } - } else if (isMajor) { - canvas.drawText(rulerLabel(wx), sx + 2, R * RULER_TEXT_BASELINE, textPaint, font) } } canvas.restore() +} + +function drawVerticalRulerTicks( + r: SkiaRenderer, + canvas: Canvas, + font: InstanceType, + step: number, + selBounds: SelectionScreenBounds | null +): void { + const R = RULER_SIZE + const vh = r.viewportHeight + const minorStep = step / 5 + const badgeW = RULER_BADGE_EXCLUSION canvas.save() canvas.clipRect(r.ck.LTRBRect(0, R, R, vh), r.ck.ClipOp.Intersect, false) @@ -102,69 +106,60 @@ export function drawRulers( if (sy < R) continue const isMajor = Math.abs(wy % step) < RULER_MAJOR_TOLERANCE const tickLen = isMajor ? R * RULER_MAJOR_TICK : R * RULER_MINOR_TICK - canvas.drawLine(R - tickLen, sy, R, sy, tickPaint) + canvas.drawLine(R - tickLen, sy, R, sy, r.rulerTickPaint) - if (isMajor && selNodes.length > 0) { - const tooClose = Math.abs(sy - sy1) < badgeW || Math.abs(sy - sy2) < badgeW - if (!tooClose) { + if (isMajor) { + const skipForBadge = + selBounds != null && + (Math.abs(sy - selBounds.sy1) < badgeW || Math.abs(sy - selBounds.sy2) < badgeW) + if (!skipForBadge) { canvas.save() canvas.translate(R * RULER_TEXT_BASELINE, sy - 2) canvas.rotate(-90, 0, 0) - canvas.drawText(rulerLabel(wy), 0, 3, textPaint, font) + canvas.drawText(rulerLabel(wy), 0, 3, r.rulerTextPaint, font) canvas.restore() } - } else if (isMajor) { - canvas.save() - canvas.translate(R * RULER_TEXT_BASELINE, sy - 2) - canvas.rotate(-90, 0, 0) - canvas.drawText(rulerLabel(wy), 0, 3, textPaint, font) - canvas.restore() } } canvas.restore() +} - if (selNodes.length > 0) { +export function drawRulers( + r: SkiaRenderer, + canvas: Canvas, + graph: SceneGraph, + selectedIds: Set +): void { + const R = RULER_SIZE + const vw = r.viewportWidth + const vh = r.viewportHeight + if (vw === 0 || vh === 0) return + + canvas.drawRect(r.ck.LTRBRect(0, 0, vw, R), r.rulerBgPaint) + canvas.drawRect(r.ck.LTRBRect(0, R, R, vh), r.rulerBgPaint) + canvas.drawRect(r.ck.LTRBRect(0, 0, R, R), r.rulerBgPaint) + + const font = r.sizeFont ?? r.textFont + if (!font) return + + const step = rulerStep(r) + const selNodes = [...selectedIds] + .map((id) => graph.getNode(id)) + .filter((n): n is SceneNode => n !== undefined) + const selBounds = selNodes.length > 0 ? getSelectionScreenBounds(r, graph, selNodes) : null + + drawHorizontalRulerTicks(r, canvas, font, step, selBounds) + drawVerticalRulerTicks(r, canvas, font, step, selBounds) + + if (selBounds) { r.rulerHlPaint.setColor(r.selColor(RULER_HIGHLIGHT_ALPHA)) + canvas.drawRect(r.ck.LTRBRect(Math.max(R, selBounds.sx1), 0, selBounds.sx2, R), r.rulerHlPaint) + canvas.drawRect(r.ck.LTRBRect(0, Math.max(R, selBounds.sy1), R, selBounds.sy2), r.rulerHlPaint) - canvas.drawRect(r.ck.LTRBRect(Math.max(R, sx1), 0, sx2, R), r.rulerHlPaint) - canvas.drawRect(r.ck.LTRBRect(0, Math.max(R, sy1), R, sy2), r.rulerHlPaint) - - drawRulerBadge( - r, - canvas, - font, - Math.round((sx1 - r.panX) / r.zoom).toString(), - Math.max(R, sx1), - 0, - 'horizontal' - ) - drawRulerBadge( - r, - canvas, - font, - Math.round((sx2 - r.panX) / r.zoom).toString(), - sx2, - 0, - 'horizontal' - ) - drawRulerBadge( - r, - canvas, - font, - Math.round((sy1 - r.panY) / r.zoom).toString(), - 0, - Math.max(R, sy1), - 'vertical' - ) - drawRulerBadge( - r, - canvas, - font, - Math.round((sy2 - r.panY) / r.zoom).toString(), - 0, - sy2, - 'vertical' - ) + drawRulerBadge(r, canvas, font, Math.round((selBounds.sx1 - r.panX) / r.zoom).toString(), Math.max(R, selBounds.sx1), 0, 'horizontal') + drawRulerBadge(r, canvas, font, Math.round((selBounds.sx2 - r.panX) / r.zoom).toString(), selBounds.sx2, 0, 'horizontal') + drawRulerBadge(r, canvas, font, Math.round((selBounds.sy1 - r.panY) / r.zoom).toString(), 0, Math.max(R, selBounds.sy1), 'vertical') + drawRulerBadge(r, canvas, font, Math.round((selBounds.sy2 - r.panY) / r.zoom).toString(), 0, selBounds.sy2, 'vertical') } } diff --git a/packages/core/src/renderer/scene.ts b/packages/core/src/renderer/scene.ts index 206e5dfcb..f7faf8a8f 100644 --- a/packages/core/src/renderer/scene.ts +++ b/packages/core/src/renderer/scene.ts @@ -1,69 +1,47 @@ import { DROP_HIGHLIGHT_ALPHA, DROP_HIGHLIGHT_STROKE, SECTION_CORNER_RADIUS } from '../constants' import type { SceneNode, SceneGraph } from '../scene-graph' -import type { Canvas, EmbindEnumEntity } from 'canvaskit-wasm' +import type { Canvas, EmbindEnumEntity, Path } from 'canvaskit-wasm' +import type { Color } from '../types' import type { SkiaRenderer, RenderOverlays } from './renderer' -export function renderNode( +function isCulled( r: SkiaRenderer, - canvas: Canvas, - graph: SceneGraph, - nodeId: string, - overlays: RenderOverlays, - parentAbsX = 0, - parentAbsY = 0 -): void { - const node = graph.getNode(nodeId) - if (!node || !node.visible) return - - r._nodeCount++ - - const absX = parentAbsX + node.x - const absY = parentAbsY + node.y - + node: SceneNode, + absX: number, + absY: number +): boolean { const canCull = node.childIds.length === 0 || ((node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'INSTANCE') && node.clipsContent) - if (canCull) { - const vp = r.worldViewport - let bw = node.width - let bh = node.height - if (node.rotation !== 0) { - const diag = Math.sqrt(bw * bw + bh * bh) - const cx = absX + bw / 2 - const cy = absY + bh / 2 - if ( - cx - diag / 2 > vp.x + vp.w || - cy - diag / 2 > vp.y + vp.h || - cx + diag / 2 < vp.x || - cy + diag / 2 < vp.y - ) { - r._culledCount++ - return - } - } else if (absX > vp.x + vp.w || absY > vp.y + vp.h || absX + bw < vp.x || absY + bh < vp.y) { - r._culledCount++ - return - } - } - - canvas.save() - canvas.translate(node.x, node.y) - - if (node.opacity < 1) { - r.opacityPaint.setAlphaf(node.opacity) - canvas.saveLayer(r.opacityPaint) - } - - const layerBlur = node.effects.find((e) => e.visible && e.type === 'LAYER_BLUR') - if (layerBlur) { - r.effectLayerPaint.setImageFilter(r.getCachedBlur(layerBlur.radius / 2)) - canvas.saveLayer(r.effectLayerPaint) + if (!canCull) return false + + const vp = r.worldViewport + const bw = node.width + const bh = node.height + if (node.rotation !== 0) { + const diag = Math.sqrt(bw * bw + bh * bh) + const cx = absX + bw / 2 + const cy = absY + bh / 2 + return ( + cx - diag / 2 > vp.x + vp.w || + cy - diag / 2 > vp.y + vp.h || + cx + diag / 2 < vp.x || + cy + diag / 2 < vp.y + ) } + return absX > vp.x + vp.w || absY > vp.y + vp.h || absX + bw < vp.x || absY + bh < vp.y +} +function applyNodeTransforms( + r: SkiaRenderer, + canvas: Canvas, + node: SceneNode, + nodeId: string, + overlays: RenderOverlays +): void { const rotation = overlays.rotationPreview?.nodeId === nodeId ? overlays.rotationPreview.angle : node.rotation - if (rotation !== 0) { canvas.rotate(rotation, node.width / 2, node.height / 2) } @@ -75,7 +53,16 @@ export function renderNode( ) canvas.scale(node.flipX ? -1 : 1, node.flipY ? -1 : 1) } +} +function renderNodeContent( + r: SkiaRenderer, + canvas: Canvas, + graph: SceneGraph, + node: SceneNode, + nodeId: string, + overlays: RenderOverlays +): void { if (node.type === 'SECTION') { r.renderSection(canvas, node, graph) } else if (node.type === 'COMPONENT_SET') { @@ -93,7 +80,17 @@ export function renderNode( r.auxStroke.setColor(r.selColor(DROP_HIGHLIGHT_ALPHA)) canvas.drawRect(r.ck.LTRBRect(0, 0, node.width, node.height), r.auxStroke) } +} +function renderChildren( + r: SkiaRenderer, + canvas: Canvas, + graph: SceneGraph, + node: SceneNode, + overlays: RenderOverlays, + absX: number, + absY: number +): void { const isClippableContainer = node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'INSTANCE' if (isClippableContainer && node.clipsContent && node.childIds.length > 0) { @@ -112,6 +109,47 @@ export function renderNode( r.renderNode(canvas, graph, childId, overlays, absX, absY) } } +} + +export function renderNode( + r: SkiaRenderer, + canvas: Canvas, + graph: SceneGraph, + nodeId: string, + overlays: RenderOverlays, + parentAbsX = 0, + parentAbsY = 0 +): void { + const node = graph.getNode(nodeId) + if (!node || !node.visible) return + + r._nodeCount++ + + const absX = parentAbsX + node.x + const absY = parentAbsY + node.y + + if (isCulled(r, node, absX, absY)) { + r._culledCount++ + return + } + + canvas.save() + canvas.translate(node.x, node.y) + + if (node.opacity < 1) { + r.opacityPaint.setAlphaf(node.opacity) + canvas.saveLayer(r.opacityPaint) + } + + const layerBlur = node.effects.find((e) => e.visible && e.type === 'LAYER_BLUR') + if (layerBlur) { + r.effectLayerPaint.setImageFilter(r.getCachedBlur(layerBlur.radius / 2)) + canvas.saveLayer(r.effectLayerPaint) + } + + applyNodeTransforms(r, canvas, node, nodeId, overlays) + renderNodeContent(r, canvas, graph, node, nodeId, overlays) + renderChildren(r, canvas, graph, node, overlays, absX, absY) if (layerBlur) { canvas.restore() @@ -215,6 +253,103 @@ export function renderShape( } } +function nodeHasRadius(node: SceneNode): boolean { + return ( + node.cornerRadius > 0 || + (node.independentCorners && + (node.topLeftRadius > 0 || + node.topRightRadius > 0 || + node.bottomRightRadius > 0 || + node.bottomLeftRadius > 0)) + ) +} + +function getCapEntity(r: SkiaRenderer, cap: string | undefined): EmbindEnumEntity { + switch (cap) { + case 'ROUND': return r.ck.StrokeCap.Round + case 'SQUARE': return r.ck.StrokeCap.Square + default: return r.ck.StrokeCap.Butt + } +} + +function getJoinEntity(r: SkiaRenderer, join: string | undefined): EmbindEnumEntity { + switch (join) { + case 'ROUND': return r.ck.StrokeJoin.Round + case 'BEVEL': return r.ck.StrokeJoin.Bevel + default: return r.ck.StrokeJoin.Miter + } +} + +function drawVectorStrokeGeometry( + r: SkiaRenderer, + canvas: Canvas, + sg: Path[], + sc: Color, + opacity: number +): void { + r.fillPaint.setColor(r.ck.Color4f(sc.r, sc.g, sc.b, sc.a)) + r.fillPaint.setAlphaf(opacity) + r.fillPaint.setShader(null) + for (const p of sg) canvas.drawPath(p, r.fillPaint) +} + +function drawVectorPathStrokes( + r: SkiaRenderer, + canvas: Canvas, + vectorPaths: Path[], + stroke: SceneNode['strokes'][0], + sc: Color +): void { + const strokeOpts = { + width: stroke.weight, + miter_limit: 4, + cap: getCapEntity(r, stroke.cap ?? 'NONE'), + join: getJoinEntity(r, stroke.join ?? 'MITER') + } + r.fillPaint.setColor(r.ck.Color4f(sc.r, sc.g, sc.b, sc.a)) + r.fillPaint.setAlphaf(stroke.opacity) + r.fillPaint.setShader(null) + for (const vp of vectorPaths) { + const outline = vp.copy().stroke(strokeOpts) + if (outline) { + canvas.drawPath(outline, r.fillPaint) + outline.delete() + } + } +} + +function drawRegularStroke( + r: SkiaRenderer, + canvas: Canvas, + node: SceneNode, + rect: Float32Array, + hasRadius: boolean, + stroke: SceneNode['strokes'][0], + sc: Color +): void { + r.strokePaint.setColor(r.ck.Color4f(sc.r, sc.g, sc.b, sc.a)) + r.strokePaint.setStrokeWidth(stroke.weight) + r.strokePaint.setAlphaf(stroke.opacity) + + if (stroke.cap) { + r.strokePaint.setStrokeCap(getCapEntity(r, stroke.cap)) + } + if (stroke.join) { + r.strokePaint.setStrokeJoin(getJoinEntity(r, stroke.join)) + } + if (stroke.dashPattern && stroke.dashPattern.length > 0) { + r.strokePaint.setPathEffect(r.ck.PathEffect.MakeDash(stroke.dashPattern, 0)) + } else { + r.strokePaint.setPathEffect(null) + } + + if (node.independentStrokeWeights && r.isRectangularType(node.type)) { + r.drawIndividualSideStrokes(canvas, node, stroke.align) + } else { + r.drawStrokeWithAlign(canvas, node, rect, hasRadius, stroke.align) + } +} + export function renderShapeUncached( r: SkiaRenderer, canvas: Canvas, @@ -222,14 +357,7 @@ export function renderShapeUncached( graph: SceneGraph ): void { const rect = r.ck.LTRBRect(0, 0, node.width, node.height) - - const hasRadius = - node.cornerRadius > 0 || - (node.independentCorners && - (node.topLeftRadius > 0 || - node.topRightRadius > 0 || - node.bottomRightRadius > 0 || - node.bottomLeftRadius > 0)) + const hasRadius = nodeHasRadius(node) r.renderEffects(canvas, node, rect, hasRadius, 'behind') @@ -238,7 +366,6 @@ export function renderShapeUncached( if (!fill.visible) continue r.applyFill(fill, node, graph, fi) r.fillPaint.setAlphaf(fill.opacity) - r.drawNodeFill(canvas, node, rect, hasRadius) r.fillPaint.setShader(null) } @@ -251,74 +378,14 @@ export function renderShapeUncached( const sc = r.resolveStrokeColor(stroke, si, node, graph) if (sg) { - r.fillPaint.setColor(r.ck.Color4f(sc.r, sc.g, sc.b, sc.a)) - r.fillPaint.setAlphaf(stroke.opacity) - r.fillPaint.setShader(null) - for (const p of sg) canvas.drawPath(p, r.fillPaint) + drawVectorStrokeGeometry(r, canvas, sg, sc, stroke.opacity) continue } - if (vectorPaths) { - const capMap: Record = { - NONE: r.ck.StrokeCap.Butt, - ROUND: r.ck.StrokeCap.Round, - SQUARE: r.ck.StrokeCap.Square - } - const joinMap: Record = { - MITER: r.ck.StrokeJoin.Miter, - ROUND: r.ck.StrokeJoin.Round, - BEVEL: r.ck.StrokeJoin.Bevel - } - const strokeOpts = { - width: stroke.weight, - miter_limit: 4, - cap: capMap[stroke.cap ?? 'NONE'] ?? r.ck.StrokeCap.Butt, - join: joinMap[stroke.join ?? 'MITER'] ?? r.ck.StrokeJoin.Miter - } - r.fillPaint.setColor(r.ck.Color4f(sc.r, sc.g, sc.b, sc.a)) - r.fillPaint.setAlphaf(stroke.opacity) - r.fillPaint.setShader(null) - for (const vp of vectorPaths) { - const outline = vp.copy().stroke(strokeOpts) - if (outline) { - canvas.drawPath(outline, r.fillPaint) - outline.delete() - } - } + drawVectorPathStrokes(r, canvas, vectorPaths, stroke, sc) continue } - - r.strokePaint.setColor(r.ck.Color4f(sc.r, sc.g, sc.b, sc.a)) - r.strokePaint.setStrokeWidth(stroke.weight) - r.strokePaint.setAlphaf(stroke.opacity) - - if (stroke.cap) { - const capMap: Record = { - NONE: r.ck.StrokeCap.Butt, - ROUND: r.ck.StrokeCap.Round, - SQUARE: r.ck.StrokeCap.Square - } - r.strokePaint.setStrokeCap(capMap[stroke.cap] ?? r.ck.StrokeCap.Butt) - } - if (stroke.join) { - const joinMap: Record = { - MITER: r.ck.StrokeJoin.Miter, - ROUND: r.ck.StrokeJoin.Round, - BEVEL: r.ck.StrokeJoin.Bevel - } - r.strokePaint.setStrokeJoin(joinMap[stroke.join] ?? r.ck.StrokeJoin.Miter) - } - if (stroke.dashPattern && stroke.dashPattern.length > 0) { - r.strokePaint.setPathEffect(r.ck.PathEffect.MakeDash(stroke.dashPattern, 0)) - } else { - r.strokePaint.setPathEffect(null) - } - - if (node.independentStrokeWeights && r.isRectangularType(node.type)) { - r.drawIndividualSideStrokes(canvas, node, stroke.align) - } else { - r.drawStrokeWithAlign(canvas, node, rect, hasRadius, stroke.align) - } + drawRegularStroke(r, canvas, node, rect, hasRadius, stroke, sc) } r.renderEffects(canvas, node, rect, hasRadius, 'front') diff --git a/packages/core/src/scene-graph.ts b/packages/core/src/scene-graph.ts index 7e8c26752..742a09ff8 100644 --- a/packages/core/src/scene-graph.ts +++ b/packages/core/src/scene-graph.ts @@ -813,6 +813,48 @@ export class SceneGraph { ) } + private static containsPoint( + px: number, + py: number, + ax: number, + ay: number, + node: SceneNode + ): boolean { + return px >= ax && px <= ax + node.width && py >= ay && py <= ay + node.height + } + + private hitTestOpaqueContainer( + px: number, + py: number, + child: SceneNode, + childId: string, + ax: number, + ay: number, + deep: boolean + ): SceneNode | null { + if (!SceneGraph.containsPoint(px, py, ax, ay, child)) return null + const childHit = this.hitTestChildren(px, py, childId, ax, ay, deep) + if (childHit) return child + if (SceneGraph.hasVisibleFillOrStroke(child)) return child + return null + } + + private hitTestTransparentContainer( + px: number, + py: number, + child: SceneNode, + childId: string, + ax: number, + ay: number, + deep: boolean + ): SceneNode | null { + const deepHit = this.hitTestChildren(px, py, childId, ax, ay, deep) + if (deepHit) return deepHit + if (child.type === 'GROUP') return null + if (SceneGraph.containsPoint(px, py, ax, ay, child) && SceneGraph.hasVisibleFillOrStroke(child)) return child + return null + } + private hitTestChildren( px: number, py: number, @@ -825,12 +867,9 @@ export class SceneGraph { if (!parent) return null if (parent.clipsContent) { - if (px < offsetX || px > offsetX + parent.width || py < offsetY || py > offsetY + parent.height) { - return null - } + if (!SceneGraph.containsPoint(px, py, offsetX, offsetY, parent)) return null } - // Reverse order = topmost first for (let i = parent.childIds.length - 1; i >= 0; i--) { const childId = parent.childIds[i] const child = this.nodes.get(childId) @@ -840,32 +879,18 @@ export class SceneGraph { const ay = offsetY + child.y if (CONTAINER_TYPES.has(child.type)) { - // Components/instances: don't recurse unless in deep mode (double-click). - // Still check fills — empty instances are click-through like frames. if (SceneGraph.OPAQUE_CONTAINER_TYPES.has(child.type) && !deep) { - if (px >= ax && px <= ax + child.width && py >= ay && py <= ay + child.height) { - const childHit = this.hitTestChildren(px, py, childId, ax, ay, deep) - if (childHit) return child - if (SceneGraph.hasVisibleFillOrStroke(child)) return child - } + const hit = this.hitTestOpaqueContainer(px, py, child, childId, ax, ay, deep) + if (hit) return hit continue } - const deepHit = this.hitTestChildren(px, py, childId, ax, ay, deep) - if (deepHit) return deepHit - - // Groups are always click-through (only children are hittable). - // Frames/sections without visible fills or strokes are also click-through. - if (child.type === 'GROUP') continue - if (px >= ax && px <= ax + child.width && py >= ay && py <= ay + child.height) { - if (SceneGraph.hasVisibleFillOrStroke(child)) return child - } + const hit = this.hitTestTransparentContainer(px, py, child, childId, ax, ay, deep) + if (hit) return hit continue } - if (px >= ax && px <= ax + child.width && py >= ay && py <= ay + child.height) { - return child - } + if (SceneGraph.containsPoint(px, py, ax, ay, child)) return child } return null } diff --git a/packages/core/src/svg-export-defs.ts b/packages/core/src/svg-export-defs.ts new file mode 100644 index 000000000..0047f07df --- /dev/null +++ b/packages/core/src/svg-export-defs.ts @@ -0,0 +1,255 @@ +import { colorToHex, colorToHex8 } from './color' +import { round } from './svg-export-paths' + +import { svg } from './svg-node' + +import type { SVGNode } from './svg-node' +import type { SceneGraph, SceneNode, Fill, Effect } from './scene-graph' +import type { Color } from './types' + +export interface SVGExportContext { + defs: SVGNode[] + defIdCounter: number + graph: SceneGraph +} + +export function nextDefId(ctx: SVGExportContext, prefix: string): string { + return `${prefix}${ctx.defIdCounter++}` +} + +export function formatColor(color: Color, opacity = 1): string { + return colorToHex8(color, opacity) +} + +function createGradientDef( + fill: Fill, + node: SceneNode, + ctx: SVGExportContext +): { id: string; node: SVGNode } | null { + const stops = fill.gradientStops + const t = fill.gradientTransform + if (!stops || !t) return null + + const stopNodes = stops.map((s) => + svg('stop', { + offset: `${round(s.position * 100)}%`, + 'stop-color': colorToHex(s.color), + 'stop-opacity': s.color.a < 1 ? round(s.color.a) : undefined + }) + ) + + const id = nextDefId(ctx, 'grad') + + if (fill.type === 'GRADIENT_LINEAR') { + const startX = round(t.m02 * 100) + const startY = round(t.m12 * 100) + const endX = round((t.m00 + t.m02) * 100) + const endY = round((t.m10 + t.m12) * 100) + return { + id, + node: svg( + 'linearGradient', + { + id, + x1: `${startX}%`, + y1: `${startY}%`, + x2: `${endX}%`, + y2: `${endY}%`, + gradientUnits: 'objectBoundingBox' + }, + ...stopNodes + ) + } + } + + if (fill.type === 'GRADIENT_RADIAL' || fill.type === 'GRADIENT_DIAMOND') { + const cx = round(t.m02 * 100) + const cy = round(t.m12 * 100) + const r = round(Math.sqrt(t.m00 * t.m00 + t.m10 * t.m10) * 100) + return { + id, + node: svg( + 'radialGradient', + { id, cx: `${cx}%`, cy: `${cy}%`, r: `${r}%`, gradientUnits: 'objectBoundingBox' }, + ...stopNodes + ) + } + } + + if (fill.type === 'GRADIENT_ANGULAR') { + const cx = round(t.m02 * node.width) + const cy = round(t.m12 * node.height) + const r = Math.max(node.width, node.height) + return { + id, + node: svg( + 'radialGradient', + { id, cx, cy, r, gradientUnits: 'userSpaceOnUse' }, + ...stopNodes + ) + } + } + + return null +} + +function createImagePattern( + fill: Fill, + node: SceneNode, + ctx: SVGExportContext +): { id: string; node: SVGNode } | null { + if (!fill.imageHash) return null + const data = ctx.graph.images.get(fill.imageHash) + if (!data) return null + + const id = nextDefId(ctx, 'img') + const base64 = btoa(String.fromCharCode(...data)) + const mime = detectImageMime(data) + + return { + id, + node: svg( + 'pattern', + { + id, + patternUnits: 'objectBoundingBox', + width: 1, + height: 1 + }, + svg('image', { + href: `data:${mime};base64,${base64}`, + width: node.width, + height: node.height, + preserveAspectRatio: fill.imageScaleMode === 'FIT' ? 'xMidYMid meet' : 'xMidYMid slice' + }) + ) + } +} + +function detectImageMime(data: Uint8Array): string { + if (data[0] === 0x89 && data[1] === 0x50) return 'image/png' + if (data[0] === 0xff && data[1] === 0xd8) return 'image/jpeg' + if (data[0] === 0x52 && data[1] === 0x49) return 'image/webp' + return 'image/png' +} + +export function createFilterDef(effects: Effect[], ctx: SVGExportContext): { id: string; node: SVGNode } | null { + const visible = effects.filter((e) => e.visible) + if (visible.length === 0) return null + + const id = nextDefId(ctx, 'fx') + const primitives: SVGNode[] = [] + + for (const effect of visible) { + if (effect.type === 'DROP_SHADOW') { + const stdDev = round(effect.radius / 2) + primitives.push( + svg('feDropShadow', { + dx: round(effect.offset.x), + dy: round(effect.offset.y), + stdDeviation: stdDev, + 'flood-color': colorToHex(effect.color), + 'flood-opacity': round(effect.color.a) + }) + ) + } else if (effect.type === 'INNER_SHADOW') { + const sid = `${id}_is` + const stdDev = round(effect.radius / 2) + primitives.push( + svg('feGaussianBlur', { in: 'SourceAlpha', stdDeviation: stdDev, result: `${sid}_blur` }), + svg('feOffset', { + dx: round(effect.offset.x), + dy: round(effect.offset.y), + result: `${sid}_off` + }), + svg('feComposite', { + in: 'SourceAlpha', + in2: `${sid}_off`, + operator: 'out', + result: `${sid}_inv` + }), + svg('feFlood', { + 'flood-color': colorToHex(effect.color), + 'flood-opacity': round(effect.color.a) + }), + svg('feComposite', { in2: `${sid}_inv`, operator: 'in', result: `${sid}_shadow` }), + svg('feComposite', { + in: `${sid}_shadow`, + in2: 'SourceGraphic', + operator: 'over' + }) + ) + } else { + const stdDev = round(effect.radius / 2) + primitives.push(svg('feGaussianBlur', { stdDeviation: stdDev })) + } + } + + if (primitives.length === 0) return null + + return { + id, + node: svg('filter', { id }, ...primitives) + } +} + +export function resolveFill( + fill: Fill, + node: SceneNode, + ctx: SVGExportContext +): string | null { + if (!fill.visible) return null + + if (fill.type === 'SOLID') { + return formatColor(fill.color, fill.opacity) + } + + if (fill.type.startsWith('GRADIENT')) { + const grad = createGradientDef(fill, node, ctx) + if (grad) { + ctx.defs.push(grad.node) + return `url(#${grad.id})` + } + } + + if (fill.type === 'IMAGE') { + const pattern = createImagePattern(fill, node, ctx) + if (pattern) { + ctx.defs.push(pattern.node) + return `url(#${pattern.id})` + } + } + + return null +} + +export const SVG_STROKE_CAP: Record = { + NONE: 'butt', + ROUND: 'round', + SQUARE: 'square' +} + +export const SVG_STROKE_JOIN: Record = { + MITER: 'miter', + ROUND: 'round', + BEVEL: 'bevel' +} + +export const SVG_BLEND_MODE: Record = { + NORMAL: 'normal', + DARKEN: 'darken', + MULTIPLY: 'multiply', + COLOR_BURN: 'color-burn', + LIGHTEN: 'lighten', + SCREEN: 'screen', + COLOR_DODGE: 'color-dodge', + OVERLAY: 'overlay', + SOFT_LIGHT: 'soft-light', + HARD_LIGHT: 'hard-light', + DIFFERENCE: 'difference', + EXCLUSION: 'exclusion', + HUE: 'hue', + SATURATION: 'saturation', + COLOR: 'color', + LUMINOSITY: 'luminosity' +} diff --git a/packages/core/src/svg-export-paths.ts b/packages/core/src/svg-export-paths.ts new file mode 100644 index 000000000..c218524a0 --- /dev/null +++ b/packages/core/src/svg-export-paths.ts @@ -0,0 +1,212 @@ +import type { SceneNode, VectorNetwork, VectorSegment, VectorVertex } from './scene-graph' + +const CMD_CLOSE = 0 +const CMD_MOVE_TO = 1 +const CMD_LINE_TO = 2 +const CMD_CUBIC_TO = 4 + +export function round(n: number, decimals = 2): number { + const factor = 10 ** decimals + return Math.round(n * factor) / factor +} + +export function geometryBlobToSVGPath(blob: Uint8Array): string { + if (blob.length === 0) return '' + const dv = new DataView(blob.buffer, blob.byteOffset, blob.byteLength) + let o = 0 + const parts: string[] = [] + + while (o < blob.length) { + const cmd = blob[o++] + switch (cmd) { + case CMD_CLOSE: + parts.push('Z') + break + case CMD_MOVE_TO: { + const x = round(dv.getFloat32(o, true)) + const y = round(dv.getFloat32(o + 4, true)) + o += 8 + parts.push(`M${x} ${y}`) + break + } + case CMD_LINE_TO: { + const x = round(dv.getFloat32(o, true)) + const y = round(dv.getFloat32(o + 4, true)) + o += 8 + parts.push(`L${x} ${y}`) + break + } + case CMD_CUBIC_TO: { + const x1 = round(dv.getFloat32(o, true)) + const y1 = round(dv.getFloat32(o + 4, true)) + const x2 = round(dv.getFloat32(o + 8, true)) + const y2 = round(dv.getFloat32(o + 12, true)) + const x = round(dv.getFloat32(o + 16, true)) + const y = round(dv.getFloat32(o + 20, true)) + o += 24 + parts.push(`C${x1} ${y1} ${x2} ${y2} ${x} ${y}`) + break + } + default: + return parts.join('') + } + } + + return parts.join('') +} + +function segmentToSVG(seg: VectorSegment, vertices: VectorVertex[], forward: boolean): string { + const start = forward ? vertices[seg.start] : vertices[seg.end] + const end = forward ? vertices[seg.end] : vertices[seg.start] + const ts = forward ? seg.tangentStart : { x: -seg.tangentEnd.x, y: -seg.tangentEnd.y } + const te = forward ? seg.tangentEnd : { x: -seg.tangentStart.x, y: -seg.tangentStart.y } + + const isStraight = + Math.abs(ts.x) < 0.001 && + Math.abs(ts.y) < 0.001 && + Math.abs(te.x) < 0.001 && + Math.abs(te.y) < 0.001 + + if (isStraight) { + return `L${round(end.x)} ${round(end.y)}` + } + + const cp1x = round(start.x + ts.x) + const cp1y = round(start.y + ts.y) + const cp2x = round(end.x + te.x) + const cp2y = round(end.y + te.y) + return `C${cp1x} ${cp1y} ${cp2x} ${cp2y} ${round(end.x)} ${round(end.y)}` +} + +export function vectorNetworkToSVGPaths(network: VectorNetwork): string[] { + const { vertices, segments, regions } = network + + if (regions.length > 0) { + return regions.map((region) => { + const parts: string[] = [] + for (const loop of region.loops) { + if (loop.length === 0) continue + const firstSeg = segments[loop[0]] + parts.push(`M${round(vertices[firstSeg.start].x)} ${round(vertices[firstSeg.start].y)}`) + for (const segIdx of loop) { + parts.push(segmentToSVG(segments[segIdx], vertices, true)) + } + parts.push('Z') + } + return parts.join('') + }) + } + + const parts: string[] = [] + for (const seg of segments) { + parts.push(`M${round(vertices[seg.start].x)} ${round(vertices[seg.start].y)}`) + parts.push(segmentToSVG(seg, vertices, true)) + } + + return parts.length > 0 ? [parts.join('')] : [] +} + +export function makePolygonPoints(node: SceneNode): string { + const cx = node.width / 2 + const cy = node.height / 2 + const rx = node.width / 2 + const ry = node.height / 2 + const n = Math.max(3, node.pointCount) + const isStar = node.type === 'STAR' + const innerRatio = isStar ? node.starInnerRadius : 1 + const totalPoints = isStar ? n * 2 : n + const angleOffset = -Math.PI / 2 + + const points: string[] = [] + for (let i = 0; i < totalPoints; i++) { + const angle = angleOffset + (2 * Math.PI * i) / totalPoints + const isInner = isStar && i % 2 === 1 + const r = isInner ? innerRatio : 1 + points.push(`${round(cx + rx * r * Math.cos(angle))},${round(cy + ry * r * Math.sin(angle))}`) + } + return points.join(' ') +} + +export function hasRadius(node: SceneNode): boolean { + return ( + node.cornerRadius > 0 || + (node.independentCorners && + (node.topLeftRadius > 0 || + node.topRightRadius > 0 || + node.bottomRightRadius > 0 || + node.bottomLeftRadius > 0)) + ) +} + +export function roundedRectPath(node: SceneNode): string { + const w = node.width + const h = node.height + let tl: number, tr: number, br: number, bl: number + if (node.independentCorners) { + tl = node.topLeftRadius + tr = node.topRightRadius + br = node.bottomRightRadius + bl = node.bottomLeftRadius + } else { + tl = tr = br = bl = node.cornerRadius + } + + tl = Math.min(tl, w / 2, h / 2) + tr = Math.min(tr, w / 2, h / 2) + br = Math.min(br, w / 2, h / 2) + bl = Math.min(bl, w / 2, h / 2) + + return [ + `M${round(tl)} 0`, + `L${round(w - tr)} 0`, + tr > 0 ? `A${round(tr)} ${round(tr)} 0 0 1 ${round(w)} ${round(tr)}` : '', + `L${round(w)} ${round(h - br)}`, + br > 0 ? `A${round(br)} ${round(br)} 0 0 1 ${round(w - br)} ${round(h)}` : '', + `L${round(bl)} ${round(h)}`, + bl > 0 ? `A${round(bl)} ${round(bl)} 0 0 1 0 ${round(h - bl)}` : '', + `L0 ${round(tl)}`, + tl > 0 ? `A${round(tl)} ${round(tl)} 0 0 1 ${round(tl)} 0` : '', + 'Z' + ] + .filter(Boolean) + .join('') +} + +export function arcPath(node: SceneNode): string { + if (!node.arcData) return '' + const { startingAngle, endingAngle, innerRadius } = node.arcData + const cx = node.width / 2 + const cy = node.height / 2 + const rx = node.width / 2 + const ry = node.height / 2 + + const fullCircle = Math.abs(endingAngle - startingAngle) >= Math.PI * 2 - 0.001 + if (fullCircle && innerRadius <= 0) { + return `M${round(cx - rx)} ${round(cy)}A${round(rx)} ${round(ry)} 0 1 1 ${round(cx + rx)} ${round(cy)}A${round(rx)} ${round(ry)} 0 1 1 ${round(cx - rx)} ${round(cy)}Z` + } + + const x1 = round(cx + rx * Math.cos(startingAngle)) + const y1 = round(cy + ry * Math.sin(startingAngle)) + const x2 = round(cx + rx * Math.cos(endingAngle)) + const y2 = round(cy + ry * Math.sin(endingAngle)) + const largeArc = Math.abs(endingAngle - startingAngle) > Math.PI ? 1 : 0 + const sweep = endingAngle > startingAngle ? 1 : 0 + + const parts = [`M${x1} ${y1}`, `A${round(rx)} ${round(ry)} 0 ${largeArc} ${sweep} ${x2} ${y2}`] + + if (innerRadius > 0) { + const irx = rx * innerRadius + const iry = ry * innerRadius + const ix1 = round(cx + irx * Math.cos(endingAngle)) + const iy1 = round(cy + iry * Math.sin(endingAngle)) + const ix2 = round(cx + irx * Math.cos(startingAngle)) + const iy2 = round(cy + iry * Math.sin(startingAngle)) + parts.push(`L${ix1} ${iy1}`) + parts.push(`A${round(irx)} ${round(iry)} 0 ${largeArc} ${sweep === 1 ? 0 : 1} ${ix2} ${iy2}`) + parts.push('Z') + } else { + parts.push(`L${round(cx)} ${round(cy)}Z`) + } + + return parts.join('') +} diff --git a/packages/core/src/svg-export.ts b/packages/core/src/svg-export.ts index 3cdac403b..111433fa0 100644 --- a/packages/core/src/svg-export.ts +++ b/packages/core/src/svg-export.ts @@ -1,494 +1,78 @@ -import { colorToHex, colorToHex8 } from './color' import { computeContentBounds } from './render-image' +import { + round, + geometryBlobToSVGPath, + vectorNetworkToSVGPaths, + makePolygonPoints, + hasRadius, + roundedRectPath, + arcPath +} from './svg-export-paths' +import { + nextDefId, + formatColor, + createFilterDef, + resolveFill, + SVG_STROKE_CAP, + SVG_STROKE_JOIN, + SVG_BLEND_MODE +} from './svg-export-defs' + +export { geometryBlobToSVGPath, vectorNetworkToSVGPaths } from './svg-export-paths' import { svg, renderSVGNode } from './svg-node' import type { SVGNode } from './svg-node' -import type { - SceneGraph, - SceneNode, - Fill, - Effect, - VectorNetwork, - VectorSegment, - VectorVertex -} from './scene-graph' -import type { Color } from './types' - -const CMD_CLOSE = 0 -const CMD_MOVE_TO = 1 -const CMD_LINE_TO = 2 -const CMD_CUBIC_TO = 4 - -interface SVGExportContext { - defs: SVGNode[] - defIdCounter: number - graph: SceneGraph -} - -function nextDefId(ctx: SVGExportContext, prefix: string): string { - return `${prefix}${ctx.defIdCounter++}` -} - -function round(n: number, decimals = 2): number { - const factor = 10 ** decimals - return Math.round(n * factor) / factor -} - -function formatColor(color: Color, opacity = 1): string { - return colorToHex8(color, opacity) -} - -// --- Path data --- - -export function geometryBlobToSVGPath(blob: Uint8Array): string { - if (blob.length === 0) return '' - const dv = new DataView(blob.buffer, blob.byteOffset, blob.byteLength) - let o = 0 - const parts: string[] = [] - - while (o < blob.length) { - const cmd = blob[o++] - switch (cmd) { - case CMD_CLOSE: - parts.push('Z') - break - case CMD_MOVE_TO: { - const x = round(dv.getFloat32(o, true)) - const y = round(dv.getFloat32(o + 4, true)) - o += 8 - parts.push(`M${x} ${y}`) - break - } - case CMD_LINE_TO: { - const x = round(dv.getFloat32(o, true)) - const y = round(dv.getFloat32(o + 4, true)) - o += 8 - parts.push(`L${x} ${y}`) - break - } - case CMD_CUBIC_TO: { - const x1 = round(dv.getFloat32(o, true)) - const y1 = round(dv.getFloat32(o + 4, true)) - const x2 = round(dv.getFloat32(o + 8, true)) - const y2 = round(dv.getFloat32(o + 12, true)) - const x = round(dv.getFloat32(o + 16, true)) - const y = round(dv.getFloat32(o + 20, true)) - o += 24 - parts.push(`C${x1} ${y1} ${x2} ${y2} ${x} ${y}`) - break - } - default: - return parts.join('') - } - } - - return parts.join('') -} - -function segmentToSVG(seg: VectorSegment, vertices: VectorVertex[], forward: boolean): string { - const start = forward ? vertices[seg.start] : vertices[seg.end] - const end = forward ? vertices[seg.end] : vertices[seg.start] - const ts = forward ? seg.tangentStart : { x: -seg.tangentEnd.x, y: -seg.tangentEnd.y } - const te = forward ? seg.tangentEnd : { x: -seg.tangentStart.x, y: -seg.tangentStart.y } - - const isStraight = - Math.abs(ts.x) < 0.001 && - Math.abs(ts.y) < 0.001 && - Math.abs(te.x) < 0.001 && - Math.abs(te.y) < 0.001 - - if (isStraight) { - return `L${round(end.x)} ${round(end.y)}` - } - - const cp1x = round(start.x + ts.x) - const cp1y = round(start.y + ts.y) - const cp2x = round(end.x + te.x) - const cp2y = round(end.y + te.y) - return `C${cp1x} ${cp1y} ${cp2x} ${cp2y} ${round(end.x)} ${round(end.y)}` -} - -export function vectorNetworkToSVGPaths(network: VectorNetwork): string[] { - const { vertices, segments, regions } = network - - if (regions.length > 0) { - return regions.map((region) => { - const parts: string[] = [] - for (const loop of region.loops) { - if (loop.length === 0) continue - const firstSeg = segments[loop[0]] - parts.push(`M${round(vertices[firstSeg.start].x)} ${round(vertices[firstSeg.start].y)}`) - for (const segIdx of loop) { - parts.push(segmentToSVG(segments[segIdx], vertices, true)) - } - parts.push('Z') - } - return parts.join('') - }) - } - - const parts: string[] = [] - for (const seg of segments) { - parts.push(`M${round(vertices[seg.start].x)} ${round(vertices[seg.start].y)}`) - parts.push(segmentToSVG(seg, vertices, true)) - } - - return parts.length > 0 ? [parts.join('')] : [] -} - -// --- Gradients --- - -function createGradientDef( - fill: Fill, - node: SceneNode, - ctx: SVGExportContext -): { id: string; node: SVGNode } | null { - const stops = fill.gradientStops - const t = fill.gradientTransform - if (!stops || !t) return null - - const stopNodes = stops.map((s) => - svg('stop', { - offset: `${round(s.position * 100)}%`, - 'stop-color': colorToHex(s.color), - 'stop-opacity': s.color.a < 1 ? round(s.color.a) : undefined - }) - ) - - const id = nextDefId(ctx, 'grad') - - if (fill.type === 'GRADIENT_LINEAR') { - const startX = round(t.m02 * 100) - const startY = round(t.m12 * 100) - const endX = round((t.m00 + t.m02) * 100) - const endY = round((t.m10 + t.m12) * 100) - return { - id, - node: svg( - 'linearGradient', - { - id, - x1: `${startX}%`, - y1: `${startY}%`, - x2: `${endX}%`, - y2: `${endY}%`, - gradientUnits: 'objectBoundingBox' - }, - ...stopNodes - ) - } - } - - if (fill.type === 'GRADIENT_RADIAL' || fill.type === 'GRADIENT_DIAMOND') { - const cx = round(t.m02 * 100) - const cy = round(t.m12 * 100) - const r = round(Math.sqrt(t.m00 * t.m00 + t.m10 * t.m10) * 100) - return { - id, - node: svg( - 'radialGradient', - { id, cx: `${cx}%`, cy: `${cy}%`, r: `${r}%`, gradientUnits: 'objectBoundingBox' }, - ...stopNodes - ) - } - } - - if (fill.type === 'GRADIENT_ANGULAR') { - const cx = round(t.m02 * node.width) - const cy = round(t.m12 * node.height) - const r = Math.max(node.width, node.height) - return { - id, - node: svg( - 'radialGradient', - { id, cx, cy, r, gradientUnits: 'userSpaceOnUse' }, - ...stopNodes - ) - } - } - - return null -} - -// --- Image fills --- - -function createImagePattern( - fill: Fill, - node: SceneNode, - ctx: SVGExportContext -): { id: string; node: SVGNode } | null { - if (!fill.imageHash) return null - const data = ctx.graph.images.get(fill.imageHash) - if (!data) return null - - const id = nextDefId(ctx, 'img') - const base64 = btoa(String.fromCharCode(...data)) - const mime = detectImageMime(data) - - return { - id, - node: svg( - 'pattern', - { - id, - patternUnits: 'objectBoundingBox', - width: 1, - height: 1 - }, - svg('image', { - href: `data:${mime};base64,${base64}`, - width: node.width, - height: node.height, - preserveAspectRatio: fill.imageScaleMode === 'FIT' ? 'xMidYMid meet' : 'xMidYMid slice' - }) - ) - } -} - -function detectImageMime(data: Uint8Array): string { - if (data[0] === 0x89 && data[1] === 0x50) return 'image/png' - if (data[0] === 0xff && data[1] === 0xd8) return 'image/jpeg' - if (data[0] === 0x52 && data[1] === 0x49) return 'image/webp' - return 'image/png' -} - -// --- Effects → SVG filters --- - -function createFilterDef(effects: Effect[], ctx: SVGExportContext): { id: string; node: SVGNode } | null { - const visible = effects.filter((e) => e.visible) - if (visible.length === 0) return null - - const id = nextDefId(ctx, 'fx') - const primitives: SVGNode[] = [] - - for (const effect of visible) { - if (effect.type === 'DROP_SHADOW') { - const stdDev = round(effect.radius / 2) - primitives.push( - svg('feDropShadow', { - dx: round(effect.offset.x), - dy: round(effect.offset.y), - stdDeviation: stdDev, - 'flood-color': colorToHex(effect.color), - 'flood-opacity': round(effect.color.a) - }) - ) - } else if (effect.type === 'INNER_SHADOW') { - const sid = `${id}_is` - const stdDev = round(effect.radius / 2) - primitives.push( - svg('feGaussianBlur', { in: 'SourceAlpha', stdDeviation: stdDev, result: `${sid}_blur` }), - svg('feOffset', { - dx: round(effect.offset.x), - dy: round(effect.offset.y), - result: `${sid}_off` - }), - svg('feComposite', { - in: 'SourceAlpha', - in2: `${sid}_off`, - operator: 'out', - result: `${sid}_inv` - }), - svg('feFlood', { - 'flood-color': colorToHex(effect.color), - 'flood-opacity': round(effect.color.a) - }), - svg('feComposite', { in2: `${sid}_inv`, operator: 'in', result: `${sid}_shadow` }), - svg('feComposite', { - in: `${sid}_shadow`, - in2: 'SourceGraphic', - operator: 'over' - }) - ) - } else { - const stdDev = round(effect.radius / 2) - primitives.push(svg('feGaussianBlur', { stdDeviation: stdDev })) - } - } - - if (primitives.length === 0) return null - - return { - id, - node: svg('filter', { id }, ...primitives) - } -} - -// --- Fill resolution --- - -function resolveFill( - fill: Fill, - node: SceneNode, - ctx: SVGExportContext -): string | null { - if (!fill.visible) return null - - if (fill.type === 'SOLID') { - return formatColor(fill.color, fill.opacity) - } - - if (fill.type.startsWith('GRADIENT')) { - const grad = createGradientDef(fill, node, ctx) - if (grad) { - ctx.defs.push(grad.node) - return `url(#${grad.id})` - } - } - - if (fill.type === 'IMAGE') { - const pattern = createImagePattern(fill, node, ctx) - if (pattern) { - ctx.defs.push(pattern.node) - return `url(#${pattern.id})` - } - } - - return null -} - -// --- Stroke helpers --- - -const SVG_STROKE_CAP: Record = { - NONE: 'butt', - ROUND: 'round', - SQUARE: 'square' -} - -const SVG_STROKE_JOIN: Record = { - MITER: 'miter', - ROUND: 'round', - BEVEL: 'bevel' -} - -const SVG_BLEND_MODE: Record = { - NORMAL: 'normal', - DARKEN: 'darken', - MULTIPLY: 'multiply', - COLOR_BURN: 'color-burn', - LIGHTEN: 'lighten', - SCREEN: 'screen', - COLOR_DODGE: 'color-dodge', - OVERLAY: 'overlay', - SOFT_LIGHT: 'soft-light', - HARD_LIGHT: 'hard-light', - DIFFERENCE: 'difference', - EXCLUSION: 'exclusion', - HUE: 'hue', - SATURATION: 'saturation', - COLOR: 'color', - LUMINOSITY: 'luminosity' -} - -// --- Shape builders --- - -function makePolygonPoints(node: SceneNode): string { - const cx = node.width / 2 - const cy = node.height / 2 - const rx = node.width / 2 - const ry = node.height / 2 - const n = Math.max(3, node.pointCount) - const isStar = node.type === 'STAR' - const innerRatio = isStar ? node.starInnerRadius : 1 - const totalPoints = isStar ? n * 2 : n - const angleOffset = -Math.PI / 2 - - const points: string[] = [] - for (let i = 0; i < totalPoints; i++) { - const angle = angleOffset + (2 * Math.PI * i) / totalPoints - const isInner = isStar && i % 2 === 1 - const r = isInner ? innerRatio : 1 - points.push(`${round(cx + rx * r * Math.cos(angle))},${round(cy + ry * r * Math.sin(angle))}`) - } - return points.join(' ') -} - -function hasRadius(node: SceneNode): boolean { - return ( - node.cornerRadius > 0 || - (node.independentCorners && - (node.topLeftRadius > 0 || - node.topRightRadius > 0 || - node.bottomRightRadius > 0 || - node.bottomLeftRadius > 0)) - ) -} - -function roundedRectPath(node: SceneNode): string { - const w = node.width - const h = node.height - let tl: number, tr: number, br: number, bl: number - if (node.independentCorners) { - tl = node.topLeftRadius - tr = node.topRightRadius - br = node.bottomRightRadius - bl = node.bottomLeftRadius - } else { - tl = tr = br = bl = node.cornerRadius - } - - tl = Math.min(tl, w / 2, h / 2) - tr = Math.min(tr, w / 2, h / 2) - br = Math.min(br, w / 2, h / 2) - bl = Math.min(bl, w / 2, h / 2) - - return [ - `M${round(tl)} 0`, - `L${round(w - tr)} 0`, - tr > 0 ? `A${round(tr)} ${round(tr)} 0 0 1 ${round(w)} ${round(tr)}` : '', - `L${round(w)} ${round(h - br)}`, - br > 0 ? `A${round(br)} ${round(br)} 0 0 1 ${round(w - br)} ${round(h)}` : '', - `L${round(bl)} ${round(h)}`, - bl > 0 ? `A${round(bl)} ${round(bl)} 0 0 1 0 ${round(h - bl)}` : '', - `L0 ${round(tl)}`, - tl > 0 ? `A${round(tl)} ${round(tl)} 0 0 1 ${round(tl)} 0` : '', - 'Z' - ] - .filter(Boolean) - .join('') -} - -function arcPath(node: SceneNode): string { - if (!node.arcData) return '' - const { startingAngle, endingAngle, innerRadius } = node.arcData - const cx = node.width / 2 - const cy = node.height / 2 - const rx = node.width / 2 - const ry = node.height / 2 - - const fullCircle = Math.abs(endingAngle - startingAngle) >= Math.PI * 2 - 0.001 - if (fullCircle && innerRadius <= 0) { - return `M${round(cx - rx)} ${round(cy)}A${round(rx)} ${round(ry)} 0 1 1 ${round(cx + rx)} ${round(cy)}A${round(rx)} ${round(ry)} 0 1 1 ${round(cx - rx)} ${round(cy)}Z` - } - - const x1 = round(cx + rx * Math.cos(startingAngle)) - const y1 = round(cy + ry * Math.sin(startingAngle)) - const x2 = round(cx + rx * Math.cos(endingAngle)) - const y2 = round(cy + ry * Math.sin(endingAngle)) - const largeArc = Math.abs(endingAngle - startingAngle) > Math.PI ? 1 : 0 - const sweep = endingAngle > startingAngle ? 1 : 0 - - const parts = [`M${x1} ${y1}`, `A${round(rx)} ${round(ry)} 0 ${largeArc} ${sweep} ${x2} ${y2}`] - - if (innerRadius > 0) { - const irx = rx * innerRadius - const iry = ry * innerRadius - const ix1 = round(cx + irx * Math.cos(endingAngle)) - const iy1 = round(cy + iry * Math.sin(endingAngle)) - const ix2 = round(cx + irx * Math.cos(startingAngle)) - const iy2 = round(cy + iry * Math.sin(startingAngle)) - parts.push(`L${ix1} ${iy1}`) - parts.push(`A${round(irx)} ${round(iry)} 0 ${largeArc} ${sweep === 1 ? 0 : 1} ${ix2} ${iy2}`) - parts.push('Z') - } else { - parts.push(`L${round(cx)} ${round(cy)}Z`) - } - - return parts.join('') -} +import type { SceneGraph, SceneNode, Fill, Stroke, CharacterStyleOverride } from './scene-graph' +import type { SVGExportContext } from './svg-export-defs' // --- Node rendering --- +function vectorShapeElements( + node: SceneNode, + common: Record, + strokeAttrs: Record +): SVGNode[] { + const elements: SVGNode[] = [] + if (node.fillGeometry.length > 0) { + for (const geo of node.fillGeometry) { + const d = geometryBlobToSVGPath(geo.commandsBlob) + if (d) { + elements.push( + svg('path', { + d, + 'fill-rule': geo.windingRule === 'EVENODD' ? 'evenodd' : undefined, + ...common + }) + ) + } + } + } else if (node.vectorNetwork) { + const paths = vectorNetworkToSVGPaths(node.vectorNetwork) + for (const d of paths) { + elements.push(svg('path', { d, ...common })) + } + } + if (node.strokeGeometry.length > 0 && strokeAttrs.stroke && strokeAttrs.stroke !== 'none') { + for (const geo of node.strokeGeometry) { + const d = geometryBlobToSVGPath(geo.commandsBlob) + if (d) { + elements.push( + svg('path', { + d, + fill: strokeAttrs.stroke as string, + 'fill-opacity': strokeAttrs['stroke-opacity'], + stroke: 'none' + }) + ) + } + } + } + return elements.length > 0 + ? elements + : [svg('rect', { width: round(node.width), height: round(node.height), ...common })] +} + function nodeShapeElements( node: SceneNode, fillAttr: string | null, @@ -531,46 +115,8 @@ function nodeShapeElements( case 'POLYGON': return [svg('polygon', { points: makePolygonPoints(node), ...common })] - case 'VECTOR': { - const elements: SVGNode[] = [] - if (node.fillGeometry.length > 0) { - for (const geo of node.fillGeometry) { - const d = geometryBlobToSVGPath(geo.commandsBlob) - if (d) { - elements.push( - svg('path', { - d, - 'fill-rule': geo.windingRule === 'EVENODD' ? 'evenodd' : undefined, - ...common - }) - ) - } - } - } else if (node.vectorNetwork) { - const paths = vectorNetworkToSVGPaths(node.vectorNetwork) - for (const d of paths) { - elements.push(svg('path', { d, ...common })) - } - } - if (node.strokeGeometry.length > 0 && strokeAttrs.stroke && strokeAttrs.stroke !== 'none') { - for (const geo of node.strokeGeometry) { - const d = geometryBlobToSVGPath(geo.commandsBlob) - if (d) { - elements.push( - svg('path', { - d, - fill: strokeAttrs.stroke as string, - 'fill-opacity': strokeAttrs['stroke-opacity'], - stroke: 'none' - }) - ) - } - } - } - return elements.length > 0 - ? elements - : [svg('rect', { width: round(node.width), height: round(node.height), ...common })] - } + case 'VECTOR': + return vectorShapeElements(node, common, strokeAttrs) default: { if (hasRadius(node)) { @@ -592,6 +138,18 @@ function nodeShapeElements( } } +function styleOverrideToTspanAttrs(style: CharacterStyleOverride): Record { + const attrs: Record = {} + if (style.fontFamily) attrs['font-family'] = style.fontFamily + if (style.fontSize) attrs['font-size'] = style.fontSize + if (style.fontWeight) attrs['font-weight'] = style.fontWeight + if (style.italic) attrs['font-style'] = 'italic' + if (style.letterSpacing) attrs['letter-spacing'] = round(style.letterSpacing) + if (style.textDecoration === 'UNDERLINE') attrs['text-decoration'] = 'underline' + if (style.textDecoration === 'STRIKETHROUGH') attrs['text-decoration'] = 'line-through' + return attrs +} + function renderTextNode(node: SceneNode, fillAttr: string | null): SVGNode { const attrs: Record = { 'font-family': node.fontFamily || undefined, @@ -628,21 +186,7 @@ function renderTextNode(node: SceneNode, fillAttr: string | null): SVGNode { for (const run of node.styleRuns) { const text = node.text.slice(pos, pos + run.length) pos += run.length - const spanAttrs: Record = {} - if (run.style.fontFamily) spanAttrs['font-family'] = run.style.fontFamily - if (run.style.fontSize) spanAttrs['font-size'] = run.style.fontSize - if (run.style.fontWeight) spanAttrs['font-weight'] = run.style.fontWeight - if (run.style.italic) spanAttrs['font-style'] = 'italic' - if (run.style.letterSpacing) spanAttrs['letter-spacing'] = round(run.style.letterSpacing) - if (run.style.textDecoration === 'UNDERLINE') spanAttrs['text-decoration'] = 'underline' - if (run.style.textDecoration === 'STRIKETHROUGH') - spanAttrs['text-decoration'] = 'line-through' - - if (Object.keys(spanAttrs).length > 0) { - spans.push(svg('tspan', spanAttrs, text)) - } else { - spans.push(svg('tspan', {}, text)) - } + spans.push(svg('tspan', styleOverrideToTspanAttrs(run.style), text)) } return svg('text', { x, y, ...attrs }, ...spans) @@ -653,13 +197,7 @@ function renderTextNode(node: SceneNode, fillAttr: string | null): SVGNode { // --- Main recursive renderer --- -function renderNode(node: SceneNode, ctx: SVGExportContext): SVGNode | null { - if (!node.visible) return null - - const children: (SVGNode | null)[] = [] - const groupAttrs: Record = {} - - // Transform +function buildTransformAttr(node: SceneNode): string | undefined { const transforms: string[] = [] if (node.x !== 0 || node.y !== 0) transforms.push(`translate(${round(node.x)}, ${round(node.y)})`) if (node.rotation !== 0) { @@ -674,25 +212,31 @@ function renderNode(node: SceneNode, ctx: SVGExportContext): SVGNode | null { const sy = node.flipY ? -1 : 1 transforms.push(`translate(${round(tx)}, ${round(ty)}) scale(${sx}, ${sy})`) } - if (transforms.length > 0) groupAttrs.transform = transforms.join(' ') + return transforms.length > 0 ? transforms.join(' ') : undefined +} - // Opacity - if (node.opacity < 1) groupAttrs.opacity = round(node.opacity) +function buildGroupAttrs( + node: SceneNode, + ctx: SVGExportContext +): { attrs: Record; clipId?: string } { + const attrs: Record = {} + + const transform = buildTransformAttr(node) + if (transform) attrs.transform = transform + + if (node.opacity < 1) attrs.opacity = round(node.opacity) - // Blend mode const blend = SVG_BLEND_MODE[node.blendMode] if (blend && blend !== 'normal' && node.blendMode !== 'PASS_THROUGH') { - groupAttrs.style = `mix-blend-mode: ${blend}` + attrs.style = `mix-blend-mode: ${blend}` } - // Effects → filter const filterDef = createFilterDef(node.effects, ctx) if (filterDef) { ctx.defs.push(filterDef.node) - groupAttrs.filter = `url(#${filterDef.id})` + attrs.filter = `url(#${filterDef.id})` } - // Clip path for clipsContent let clipId: string | undefined if (node.clipsContent && node.childIds.length > 0) { clipId = nextDefId(ctx, 'clip') @@ -705,43 +249,43 @@ function renderNode(node: SceneNode, ctx: SVGExportContext): SVGNode | null { ) } - // Text node - if (node.type === 'TEXT') { - const firstFill = node.fills.find((f) => f.visible) - const fillAttr = firstFill ? resolveFill(firstFill, node, ctx) : null - const textEl = renderTextNode(node, fillAttr) - return svg('g', groupAttrs, textEl) + return { attrs, clipId } +} + +function buildSVGStrokeAttrs(visibleStrokes: Stroke[]): Record { + if (visibleStrokes.length === 0) return {} + const stroke = visibleStrokes[0] + const attrs: Record = { + stroke: formatColor(stroke.color, 1), + 'stroke-width': round(stroke.weight) } - - // Resolve fills and strokes - const visibleFills = node.fills.filter((f) => f.visible) - const visibleStrokes = node.strokes.filter((s) => s.visible) - - const fillAttr = visibleFills.length > 0 ? resolveFill(visibleFills[0], node, ctx) : null - const strokeAttrs: Record = {} - - if (visibleStrokes.length > 0) { - const stroke = visibleStrokes[0] - strokeAttrs.stroke = formatColor(stroke.color, 1) - strokeAttrs['stroke-width'] = round(stroke.weight) - if (stroke.opacity < 1) strokeAttrs['stroke-opacity'] = round(stroke.opacity) - if (stroke.cap && stroke.cap !== 'NONE') { - strokeAttrs['stroke-linecap'] = SVG_STROKE_CAP[stroke.cap] ?? 'butt' - } - if (stroke.join && stroke.join !== 'MITER') { - strokeAttrs['stroke-linejoin'] = SVG_STROKE_JOIN[stroke.join] ?? 'miter' - } - if (stroke.dashPattern && stroke.dashPattern.length > 0) { - strokeAttrs['stroke-dasharray'] = stroke.dashPattern.map((n) => round(n)).join(' ') - } + if (stroke.opacity < 1) attrs['stroke-opacity'] = round(stroke.opacity) + if (stroke.cap && stroke.cap !== 'NONE') { + attrs['stroke-linecap'] = SVG_STROKE_CAP[stroke.cap] ?? 'butt' } + if (stroke.join && stroke.join !== 'MITER') { + attrs['stroke-linejoin'] = SVG_STROKE_JOIN[stroke.join] ?? 'miter' + } + if (stroke.dashPattern && stroke.dashPattern.length > 0) { + attrs['stroke-dasharray'] = stroke.dashPattern.map((n) => round(n)).join(' ') + } + return attrs +} - // Multiple fills need stacking +function buildShapeChildren( + node: SceneNode, + visibleFills: Fill[], + fillAttr: string | null, + strokeAttrs: Record, + visibleStrokeCount: number, + ctx: SVGExportContext +): SVGNode[] { if (visibleFills.length > 1) { + const elements: SVGNode[] = [] for (const fill of visibleFills) { const ref = resolveFill(fill, node, ctx) if (ref) { - children.push( + elements.push( ...nodeShapeElements( node, ref, @@ -750,14 +294,38 @@ function renderNode(node: SceneNode, ctx: SVGExportContext): SVGNode | null { ) } } - } else { - const hasFillOrStroke = fillAttr || visibleStrokes.length > 0 - if (hasFillOrStroke && !isGroupLike(node)) { - children.push(...nodeShapeElements(node, fillAttr, strokeAttrs)) - } + return elements } - // Render children + const hasFillOrStroke = fillAttr || visibleStrokeCount > 0 + if (hasFillOrStroke && !isGroupLike(node)) { + return nodeShapeElements(node, fillAttr, strokeAttrs) + } + + return [] +} + +function renderNode(node: SceneNode, ctx: SVGExportContext): SVGNode | null { + if (!node.visible) return null + + const { attrs: groupAttrs, clipId } = buildGroupAttrs(node, ctx) + + if (node.type === 'TEXT') { + const firstFill = node.fills.find((f) => f.visible) + const fillAttr = firstFill ? resolveFill(firstFill, node, ctx) : null + const textEl = renderTextNode(node, fillAttr) + return svg('g', groupAttrs, textEl) + } + + const visibleFills = node.fills.filter((f) => f.visible) + const visibleStrokes = node.strokes.filter((s) => s.visible) + const fillAttr = visibleFills.length > 0 ? resolveFill(visibleFills[0], node, ctx) : null + const strokeAttrs = buildSVGStrokeAttrs(visibleStrokes) + + const children: (SVGNode | null)[] = buildShapeChildren( + node, visibleFills, fillAttr, strokeAttrs, visibleStrokes.length, ctx + ) + const childNodes = ctx.graph.getChildren(node.id) const childContent: SVGNode[] = [] for (const child of childNodes) { @@ -818,7 +386,7 @@ export function renderNodesToSVG( for (const id of nodeIds) { const node = graph.getNode(id) - if (!node || !node.visible) continue + if (!node?.visible) continue const abs = graph.getAbsolutePosition(id) const offsetX = abs.x - minX diff --git a/packages/core/src/tools/analyze.ts b/packages/core/src/tools/analyze.ts index e92669e03..60030feae 100644 --- a/packages/core/src/tools/analyze.ts +++ b/packages/core/src/tools/analyze.ts @@ -32,12 +32,7 @@ function trackColor( // ─── Diff helpers ───────────────────────────────────────────── -function serializeNodeProps(raw: SceneNode): string { - const lines: string[] = [] - lines.push(`type: ${raw.type}`) - lines.push(`size: ${raw.width} ${raw.height}`) - lines.push(`pos: ${raw.x} ${raw.y}`) - +function serializePaintProps(raw: SceneNode, lines: string[]): void { const solidFill = raw.fills.find((f) => f.type === 'SOLID' && f.visible) if (solidFill) lines.push(`fill: ${colorToHex(solidFill.color)}`) @@ -46,9 +41,9 @@ function serializeNodeProps(raw: SceneNode): string { lines.push(`stroke: ${colorToHex(solidStroke.color)}`) if (solidStroke.weight) lines.push(`strokeWeight: ${solidStroke.weight}`) } +} - if (raw.opacity !== 1) lines.push(`opacity: ${Math.round(raw.opacity * 100) / 100}`) - +function serializeCornerRadii(raw: SceneNode, lines: string[]): void { const tl = raw.topLeftRadius const tr = raw.topRightRadius const br = raw.bottomRightRadius @@ -60,11 +55,9 @@ function serializeNodeProps(raw: SceneNode): string { lines.push(`radii: ${tl} ${tr} ${br} ${bl}`) } } +} - if (raw.blendMode !== 'NORMAL') lines.push(`blendMode: ${raw.blendMode}`) - if (raw.rotation !== 0) lines.push(`rotation: ${Math.round(raw.rotation * 100) / 100}`) - if (raw.clipsContent) lines.push(`clipsContent: true`) - +function serializeEffects(raw: SceneNode, lines: string[]): void { for (const effect of raw.effects) { const parts: string[] = [effect.type] parts.push(`r=${effect.radius}`) @@ -73,13 +66,34 @@ function serializeNodeProps(raw: SceneNode): string { parts.push(`s=${effect.spread}`) lines.push(`effect: ${parts.join(' ')}`) } +} - if (raw.type === 'TEXT') { - if (raw.text) lines.push(`text: ${JSON.stringify(raw.text)}`) - if (raw.fontSize) lines.push(`fontSize: ${raw.fontSize}`) - if (raw.fontFamily) lines.push(`fontFamily: ${raw.fontFamily}`) - if (raw.fontWeight) lines.push(`fontWeight: ${raw.fontWeight}`) - } +function serializeTextProps(raw: SceneNode, lines: string[]): void { + if (raw.type !== 'TEXT') return + if (raw.text) lines.push(`text: ${JSON.stringify(raw.text)}`) + if (raw.fontSize) lines.push(`fontSize: ${raw.fontSize}`) + if (raw.fontFamily) lines.push(`fontFamily: ${raw.fontFamily}`) + if (raw.fontWeight) lines.push(`fontWeight: ${raw.fontWeight}`) +} + +function serializeNodeProps(raw: SceneNode): string { + const lines: string[] = [] + lines.push(`type: ${raw.type}`) + lines.push(`size: ${raw.width} ${raw.height}`) + lines.push(`pos: ${raw.x} ${raw.y}`) + + serializePaintProps(raw, lines) + + if (raw.opacity !== 1) lines.push(`opacity: ${Math.round(raw.opacity * 100) / 100}`) + + serializeCornerRadii(raw, lines) + + if (raw.blendMode !== 'NORMAL') lines.push(`blendMode: ${raw.blendMode}`) + if (raw.rotation !== 0) lines.push(`rotation: ${Math.round(raw.rotation * 100) / 100}`) + if (raw.clipsContent) lines.push(`clipsContent: true`) + + serializeEffects(raw, lines) + serializeTextProps(raw, lines) if (!raw.visible) lines.push(`visible: false`) if (raw.locked) lines.push(`locked: true`)