Revert "Revert "fix(fig): preserve OpenPencil round trips""
This reverts commit 9ce6096fc1.
This commit is contained in:
parent
9ce6096fc1
commit
c11d65fe55
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue