diff --git a/CHANGELOG.md b/CHANGELOG.md index a0801f71a..2ab2adc51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - `diff_create` and `diff_show` patches list changed JSX attributes per node, such as `-rounded={8}` and `+rounded={12}`, instead of `key: value` property lines, so they cover every property the JSX export writes and report reordered children as moves. `diff_show` takes JSX attributes in `attributes`, such as `w={200} bg="#FF0000"`, instead of a JSON `props` object, and patches in the old format no longer apply. - `openpencil documents` is now a command group: list open documents with `openpencil documents list`. - The MCP `close_file` tool no longer asks in the app whether to save unsaved changes, a question an agent could not answer and that left the call timing out. With unsaved changes it now fails unless `unsaved` is `"save"` or `"discard"`, and it is marked as a write tool. +- OpenPencil's plugin-data keys are defined once in `OPEN_PENCIL_PLUGIN_DATA` from `@open-pencil/scene-graph`, each with the schema that reads and writes it, and read or replaced with `readPluginData` and `withPluginData`. `@open-pencil/fig/node-change` no longer exports `OPEN_PENCIL_PLUGIN_ID` (now in `@open-pencil/scene-graph`), the `*_PLUGIN_KEY` constants, `upsertPluginData`, `removePluginData`, or `getOpenPencilPluginValue`; read a `.fig` record's value with `readNodeChangePluginData`. `@open-pencil/core` no longer exports `SOURCE_LIBRARY_PUBLICATION_PLUGIN_KEY`, and `SourceLibraryPublication` moved to `@open-pencil/scene-graph`. ### Added @@ -74,6 +75,7 @@ - Save a `.fig` file that was opened and not edited yet. In the app the save never finished, and MCP `save_file` timed out without writing the file. - Show `.fig` thumbnails in the desktop app's recent files, which the app was not permitted to read. - Give paints set through the plugin API, `eval`, and AI and MCP scripts an opacity of 1 and make them visible when the script leaves those out, as Figma does. Such paints were stored without them, which the Design panel could not show. +- Keep a layer's other plugin data when you pick or clear a colour in OkHCL. Picking one rewrote every plugin-data entry on the layer, including other plugins' and its export settings, as OkHCL data. - Show variable-bound colours and numbers correctly when a `.fig` exported from OpenPencil opens in Figma. Figma draws the value a bound field stores until something makes it resolve the variable again, and exports stored the colour from before the binding, so a bound fill appeared in its old colour. Each bound field is now written as it resolves in its layer's mode, or in the collection's default mode when the layer sets none. - Give strokes the same paints fills have. A `.fig` file's gradient or image stroke imported as opaque black, because a stroke could hold only one color; it now keeps its stops, transform, and image, and renders and saves the way the same paint does as a fill. The stroke panel opens the fill picker, so you can give a stroke a gradient or an image and its weight, align, cap, join, and dashes stay as they were ([#797](https://github.com/open-pencil/open-pencil/issues/797)). - Stop showing a “signal is aborted without reason” error when you switch pages again before the previous page has finished loading. diff --git a/bun.lock b/bun.lock index 61d0e64f0..4867f46d6 100644 --- a/bun.lock +++ b/bun.lock @@ -412,6 +412,7 @@ "es-toolkit": "^1.52.0", "nanoevents": "^9.1.0", "svgpath": "^2.6.0", + "valibot": "^1.5.0", }, "devDependencies": { "@types/bun": "^1.3.14", diff --git a/packages/core/src/io/formats/fig/library-metadata.ts b/packages/core/src/io/formats/fig/library-metadata.ts index 4cdecb768..b4b55be93 100644 --- a/packages/core/src/io/formats/fig/library-metadata.ts +++ b/packages/core/src/io/formats/fig/library-metadata.ts @@ -1,10 +1,14 @@ -import { - ENABLED_LIBRARIES_PLUGIN_KEY, - mergePluginData, - OPEN_PENCIL_PLUGIN_ID -} from '@open-pencil/fig/node-change' +import { mergePluginData } from '@open-pencil/fig/node-change' import type { KiwiNodeChange } from '@open-pencil/fig/node-change' -import type { SceneGraph } from '@open-pencil/scene-graph' +import { + isPluginDataEntry, + OPEN_PENCIL_PLUGIN_DATA, + withoutPluginData, + withPluginData, + type SceneGraph +} from '@open-pencil/scene-graph' + +const { enabledLibraries } = OPEN_PENCIL_PLUGIN_DATA export function applyEnabledLibrariesPluginData( documentNodeChange: KiwiNodeChange, @@ -12,23 +16,11 @@ export function applyEnabledLibrariesPluginData( ): void { const rootPluginData = graph.getNode(graph.rootId)?.pluginData ?? [] const bindings = [...graph.enabledLibraries.values()] - const managedBinding = rootPluginData.find( - (entry) => - entry.pluginId === OPEN_PENCIL_PLUGIN_ID && entry.key === ENABLED_LIBRARIES_PLUGIN_KEY - ) - const bindingEntry = + // With no libraries enabled, the entry the document was opened with is kept as it was. + const opened = rootPluginData.find((entry) => isPluginDataEntry(entry, enabledLibraries)) + documentNodeChange.pluginData = mergePluginData( bindings.length > 0 - ? { - pluginId: OPEN_PENCIL_PLUGIN_ID, - key: ENABLED_LIBRARIES_PLUGIN_KEY, - value: JSON.stringify(bindings) - } - : managedBinding - documentNodeChange.pluginData = mergePluginData([ - ...rootPluginData.filter( - (entry) => - !(entry.pluginId === OPEN_PENCIL_PLUGIN_ID && entry.key === ENABLED_LIBRARIES_PLUGIN_KEY) - ), - ...(bindingEntry ? [bindingEntry] : []) - ]) + ? withPluginData(rootPluginData, enabledLibraries, bindings) + : [...withoutPluginData(rootPluginData, [enabledLibraries]), ...(opened ? [opened] : [])] + ) } diff --git a/packages/core/src/library/source-publication.ts b/packages/core/src/library/source-publication.ts index 3a1eea874..df58c9a4d 100644 --- a/packages/core/src/library/source-publication.ts +++ b/packages/core/src/library/source-publication.ts @@ -1,37 +1,14 @@ -import * as v from 'valibot' - -import type { SceneGraph } from '@open-pencil/scene-graph' - -export const SOURCE_LIBRARY_PUBLICATION_PLUGIN_KEY = 'sourceLibraryPublication' -const OPEN_PENCIL_PLUGIN_ID = 'open-pencil' - -export interface SourceLibraryPublication { - libraryId: string - revisionId: string - name: string - catalogSource?: string -} - -const SourceLibraryPublicationJSON = v.pipe( - v.string(), - v.parseJson(), - v.object({ - libraryId: v.string(), - revisionId: v.string(), - name: v.string(), - catalogSource: v.optional(v.string()) - }) -) satisfies v.GenericSchema +import { + OPEN_PENCIL_PLUGIN_DATA, + readPluginData, + withPluginData, + type SceneGraph, + type SourceLibraryPublication +} from '@open-pencil/scene-graph' export function readSourceLibraryPublication(graph: SceneGraph): SourceLibraryPublication | null { const root = graph.getNode(graph.rootId) - const entry = root?.pluginData.find( - (item) => - item.pluginId === OPEN_PENCIL_PLUGIN_ID && item.key === SOURCE_LIBRARY_PUBLICATION_PLUGIN_KEY - ) - if (!entry) return null - const parsed = v.safeParse(SourceLibraryPublicationJSON, entry.value) - return parsed.success ? parsed.output : null + return readPluginData(root?.pluginData, OPEN_PENCIL_PLUGIN_DATA.sourceLibraryPublication) ?? null } export function writeSourceLibraryPublication( @@ -41,19 +18,10 @@ export function writeSourceLibraryPublication( const root = graph.getNode(graph.rootId) if (!root) return graph.updateNode(root.id, { - pluginData: [ - ...root.pluginData.filter( - (entry) => - !( - entry.pluginId === OPEN_PENCIL_PLUGIN_ID && - entry.key === SOURCE_LIBRARY_PUBLICATION_PLUGIN_KEY - ) - ), - { - pluginId: OPEN_PENCIL_PLUGIN_ID, - key: SOURCE_LIBRARY_PUBLICATION_PLUGIN_KEY, - value: JSON.stringify(publication) - } - ] + pluginData: withPluginData( + root.pluginData, + OPEN_PENCIL_PLUGIN_DATA.sourceLibraryPublication, + publication + ) }) } diff --git a/packages/fig/src/document/metadata.ts b/packages/fig/src/document/metadata.ts index 8d1149b52..582ae463f 100644 --- a/packages/fig/src/document/metadata.ts +++ b/packages/fig/src/document/metadata.ts @@ -1,18 +1,7 @@ -import * as v from 'valibot' - import type { NodeChange } from '@open-pencil/kiwi/fig/codec' -import type { SceneGraph } from '@open-pencil/scene-graph' +import { OPEN_PENCIL_PLUGIN_DATA, type SceneGraph } from '@open-pencil/scene-graph' -import { ENABLED_LIBRARIES_PLUGIN_KEY, getOpenPencilPluginValue } from '../node-change/plugin-data' - -const EnabledLibrariesJSON = v.pipe(v.string(), v.parseJson(), v.array(v.unknown())) - -/** One enabled-library entry; invalid entries are skipped individually. */ -const EnabledLibraryEntry = v.object({ - libraryId: v.string(), - revisionId: v.string(), - enabled: v.optional(v.unknown()) -}) +import { readNodeChangePluginData } from '../node-change/plugin-data' export function applyDocumentMetadata(graph: SceneGraph, document: NodeChange | undefined): void { const root = graph.getNode(graph.rootId) @@ -26,15 +15,6 @@ export function applyDocumentMetadata(graph: SceneGraph, document: NodeChange | })) ?? [] root.source.fig.rawNodeFields.strokeJoin = document.strokeJoin root.source.fig.rawNodeFields.strokeWeight = document.strokeWeight - const bindings = v.safeParse( - EnabledLibrariesJSON, - getOpenPencilPluginValue(document, ENABLED_LIBRARIES_PLUGIN_KEY) - ) - if (!bindings.success) return - for (const candidate of bindings.output) { - const entry = v.safeParse(EnabledLibraryEntry, candidate) - if (!entry.success) continue - const { libraryId, revisionId, enabled } = entry.output - graph.enabledLibraries.set(libraryId, { libraryId, revisionId, enabled: enabled === true }) - } + const bindings = readNodeChangePluginData(document, OPEN_PENCIL_PLUGIN_DATA.enabledLibraries) + for (const binding of bindings ?? []) graph.enabledLibraries.set(binding.libraryId, binding) } diff --git a/packages/fig/src/node-change/convert.ts b/packages/fig/src/node-change/convert.ts index 22d2c374c..bd22620ba 100644 --- a/packages/fig/src/node-change/convert.ts +++ b/packages/fig/src/node-change/convert.ts @@ -2,6 +2,7 @@ import { guidToString } from '@open-pencil/kiwi/fig/guid' import { DEFAULT_FONT_FAMILY, DEFAULT_STROKE_MITER_LIMIT, + OPEN_PENCIL_PLUGIN_DATA, styleToWeight } from '@open-pencil/scene-graph' import { createDefaultSourceMetadata } from '@open-pencil/scene-graph/node-defaults' @@ -21,10 +22,7 @@ import { extractTextPathBox, extractPluginData, extractPluginRelaunchData, - getOpenPencilPluginValue, - LAYOUT_DIRECTION_PLUGIN_KEY, - NODE_TYPE_PLUGIN_KEY, - TEXT_DIRECTION_PLUGIN_KEY + readNodeChangePluginData } from './plugin-data' import { importStyleRuns } from './style/runs' import { convertLetterSpacing, convertLineHeight, mapTextDecoration } from './text/values' @@ -373,10 +371,7 @@ function convertTextProps(nc: NodeChange, blobs: Uint8Array[]): TextProps { fontVariations: convertFontVariations(nc), fontFeatures: convertFontFeatures(nc), textTruncation: (nc.textTruncation as string) === 'ENDING' ? 'ENDING' : 'DISABLED', - textDirection: - (getOpenPencilPluginValue(nc, TEXT_DIRECTION_PLUGIN_KEY) as - | SceneNode['textDirection'] - | null) || 'AUTO', + textDirection: readNodeChangePluginData(nc, OPEN_PENCIL_PLUGIN_DATA.textDirection) ?? 'AUTO', derivedLayout: nc.derivedTextData?.layoutSize ? { width: nc.derivedTextData.layoutSize.x, @@ -486,9 +481,7 @@ function convertLayoutProps( itemReverseZIndex: (nc.stackReverseZIndex ?? false) as boolean, strokesIncludedInLayout: (nc.strokesIncludedInLayout ?? false) as boolean, layoutDirection: - (getOpenPencilPluginValue(nc, LAYOUT_DIRECTION_PLUGIN_KEY) as - | SceneNode['layoutDirection'] - | null) || 'AUTO', + readNodeChangePluginData(nc, OPEN_PENCIL_PLUGIN_DATA.layoutDirection) ?? 'AUTO', ...(derivedLayout ? { derivedLayout } : {}) } } @@ -591,7 +584,7 @@ function resolveNodeType(nc: NodeChange): NodeType | 'DOCUMENT' | 'VARIABLE' { const nodeType = mapNodeType(nc.type) if ( (nodeType === 'FRAME' && isComponentSet(nc)) || - getOpenPencilPluginValue(nc, NODE_TYPE_PLUGIN_KEY) === 'COMPONENT_SET' + readNodeChangePluginData(nc, OPEN_PENCIL_PLUGIN_DATA.nodeType) === 'COMPONENT_SET' ) { return 'COMPONENT_SET' } diff --git a/packages/fig/src/node-change/export/node.ts b/packages/fig/src/node-change/export/node.ts index 5281201f9..1e914e5fa 100644 --- a/packages/fig/src/node-change/export/node.ts +++ b/packages/fig/src/node-change/export/node.ts @@ -9,7 +9,11 @@ import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' -import { DEFAULT_STROKE_MITER_LIMIT } from '@open-pencil/scene-graph' +import { + DEFAULT_STROKE_MITER_LIMIT, + OPEN_PENCIL_PLUGIN_DATA, + withPluginData +} from '@open-pencil/scene-graph' import type { GUID, Matrix, Vector } from '@open-pencil/scene-graph/primitives' /* eslint-disable max-lines */ @@ -22,9 +26,7 @@ import { applyLibrarySourcePluginData, applyTextPathBoxPluginData, mergePluginData, - NODE_TYPE_PLUGIN_KEY, - serializePluginRelaunchData, - upsertPluginData + serializePluginRelaunchData } from '../plugin-data' import { applyColorVariableBinding, @@ -931,7 +933,8 @@ export function sceneNodeToKiwiWithContext( applyNodeVisualProps(context, node, nc) applyComponentMetadata(context, node, nc, localIdCounter) applyInstancePayload(context, node, nc, localIdCounter) - if (node.type === 'COMPONENT_SET') upsertPluginData(node, NODE_TYPE_PLUGIN_KEY, node.type) + if (node.type === 'COMPONENT_SET') + node.pluginData = withPluginData(node.pluginData, OPEN_PENCIL_PLUGIN_DATA.nodeType, node.type) if (nc.type === 'CANVAS') nc.pageType = 'DESIGN' if (node.type === 'BOOLEAN_OPERATION') nc.booleanOperation = toKiwiBooleanOperation(node.booleanOperation) diff --git a/packages/fig/src/node-change/plugin-data.ts b/packages/fig/src/node-change/plugin-data.ts index 53a2ea175..6c1e3b003 100644 --- a/packages/fig/src/node-change/plugin-data.ts +++ b/packages/fig/src/node-change/plugin-data.ts @@ -1,14 +1,17 @@ import { omit } from 'es-toolkit/object' -import * as v from 'valibot' import type { NodeChange, PluginData, PluginRelaunchData } from '@open-pencil/kiwi/fig/codec' import { guidToString } from '@open-pencil/kiwi/fig/guid' import { clampExportScale, - isExportFormatId, + hasPluginData, + OPEN_PENCIL_PLUGIN_DATA, + OPEN_PENCIL_PLUGIN_ID, + withPluginData, type ExportFormatId, type ExportSetting, type PluginDataEntry, + type PluginDataField, type PluginRelaunchDataEntry, type SceneNode } from '@open-pencil/scene-graph' @@ -22,74 +25,7 @@ import { referencesVariable } from './variable/bindings' -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 const EXPORT_SETTINGS_PLUGIN_KEY = 'exportSettings' -export const TEXT_PATH_BOX_PLUGIN_KEY = 'textPathBox' -export const LIBRARY_SOURCE_PLUGIN_KEY = 'librarySource' -export const ENABLED_LIBRARIES_PLUGIN_KEY = 'enabledLibraries' - -const TextPathBoxJSON = v.pipe( - v.string(), - v.parseJson(), - v.object({ x: v.number(), y: v.number(), width: v.number(), height: v.number() }), - v.check( - ({ x, y, width, height }) => Number.isFinite(x + y + width + height) && width > 0 && height > 0 - ) -) - -/** String-valued entries of a JSON object; other entries are dropped, not rejected. */ -const BoundVariablesJSON = v.pipe( - v.string(), - v.parseJson(), - v.check((value) => !Array.isArray(value)), - v.record(v.string(), v.unknown()), - v.transform((value) => - Object.fromEntries( - Object.entries(value).filter( - (entry): entry is [string, string] => typeof entry[1] === 'string' - ) - ) - ) -) - -/** Every entry must be valid, or the plugin value is ignored in favour of native settings. */ -const ExportSettingsJSON = v.pipe( - v.string(), - v.parseJson(), - v.array( - v.pipe( - v.object({ - scale: v.pipe(v.number(), v.finite()), - format: v.custom(isExportFormatId) - }), - // Clamp at the file-format boundary: imported plugin data may carry an - // out-of-range scale the UI would never produce. - v.transform(({ scale, format }): ExportSetting => ({ - scale: clampExportScale(scale), - format - })) - ) - ) -) - -const LibrarySourceJSON = v.pipe( - v.string(), - v.parseJson(), - v.object({ - identity: v.object({ libraryId: v.string(), assetKey: v.string(), revisionId: v.string() }), - sourceNodeId: v.optional(v.unknown()), - readOnly: v.optional(v.unknown()) - }), - v.transform(({ identity, sourceNodeId, readOnly }): NonNullable => ({ - identity, - sourceNodeId: typeof sourceNodeId === 'string' ? sourceNodeId : null, - readOnly: readOnly === true - })) -) +const { boundVariables, exportSettings, librarySource, textPathBox } = OPEN_PENCIL_PLUGIN_DATA const NATIVE_EXPORT_FORMATS: Record = { PNG: 'png', @@ -98,21 +34,15 @@ const NATIVE_EXPORT_FORMATS: Record = { PDF: 'pdf' } -export function upsertPluginData( - node: { pluginData: PluginDataEntry[] }, - key: string, - value: string -): void { - const pluginData = node.pluginData.filter( - (entry) => !(entry.pluginId === OPEN_PENCIL_PLUGIN_ID && entry.key === key) - ) - pluginData.push({ pluginId: OPEN_PENCIL_PLUGIN_ID, key, value }) - node.pluginData = pluginData -} - -export function removePluginData(node: { pluginData: PluginDataEntry[] }, key: string): void { - node.pluginData = node.pluginData.filter( - (entry) => !(entry.pluginId === OPEN_PENCIL_PLUGIN_ID && entry.key === key) +/** An OpenPencil plugin-data value on a record read from a `.fig` file. */ +export function readNodeChangePluginData( + nc: Pick, + field: PluginDataField +): T | undefined { + return field.decode( + nc.pluginData?.find( + (entry) => entry.pluginID === OPEN_PENCIL_PLUGIN_ID && entry.key === field.key + )?.value ) } @@ -121,12 +51,12 @@ export function applyExportSettingsPluginData( ): void { if (node.exportSettings.length === 0) return if ( - !hasOpenPencilExportSettingsPluginData(node.pluginData) && + !hasPluginData(node.pluginData, exportSettings) && Array.isArray(readEffectiveFigmaRawField(node, 'exportSettings')) ) { return } - upsertPluginData(node, EXPORT_SETTINGS_PLUGIN_KEY, JSON.stringify(node.exportSettings)) + node.pluginData = withPluginData(node.pluginData, exportSettings, node.exportSettings) } /** @@ -141,29 +71,15 @@ export function applyTextPathBoxPluginData(node: { pluginData: PluginDataEntry[] }): void { if (!node.textPathBox) return - upsertPluginData(node, TEXT_PATH_BOX_PLUGIN_KEY, JSON.stringify(node.textPathBox)) + node.pluginData = withPluginData(node.pluginData, textPathBox, node.textPathBox) } export function extractTextPathBox(nc: NodeChange): Rect | null { - const parsed = v.safeParse( - TextPathBoxJSON, - getOpenPencilPluginValue(nc, TEXT_PATH_BOX_PLUGIN_KEY) - ) - return parsed.success ? parsed.output : null -} - -function hasOpenPencilExportSettingsPluginData(pluginData: PluginDataEntry[]): boolean { - return pluginData.some( - (entry) => entry.pluginId === OPEN_PENCIL_PLUGIN_ID && entry.key === EXPORT_SETTINGS_PLUGIN_KEY - ) + return readNodeChangePluginData(nc, textPathBox) ?? null } export function extractBoundVariables(nc: NodeChange): Record { - const stored = v.safeParse( - BoundVariablesJSON, - getOpenPencilPluginValue(nc, BOUND_VARIABLES_PLUGIN_KEY) - ) - let bindings: Record = stored.success ? stored.output : {} + let bindings = readNodeChangePluginData(nc, boundVariables) ?? {} for (const entry of variableConsumptionEntries(nc)) { const binding = resolveVariableConsumptionEntry(entry) if (binding) bindings[binding.field] = binding.variableId @@ -202,11 +118,8 @@ function extractNativeConstraintScale(constraint: unknown): number { } export function extractExportSettings(nc: NodeChange): ExportSetting[] { - const pluginSettings = v.safeParse( - ExportSettingsJSON, - getOpenPencilPluginValue(nc, EXPORT_SETTINGS_PLUGIN_KEY) - ) - if (pluginSettings.success) return pluginSettings.output + const pluginSettings = readNodeChangePluginData(nc, exportSettings) + if (pluginSettings) return pluginSettings return (nc.exportSettings ?? []).flatMap((entry): ExportSetting[] => { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return [] @@ -230,29 +143,11 @@ export function extractPluginData(nc: NodeChange): PluginDataEntry[] { } export function extractLibrarySource(nc: NodeChange): SceneNode['librarySource'] { - const parsed = v.safeParse( - LibrarySourceJSON, - getOpenPencilPluginValue(nc, LIBRARY_SOURCE_PLUGIN_KEY) - ) - return parsed.success ? parsed.output : null + return readNodeChangePluginData(nc, librarySource) ?? null } export function applyLibrarySourcePluginData(node: SceneNode): void { - if (node.librarySource) { - upsertPluginData(node, LIBRARY_SOURCE_PLUGIN_KEY, JSON.stringify(node.librarySource)) - } else { - node.pluginData = node.pluginData.filter( - (entry) => - !(entry.pluginId === OPEN_PENCIL_PLUGIN_ID && entry.key === LIBRARY_SOURCE_PLUGIN_KEY) - ) - } -} - -export function getOpenPencilPluginValue(nc: NodeChange, key: string): string | null { - return ( - nc.pluginData?.find((entry) => entry.pluginID === OPEN_PENCIL_PLUGIN_ID && entry.key === key) - ?.value ?? null - ) + node.pluginData = withPluginData(node.pluginData, librarySource, node.librarySource ?? undefined) } export function extractPluginRelaunchData(nc: NodeChange): PluginRelaunchDataEntry[] { diff --git a/packages/fig/src/node-change/serialize.ts b/packages/fig/src/node-change/serialize.ts index c87d38a70..a1dfe9fe8 100644 --- a/packages/fig/src/node-change/serialize.ts +++ b/packages/fig/src/node-change/serialize.ts @@ -1,4 +1,9 @@ -import { normalizeFontFamily, weightToStyle } from '@open-pencil/scene-graph' +import { + normalizeFontFamily, + OPEN_PENCIL_PLUGIN_DATA, + weightToStyle, + withPluginData +} from '@open-pencil/scene-graph' import { effectiveFigmaRawNodeFields } from '../source-metadata' import { computeExportTransform, fractionalPosition, mapToFigmaType } from './basics' @@ -9,14 +14,6 @@ import { applyFontFeaturesToKiwi } from './font/features' import { weightToFigmaStyle } from './font/style' import { fillToKiwiPaint, safeColor } from './paint' import { bakeGlyphScale, encodePathCommandsBlob } from './path/commands' -import { - BOUND_VARIABLES_PLUGIN_KEY, - removePluginData, - LAYOUT_DIRECTION_PLUGIN_KEY, - TEXT_DIRECTION_PLUGIN_KEY, - upsertPluginData, - OPEN_PENCIL_PLUGIN_ID -} from './plugin-data' import { exportedVariableConsumptionEntries, mergeVariableConsumptionMaps @@ -202,7 +199,11 @@ function serializeTextProps( glyphBlobMap: Map | undefined, runtime: FigNodeChangeExportRuntime ): void { - upsertPluginData(node, TEXT_DIRECTION_PLUGIN_KEY, node.textDirection) + node.pluginData = withPluginData( + node.pluginData, + OPEN_PENCIL_PLUGIN_DATA.textDirection, + node.textDirection + ) nc.fontSize = node.fontSize nc.fontName = { family: normalizeFontFamily(node.fontFamily), @@ -355,13 +356,19 @@ function applyEditedLayoutFields(node: SceneNode, nc: KiwiNodeChange): void { for (const field of node.source.editedFields) { if (field in values) Object.assign(nc, values[field as keyof SceneNode]) } - if (node.source.editedFields.includes('layoutDirection')) { - upsertPluginData(node, LAYOUT_DIRECTION_PLUGIN_KEY, node.layoutDirection) - } + if (node.source.editedFields.includes('layoutDirection')) writeLayoutDirection(node) +} + +function writeLayoutDirection(node: SceneNode): void { + node.pluginData = withPluginData( + node.pluginData, + OPEN_PENCIL_PLUGIN_DATA.layoutDirection, + node.layoutDirection + ) } function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange, graph: SceneGraph): void { - if (!node.source.id) upsertPluginData(node, LAYOUT_DIRECTION_PLUGIN_KEY, node.layoutDirection) + if (!node.source.id) writeLayoutDirection(node) serializeSizeConstraints(node, nc) const figLayout = node.source.fig.layout if (figLayout) { @@ -494,9 +501,11 @@ function serializeVariableBindings( } // An entry the node was imported with is dropped rather than emptied: nothing reads an // empty map, and writing one leaves the record in every file the node is exported to. - if (Object.keys(roundtripBindings).length > 0) - upsertPluginData(node, BOUND_VARIABLES_PLUGIN_KEY, JSON.stringify(roundtripBindings)) - else removePluginData(node, BOUND_VARIABLES_PLUGIN_KEY) + node.pluginData = withPluginData( + node.pluginData, + OPEN_PENCIL_PLUGIN_DATA.boundVariables, + Object.keys(roundtripBindings).length > 0 ? roundtripBindings : undefined + ) if (entries.length > 0) { nc.variableConsumptionMap = { entries } Object.assign( diff --git a/packages/fig/src/node-change/variable/token.ts b/packages/fig/src/node-change/variable/token.ts index 51d629974..55639cbf7 100644 --- a/packages/fig/src/node-change/variable/token.ts +++ b/packages/fig/src/node-change/variable/token.ts @@ -1,48 +1,26 @@ import { mapKeys } from 'es-toolkit/object' import { isEmptyObject } from 'es-toolkit/predicate' -import * as v from 'valibot' import type { NodeChange } from '@open-pencil/kiwi/fig/codec' import { - TOKEN_UNITS, + OPEN_PENCIL_PLUGIN_DATA, + pluginDataEntry, + withoutPluginData, type PluginDataEntry, type Variable, type VariableCollection, type VariableValue } from '@open-pencil/scene-graph' -import { getOpenPencilPluginValue, OPEN_PENCIL_PLUGIN_ID } from '../plugin-data' +import { readNodeChangePluginData } from '../plugin-data' -/** Token fields Figma has no slot for, on each VARIABLE. */ -export const TOKEN_PLUGIN_KEY = 'token' -/** Mode conditions by mode id, on each VARIABLE_SET. */ -export const MODE_CONDITIONS_PLUGIN_KEY = 'modeConditions' - -// Shape only. Whether a string is valid CSS is checked where it is written into a stylesheet. -const cssText = v.pipe(v.string(), v.trim(), v.nonEmpty(), v.maxLength(1000)) - -// Plugin data is a JSON string; invalid JSON is an issue like any wrong shape, never a throw. -const TokenJSON = v.pipe( - v.string(), - v.parseJson(), - v.object({ - unit: v.optional(v.picklist(TOKEN_UNITS)), - expressions: v.optional( - v.record(v.string(), v.object({ css: cssText, resolved: v.pipe(v.number(), v.finite()) })) - ) - }) -) -const ModeConditionsJSON = v.pipe(v.string(), v.parseJson(), v.record(v.string(), cssText)) +const { token, modeConditions } = OPEN_PENCIL_PLUGIN_DATA type TokenFields = Pick -/** Plugin data other than the entries this module owns, which are rebuilt on every save. */ +/** Plugin data other than the token entries, which are rebuilt on every save. */ export function withoutTokenPluginData(pluginData: PluginDataEntry[]): PluginDataEntry[] { - return pluginData.filter( - (entry) => - entry.pluginId !== OPEN_PENCIL_PLUGIN_ID || - (entry.key !== TOKEN_PLUGIN_KEY && entry.key !== MODE_CONDITIONS_PLUGIN_KEY) - ) + return withoutPluginData(pluginData, [token, modeConditions]) } function nonEmpty(record: T): T | undefined { @@ -62,24 +40,15 @@ export function readVariableToken( nc: NodeChange, valuesByMode: Record ): TokenFields { - const parsed = v.safeParse(TokenJSON, getOpenPencilPluginValue(nc, TOKEN_PLUGIN_KEY)) - const token = parsed.success ? parsed.output : {} - const expressions = Object.entries(token.expressions ?? {}).filter(([mode, expression]) => + const stored = readNodeChangePluginData(nc, token) ?? {} + const expressions = Object.entries(stored.expressions ?? {}).filter(([mode, expression]) => sameNumber(valuesByMode[mode], expression.resolved) ) - return { unit: token.unit, expressions: nonEmpty(Object.fromEntries(expressions)) } + return { unit: stored.unit, expressions: nonEmpty(Object.fromEntries(expressions)) } } export function readModeConditions(nc: NodeChange): Record { - const parsed = v.safeParse( - ModeConditionsJSON, - getOpenPencilPluginValue(nc, MODE_CONDITIONS_PLUGIN_KEY) - ) - return parsed.success ? parsed.output : {} -} - -function entry(key: string, value: object): PluginDataEntry { - return { pluginId: OPEN_PENCIL_PLUGIN_ID, key, value: JSON.stringify(value) } + return readNodeChangePluginData(nc, modeConditions) ?? {} } /** Mode ids in the file differ from the model's, so callers map them. */ @@ -87,12 +56,12 @@ export function tokenPluginData( variable: Variable, modeKey: (modeId: string) => string ): PluginDataEntry | undefined { - const token = { + const stored = { unit: variable.unit, expressions: nonEmpty(mapKeys(variable.expressions ?? {}, (_, mode) => modeKey(mode))) } - if (!token.unit && !token.expressions) return undefined - return entry(TOKEN_PLUGIN_KEY, token) + if (!stored.unit && !stored.expressions) return undefined + return pluginDataEntry(token, stored) } export function modeConditionsPluginData( @@ -103,6 +72,6 @@ export function modeConditionsPluginData( mode.condition ? [[modeKey(mode.modeId), mode.condition] as const] : [] ) return conditions.length > 0 - ? entry(MODE_CONDITIONS_PLUGIN_KEY, Object.fromEntries(conditions)) + ? pluginDataEntry(modeConditions, Object.fromEntries(conditions)) : undefined } diff --git a/packages/fig/tests/document/metadata.test.ts b/packages/fig/tests/document/metadata.test.ts index 3ef836895..cce0e9f5c 100644 --- a/packages/fig/tests/document/metadata.test.ts +++ b/packages/fig/tests/document/metadata.test.ts @@ -1,14 +1,19 @@ import { describe, expect, test } from 'bun:test' import { applyDocumentMetadata } from '#fig/document/metadata' -import { ENABLED_LIBRARIES_PLUGIN_KEY, OPEN_PENCIL_PLUGIN_ID } from '#fig/node-change/plugin-data' -import { SceneGraph } from '@open-pencil/scene-graph' +import { + OPEN_PENCIL_PLUGIN_DATA, + OPEN_PENCIL_PLUGIN_ID, + SceneGraph +} from '@open-pencil/scene-graph' function enabledLibraries(value: string) { const graph = new SceneGraph() applyDocumentMetadata(graph, { - pluginData: [{ pluginID: OPEN_PENCIL_PLUGIN_ID, key: ENABLED_LIBRARIES_PLUGIN_KEY, value }] + pluginData: [ + { pluginID: OPEN_PENCIL_PLUGIN_ID, key: OPEN_PENCIL_PLUGIN_DATA.enabledLibraries.key, value } + ] }) return [...graph.enabledLibraries.values()] } diff --git a/packages/fig/tests/node-change/plugin-data.test.ts b/packages/fig/tests/node-change/plugin-data.test.ts index 10f1869a9..01e32a9bd 100644 --- a/packages/fig/tests/node-change/plugin-data.test.ts +++ b/packages/fig/tests/node-change/plugin-data.test.ts @@ -1,19 +1,18 @@ import { describe, expect, test } from 'bun:test' import { - BOUND_VARIABLES_PLUGIN_KEY, - EXPORT_SETTINGS_PLUGIN_KEY, extractBoundVariables, extractExportSettings, extractLibrarySource, - extractTextPathBox, - LIBRARY_SOURCE_PLUGIN_KEY, - OPEN_PENCIL_PLUGIN_ID, - TEXT_PATH_BOX_PLUGIN_KEY + extractTextPathBox } from '#fig/node-change/plugin-data' import type { NodeChange } from '@open-pencil/kiwi/fig/codec' -import { clampExportScale } from '@open-pencil/scene-graph' +import { + clampExportScale, + OPEN_PENCIL_PLUGIN_DATA, + OPEN_PENCIL_PLUGIN_ID +} from '@open-pencil/scene-graph' function withPluginValue(key: string, value: string, extra: NodeChange = {}): NodeChange { return { ...extra, pluginData: [{ pluginID: OPEN_PENCIL_PLUGIN_ID, key, value }] } @@ -23,7 +22,9 @@ describe('OpenPencil plugin data readers', () => { test('textPathBox requires finite numbers and a positive size', () => { const box = { x: 1, y: 2, width: 30, height: 40 } expect( - extractTextPathBox(withPluginValue(TEXT_PATH_BOX_PLUGIN_KEY, JSON.stringify(box))) + extractTextPathBox( + withPluginValue(OPEN_PENCIL_PLUGIN_DATA.textPathBox.key, JSON.stringify(box)) + ) ).toEqual(box) for (const value of [ '{not json', @@ -31,19 +32,25 @@ describe('OpenPencil plugin data readers', () => { JSON.stringify({ ...box, width: 0 }), JSON.stringify({ ...box, x: '1' }) ]) { - expect(extractTextPathBox(withPluginValue(TEXT_PATH_BOX_PLUGIN_KEY, value))).toBeNull() + expect( + extractTextPathBox(withPluginValue(OPEN_PENCIL_PLUGIN_DATA.textPathBox.key, value)) + ).toBeNull() } }) test('bound variables keep string entries and drop the rest', () => { const value = JSON.stringify({ opacity: 'VariableID:1:2', width: 3 }) - expect(extractBoundVariables(withPluginValue(BOUND_VARIABLES_PLUGIN_KEY, value))).toEqual({ + expect( + extractBoundVariables(withPluginValue(OPEN_PENCIL_PLUGIN_DATA.boundVariables.key, value)) + ).toEqual({ opacity: 'VariableID:1:2' }) for (const malformed of ['{not json', '["VariableID:1:2"]', '"VariableID:1:2"']) { - expect(extractBoundVariables(withPluginValue(BOUND_VARIABLES_PLUGIN_KEY, malformed))).toEqual( - {} - ) + expect( + extractBoundVariables( + withPluginValue(OPEN_PENCIL_PLUGIN_DATA.boundVariables.key, malformed) + ) + ).toEqual({}) } }) @@ -54,7 +61,7 @@ describe('OpenPencil plugin data readers', () => { expect( extractExportSettings( withPluginValue( - EXPORT_SETTINGS_PLUGIN_KEY, + OPEN_PENCIL_PLUGIN_DATA.exportSettings.key, JSON.stringify([{ scale: 1000, format: 'png' }]) ) ) @@ -68,7 +75,9 @@ describe('OpenPencil plugin data readers', () => { ]) ]) { expect( - extractExportSettings(withPluginValue(EXPORT_SETTINGS_PLUGIN_KEY, value, native)) + extractExportSettings( + withPluginValue(OPEN_PENCIL_PLUGIN_DATA.exportSettings.key, value, native) + ) ).toEqual([{ scale: 1, format: 'svg' }]) } }) @@ -77,13 +86,16 @@ describe('OpenPencil plugin data readers', () => { const identity = { libraryId: 'lib', assetKey: 'button', revisionId: 'r1' } expect( extractLibrarySource( - withPluginValue(LIBRARY_SOURCE_PLUGIN_KEY, JSON.stringify({ identity, readOnly: 'yes' })) + withPluginValue( + OPEN_PENCIL_PLUGIN_DATA.librarySource.key, + JSON.stringify({ identity, readOnly: 'yes' }) + ) ) ).toEqual({ identity, sourceNodeId: null, readOnly: false }) expect( extractLibrarySource( withPluginValue( - LIBRARY_SOURCE_PLUGIN_KEY, + OPEN_PENCIL_PLUGIN_DATA.librarySource.key, JSON.stringify({ identity, sourceNodeId: '1:2', readOnly: true }) ) ) @@ -92,7 +104,9 @@ describe('OpenPencil plugin data readers', () => { '{not json', JSON.stringify({ identity: { ...identity, revisionId: 1 } }) ]) { - expect(extractLibrarySource(withPluginValue(LIBRARY_SOURCE_PLUGIN_KEY, value))).toBeNull() + expect( + extractLibrarySource(withPluginValue(OPEN_PENCIL_PLUGIN_DATA.librarySource.key, value)) + ).toBeNull() } }) }) diff --git a/packages/fig/tests/node-change/variable-token.test.ts b/packages/fig/tests/node-change/variable-token.test.ts index 5b811d9d4..50afd6fd0 100644 --- a/packages/fig/tests/node-change/variable-token.test.ts +++ b/packages/fig/tests/node-change/variable-token.test.ts @@ -1,14 +1,9 @@ import { describe, expect, test } from 'bun:test' -import { - MODE_CONDITIONS_PLUGIN_KEY, - OPEN_PENCIL_PLUGIN_ID, - readModeConditions, - readVariableToken, - TOKEN_PLUGIN_KEY -} from '#fig/node-change/index' +import { readModeConditions, readVariableToken } from '#fig/node-change/index' import type { NodeChange } from '@open-pencil/kiwi/fig/codec' +import { OPEN_PENCIL_PLUGIN_DATA, OPEN_PENCIL_PLUGIN_ID } from '@open-pencil/scene-graph' function record(key: string, value: string): NodeChange { return { pluginData: [{ pluginID: OPEN_PENCIL_PLUGIN_ID, key, value }] } @@ -16,21 +11,29 @@ function record(key: string, value: string): NodeChange { describe('token plugin data', () => { test('a malformed or wrongly shaped entry reads as no token data', () => { - expect(readVariableToken(record(TOKEN_PLUGIN_KEY, '{not json'), { m: 8 })).toEqual({ + expect( + readVariableToken(record(OPEN_PENCIL_PLUGIN_DATA.token.key, '{not json'), { m: 8 }) + ).toEqual({ unit: undefined, expressions: undefined }) - expect(readVariableToken(record(TOKEN_PLUGIN_KEY, '{"unit":"furlong"}'), { m: 8 })).toEqual({ + expect( + readVariableToken(record(OPEN_PENCIL_PLUGIN_DATA.token.key, '{"unit":"furlong"}'), { m: 8 }) + ).toEqual({ unit: undefined, expressions: undefined }) - expect(readModeConditions(record(MODE_CONDITIONS_PLUGIN_KEY, '{"m":42}'))).toEqual({}) - expect(readModeConditions(record(MODE_CONDITIONS_PLUGIN_KEY, '{"m":" "}'))).toEqual({}) + expect( + readModeConditions(record(OPEN_PENCIL_PLUGIN_DATA.modeConditions.key, '{"m":42}')) + ).toEqual({}) + expect( + readModeConditions(record(OPEN_PENCIL_PLUGIN_DATA.modeConditions.key, '{"m":" "}')) + ).toEqual({}) }) test('keep expressions only for modes whose value still matches', () => { const nc = record( - TOKEN_PLUGIN_KEY, + OPEN_PENCIL_PLUGIN_DATA.token.key, JSON.stringify({ unit: 'rem', expressions: { @@ -47,7 +50,7 @@ describe('token plugin data', () => { test('match a mode value stored at float32 precision', () => { const nc = record( - TOKEN_PLUGIN_KEY, + OPEN_PENCIL_PLUGIN_DATA.token.key, JSON.stringify({ expressions: { a: { css: 'calc(100vw / 3)', resolved: 1234.567 } } }) ) // What .fig hands back for 1234.567 after storing it as float32. diff --git a/packages/scene-graph/AGENTS.md b/packages/scene-graph/AGENTS.md index c94bdafe8..7d9e89a58 100644 --- a/packages/scene-graph/AGENTS.md +++ b/packages/scene-graph/AGENTS.md @@ -11,6 +11,7 @@ Framework-neutral document model. Owns node types, primitives, geometry, matrice - A bound numeric field records its unit conversion in `variableBindingScales`; preserve it through clone, copy, and transfer so a binding keeps meaning in its new scope. Core reconciles bindings and layout together in `packages/core/src/layout/variables.ts`, which the editor and the Figma API both call rather than reimplementing. - `componentScale` records an occurrence's coordinates relative to its component definition. Shared rescaling lives in `packages/scene-graph/src/scaling/`; Core's Figma API delegates there instead of scaling nodes itself. - Shared primitives that format packages need live here: color conversion and management under `@open-pencil/scene-graph/color` and text/layout direction under `@open-pencil/scene-graph/text-direction`. +- Every plugin-data key OpenPencil writes is a field of `OPEN_PENCIL_PLUGIN_DATA` in `packages/scene-graph/src/plugin-data/fields.ts`, with the Valibot schema that reads it; read and replace entries with `readPluginData` and `withPluginData` instead of matching `pluginId` and `key` by hand. The fields test checks that keys stay unique. - Vector network types live here; the reverse-engineered `vectorNetworkBlob` codecs live in `packages/core/src/vector/`. - `packages/scene-graph/src/checkpoint.ts` owns transaction checkpoint recovery, including hierarchy and indexes; Core's atomic tool execution relies on it. - Export named types and primitives (`Color`, `Vector`, `Rect`, `SceneNode`, `Effect`, `Fill`, `Stroke`) from the public entry; downstream packages reuse them instead of respelling shapes. diff --git a/packages/scene-graph/package.json b/packages/scene-graph/package.json index d2ce00259..c4ab2c3cc 100644 --- a/packages/scene-graph/package.json +++ b/packages/scene-graph/package.json @@ -202,7 +202,8 @@ "culori": "^4.0.2", "es-toolkit": "^1.52.0", "nanoevents": "^9.1.0", - "svgpath": "^2.6.0" + "svgpath": "^2.6.0", + "valibot": "^1.5.0" }, "devDependencies": { "@types/bun": "^1.3.14", diff --git a/packages/scene-graph/src/color/okhcl.ts b/packages/scene-graph/src/color/okhcl.ts index 9e84cd4e4..d2a57e0c9 100644 --- a/packages/scene-graph/src/color/okhcl.ts +++ b/packages/scene-graph/src/color/okhcl.ts @@ -1,8 +1,10 @@ import { converter, toGamut } from 'culori' import { copyFill, copyStroke } from '../copy' +import { readAllPluginData, withAllPluginData } from '../plugin-data/field' +import { OPEN_PENCIL_PLUGIN_DATA } from '../plugin-data/fields' import type { Color } from '../primitives' -import type { SceneNode } from '../types' +import type { PluginDataEntry, SceneNode } from '../types' import { normalizeColor } from './normalize' export interface OkHCLColor { @@ -27,7 +29,6 @@ const toDisplayableP3 = toGamut('p3', 'oklch') /** Colour spaces a document's stored numbers can be in. */ export type OkHCLColorSpace = 'srgb' | 'display-p3' -const OKHCL_PLUGIN_KEY = 'okhcl' function clampUnit(value: number): number { if (value < 0) return 0 @@ -93,28 +94,11 @@ export function rgbaToOkHCL(color: Color, colorSpace: OkHCLColorSpace = 'srgb'): } export function serializeOkHCLPayload(payload: OkHCLPayload): string { - return JSON.stringify(payload) -} - -function isJSONObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null + return OPEN_PENCIL_PLUGIN_DATA.okhcl.encode(payload) } export function parseOkHCLPayload(value: string): OkHCLPayload | null { - let parsed: unknown - try { - parsed = JSON.parse(value) - } catch { - return null - } - if (!isJSONObject(parsed) || parsed.version !== 1) return null - const { kind, index, color } = parsed - if (kind !== 'fill' && kind !== 'stroke') return null - if (typeof index !== 'number') return null - if (!isJSONObject(color)) return null - const { h, c, l, a } = color - if (typeof h !== 'number' || typeof c !== 'number' || typeof l !== 'number') return null - return { version: 1, kind, index, color: { h, c, l, a: typeof a === 'number' ? a : undefined } } + return OPEN_PENCIL_PLUGIN_DATA.okhcl.decode(value) ?? null } function createOkHCLPayload( @@ -130,17 +114,18 @@ function createOkHCLPayload( } } -function filterOkHCLPayloads( - entries: string[], - kind?: 'fill' | 'stroke', - index?: number -): string[] { - return entries.filter((entry) => { - const payload = parseOkHCLPayload(entry) - if (!payload) return true - if (kind === undefined || index === undefined) return false - return payload.kind !== kind || payload.index !== index - }) +/** The node's plugin data with the payload for one paint replaced, or removed without `color`. */ +function withOkHCLPayload( + node: SceneNode, + kind: 'fill' | 'stroke', + index: number, + color?: OkHCLColor +): PluginDataEntry[] { + const payloads = getNodeOkHCLPayloads(node).filter( + (payload) => payload.kind !== kind || payload.index !== index + ) + if (color) payloads.push(createOkHCLPayload(kind, index, color)) + return withAllPluginData(node.pluginData, OPEN_PENCIL_PLUGIN_DATA.okhcl, payloads) } export function setNodeFillOkHCL( @@ -159,16 +144,9 @@ export function setNodeFillOkHCL( opacity: rgba.a } - const payloads = filterOkHCLPayloads( - node.pluginData.map((entry) => entry.value), - 'fill', - index - ) - payloads.push(serializeOkHCLPayload(createOkHCLPayload('fill', index, color))) - return { fills, - pluginData: payloads.map((value) => ({ pluginId: 'open-pencil', key: OKHCL_PLUGIN_KEY, value })) + pluginData: withOkHCLPayload(node, 'fill', index, color) } } @@ -188,54 +166,22 @@ export function setNodeStrokeOkHCL( opacity: rgba.a } - const payloads = filterOkHCLPayloads( - node.pluginData.map((entry) => entry.value), - 'stroke', - index - ) - payloads.push(serializeOkHCLPayload(createOkHCLPayload('stroke', index, color))) - return { strokes, - pluginData: payloads.map((value) => ({ pluginId: 'open-pencil', key: OKHCL_PLUGIN_KEY, value })) + pluginData: withOkHCLPayload(node, 'stroke', index, color) } } export function clearNodeFillOkHCL(node: SceneNode, index: number): Partial { - const okhclValues = filterOkHCLPayloads( - node.pluginData.map((entry) => entry.value), - 'fill', - index - ) - return { - pluginData: okhclValues.map((value) => ({ - pluginId: 'open-pencil', - key: OKHCL_PLUGIN_KEY, - value - })) - } + return { pluginData: withOkHCLPayload(node, 'fill', index) } } export function clearNodeStrokeOkHCL(node: SceneNode, index: number): Partial { - const okhclValues = filterOkHCLPayloads( - node.pluginData.map((entry) => entry.value), - 'stroke', - index - ) - return { - pluginData: okhclValues.map((value) => ({ - pluginId: 'open-pencil', - key: OKHCL_PLUGIN_KEY, - value - })) - } + return { pluginData: withOkHCLPayload(node, 'stroke', index) } } export function getNodeOkHCLPayloads(node: SceneNode): OkHCLPayload[] { - return node.pluginData - .filter((entry) => entry.pluginId === 'open-pencil' && entry.key === OKHCL_PLUGIN_KEY) - .map((entry) => parseOkHCLPayload(entry.value)) - .filter((payload): payload is OkHCLPayload => payload !== null) + return readAllPluginData(node.pluginData, OPEN_PENCIL_PLUGIN_DATA.okhcl) } export function getFillOkHCL(node: SceneNode, index: number): OkHCLPayload | null { diff --git a/packages/scene-graph/src/index.ts b/packages/scene-graph/src/index.ts index 799e8033a..3dcc75b02 100644 --- a/packages/scene-graph/src/index.ts +++ b/packages/scene-graph/src/index.ts @@ -4,6 +4,8 @@ export * from './variables/bindings' export type { VariableModeFallback } from './variables' export { modesDefaultFirst } from './variables' export * from './variables/token' +export * from './plugin-data/field' +export * from './plugin-data/fields' export { rescaleNodeTree, scaleNodeChanges } from './scaling' import { TRANSFORM_FIELDS, SIZE_FIELDS } from './fields/geometry' export { TRANSFORM_FIELDS, SIZE_FIELDS } from './fields/geometry' diff --git a/packages/scene-graph/src/plugin-data/field.ts b/packages/scene-graph/src/plugin-data/field.ts new file mode 100644 index 000000000..b1592ece7 --- /dev/null +++ b/packages/scene-graph/src/plugin-data/field.ts @@ -0,0 +1,116 @@ +import * as v from 'valibot' + +import type { PluginDataEntry } from '../types' + +/** The plugin id OpenPencil keeps its own state under, on nodes and in `.fig` files. */ +export const OPEN_PENCIL_PLUGIN_ID = 'open-pencil' + +/** One OpenPencil plugin-data key. */ +export interface PluginDataKey { + readonly key: string +} + +/** A plugin-data key and how its string value is read and written. */ +export interface PluginDataField extends PluginDataKey { + /** The stored value, or `undefined` when it is missing or not a valid value. */ + decode(value: string | null | undefined): T | undefined + encode(value: T): string +} + +/** + * A value stored as JSON. Invalid JSON and a wrong shape both read as missing: plugin data + * comes from files anyone can edit, so a bad entry is ignored rather than thrown on. + */ +export function jsonPluginDataField( + key: string, + schema: v.GenericSchema +): PluginDataField { + const stored = v.pipe(v.string(), v.parseJson(), schema) + return { + key, + decode(value) { + const parsed = v.safeParse(stored, value) + return parsed.success ? parsed.output : undefined + }, + encode: (value) => JSON.stringify(value) + } +} + +/** A value stored as one of a fixed set of plain strings. */ +export function textPluginDataField( + key: string, + values: readonly T[] +): PluginDataField { + const schema = v.picklist(values) + return { + key, + decode(value) { + const parsed = v.safeParse(schema, value) + return parsed.success ? parsed.output : undefined + }, + encode: (value) => value + } +} + +export function isPluginDataEntry(entry: PluginDataEntry, field: PluginDataKey): boolean { + return entry.pluginId === OPEN_PENCIL_PLUGIN_ID && entry.key === field.key +} + +export function hasPluginData( + entries: readonly PluginDataEntry[] | undefined, + field: PluginDataKey +): boolean { + return entries?.some((entry) => isPluginDataEntry(entry, field)) ?? false +} + +export function readPluginData( + entries: readonly PluginDataEntry[] | undefined, + field: PluginDataField +): T | undefined { + return field.decode(entries?.find((entry) => isPluginDataEntry(entry, field))?.value) +} + +/** Every valid value of a field kept as several entries under one key. */ +export function readAllPluginData( + entries: readonly PluginDataEntry[] | undefined, + field: PluginDataField +): T[] { + return (entries ?? []).flatMap((entry) => { + if (!isPluginDataEntry(entry, field)) return [] + const value = field.decode(entry.value) + return value === undefined ? [] : [value] + }) +} + +export function pluginDataEntry(field: PluginDataField, value: T): PluginDataEntry { + return { pluginId: OPEN_PENCIL_PLUGIN_ID, key: field.key, value: field.encode(value) } +} + +/** Entries other than those of `fields`; other plugins' entries are always kept. */ +export function withoutPluginData( + entries: readonly PluginDataEntry[], + fields: readonly PluginDataKey[] +): PluginDataEntry[] { + return entries.filter((entry) => !fields.some((field) => isPluginDataEntry(entry, field))) +} + +/** Entries with `field` set to `value`, appended last; `undefined` removes the field. */ +export function withPluginData( + entries: readonly PluginDataEntry[], + field: PluginDataField, + value: T | undefined +): PluginDataEntry[] { + return withAllPluginData(entries, field, value === undefined ? [] : [value]) +} + +/** Entries with every entry of `field` replaced by one per value. */ +export function withAllPluginData( + entries: readonly PluginDataEntry[], + field: PluginDataField, + values: readonly T[] +): PluginDataEntry[] { + return [ + ...withoutPluginData(entries, [field]), + ...values.map((value) => pluginDataEntry(field, value)) + ] +} diff --git a/packages/scene-graph/src/plugin-data/fields.ts b/packages/scene-graph/src/plugin-data/fields.ts new file mode 100644 index 000000000..7a53c1695 --- /dev/null +++ b/packages/scene-graph/src/plugin-data/fields.ts @@ -0,0 +1,156 @@ +import * as v from 'valibot' + +import type { OkHCLPayload } from '../color/okhcl' +import { isExportFormatId, type ExportFormatId } from '../export-format' +import { clampExportScale } from '../export-scale' +import type { Rect } from '../primitives' +import { + TOKEN_UNITS, + type EnabledLibraryBinding, + type ExportSetting, + type LayoutDirection, + type LibraryAssetSource, + type SourceLibraryPublication, + type TextDirection, + type TokenUnit +} from '../types' +import { jsonPluginDataField, textPluginDataField, type PluginDataKey } from './field' + +const DIRECTIONS = ['AUTO', 'LTR', 'RTL'] as const satisfies readonly ( + | TextDirection + | LayoutDirection +)[] + +const textPathBox: v.GenericSchema = v.pipe( + v.object({ x: v.number(), y: v.number(), width: v.number(), height: v.number() }), + v.check( + ({ x, y, width, height }) => Number.isFinite(x + y + width + height) && width > 0 && height > 0 + ) +) + +/** String-valued entries of a JSON object; other entries are dropped, not rejected. */ +const boundVariables = v.pipe( + v.custom>( + (value) => typeof value === 'object' && value !== null && !Array.isArray(value) + ), + v.transform((value) => + Object.fromEntries( + Object.entries(value).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + ) + ) +) + +/** Every entry must be valid, or the whole value is ignored in favour of native settings. */ +const exportSettings: v.GenericSchema = v.array( + v.pipe( + v.object({ + scale: v.pipe(v.number(), v.finite()), + format: v.custom(isExportFormatId) + }), + // Clamp at the file boundary: imported plugin data may carry a scale the UI never produces. + v.transform(({ scale, format }) => ({ scale: clampExportScale(scale), format })) + ) +) + +const librarySource: v.GenericSchema = v.pipe( + v.object({ + identity: v.object({ libraryId: v.string(), assetKey: v.string(), revisionId: v.string() }), + sourceNodeId: v.optional(v.unknown()), + readOnly: v.optional(v.unknown()) + }), + v.transform(({ identity, sourceNodeId, readOnly }) => ({ + identity, + sourceNodeId: typeof sourceNodeId === 'string' ? sourceNodeId : null, + readOnly: readOnly === true + })) +) + +const enabledLibrary = v.object({ + libraryId: v.string(), + revisionId: v.string(), + enabled: v.optional(v.unknown()) +}) + +/** Invalid entries are skipped one by one, so one bad binding keeps the others. */ +const enabledLibraries: v.GenericSchema = v.pipe( + v.array(v.unknown()), + v.transform((items) => + items.flatMap((item) => { + const parsed = v.safeParse(enabledLibrary, item) + if (!parsed.success) return [] + const { libraryId, revisionId, enabled } = parsed.output + return [{ libraryId, revisionId, enabled: enabled === true }] + }) + ) +) + +// Shape only. Whether a string is valid CSS is checked where it is written into a stylesheet. +const cssText = v.pipe(v.string(), v.trim(), v.nonEmpty(), v.maxLength(1000)) + +/** Token fields Figma has no slot for, with mode ids as the file spells them. */ +export interface StoredToken { + unit?: TokenUnit + expressions?: Record +} + +const token: v.GenericSchema = v.object({ + unit: v.optional(v.picklist(TOKEN_UNITS)), + expressions: v.optional( + v.record(v.string(), v.object({ css: cssText, resolved: v.pipe(v.number(), v.finite()) })) + ) +}) + +const okhcl: v.GenericSchema = v.object({ + version: v.literal(1), + kind: v.picklist(['fill', 'stroke']), + index: v.number(), + color: v.object({ + h: v.number(), + c: v.number(), + l: v.number(), + a: v.fallback(v.optional(v.number()), undefined) + }) +}) + +const sourceLibraryPublication: v.GenericSchema = v.object({ + libraryId: v.string(), + revisionId: v.string(), + name: v.string(), + catalogSource: v.optional(v.string()) +}) + +/** + * Every plugin-data entry OpenPencil writes under {@link OPEN_PENCIL_PLUGIN_ID}: state Figma's + * format has no field for. Add a key here, never as a string elsewhere, so reading and writing + * it share one schema. + */ +export const OPEN_PENCIL_PLUGIN_DATA = { + /** A text node's direction, on TEXT nodes. */ + textDirection: textPluginDataField('textDirection', DIRECTIONS), + /** An auto-layout frame's direction. */ + layoutDirection: textPluginDataField('layoutDirection', DIRECTIONS), + /** Marks a component set Figma would read as a plain frame. */ + nodeType: textPluginDataField('nodeType', ['COMPONENT_SET']), + /** Variable bindings by field path, including those Figma cannot store natively. */ + boundVariables: jsonPluginDataField('boundVariables', boundVariables), + exportSettings: jsonPluginDataField('exportSettings', exportSettings), + /** The node-local rect a TEXT_PATH layout maps onto, which Kiwi has no field for. */ + textPathBox: jsonPluginDataField('textPathBox', textPathBox), + /** The library asset a node was inserted from. */ + librarySource: jsonPluginDataField('librarySource', librarySource), + /** Libraries enabled for the document, on the DOCUMENT node. */ + enabledLibraries: jsonPluginDataField('enabledLibraries', enabledLibraries), + /** The library this document publishes as, on the DOCUMENT node. */ + sourceLibraryPublication: jsonPluginDataField( + 'sourceLibraryPublication', + sourceLibraryPublication + ), + /** Unit and CSS expressions, on each VARIABLE. */ + token: jsonPluginDataField('token', token), + /** Mode conditions by mode id, on each VARIABLE_SET. */ + modeConditions: jsonPluginDataField('modeConditions', v.record(v.string(), cssText)), + /** One entry per paint picked in OkHCL, so the picker reopens on the same coordinates. */ + okhcl: jsonPluginDataField('okhcl', okhcl) +} satisfies Record diff --git a/packages/scene-graph/src/types.ts b/packages/scene-graph/src/types.ts index 741901506..35c8150cb 100644 --- a/packages/scene-graph/src/types.ts +++ b/packages/scene-graph/src/types.ts @@ -404,6 +404,14 @@ export interface EnabledLibraryBinding { enabled: boolean } +/** The library a document publishes as. */ +export interface SourceLibraryPublication { + libraryId: string + revisionId: string + name: string + catalogSource?: string +} + export interface SceneNode { id: string type: NodeType diff --git a/packages/scene-graph/tests/plugin-data/fields.test.ts b/packages/scene-graph/tests/plugin-data/fields.test.ts new file mode 100644 index 000000000..74a6f2b9e --- /dev/null +++ b/packages/scene-graph/tests/plugin-data/fields.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from 'bun:test' + +import { + OPEN_PENCIL_PLUGIN_DATA, + OPEN_PENCIL_PLUGIN_ID, + readAllPluginData, + readPluginData, + SceneGraph, + withPluginData, + type PluginDataEntry +} from '@open-pencil/scene-graph' +import { + clearNodeFillOkHCL, + getNodeOkHCLPayloads, + setNodeFillOkHCL, + setNodeStrokeOkHCL +} from '@open-pencil/scene-graph/color' + +const { exportSettings, okhcl, textDirection } = OPEN_PENCIL_PLUGIN_DATA +const OTHER_PLUGIN: PluginDataEntry = { pluginId: 'other-plugin', key: 'okhcl', value: 'theirs' } + +function entry(key: string, value: string): PluginDataEntry { + return { pluginId: OPEN_PENCIL_PLUGIN_ID, key, value } +} + +describe('OpenPencil plugin data', () => { + test('gives every field its own key', () => { + const keys = Object.values(OPEN_PENCIL_PLUGIN_DATA).map((field) => field.key) + expect(new Set(keys).size).toBe(keys.length) + }) + + test('reads a value that is not JSON or has the wrong shape as missing', () => { + for (const value of ['{not json', '{"scale":2}', '[{"scale":2,"format":"gif"}]']) { + expect(readPluginData([entry('exportSettings', value)], exportSettings)).toBeUndefined() + } + expect(readPluginData([entry('textDirection', 'SIDEWAYS')], textDirection)).toBeUndefined() + expect(readPluginData([entry('textDirection', 'RTL')], textDirection)).toBe('RTL') + }) + + test('ignores the same key written by another plugin', () => { + const entries = [{ ...entry('textDirection', 'RTL'), pluginId: 'other-plugin' }] + expect(readPluginData(entries, textDirection)).toBeUndefined() + }) + + test('replaces its own entry and keeps everything else', () => { + const settings = [{ scale: 2, format: 'png' as const }] + const entries = withPluginData( + [entry('exportSettings', '[]'), OTHER_PLUGIN, entry('textDirection', 'LTR')], + exportSettings, + settings + ) + expect(entries).toEqual([ + OTHER_PLUGIN, + entry('textDirection', 'LTR'), + entry('exportSettings', JSON.stringify(settings)) + ]) + expect(readPluginData(entries, exportSettings)).toEqual(settings) + expect(withPluginData(entries, exportSettings, undefined)).toEqual(entries.slice(0, 2)) + }) + + test('picking an OkHCL color keeps the node’s other plugin data', () => { + const graph = new SceneGraph() + const paint = { type: 'SOLID' as const, visible: true, opacity: 1, color: { r: 1, g: 0, b: 0, a: 1 } } + const node = graph.createNode('FRAME', graph.getPages()[0].id, { + fills: [paint], + strokes: [{ ...paint, weight: 1, align: 'INSIDE' as const }], + pluginData: [OTHER_PLUGIN, entry('exportSettings', '[{"scale":1,"format":"png"}]')] + }) + const color = { h: 30, c: 0.1, l: 0.6, a: 1 } + + graph.updateNode(node.id, setNodeFillOkHCL(node, 0, color)) + graph.updateNode(node.id, setNodeStrokeOkHCL(node, 0, color)) + graph.updateNode(node.id, clearNodeFillOkHCL(node, 0)) + + const pluginData = graph.getNode(node.id)?.pluginData ?? [] + expect(pluginData.slice(0, 2)).toEqual([ + OTHER_PLUGIN, + entry('exportSettings', '[{"scale":1,"format":"png"}]') + ]) + expect(readAllPluginData(pluginData, okhcl).map(({ kind }) => kind)).toEqual(['stroke']) + expect(getNodeOkHCLPayloads({ ...node, pluginData })).toHaveLength(1) + }) +}) diff --git a/packages/vue/src/document/export/helpers.ts b/packages/vue/src/document/export/helpers.ts index 74241a6bc..df7abc754 100644 --- a/packages/vue/src/document/export/helpers.ts +++ b/packages/vue/src/document/export/helpers.ts @@ -7,7 +7,9 @@ import { EXPORT_FORMAT_IDS, MAX_EXPORT_SCALE, MIN_EXPORT_SCALE, - clampExportScale + OPEN_PENCIL_PLUGIN_DATA, + clampExportScale, + withPluginData } from '@open-pencil/scene-graph' import type { ExportFormatId, ExportSetting, PluginDataEntry } from '@open-pencil/scene-graph' @@ -24,9 +26,6 @@ export interface ExportFormatOption { export type ExportPanelTarget = 'selection' | 'page' -const OPEN_PENCIL_PLUGIN_ID = 'open-pencil' -const EXPORT_SETTINGS_PLUGIN_KEY = 'exportSettings' - // Re-exported from core so the UI and the .fig file-format boundary share one // definition of the export-scale bounds (see scene-graph/export-scale). export { MIN_EXPORT_SCALE, MAX_EXPORT_SCALE, clampExportScale } @@ -124,19 +123,11 @@ function syncExportSettingsPluginData( pluginData: PluginDataEntry[], settings: ExportSetting[] ): PluginDataEntry[] { - const withoutExportSettings = pluginData.filter( - (entry) => - !(entry.pluginId === OPEN_PENCIL_PLUGIN_ID && entry.key === EXPORT_SETTINGS_PLUGIN_KEY) + return withPluginData( + pluginData, + OPEN_PENCIL_PLUGIN_DATA.exportSettings, + settings.length > 0 ? settings : undefined ) - if (settings.length === 0) return withoutExportSettings - return [ - ...withoutExportSettings, - { - pluginId: OPEN_PENCIL_PLUGIN_ID, - key: EXPORT_SETTINGS_PLUGIN_KEY, - value: JSON.stringify(settings) - } - ] } function updateEveryTarget(