From 02b7261c9e922ee8b6013615e3f42637e546d1f5 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 18 Jul 2026 02:22:01 +0300 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + packages/core/src/canvas/layout-grids.ts | 3 +- packages/core/src/canvas/shadows.ts | 3 +- .../src/kiwi/fig/node-change/export-node.ts | 33 ++--- .../src/kiwi/fig/node-change/serialize.ts | 7 +- packages/docs/development/roadmap.md | 5 +- packages/fig/README.md | 1 + packages/fig/scripts/smoke-dist.ts | 1 + packages/fig/src/index.ts | 6 + packages/fig/src/node-change/convert.ts | 1 + packages/fig/src/node-change/plugin-data.ts | 15 ++- packages/fig/src/source-metadata.ts | 122 ++++++++++++++++++ packages/fig/tests/source-metadata.test.ts | 41 ++++++ packages/scene-graph/src/index.ts | 4 +- packages/scene-graph/src/node-defaults.ts | 1 + packages/scene-graph/src/source-metadata.ts | 94 +------------- packages/scene-graph/src/types.ts | 1 + .../io/fig/roundtrip/export-settings.test.ts | 9 +- .../io/fig/roundtrip/source-metadata.test.ts | 20 +-- .../scene-graph/source-metadata.test.ts | 42 ++++++ tools/package-quality/src/smoke.ts | 2 +- 21 files changed, 276 insertions(+), 136 deletions(-) create mode 100644 packages/fig/src/source-metadata.ts create mode 100644 packages/fig/tests/source-metadata.test.ts create mode 100644 tests/engine/scene-graph/source-metadata.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f4447c27f..08d381179 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/core/src/canvas/layout-grids.ts b/packages/core/src/canvas/layout-grids.ts index 8ed6abe24..71272a870 100644 --- a/packages/core/src/canvas/layout-grids.ts +++ b/packages/core/src/canvas/layout-grids.ts @@ -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).layoutGrids ?? [] if (modeledGrids.length > 0) return modeledGrids const source = (node as Partial).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') } diff --git a/packages/core/src/canvas/shadows.ts b/packages/core/src/canvas/shadows.ts index ce633ef52..a14eb3360 100644 --- a/packages/core/src/canvas/shadows.ts +++ b/packages/core/src/canvas/shadows.ts @@ -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).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 => diff --git a/packages/core/src/kiwi/fig/node-change/export-node.ts b/packages/core/src/kiwi/fig/node-change/export-node.ts index ab568f4b0..1fcdf50e4 100644 --- a/packages/core/src/kiwi/fig/node-change/export-node.ts +++ b/packages/core/src/kiwi/fig/node-change/export-node.ts @@ -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 diff --git a/packages/core/src/kiwi/fig/node-change/serialize.ts b/packages/core/src/kiwi/fig/node-change/serialize.ts index 1ac8349fb..5a98cce44 100644 --- a/packages/core/src/kiwi/fig/node-change/serialize.ts +++ b/packages/core/src/kiwi/fig/node-change/serialize.ts @@ -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 } } diff --git a/packages/docs/development/roadmap.md b/packages/docs/development/roadmap.md index ef5bc0500..6e87bc2bc 100644 --- a/packages/docs/development/roadmap.md +++ b/packages/docs/development/roadmap.md @@ -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` | diff --git a/packages/fig/README.md b/packages/fig/README.md index 9e01d550a..264b238f8 100644 --- a/packages/fig/README.md +++ b/packages/fig/README.md @@ -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: diff --git a/packages/fig/scripts/smoke-dist.ts b/packages/fig/scripts/smoke-dist.ts index afaa78035..c63863d6f 100644 --- a/packages/fig/scripts/smoke-dist.ts +++ b/packages/fig/scripts/smoke-dist.ts @@ -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' || diff --git a/packages/fig/src/index.ts b/packages/fig/src/index.ts index d12f8ba48..72eb879af 100644 --- a/packages/fig/src/index.ts +++ b/packages/fig/src/index.ts @@ -6,6 +6,12 @@ export { type FigParseResult, type WriteFigArchiveInput } from './archive' +export { + effectiveFigmaRawNodeFields, + effectiveFigmaSourcePayload, + readEffectiveFigmaRawField, + staleFigmaRawFields +} from './source-metadata' import { FIG_KIWI_DEFAULT_VERSION, diff --git a/packages/fig/src/node-change/convert.ts b/packages/fig/src/node-change/convert.ts index 6ad2ff1b0..34a53edf2 100644 --- a/packages/fig/src/node-change/convert.ts +++ b/packages/fig/src/node-change/convert.ts @@ -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), diff --git a/packages/fig/src/node-change/plugin-data.ts b/packages/fig/src/node-change/plugin-data.ts index 5232ff358..1d4e70a20 100644 --- a/packages/fig/src/node-change/plugin-data.ts +++ b/packages/fig/src/node-change/plugin-data.ts @@ -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 } } -}): void { +export function applyExportSettingsPluginData( + node: Pick +): void { if (node.exportSettings.length === 0) return if ( !hasOpenPencilExportSettingsPluginData(node.pluginData) && - Array.isArray(node.source?.fig?.rawNodeFields?.exportSettings) + Array.isArray(readEffectiveFigmaRawField(node, 'exportSettings')) ) { return } diff --git a/packages/fig/src/source-metadata.ts b/packages/fig/src/source-metadata.ts new file mode 100644 index 000000000..f7d823657 --- /dev/null +++ b/packages/fig/src/source-metadata.ts @@ -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> = { + 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 { + return new Set(editedFields.flatMap((key) => EDITED_RAW_FIELDS[key] ?? [])) +} + +export function effectiveFigmaRawNodeFields( + node: Pick +): Record { + 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): 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, + field: string +): unknown { + if (staleFigmaRawFields(node.source.editedFields).has(field)) return undefined + return node.source.fig.rawNodeFields[field] +} diff --git a/packages/fig/tests/source-metadata.test.ts b/packages/fig/tests/source-metadata.test.ts new file mode 100644 index 000000000..4f71cc62f --- /dev/null +++ b/packages/fig/tests/source-metadata.test.ts @@ -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() + }) +}) diff --git a/packages/scene-graph/src/index.ts b/packages/scene-graph/src/index.ts index c9e2d0f3d..dda9ef08c 100644 --- a/packages/scene-graph/src/index.ts +++ b/packages/scene-graph/src/index.ts @@ -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 if (this.sourceMetadataPreservationDepth === 0) { - clearEditedSourceMetadata(node, Object.keys(changes)) + markSourceFieldsEdited(node, Object.keys(changes)) } if (changes.vectorNetwork) { changes = { ...changes, vectorNetwork: normalizeVectorNetwork(changes.vectorNetwork) } diff --git a/packages/scene-graph/src/node-defaults.ts b/packages/scene-graph/src/node-defaults.ts index f0e8428a2..9d68e854d 100644 --- a/packages/scene-graph/src/node-defaults.ts +++ b/packages/scene-graph/src/node-defaults.ts @@ -21,6 +21,7 @@ export function createDefaultNode( format: null, id: null, orderKey: null, + editedFields: [], fig: { rawSize: null, rawTransform: null, diff --git a/packages/scene-graph/src/source-metadata.ts b/packages/scene-graph/src/source-metadata.ts index 99fb3e6fe..d5b575c82 100644 --- a/packages/scene-graph/src/source-metadata.ts +++ b/packages/scene-graph/src/source-metadata.ts @@ -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> = { - 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] } diff --git a/packages/scene-graph/src/types.ts b/packages/scene-graph/src/types.ts index ecc1dfadd..80930e466 100644 --- a/packages/scene-graph/src/types.ts +++ b/packages/scene-graph/src/types.ts @@ -36,6 +36,7 @@ export interface SourceMetadata { format: 'fig' | null id: string | null orderKey: string | null + editedFields: string[] fig: FigmaSourcePayload } diff --git a/tests/engine/io/fig/roundtrip/export-settings.test.ts b/tests/engine/io/fig/roundtrip/export-settings.test.ts index 8802b5f0e..d87f85193 100644 --- a/tests/engine/io/fig/roundtrip/export-settings.test.ts +++ b/tests/engine/io/fig/roundtrip/export-settings.test.ts @@ -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') diff --git a/tests/engine/io/fig/roundtrip/source-metadata.test.ts b/tests/engine/io/fig/roundtrip/source-metadata.test.ts index b911015a5..c5c9238ef 100644 --- a/tests/engine/io/fig/roundtrip/source-metadata.test.ts +++ b/tests/engine/io/fig/roundtrip/source-metadata.test.ts @@ -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 } ]) diff --git a/tests/engine/scene-graph/source-metadata.test.ts b/tests/engine/scene-graph/source-metadata.test.ts new file mode 100644 index 000000000..f8fe330fd --- /dev/null +++ b/tests/engine/scene-graph/source-metadata.test.ts @@ -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']) + }) +}) diff --git a/tools/package-quality/src/smoke.ts b/tools/package-quality/src/smoke.ts index 8998dd9a0..8d0b5dc91 100644 --- a/tools/package-quality/src/smoke.ts +++ b/tools/package-quality/src/smoke.ts @@ -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(