Revert "Revert "fix(fig): preserve OpenPencil round trips""

This reverts commit 9ce6096fc1.
This commit is contained in:
Danila Poyarkov 2026-05-19 00:23:23 +03:00
parent 9ce6096fc1
commit c11d65fe55
11 changed files with 170 additions and 30 deletions

View file

@ -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<SceneNode> & { 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)

View file

@ -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<string, unknown>
@ -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)

View file

@ -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<string, string> {
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<string, string> {
const bindings: Record<string, string> = {}
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)

View file

@ -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<string, string> = {}
const typeMap: Record<string, string> = { 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 }
}

View file

@ -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<SceneNode>
changes = Object.fromEntries(
entries.filter(([, value]) => value !== undefined)
) as Partial<SceneNode>
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
}

View file

@ -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<HTMLCanvasElement | null>, editor: Editor) {

View file

@ -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()

View file

@ -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()

View file

@ -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)

View file

@ -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)

View file

@ -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 () => {