From c11d65fe5543080f4eaba98622f43e5cdeeb05e9 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 19 May 2026 00:23:23 +0300 Subject: [PATCH] Revert "Revert "fix(fig): preserve OpenPencil round trips"" This reverts commit 9ce6096fc114a88ba7272a89e0bafe67214fa934. --- packages/core/src/kiwi/node-change/convert.ts | 15 ++++- .../core/src/kiwi/node-change/export-node.ts | 56 ++++++++++++++++--- .../core/src/kiwi/node-change/plugin-data.ts | 22 +++++++- .../core/src/kiwi/node-change/serialize.ts | 18 ++++-- packages/core/src/scene-graph/index.ts | 8 +-- packages/vue/src/shared/input/wheel.ts | 13 +++-- src/app/document/io/imported-document.ts | 2 + src/app/document/io/read.ts | 2 + src/app/tabs/index.ts | 2 + tests/engine/io/fig/roundtrip/basic.test.ts | 24 ++++++++ .../engine/io/fig/roundtrip/variables.test.ts | 38 ++++++++++++- 11 files changed, 170 insertions(+), 30 deletions(-) diff --git a/packages/core/src/kiwi/node-change/convert.ts b/packages/core/src/kiwi/node-change/convert.ts index 6bd47b35b..4f6c17241 100644 --- a/packages/core/src/kiwi/node-change/convert.ts +++ b/packages/core/src/kiwi/node-change/convert.ts @@ -17,6 +17,7 @@ import { extractPluginRelaunchData, getOpenPencilPluginValue, LAYOUT_DIRECTION_PLUGIN_KEY, + NODE_TYPE_PLUGIN_KEY, TEXT_DIRECTION_PLUGIN_KEY } from './plugin-data' import { resolveGeometryPaths, resolveVectorNetwork } from './vector-geometry' @@ -255,7 +256,8 @@ function convertCornerProps( function importedTextLineHeight(nc: NodeChange): number | null { const derivedLineHeight = nc.derivedTextData?.baselines?.[0]?.lineHeight - if (derivedLineHeight !== undefined && Number.isFinite(derivedLineHeight)) return derivedLineHeight + if (derivedLineHeight !== undefined && Number.isFinite(derivedLineHeight)) + return derivedLineHeight return convertLineHeight(nc.lineHeight, nc.fontSize) } @@ -348,7 +350,9 @@ function visibleContainerDerivedLayout( } } -function convertLayoutProps(nc: NodeChange): Pick< +function convertLayoutProps( + nc: NodeChange +): Pick< SceneNode, | 'layoutMode' | 'itemSpacing' @@ -444,7 +448,12 @@ export function nodeChangeToProps( blobs: Uint8Array[] ): Partial & { nodeType: NodeType | 'DOCUMENT' | 'VARIABLE' } { let nodeType = mapNodeType(nc.type) - if (nodeType === 'FRAME' && isComponentSet(nc)) nodeType = 'COMPONENT_SET' + if ( + (nodeType === 'FRAME' && isComponentSet(nc)) || + getOpenPencilPluginValue(nc, NODE_TYPE_PLUGIN_KEY) === 'COMPONENT_SET' + ) { + nodeType = 'COMPONENT_SET' + } const vectorAndStrokeProps = convertVectorAndStrokeProps(nc, blobs) diff --git a/packages/core/src/kiwi/node-change/export-node.ts b/packages/core/src/kiwi/node-change/export-node.ts index 0f62e4fef..b3240761f 100644 --- a/packages/core/src/kiwi/node-change/export-node.ts +++ b/packages/core/src/kiwi/node-change/export-node.ts @@ -3,7 +3,12 @@ import type { SceneGraph, SceneNode } from '#core/scene-graph' import type { Color, GUID, Matrix } from '#core/types' import { stringToGuid } from './guid' -import { mergePluginData, serializePluginRelaunchData } from './plugin-data' +import { + mergePluginData, + NODE_TYPE_PLUGIN_KEY, + serializePluginRelaunchData, + upsertPluginData +} from './plugin-data' export type KiwiNodeChange = NodeChange & Record @@ -45,14 +50,37 @@ interface SceneNodeToKiwiContext { const DEFAULT_STROKE_WEIGHT = 1 +function applyColorVariableBinding( + context: SceneNodeToKiwiContext, + node: SceneNode, + paint: Paint, + field: string +): Paint { + const variableId = node.boundVariables[field] + if (!variableId) return paint + return { + ...paint, + colorVariableBinding: { + variableID: context.varIdToGuid?.get(variableId) ?? stringToGuid(variableId) + } + } +} + function createStrokePaints(context: SceneNodeToKiwiContext, node: SceneNode): Paint[] { - return node.strokes.map((stroke) => ({ - type: 'SOLID', - color: context.safeColor(stroke.color), - opacity: stroke.opacity, - visible: stroke.visible, - blendMode: 'NORMAL' - })) + return node.strokes.map((stroke, index) => + applyColorVariableBinding( + context, + node, + { + type: 'SOLID', + color: context.safeColor(stroke.color), + opacity: stroke.opacity, + visible: stroke.visible, + blendMode: 'NORMAL' + }, + `strokes/${index}/color` + ) + ) } function componentPropertyValue(value: string) { @@ -119,7 +147,16 @@ function applyNodeVisualProps( nc.borderLeftWeight = node.borderLeftWeight } - if (node.fills.length > 0) nc.fillPaints = node.fills.map(context.fillToKiwiPaint) + if (node.fills.length > 0) { + nc.fillPaints = node.fills.map((fill, index) => + applyColorVariableBinding( + context, + node, + context.fillToKiwiPaint(fill), + `fills/${index}/color` + ) + ) + } context.serializeCornerRadii(node, nc) @@ -184,6 +221,7 @@ export function sceneNodeToKiwiWithContext( applyNodeVisualProps(context, node, nc) applyComponentMetadata(node, nc) + if (node.type === 'COMPONENT_SET') upsertPluginData(node, NODE_TYPE_PLUGIN_KEY, node.type) if (strokePaints.length > 0) nc.strokePaints = strokePaints context.serializeLayoutProps(node, nc) diff --git a/packages/core/src/kiwi/node-change/plugin-data.ts b/packages/core/src/kiwi/node-change/plugin-data.ts index 8b827fb08..a21b382cf 100644 --- a/packages/core/src/kiwi/node-change/plugin-data.ts +++ b/packages/core/src/kiwi/node-change/plugin-data.ts @@ -6,6 +6,8 @@ import { guidToString } from './guid' export const OPEN_PENCIL_PLUGIN_ID = 'open-pencil' export const TEXT_DIRECTION_PLUGIN_KEY = 'textDirection' export const LAYOUT_DIRECTION_PLUGIN_KEY = 'layoutDirection' +export const NODE_TYPE_PLUGIN_KEY = 'nodeType' +export const BOUND_VARIABLES_PLUGIN_KEY = 'boundVariables' export function upsertPluginData( node: { pluginData: PluginDataEntry[] }, @@ -19,8 +21,26 @@ export function upsertPluginData( node.pluginData = pluginData } +function parseBoundVariablesPluginValue(value: string | null): Record { + if (!value) return {} + try { + const parsed = JSON.parse(value) as unknown + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {} + return Object.fromEntries( + Object.entries(parsed).filter( + (entry): entry is [string, string] => + typeof entry[0] === 'string' && typeof entry[1] === 'string' + ) + ) + } catch { + return {} + } +} + export function extractBoundVariables(nc: NodeChange): Record { - const bindings: Record = {} + const bindings = parseBoundVariablesPluginValue( + getOpenPencilPluginValue(nc, BOUND_VARIABLES_PLUGIN_KEY) + ) nc.fillPaints?.forEach((paint, i) => { if (paint.colorVariableBinding) { bindings[`fills/${i}/color`] = guidToString(paint.colorVariableBinding.variableID) diff --git a/packages/core/src/kiwi/node-change/serialize.ts b/packages/core/src/kiwi/node-change/serialize.ts index c22fe9f7d..867dd58a2 100644 --- a/packages/core/src/kiwi/node-change/serialize.ts +++ b/packages/core/src/kiwi/node-change/serialize.ts @@ -1,7 +1,7 @@ import { hexToBytes } from '#core/bytes/hex' +import { encodePathCommandsBlob } from '#core/kiwi/node-change/path-commands' import { buildDerivedTextData as buildSharedDerivedTextData } from '#core/text/derived-text/data' import { normalizeFontFamily, weightToFigmaStyle, weightToStyle } from '#core/text/fonts' -import { encodePathCommandsBlob } from '#core/kiwi/node-change/path-commands' import { getGlyphOutlineMetricsSync } from '#core/text/opentype' import { encodeVectorNetworkBlob, buildStyleOverrideTable } from '#core/vector' export { @@ -17,9 +17,10 @@ import type { NodeChange, Paint, VariableConsumptionEntry } from '#core/kiwi/bin import type { SceneGraph, SceneNode, CharacterStyleOverride } from '#core/scene-graph' import type { Color, GUID, Matrix } from '#core/types' -import { stringToGuid, VARIABLE_BINDING_FIELDS } from './convert' +import { guidToString, stringToGuid, VARIABLE_BINDING_FIELDS } from './convert' import { sceneNodeToKiwiWithContext, type KiwiNodeChange } from './export-node' import { + BOUND_VARIABLES_PLUGIN_KEY, LAYOUT_DIRECTION_PLUGIN_KEY, TEXT_DIRECTION_PLUGIN_KEY, upsertPluginData @@ -106,7 +107,8 @@ function buildDerivedTextData( } const style = weightToStyle(node.fontWeight, node.italic) - const glyphMetrics = getGlyphOutlineMetricsSync(node.fontFamily, style, node.text, node.fontSize) ?? [] + const glyphMetrics = + getGlyphOutlineMetricsSync(node.fontFamily, style, node.text, node.fontSize) ?? [] const lineHeight = node.lineHeight ?? Math.ceil(node.fontSize * 1.2) const glyphAdvance = node.text.length > 0 ? node.width / Math.max(node.text.length, 1) : 0 @@ -344,13 +346,16 @@ function serializeVariableBindings( ): void { if (Object.keys(node.boundVariables).length === 0) return const entries: VariableConsumptionEntry[] = [] + const roundtripBindings: Record = {} 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 = varIdToGuid?.get(varId) ?? stringToGuid(varId) + roundtripBindings[field] = guidToString(varGuid) + + const kiwiField = VARIABLE_BINDING_FIELDS[field] + if (!kiwiField) continue const resolvedType = typeMap[variable.type] ?? 'FLOAT' entries.push({ variableData: { @@ -361,6 +366,9 @@ function serializeVariableBindings( variableField: kiwiField }) } + if (Object.keys(roundtripBindings).length > 0) { + upsertPluginData(node, BOUND_VARIABLES_PLUGIN_KEY, JSON.stringify(roundtripBindings)) + } if (entries.length > 0) nc.variableConsumptionMap = { entries } } diff --git a/packages/core/src/scene-graph/index.ts b/packages/core/src/scene-graph/index.ts index 6bec41eeb..f196b89ad 100644 --- a/packages/core/src/scene-graph/index.ts +++ b/packages/core/src/scene-graph/index.ts @@ -403,7 +403,9 @@ export class SceneGraph { if (node.figmaDerivedTextGlyphs && 'text' in changes) node.figmaDerivedTextGlyphs = null } const entries = Object.entries(changes) as Array<[string, unknown]> - changes = Object.fromEntries(entries.filter(([, value]) => value !== undefined)) as Partial + changes = Object.fromEntries( + entries.filter(([, value]) => value !== undefined) + ) as Partial if (changes.vectorNetwork) { changes = { ...changes, vectorNetwork: normalizeVectorNetwork(changes.vectorNetwork) } } @@ -591,9 +593,7 @@ export class SceneGraph { const child = this.nodes.get(childId) if (!child) continue result.push({ node: child, depth }) - if (child.childIds.length > 0) { - result.push(...this.flattenTree(childId, depth + 1)) - } + if (child.childIds.length > 0) result.push(...this.flattenTree(childId, depth + 1)) } return result } diff --git a/packages/vue/src/shared/input/wheel.ts b/packages/vue/src/shared/input/wheel.ts index 43ff7c9eb..5bd84f71b 100644 --- a/packages/vue/src/shared/input/wheel.ts +++ b/packages/vue/src/shared/input/wheel.ts @@ -2,6 +2,7 @@ import { useEventListener } from '@vueuse/core' import type { Ref } from 'vue' import type { Editor } from '@open-pencil/core/editor' + import { createRafScheduler } from '#vue/shared/input/raf-scheduler' type WheelAccum = { @@ -31,14 +32,14 @@ function normalizeWheelDelta(e: WheelEvent): { dx: number; dy: number } { const WHEEL_ZOOM_SPEED = 1.25 +function wheelDeltaModeScale(event: WheelEvent) { + if (event.deltaMode === 1) return 0.05 + return event.deltaMode ? 1 : 0.002 +} + function wheelZoomDelta(event: WheelEvent) { const factor = event.ctrlKey && isMacOs() ? 10 : 1 - return ( - -event.deltaY * - (event.deltaMode === 1 ? 0.05 : (event.deltaMode ? 1 : 0.002)) * - factor * - WHEEL_ZOOM_SPEED - ) + return -event.deltaY * wheelDeltaModeScale(event) * factor * WHEEL_ZOOM_SPEED } export function setupWheelPanZoom(canvasRef: Ref, editor: Editor) { diff --git a/src/app/document/io/imported-document.ts b/src/app/document/io/imported-document.ts index 98a4f0356..000b8b64a 100644 --- a/src/app/document/io/imported-document.ts +++ b/src/app/document/io/imported-document.ts @@ -1,7 +1,9 @@ import type { Editor } from '@open-pencil/core/editor' +import { computeAllLayouts } from '@open-pencil/core/layout' import type { SceneGraph, SceneNode } from '@open-pencil/core/scene-graph' export async function applyImportedDocument(editor: Editor, imported: SceneGraph) { + computeAllLayouts(imported) editor.replaceGraph(imported) editor.undo.clear() editor.clearSelection() diff --git a/src/app/document/io/read.ts b/src/app/document/io/read.ts index d145aa3b4..e755bceed 100644 --- a/src/app/document/io/read.ts +++ b/src/app/document/io/read.ts @@ -1,5 +1,6 @@ import type { Editor, EditorState } from '@open-pencil/core/editor' import { readFigFile } from '@open-pencil/core/io/formats/fig' +import { computeAllLayouts } from '@open-pencil/core/layout' import { yieldToUI } from '@/app/document/io/browser' import { applyImportedDocument } from '@/app/document/io/imported-document' @@ -80,6 +81,7 @@ export function createReloadActions({ fileHandle }) if (!imported) return + computeAllLayouts(imported) editor.replaceGraph(imported) editor.undo.clear() diff --git a/src/app/tabs/index.ts b/src/app/tabs/index.ts index 3aca12909..f08777e7d 100644 --- a/src/app/tabs/index.ts +++ b/src/app/tabs/index.ts @@ -2,6 +2,7 @@ import { shallowRef, computed, triggerRef } from 'vue' import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core/io' import { readFigFile } from '@open-pencil/core/io/formats/fig' +import { computeAllLayouts } from '@open-pencil/core/layout' import type { SceneGraph } from '@open-pencil/core/scene-graph' import { setOpenPencilStore } from '@/app/browser-bridge' @@ -115,6 +116,7 @@ export async function openFileInNewTab( data: new Uint8Array(await file.arrayBuffer()) }) + computeAllLayouts(imported) store.replaceGraph(imported) store.undo.clear() store.setDocumentSource(file.name, sourceFormat, handle, path) diff --git a/tests/engine/io/fig/roundtrip/basic.test.ts b/tests/engine/io/fig/roundtrip/basic.test.ts index 356c15e97..b35730b1b 100644 --- a/tests/engine/io/fig/roundtrip/basic.test.ts +++ b/tests/engine/io/fig/roundtrip/basic.test.ts @@ -180,6 +180,30 @@ describe('roundtrip: export → re-import', () => { expect(types.get('ELLIPSE')).toBe(1) }) + test('preserves component sets', async () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const componentSet = graph.createNode('COMPONENT_SET', page.id, { + name: 'Button', + width: 240, + height: 80 + }) + graph.createNode('COMPONENT', componentSet.id, { + name: 'Primary', + x: 16, + y: 16, + width: 96, + height: 40 + }) + + const figBytes = await exportFigFile(graph) + const parsed = await parseFigFile(figBytes.buffer as ArrayBuffer) + const parsedSet = parsed.getAllNodes().find((node) => node.name === 'Button') + + expect(parsedSet).toBeDefined() + expect(expectDefined(parsedSet, 'parsedSet').type).toBe('COMPONENT_SET') + }) + test('preserves node names', () => { const names = new Set(reImportedNodes.map((n) => n.name)) expect(names.has('Container')).toBe(true) diff --git a/tests/engine/io/fig/roundtrip/variables.test.ts b/tests/engine/io/fig/roundtrip/variables.test.ts index 478ceea77..e585df5e8 100644 --- a/tests/engine/io/fig/roundtrip/variables.test.ts +++ b/tests/engine/io/fig/roundtrip/variables.test.ts @@ -64,22 +64,56 @@ describe('variable roundtrip', () => { const graph = new SceneGraph() const col = graph.createCollection('Tokens') const floatVar = graph.createVariable('radius', 'FLOAT', col.id, 12) + const fillVar = graph.createVariable('surface', 'COLOR', col.id, { r: 1, g: 1, b: 1, a: 1 }) + const strokeVar = graph.createVariable('border', 'COLOR', col.id, { + r: 0.1, + g: 0.2, + b: 0.3, + a: 1 + }) const page = graph.getPages()[0] const rect = graph.createNode('RECTANGLE', page.id, { name: 'Bound Rect', width: 100, height: 100, - cornerRadius: 12 + cornerRadius: 12, + fills: [ + { + type: 'SOLID', + color: { r: 1, g: 1, b: 1, a: 1 }, + opacity: 1, + visible: true, + blendMode: 'NORMAL' + } + ], + strokes: [ + { + color: { r: 0.1, g: 0.2, b: 0.3, a: 1 }, + weight: 1, + opacity: 1, + visible: true, + align: 'INSIDE', + cap: 'NONE', + join: 'MITER', + dashPattern: [] + } + ] }) graph.bindVariable(rect.id, 'cornerRadius', floatVar.id) + graph.bindVariable(rect.id, 'fills/0/color', fillVar.id) + graph.bindVariable(rect.id, 'strokes/0/color', strokeVar.id) const exported = await exportFigFile(graph) const reimported = await parseFigFile(exported.buffer as ArrayBuffer) const reimportedRect = [...reimported.getAllNodes()].find((n) => n.name === 'Bound Rect') expect(reimportedRect).toBeDefined() - expect(Object.keys(reimportedRect.boundVariables)).toContain('cornerRadius') + expect(Object.keys(expectDefined(reimportedRect, 'reimportedRect').boundVariables)).toContain( + 'cornerRadius' + ) + expect(Object.keys(reimportedRect.boundVariables)).toContain('fills/0/color') + expect(Object.keys(reimportedRect.boundVariables)).toContain('strokes/0/color') }) test('material3.fig variables survive round-trip', async () => {