refactor(fig): own raw metadata invalidation
- Track normalized edited fields in SceneGraph without naming Figma payload fields\n- Preserve original raw provenance while filtering stale values through Fig-owned effective readers\n- Rewire export and renderer fallbacks and add package, SceneGraph, and exhaustive round-trip coverage
This commit is contained in:
parent
2bd2304bcb
commit
02b7261c9e
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
- Move complete `.fig` archive parsing, NodeChange-to-SceneGraph conversion, and component/instance interpretation into `@open-pencil/fig`, keeping `@open-pencil/kiwi` focused on Kiwi schema, message, and raw container mechanics.
|
||||
- Remove internal cross-package forwarding modules; import `@open-pencil/fig`, `@open-pencil/pen`, and `@open-pencil/scene-graph` from their owning public exports.
|
||||
- Track normalized source edits in SceneGraph while preserving original `.fig` provenance and filtering stale raw fields through Fig-owned metadata policy.
|
||||
- Add Figma-style page management in the Pages panel, including rename/delete actions and drag-and-drop page reordering.
|
||||
- Add DOM/CSS import and authoring support so HTML, CSS, Tailwind, and JSX can be converted into editable OpenPencil documents from the app, CLI, and SDK.
|
||||
- Add Tailwind class serialization for DOM/CSS HTML export in the SDK and CLI.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Canvas } from 'canvaskit-wasm'
|
||||
|
||||
import { readEffectiveFigmaRawField } from '@open-pencil/fig'
|
||||
import type { SceneNode } from '@open-pencil/scene-graph'
|
||||
import type { Color } from '@open-pencil/scene-graph/primitives'
|
||||
|
||||
|
|
@ -35,7 +36,7 @@ function rawLayoutGrids(node: SceneNode): RawLayoutGrid[] {
|
|||
const modeledGrids = (node as Partial<SceneNode>).layoutGrids ?? []
|
||||
if (modeledGrids.length > 0) return modeledGrids
|
||||
const source = (node as Partial<SceneNode>).source
|
||||
const grids = source?.fig.rawNodeFields.layoutGrids
|
||||
const grids = source ? readEffectiveFigmaRawField(node, 'layoutGrids') : undefined
|
||||
if (!Array.isArray(grids)) return []
|
||||
return grids.filter((grid): grid is RawLayoutGrid => grid !== null && typeof grid === 'object')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Canvas, Path } from 'canvaskit-wasm'
|
||||
|
||||
import { readEffectiveFigmaRawField } from '@open-pencil/fig'
|
||||
import type { SceneNode } from '@open-pencil/scene-graph'
|
||||
import type { Color, Vector } from '@open-pencil/scene-graph/primitives'
|
||||
|
||||
|
|
@ -35,7 +36,7 @@ function resetEffectLayerPaint(r: SkiaRenderer): void {
|
|||
|
||||
function rawNoiseEffects(node: SceneNode): RawNoiseEffect[] {
|
||||
const source = (node as Partial<SceneNode>).source
|
||||
const effects = source?.fig.rawNodeFields.effects
|
||||
const effects = source ? readEffectiveFigmaRawField(node, 'effects') : undefined
|
||||
if (!Array.isArray(effects)) return []
|
||||
return effects.filter(
|
||||
(effect): effect is RawNoiseEffect =>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { effectiveFigmaRawNodeFields, effectiveFigmaSourcePayload } from '@open-pencil/fig'
|
||||
import {
|
||||
applyExportSettingsPluginData,
|
||||
mergePluginData,
|
||||
|
|
@ -363,7 +364,7 @@ function applyRawFigmaNodeFields(
|
|||
node: SceneNode,
|
||||
nc: KiwiNodeChange
|
||||
): void {
|
||||
const materialized = materializeFigmaPayload(node.source.fig.rawNodeFields, context.blobs, {
|
||||
const materialized = materializeFigmaPayload(effectiveFigmaRawNodeFields(node), context.blobs, {
|
||||
blobIndexByHex: context.blobIndexByHex,
|
||||
includePaintVariables: true,
|
||||
includeVariableMaps: true
|
||||
|
|
@ -543,7 +544,7 @@ function shouldSerializeRawBackedField(
|
|||
hasValue: boolean,
|
||||
alreadySerialized = false
|
||||
): boolean {
|
||||
return hasValue && !(rawField in node.source.fig.rawNodeFields) && !alreadySerialized
|
||||
return hasValue && !(rawField in effectiveFigmaRawNodeFields(node)) && !alreadySerialized
|
||||
}
|
||||
|
||||
function applyComponentMetadata(
|
||||
|
|
@ -629,26 +630,22 @@ function applyComponentMetadata(
|
|||
}
|
||||
|
||||
function exportNodeSize(node: SceneNode): Vector {
|
||||
return node.source.fig.rawSize
|
||||
? { ...node.source.fig.rawSize }
|
||||
: { x: node.width, y: node.height }
|
||||
const rawSize = effectiveFigmaSourcePayload(node).rawSize
|
||||
return rawSize ? { ...rawSize } : { x: node.width, y: node.height }
|
||||
}
|
||||
|
||||
function exportNodeTransform(context: SceneNodeToKiwiContext, node: SceneNode): Matrix {
|
||||
return node.source.fig.rawTransform
|
||||
? { ...node.source.fig.rawTransform }
|
||||
: context.computeExportTransform(node)
|
||||
const rawTransform = effectiveFigmaSourcePayload(node).rawTransform
|
||||
return rawTransform ? { ...rawTransform } : context.computeExportTransform(node)
|
||||
}
|
||||
|
||||
function hasRawGeometryPayload(node: SceneNode): boolean {
|
||||
return (
|
||||
'fillGeometry' in node.source.fig.rawNodeFields ||
|
||||
'strokeGeometry' in node.source.fig.rawNodeFields
|
||||
)
|
||||
const rawNodeFields = effectiveFigmaRawNodeFields(node)
|
||||
return 'fillGeometry' in rawNodeFields || 'strokeGeometry' in rawNodeFields
|
||||
}
|
||||
|
||||
function hasRawVectorPayload(node: SceneNode): boolean {
|
||||
return 'vectorData' in node.source.fig.rawNodeFields
|
||||
return 'vectorData' in effectiveFigmaRawNodeFields(node)
|
||||
}
|
||||
|
||||
const SUPPORTED_NORMALIZED_EFFECT_TYPES = new Set([
|
||||
|
|
@ -660,7 +657,7 @@ const SUPPORTED_NORMALIZED_EFFECT_TYPES = new Set([
|
|||
])
|
||||
|
||||
function hasRawUnsupportedEffects(node: SceneNode): boolean {
|
||||
const effects = node.source.fig.rawNodeFields.effects
|
||||
const effects = effectiveFigmaRawNodeFields(node).effects
|
||||
return (
|
||||
Array.isArray(effects) &&
|
||||
effects.some(
|
||||
|
|
@ -747,13 +744,11 @@ function applyNodeVisualProps(
|
|||
if (node.horizontalConstraint !== 'MIN') nc.horizontalConstraint = node.horizontalConstraint
|
||||
if (node.verticalConstraint !== 'MIN') nc.verticalConstraint = node.verticalConstraint
|
||||
if (node.strokeCap !== 'NONE') nc.strokeCap = node.strokeCap
|
||||
if (node.strokeJoin !== 'MITER' || 'strokeJoin' in node.source.fig.rawNodeFields) {
|
||||
const rawNodeFields = effectiveFigmaRawNodeFields(node)
|
||||
if (node.strokeJoin !== 'MITER' || 'strokeJoin' in rawNodeFields) {
|
||||
nc.strokeJoin = node.strokeJoin
|
||||
}
|
||||
if (
|
||||
node.strokeMiterLimit !== DEFAULT_STROKE_MITER_LIMIT ||
|
||||
'miterLimit' in node.source.fig.rawNodeFields
|
||||
) {
|
||||
if (node.strokeMiterLimit !== DEFAULT_STROKE_MITER_LIMIT || 'miterLimit' in rawNodeFields) {
|
||||
nc.miterLimit = node.strokeMiterLimit
|
||||
}
|
||||
if (node.dashPattern.length > 0) nc.dashPattern = node.dashPattern
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { effectiveFigmaRawNodeFields } from '@open-pencil/fig'
|
||||
|
||||
import { bytesToHex, hexToBytes } from '#core/bytes/hex'
|
||||
import { encodePathCommandsBlob } from '#core/kiwi/fig/node-change/path-commands'
|
||||
import { buildDerivedTextData as buildSharedDerivedTextData } from '#core/text/derived-text/data'
|
||||
|
|
@ -247,7 +249,8 @@ function serializeCornerRadii(node: SceneNode, nc: KiwiNodeChange): void {
|
|||
// the raw Figma data. Figma may emit per-corner radii without setting the
|
||||
// independent flag (preserve rectangleCornerRadiiIndependent).
|
||||
const rawIndependent = node.source.id
|
||||
? (node.source.fig.rawNodeFields as JsonObject | undefined)?.rectangleCornerRadiiIndependent
|
||||
? (effectiveFigmaRawNodeFields(node) as JsonObject | undefined)
|
||||
?.rectangleCornerRadiiIndependent
|
||||
: undefined
|
||||
nc.rectangleCornerRadiiIndependent =
|
||||
typeof rawIndependent === 'boolean' ? rawIndependent : node.independentCorners
|
||||
|
|
@ -256,7 +259,7 @@ function serializeCornerRadii(node: SceneNode, nc: KiwiNodeChange): void {
|
|||
nc.rectangleBottomLeftCornerRadius = node.bottomLeftRadius
|
||||
nc.rectangleBottomRightCornerRadius = node.bottomRightRadius
|
||||
}
|
||||
if (node.cornerSmoothing > 0 || 'cornerSmoothing' in node.source.fig.rawNodeFields) {
|
||||
if (node.cornerSmoothing > 0 || 'cornerSmoothing' in effectiveFigmaRawNodeFields(node)) {
|
||||
nc.cornerSmoothing = node.cornerSmoothing
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -224,8 +224,9 @@ These are parsed or visible in Figma docs and most likely to cause visible diffe
|
|||
| Concern | Files |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| Scene graph fields | `packages/scene-graph/src/types.ts` |
|
||||
| Source metadata invalidation | `packages/scene-graph/src/source-metadata.ts` |
|
||||
| Kiwi import mapping | `packages/core/src/kiwi/fig/node-change/convert.ts` |
|
||||
| Source edit tracking | `packages/scene-graph/src/source-metadata.ts` |
|
||||
| `.fig` metadata policy | `packages/fig/src/source-metadata.ts` |
|
||||
| Kiwi import mapping | `packages/fig/src/node-change/convert.ts` |
|
||||
| Kiwi export mapping | `packages/core/src/kiwi/fig/node-change/export-node.ts`, `packages/core/src/kiwi/fig/node-change/serialize.ts` |
|
||||
| Kiwi schema | `packages/kiwi/src/fig/schema/fig.kiwi`, `tests/engine/io/fig/import/schema-coverage.test.ts` |
|
||||
| Renderer dispatch | `packages/core/src/canvas/scene.ts` |
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ Current ownership:
|
|||
- `.fig` source and archive result types
|
||||
- NodeChange-to-SceneGraph property conversion, including styles, plugin metadata, text, paint, vector, and font policy, through `@open-pencil/fig/node-change`
|
||||
- Component-property, symbol-override, derived-symbol-data, and instance synchronization policy through `@open-pencil/fig/instance-overrides`
|
||||
- Effective raw-metadata precedence and invalidation over SceneGraph's format-neutral edited-field tracking
|
||||
- Package-local archive, conversion, instance, and dist smoke tests
|
||||
|
||||
Planned ownership:
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const nodeChange = await import('../dist/node-change.js')
|
|||
|
||||
if (
|
||||
mod.FIG_PACKAGE_STATUS !== 'archive-api' ||
|
||||
typeof mod.effectiveFigmaRawNodeFields !== 'function' ||
|
||||
typeof mod.parseFigBuffer !== 'function' ||
|
||||
typeof mod.writeFigArchive !== 'function' ||
|
||||
typeof instanceOverrides.populateAndApplyOverrides !== 'function' ||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ export {
|
|||
type FigParseResult,
|
||||
type WriteFigArchiveInput
|
||||
} from './archive'
|
||||
export {
|
||||
effectiveFigmaRawNodeFields,
|
||||
effectiveFigmaSourcePayload,
|
||||
readEffectiveFigmaRawField,
|
||||
staleFigmaRawFields
|
||||
} from './source-metadata'
|
||||
|
||||
import {
|
||||
FIG_KIWI_DEFAULT_VERSION,
|
||||
|
|
|
|||
|
|
@ -896,6 +896,7 @@ function extractSourceMetadata(nc: NodeChange, blobs: Uint8Array[]): SceneNode['
|
|||
format: 'fig',
|
||||
id: nc.guid ? guidToString(nc.guid) : null,
|
||||
orderKey: nc.parentIndex?.position ?? null,
|
||||
editedFields: [],
|
||||
fig: {
|
||||
...extractFigmaRawGeometry(nc, blobs),
|
||||
...extractFigmaSymbolMetadata(nc, blobs),
|
||||
|
|
|
|||
|
|
@ -5,9 +5,12 @@ import {
|
|||
type ExportFormatId,
|
||||
type ExportSetting,
|
||||
type PluginDataEntry,
|
||||
type PluginRelaunchDataEntry
|
||||
type PluginRelaunchDataEntry,
|
||||
type SceneNode
|
||||
} from '@open-pencil/scene-graph'
|
||||
|
||||
import { readEffectiveFigmaRawField } from '../source-metadata'
|
||||
|
||||
export const OPEN_PENCIL_PLUGIN_ID = 'open-pencil'
|
||||
export const TEXT_DIRECTION_PLUGIN_KEY = 'textDirection'
|
||||
export const LAYOUT_DIRECTION_PLUGIN_KEY = 'layoutDirection'
|
||||
|
|
@ -34,15 +37,13 @@ export function upsertPluginData(
|
|||
node.pluginData = pluginData
|
||||
}
|
||||
|
||||
export function applyExportSettingsPluginData(node: {
|
||||
exportSettings: ExportSetting[]
|
||||
pluginData: PluginDataEntry[]
|
||||
source?: { fig?: { rawNodeFields?: Record<string, unknown> } }
|
||||
}): void {
|
||||
export function applyExportSettingsPluginData(
|
||||
node: Pick<SceneNode, 'exportSettings' | 'pluginData' | 'source'>
|
||||
): void {
|
||||
if (node.exportSettings.length === 0) return
|
||||
if (
|
||||
!hasOpenPencilExportSettingsPluginData(node.pluginData) &&
|
||||
Array.isArray(node.source?.fig?.rawNodeFields?.exportSettings)
|
||||
Array.isArray(readEffectiveFigmaRawField(node, 'exportSettings'))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
122
packages/fig/src/source-metadata.ts
Normal file
122
packages/fig/src/source-metadata.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import type { FigmaSourcePayload, SceneNode } from '@open-pencil/scene-graph'
|
||||
|
||||
const RAW_SIZE_KEYS = new Set(['width', 'height'])
|
||||
const RAW_TRANSFORM_KEYS = new Set(['x', 'y', 'rotation', 'flipX', 'flipY'])
|
||||
|
||||
const TEXT_DERIVED_RAW_FIELDS = [
|
||||
'textData',
|
||||
'derivedTextData',
|
||||
'textUserLayoutVersion',
|
||||
'textExplicitLayoutVersion'
|
||||
] as const
|
||||
const STROKE_GEOMETRY_RAW_FIELDS = ['strokeGeometry', 'vectorData'] as const
|
||||
|
||||
const EDITED_RAW_FIELDS: Partial<Record<string, readonly string[]>> = {
|
||||
fillStyleId: ['styleIdForFill'],
|
||||
strokeStyleId: ['styleIdForStrokeFill'],
|
||||
textStyleId: ['styleIdForText'],
|
||||
effectStyleId: ['styleIdForEffect'],
|
||||
gridStyleId: ['styleIdForGrid'],
|
||||
fills: ['fillPaints', 'backgroundPaints', 'backgroundColor'],
|
||||
strokes: ['strokePaints'],
|
||||
effects: ['effects'],
|
||||
layoutGrids: ['layoutGrids'],
|
||||
exportSettings: ['exportSettings'],
|
||||
cornerRadius: ['cornerRadius'],
|
||||
independentCorners: ['rectangleCornerRadiiIndependent'],
|
||||
topLeftRadius: ['rectangleTopLeftCornerRadius', 'rectangleCornerRadiiIndependent'],
|
||||
topRightRadius: ['rectangleTopRightCornerRadius', 'rectangleCornerRadiiIndependent'],
|
||||
bottomLeftRadius: ['rectangleBottomLeftCornerRadius', 'rectangleCornerRadiiIndependent'],
|
||||
bottomRightRadius: ['rectangleBottomRightCornerRadius', 'rectangleCornerRadiiIndependent'],
|
||||
cornerSmoothing: ['cornerSmoothing'],
|
||||
borderTopWeight: ['borderTopWeight', ...STROKE_GEOMETRY_RAW_FIELDS],
|
||||
borderRightWeight: ['borderRightWeight', ...STROKE_GEOMETRY_RAW_FIELDS],
|
||||
borderBottomWeight: ['borderBottomWeight', ...STROKE_GEOMETRY_RAW_FIELDS],
|
||||
borderLeftWeight: ['borderLeftWeight', ...STROKE_GEOMETRY_RAW_FIELDS],
|
||||
independentStrokeWeights: [
|
||||
'borderStrokeWeightsIndependent',
|
||||
'borderTopWeight',
|
||||
'borderRightWeight',
|
||||
'borderBottomWeight',
|
||||
'borderLeftWeight',
|
||||
...STROKE_GEOMETRY_RAW_FIELDS
|
||||
],
|
||||
strokeWeight: ['strokeWeight', ...STROKE_GEOMETRY_RAW_FIELDS],
|
||||
strokeJoin: ['strokeJoin', ...STROKE_GEOMETRY_RAW_FIELDS],
|
||||
strokeMiterLimit: ['miterLimit', ...STROKE_GEOMETRY_RAW_FIELDS],
|
||||
strokeCap: [...STROKE_GEOMETRY_RAW_FIELDS],
|
||||
dashPattern: [...STROKE_GEOMETRY_RAW_FIELDS],
|
||||
text: [...TEXT_DERIVED_RAW_FIELDS],
|
||||
styleRuns: [...TEXT_DERIVED_RAW_FIELDS],
|
||||
fontSize: ['fontSize', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
fontFamily: ['fontName', 'fontVersion', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
fontWeight: ['semanticWeight', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
italic: ['semanticItalic', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
lineHeight: ['lineHeight', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
letterSpacing: ['letterSpacing', 'textTracking', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
textAutoResize: ['textAutoResize', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
textDecorationStyle: ['textDecorationStyle', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
textDecorationThickness: ['textDecorationThickness', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
textDecorationFills: ['textDecorationFillPaints', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
textUnderlineOffset: ['textUnderlineOffset', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
leadingTrim: ['leadingTrim', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
maxLines: ['maxLines', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
fontVariations: ['fontVariations', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
fontFeatures: [
|
||||
'fontVariantCommonLigatures',
|
||||
'fontVariantContextualLigatures',
|
||||
'toggledOnOTFeatures',
|
||||
'toggledOffOTFeatures',
|
||||
...TEXT_DERIVED_RAW_FIELDS
|
||||
],
|
||||
minWidth: ['minSize'],
|
||||
minHeight: ['minSize'],
|
||||
maxWidth: ['maxSize'],
|
||||
maxHeight: ['maxSize'],
|
||||
vectorNetwork: ['vectorData', 'fillGeometry', 'strokeGeometry'],
|
||||
fillGeometry: ['fillGeometry', 'vectorData'],
|
||||
strokeGeometry: ['strokeGeometry', 'vectorData'],
|
||||
isMask: ['mask'],
|
||||
maskType: ['maskType'],
|
||||
maskIsOutline: ['maskIsOutline'],
|
||||
componentPropertyDefinitions: ['componentPropDefs'],
|
||||
componentPropertyReferences: ['componentPropRefs'],
|
||||
componentPropertyAssignments: ['componentPropAssignments'],
|
||||
variantPropSpecs: ['variantPropSpecs']
|
||||
}
|
||||
|
||||
export function staleFigmaRawFields(editedFields: readonly string[]): ReadonlySet<string> {
|
||||
return new Set(editedFields.flatMap((key) => EDITED_RAW_FIELDS[key] ?? []))
|
||||
}
|
||||
|
||||
export function effectiveFigmaRawNodeFields(
|
||||
node: Pick<SceneNode, 'source'>
|
||||
): Record<string, unknown> {
|
||||
const staleFields = staleFigmaRawFields(node.source.editedFields)
|
||||
if (staleFields.size === 0) return node.source.fig.rawNodeFields
|
||||
return Object.fromEntries(
|
||||
Object.entries(node.source.fig.rawNodeFields).filter(([key]) => !staleFields.has(key))
|
||||
)
|
||||
}
|
||||
|
||||
export function effectiveFigmaSourcePayload(node: Pick<SceneNode, 'source'>): FigmaSourcePayload {
|
||||
const sourceEditedFields = node.source.editedFields
|
||||
return {
|
||||
...node.source.fig,
|
||||
rawNodeFields: effectiveFigmaRawNodeFields(node),
|
||||
rawSize: sourceEditedFields.some((key) => RAW_SIZE_KEYS.has(key))
|
||||
? null
|
||||
: node.source.fig.rawSize,
|
||||
rawTransform: sourceEditedFields.some((key) => RAW_TRANSFORM_KEYS.has(key))
|
||||
? null
|
||||
: node.source.fig.rawTransform
|
||||
}
|
||||
}
|
||||
|
||||
export function readEffectiveFigmaRawField(
|
||||
node: Pick<SceneNode, 'source'>,
|
||||
field: string
|
||||
): unknown {
|
||||
if (staleFigmaRawFields(node.source.editedFields).has(field)) return undefined
|
||||
return node.source.fig.rawNodeFields[field]
|
||||
}
|
||||
41
packages/fig/tests/source-metadata.test.ts
Normal file
41
packages/fig/tests/source-metadata.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
import { effectiveFigmaRawNodeFields, effectiveFigmaSourcePayload } from '../src/source-metadata'
|
||||
|
||||
describe('@open-pencil/fig source metadata policy', () => {
|
||||
test('filters only raw fields made stale by normalized edits', () => {
|
||||
const graph = new SceneGraph()
|
||||
const node = graph.createNode('RECTANGLE', graph.getPages()[0].id)
|
||||
node.source.fig.rawNodeFields = {
|
||||
fillPaints: [{ type: 'SOLID' }],
|
||||
effects: [{ type: 'NOISE' }],
|
||||
prototypeInteractions: [{ trigger: 'ON_CLICK' }]
|
||||
}
|
||||
|
||||
graph.updateNode(node.id, { fills: [] })
|
||||
|
||||
expect(effectiveFigmaRawNodeFields(node)).toEqual({
|
||||
effects: [{ type: 'NOISE' }],
|
||||
prototypeInteractions: [{ trigger: 'ON_CLICK' }]
|
||||
})
|
||||
expect(node.source.fig.rawNodeFields.fillPaints).toBeDefined()
|
||||
})
|
||||
|
||||
test('invalidates effective raw geometry without deleting provenance', () => {
|
||||
const graph = new SceneGraph()
|
||||
const node = graph.createNode('RECTANGLE', graph.getPages()[0].id)
|
||||
node.source.fig.rawSize = { x: 100, y: 100 }
|
||||
node.source.fig.rawTransform = { m00: 1, m01: 0, m02: 4, m10: 0, m11: 1, m12: 8 }
|
||||
|
||||
graph.updateNode(node.id, { width: 200, x: 20 })
|
||||
|
||||
expect(effectiveFigmaSourcePayload(node)).toMatchObject({
|
||||
rawSize: null,
|
||||
rawTransform: null
|
||||
})
|
||||
expect(node.source.fig.rawSize).toEqual({ x: 100, y: 100 })
|
||||
expect(node.source.fig.rawTransform).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -21,7 +21,7 @@ import * as Instances from './instances'
|
|||
import { CONTAINER_TYPES, createDefaultNode } from './node-defaults'
|
||||
import { updateNodePreview } from './preview'
|
||||
import { styleDetachmentChanges } from './shared-styles'
|
||||
import { clearEditedSourceMetadata } from './source-metadata'
|
||||
import { markSourceFieldsEdited } from './source-metadata'
|
||||
import { TEXT_PICTURE_KEYS } from './text-picture'
|
||||
import * as Variables from './variables'
|
||||
import { normalizeVectorNetwork } from './vector-network'
|
||||
|
|
@ -390,7 +390,7 @@ export class SceneGraph {
|
|||
entries.filter(([, value]) => value !== undefined)
|
||||
) as Partial<SceneNode>
|
||||
if (this.sourceMetadataPreservationDepth === 0) {
|
||||
clearEditedSourceMetadata(node, Object.keys(changes))
|
||||
markSourceFieldsEdited(node, Object.keys(changes))
|
||||
}
|
||||
if (changes.vectorNetwork) {
|
||||
changes = { ...changes, vectorNetwork: normalizeVectorNetwork(changes.vectorNetwork) }
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export function createDefaultNode(
|
|||
format: null,
|
||||
id: null,
|
||||
orderKey: null,
|
||||
editedFields: [],
|
||||
fig: {
|
||||
rawSize: null,
|
||||
rawTransform: null,
|
||||
|
|
|
|||
|
|
@ -1,92 +1,8 @@
|
|||
import { omit } from 'es-toolkit/object'
|
||||
|
||||
import type { SceneNode } from './types'
|
||||
|
||||
const RAW_SIZE_KEYS = new Set(['width', 'height'])
|
||||
|
||||
const RAW_TRANSFORM_KEYS = new Set(['x', 'y', 'rotation', 'flipX', 'flipY'])
|
||||
|
||||
const TEXT_DERIVED_RAW_FIELDS = [
|
||||
'textData',
|
||||
'derivedTextData',
|
||||
'textUserLayoutVersion',
|
||||
'textExplicitLayoutVersion'
|
||||
] as const
|
||||
|
||||
const STROKE_GEOMETRY_RAW_FIELDS = ['strokeGeometry', 'vectorData'] as const
|
||||
|
||||
const EDITED_RAW_FIELDS: Partial<Record<string, readonly string[]>> = {
|
||||
fillStyleId: ['styleIdForFill'],
|
||||
strokeStyleId: ['styleIdForStrokeFill'],
|
||||
textStyleId: ['styleIdForText'],
|
||||
effectStyleId: ['styleIdForEffect'],
|
||||
gridStyleId: ['styleIdForGrid'],
|
||||
fills: ['fillPaints', 'backgroundPaints', 'backgroundColor'],
|
||||
strokes: ['strokePaints'],
|
||||
effects: ['effects'],
|
||||
layoutGrids: ['layoutGrids'],
|
||||
exportSettings: ['exportSettings'],
|
||||
borderTopWeight: ['borderTopWeight', ...STROKE_GEOMETRY_RAW_FIELDS],
|
||||
borderRightWeight: ['borderRightWeight', ...STROKE_GEOMETRY_RAW_FIELDS],
|
||||
borderBottomWeight: ['borderBottomWeight', ...STROKE_GEOMETRY_RAW_FIELDS],
|
||||
borderLeftWeight: ['borderLeftWeight', ...STROKE_GEOMETRY_RAW_FIELDS],
|
||||
independentStrokeWeights: [
|
||||
'borderStrokeWeightsIndependent',
|
||||
'borderTopWeight',
|
||||
'borderRightWeight',
|
||||
'borderBottomWeight',
|
||||
'borderLeftWeight',
|
||||
...STROKE_GEOMETRY_RAW_FIELDS
|
||||
],
|
||||
strokeWeight: ['strokeWeight', ...STROKE_GEOMETRY_RAW_FIELDS],
|
||||
strokeJoin: ['strokeJoin', ...STROKE_GEOMETRY_RAW_FIELDS],
|
||||
strokeMiterLimit: ['miterLimit', ...STROKE_GEOMETRY_RAW_FIELDS],
|
||||
strokeCap: [...STROKE_GEOMETRY_RAW_FIELDS],
|
||||
dashPattern: [...STROKE_GEOMETRY_RAW_FIELDS],
|
||||
text: [...TEXT_DERIVED_RAW_FIELDS],
|
||||
styleRuns: [...TEXT_DERIVED_RAW_FIELDS],
|
||||
fontSize: ['fontSize', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
fontFamily: ['fontName', 'fontVersion', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
fontWeight: ['semanticWeight', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
italic: ['semanticItalic', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
lineHeight: ['lineHeight', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
letterSpacing: ['letterSpacing', 'textTracking', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
textAutoResize: ['textAutoResize', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
textDecorationStyle: ['textDecorationStyle', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
textDecorationThickness: ['textDecorationThickness', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
textDecorationFills: ['textDecorationFillPaints', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
textUnderlineOffset: ['textUnderlineOffset', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
leadingTrim: ['leadingTrim', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
maxLines: ['maxLines', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
fontVariations: ['fontVariations', ...TEXT_DERIVED_RAW_FIELDS],
|
||||
fontFeatures: [
|
||||
'fontVariantCommonLigatures',
|
||||
'fontVariantContextualLigatures',
|
||||
'toggledOnOTFeatures',
|
||||
'toggledOffOTFeatures',
|
||||
...TEXT_DERIVED_RAW_FIELDS
|
||||
],
|
||||
minWidth: ['minSize'],
|
||||
minHeight: ['minSize'],
|
||||
maxWidth: ['maxSize'],
|
||||
maxHeight: ['maxSize'],
|
||||
vectorNetwork: ['vectorData', 'fillGeometry', 'strokeGeometry'],
|
||||
fillGeometry: ['fillGeometry', 'vectorData'],
|
||||
strokeGeometry: ['strokeGeometry', 'vectorData'],
|
||||
isMask: ['mask'],
|
||||
maskType: ['maskType'],
|
||||
maskIsOutline: ['maskIsOutline'],
|
||||
componentPropertyDefinitions: ['componentPropDefs'],
|
||||
componentPropertyReferences: ['componentPropRefs'],
|
||||
componentPropertyAssignments: ['componentPropAssignments'],
|
||||
variantPropSpecs: ['variantPropSpecs']
|
||||
}
|
||||
|
||||
export function clearEditedSourceMetadata(node: SceneNode, changeKeys: string[]): void {
|
||||
const editedRawFields = [...new Set(changeKeys.flatMap((key) => EDITED_RAW_FIELDS[key] ?? []))]
|
||||
if (editedRawFields.length > 0) {
|
||||
node.source.fig.rawNodeFields = omit(node.source.fig.rawNodeFields, editedRawFields)
|
||||
}
|
||||
if (changeKeys.some((key) => RAW_SIZE_KEYS.has(key))) node.source.fig.rawSize = null
|
||||
if (changeKeys.some((key) => RAW_TRANSFORM_KEYS.has(key))) node.source.fig.rawTransform = null
|
||||
export function markSourceFieldsEdited(node: SceneNode, changeKeys: string[]): void {
|
||||
if (changeKeys.length === 0) return
|
||||
const editedFields = new Set(node.source.editedFields)
|
||||
for (const key of changeKeys) editedFields.add(key)
|
||||
node.source.editedFields = [...editedFields]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export interface SourceMetadata {
|
|||
format: 'fig' | null
|
||||
id: string | null
|
||||
orderKey: string | null
|
||||
editedFields: string[]
|
||||
fig: FigmaSourcePayload
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
SceneGraph,
|
||||
type NodeChange
|
||||
} from '@open-pencil/core'
|
||||
import { parseFigBuffer } from '@open-pencil/fig'
|
||||
import { effectiveFigmaRawNodeFields, parseFigBuffer } from '@open-pencil/fig'
|
||||
import { MAX_EXPORT_SCALE } from '@open-pencil/scene-graph'
|
||||
|
||||
function decodeExport(bytes: Uint8Array) {
|
||||
|
|
@ -129,10 +129,11 @@ describe('fig roundtrip export settings', () => {
|
|||
if (!node) throw new Error('imported frame not found')
|
||||
expect(node.exportSettings).toEqual([{ scale: 2, format: 'png' }])
|
||||
|
||||
// User removes every export row; the raw native settings must be dropped so they
|
||||
// don't come back via the import fallback on reopen.
|
||||
// User removes every export row; the raw native settings become stale so they
|
||||
// cannot come back through the import fallback on reopen.
|
||||
graph.updateNode(node.id, { exportSettings: [] })
|
||||
expect(node.source.fig.rawNodeFields?.exportSettings).toBeUndefined()
|
||||
expect(effectiveFigmaRawNodeFields(node).exportSettings).toBeUndefined()
|
||||
expect(node.source.fig.rawNodeFields.exportSettings).toBeDefined()
|
||||
|
||||
const reimported = await parseFigFile((await exportFigFile(graph)).buffer as ArrayBuffer)
|
||||
const reNode = [...reimported.getAllNodes()].find((n) => n.name === 'Export settings frame')
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { beforeAll, describe, expect, test } from 'bun:test'
|
||||
|
||||
import { exportFigFile, initCodec, parseFigFile, SceneGraph } from '@open-pencil/core'
|
||||
import { parseFigBuffer } from '@open-pencil/fig'
|
||||
import { effectiveFigmaRawNodeFields, parseFigBuffer } from '@open-pencil/fig'
|
||||
import { guidToString } from '@open-pencil/fig/node-change'
|
||||
|
||||
function decodeExport(bytes: Uint8Array) {
|
||||
|
|
@ -256,11 +256,12 @@ describe('fig roundtrip source metadata', () => {
|
|||
]
|
||||
})
|
||||
|
||||
expect(frame.source.fig.rawNodeFields).toEqual({
|
||||
expect(effectiveFigmaRawNodeFields(frame)).toEqual({
|
||||
layoutGrids: [{ type: 'MIN', axis: 'X', visible: true }],
|
||||
exportSettings: [{ suffix: '@2x' }],
|
||||
prototypeInteractions: [{ trigger: 'ON_CLICK' }]
|
||||
})
|
||||
expect(frame.source.fig.rawNodeFields.fillPaints).toEqual([{ type: 'SOLID' }])
|
||||
})
|
||||
|
||||
test('preserves imported unsupported effect payloads for round-trip', async () => {
|
||||
|
|
@ -306,7 +307,7 @@ describe('fig roundtrip source metadata', () => {
|
|||
expect(exported?.effects?.[0]?.density).toBeCloseTo(0.4)
|
||||
})
|
||||
|
||||
test('clears raw unsupported effects when normalized effects are edited', () => {
|
||||
test('ignores raw unsupported effects when normalized effects are edited', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const rect = graph.createNode('RECTANGLE', page.id, { name: 'Edited effect metadata' })
|
||||
|
|
@ -339,10 +340,11 @@ describe('fig roundtrip source metadata', () => {
|
|||
]
|
||||
})
|
||||
|
||||
expect(rect.source.fig.rawNodeFields.effects).toBeUndefined()
|
||||
expect(effectiveFigmaRawNodeFields(rect).effects).toBeUndefined()
|
||||
expect(rect.source.fig.rawNodeFields.effects).toBeDefined()
|
||||
})
|
||||
|
||||
test('clears raw geometry payloads when independent stroke weights are edited', () => {
|
||||
test('ignores raw geometry payloads when independent stroke weights are edited', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const rect = graph.createNode('RECTANGLE', page.id, { name: 'Independent stroke metadata' })
|
||||
|
|
@ -359,12 +361,13 @@ describe('fig roundtrip source metadata', () => {
|
|||
borderLeftWeight: 8
|
||||
})
|
||||
|
||||
expect(rect.source.fig.rawNodeFields).toEqual({
|
||||
expect(effectiveFigmaRawNodeFields(rect)).toEqual({
|
||||
fillGeometry: [{ windingRule: 'NONZERO', commands: [] }]
|
||||
})
|
||||
expect(rect.source.fig.rawNodeFields.strokeGeometry).toBeDefined()
|
||||
})
|
||||
|
||||
test('clears raw font variation payloads when normalized axes are edited', async () => {
|
||||
test('ignores raw font variation payloads when normalized axes are edited', async () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const text = graph.createNode('TEXT', page.id, {
|
||||
|
|
@ -383,7 +386,8 @@ describe('fig roundtrip source metadata', () => {
|
|||
(nodeChange) => nodeChange.guid && guidToString(nodeChange.guid) === '4:501'
|
||||
)
|
||||
|
||||
expect(text.source.fig.rawNodeFields.fontVariations).toBeUndefined()
|
||||
expect(effectiveFigmaRawNodeFields(text).fontVariations).toBeUndefined()
|
||||
expect(text.source.fig.rawNodeFields.fontVariations).toBeDefined()
|
||||
expect(exported?.fontVariations).toEqual([
|
||||
{ axisTag: 0x77676874, axisName: 'wght', value: 650 }
|
||||
])
|
||||
|
|
|
|||
42
tests/engine/scene-graph/source-metadata.test.ts
Normal file
42
tests/engine/scene-graph/source-metadata.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
function firstPageId(graph: SceneGraph): string {
|
||||
return graph.getPages()[0].id
|
||||
}
|
||||
|
||||
describe('SceneGraph source dirty tracking', () => {
|
||||
test('records normalized edited fields once in update order', () => {
|
||||
const graph = new SceneGraph()
|
||||
const node = graph.createNode('RECTANGLE', firstPageId(graph))
|
||||
|
||||
graph.updateNode(node.id, { width: 200, fills: [] })
|
||||
graph.updateNode(node.id, { width: 240, opacity: 0.5 })
|
||||
|
||||
expect(node.source.editedFields).toEqual(['width', 'fills', 'opacity'])
|
||||
})
|
||||
|
||||
test('preservation scope suppresses import-time dirty tracking', () => {
|
||||
const graph = new SceneGraph()
|
||||
const node = graph.createNode('RECTANGLE', firstPageId(graph))
|
||||
|
||||
graph.preserveSourceMetadataDuring(() => graph.updateNode(node.id, { width: 200 }))
|
||||
|
||||
expect(node.source.editedFields).toEqual([])
|
||||
})
|
||||
|
||||
test('cloned source dirty fields are independent', () => {
|
||||
const graph = new SceneGraph()
|
||||
const node = graph.createNode('RECTANGLE', firstPageId(graph))
|
||||
graph.updateNode(node.id, { width: 200 })
|
||||
const clone = graph.cloneTree(node.id, firstPageId(graph))
|
||||
expect(clone).not.toBeNull()
|
||||
if (!clone) return
|
||||
|
||||
graph.updateNode(clone.id, { height: 300 })
|
||||
|
||||
expect(node.source.editedFields).toEqual(['width'])
|
||||
expect(clone.source.editedFields).toEqual(['width', 'height'])
|
||||
})
|
||||
})
|
||||
|
|
@ -90,7 +90,7 @@ try {
|
|||
tempDir
|
||||
)
|
||||
nodeEval(
|
||||
"const { FIG_PACKAGE_STATUS, parseFigBuffer, writeFigArchive, readFigContainer, writeFigContainer } = await import('@open-pencil/fig'); if (FIG_PACKAGE_STATUS !== 'archive-api' || typeof parseFigBuffer !== 'function' || typeof writeFigArchive !== 'function') throw new Error('Fig package status smoke failed'); const document = readFigContainer(writeFigContainer({ schemaDeflated: new Uint8Array([1]), dataRaw: new Uint8Array([2]) })); if (document.dataRaw[0] !== 2) throw new Error('Fig container smoke failed')",
|
||||
"const { FIG_PACKAGE_STATUS, effectiveFigmaRawNodeFields, parseFigBuffer, writeFigArchive, readFigContainer, writeFigContainer } = await import('@open-pencil/fig'); if (FIG_PACKAGE_STATUS !== 'archive-api' || typeof effectiveFigmaRawNodeFields !== 'function' || typeof parseFigBuffer !== 'function' || typeof writeFigArchive !== 'function') throw new Error('Fig package status smoke failed'); const document = readFigContainer(writeFigContainer({ schemaDeflated: new Uint8Array([1]), dataRaw: new Uint8Array([2]) })); if (document.dataRaw[0] !== 2) throw new Error('Fig container smoke failed')",
|
||||
tempDir
|
||||
)
|
||||
nodeEval(
|
||||
|
|
|
|||
Loading…
Reference in a new issue