refactor: define OpenPencil plugin data in one typed registry (#878)
* fix: validate parsed JSON at untrusted boundaries with Valibot Clipboard HTML, library revisions from shared storage, MCP and automation WebSocket messages, the MCP discovery file, sidecar output and AI/MCP tool arguments were JSON.parse'd and cast to their expected types, so a malformed payload reached the document or crashed paste. They now go through v.pipe(v.string(), v.parseJson(), Schema), which reports bad JSON and a wrong shape as the same validation failure. The path_set tool rejects an invalid VectorNetwork and shares its parser with create_vector. The CLI library catalog validates its files and runs revisions through the same size, identity and content-hash checks as the app; reading image bytes as index-keyed records also stops them coming back empty. Hand-rolled typeof readers for plugin data, document metadata, caches and preferences become schemas with their behaviour preserved, and readCacheJSON takes a schema for its payload. open-pencil/no-unvalidated-json-parse rejects type assertions on JSON.parse results other than `as unknown` in src and packages/*/src. * refactor: validate parsed JSON in tests and tooling Extend open-pencil/no-unvalidated-json-parse beyond source: tests, helpers and repo tooling now parse JSON through Valibot schemas instead of asserting a type. The shared fixture reader returns a validated object; its old array annotation never matched the fixtures. * refactor: define OpenPencil plugin data in one typed registry Every plugin-data key OpenPencil writes is now a field of OPEN_PENCIL_PLUGIN_DATA in scene-graph, with the Valibot schema that reads it; readPluginData and withPluginData replace per-key constants, JSON.parse and hand-matched pluginId/key filters across fig, core, and vue. Moving OkHCL onto it fixes picking a colour rewriting the layer's other plugin data as OkHCL entries.
This commit is contained in:
parent
7ad6475e2b
commit
69dba7002f
|
|
@ -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.
|
||||
|
|
|
|||
1
bun.lock
1
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",
|
||||
|
|
|
|||
|
|
@ -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] : [])]
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, SourceLibraryPublication>
|
||||
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
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<ExportFormatId>(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<SceneNode['librarySource']> => ({
|
||||
identity,
|
||||
sourceNodeId: typeof sourceNodeId === 'string' ? sourceNodeId : null,
|
||||
readOnly: readOnly === true
|
||||
}))
|
||||
)
|
||||
const { boundVariables, exportSettings, librarySource, textPathBox } = OPEN_PENCIL_PLUGIN_DATA
|
||||
|
||||
const NATIVE_EXPORT_FORMATS: Record<string, ExportFormatId> = {
|
||||
PNG: 'png',
|
||||
|
|
@ -98,21 +34,15 @@ const NATIVE_EXPORT_FORMATS: Record<string, ExportFormatId> = {
|
|||
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<T>(
|
||||
nc: Pick<NodeChange, 'pluginData'>,
|
||||
field: PluginDataField<T>
|
||||
): 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<string, string> {
|
||||
const stored = v.safeParse(
|
||||
BoundVariablesJSON,
|
||||
getOpenPencilPluginValue(nc, BOUND_VARIABLES_PLUGIN_KEY)
|
||||
)
|
||||
let bindings: Record<string, string> = 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[] {
|
||||
|
|
|
|||
|
|
@ -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<string, number> | 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(
|
||||
|
|
|
|||
|
|
@ -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<Variable, 'unit' | 'expressions'>
|
||||
|
||||
/** 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<T extends object>(record: T): T | undefined {
|
||||
|
|
@ -62,24 +40,15 @@ export function readVariableToken(
|
|||
nc: NodeChange,
|
||||
valuesByMode: Record<string, VariableValue>
|
||||
): 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<string, string> {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> {
|
||||
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<SceneNode> {
|
||||
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<SceneNode> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
116
packages/scene-graph/src/plugin-data/field.ts
Normal file
116
packages/scene-graph/src/plugin-data/field.ts
Normal file
|
|
@ -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<T> 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<T>(
|
||||
key: string,
|
||||
schema: v.GenericSchema<unknown, T>
|
||||
): PluginDataField<T> {
|
||||
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<const T extends string>(
|
||||
key: string,
|
||||
values: readonly T[]
|
||||
): PluginDataField<T> {
|
||||
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<T>(
|
||||
entries: readonly PluginDataEntry[] | undefined,
|
||||
field: PluginDataField<T>
|
||||
): 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<T>(
|
||||
entries: readonly PluginDataEntry[] | undefined,
|
||||
field: PluginDataField<T>
|
||||
): T[] {
|
||||
return (entries ?? []).flatMap((entry) => {
|
||||
if (!isPluginDataEntry(entry, field)) return []
|
||||
const value = field.decode(entry.value)
|
||||
return value === undefined ? [] : [value]
|
||||
})
|
||||
}
|
||||
|
||||
export function pluginDataEntry<T>(field: PluginDataField<T>, 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<T>(
|
||||
entries: readonly PluginDataEntry[],
|
||||
field: PluginDataField<T>,
|
||||
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<T>(
|
||||
entries: readonly PluginDataEntry[],
|
||||
field: PluginDataField<T>,
|
||||
values: readonly T[]
|
||||
): PluginDataEntry[] {
|
||||
return [
|
||||
...withoutPluginData(entries, [field]),
|
||||
...values.map((value) => pluginDataEntry(field, value))
|
||||
]
|
||||
}
|
||||
156
packages/scene-graph/src/plugin-data/fields.ts
Normal file
156
packages/scene-graph/src/plugin-data/fields.ts
Normal file
|
|
@ -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<unknown, Rect> = 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<Record<string, unknown>>(
|
||||
(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<unknown, ExportSetting[]> = v.array(
|
||||
v.pipe(
|
||||
v.object({
|
||||
scale: v.pipe(v.number(), v.finite()),
|
||||
format: v.custom<ExportFormatId>(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<unknown, LibraryAssetSource> = 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<unknown, EnabledLibraryBinding[]> = 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<string, { css: string; resolved: number }>
|
||||
}
|
||||
|
||||
const token: v.GenericSchema<unknown, StoredToken> = 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<unknown, OkHCLPayload> = 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<unknown, SourceLibraryPublication> = 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<string, PluginDataKey>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
83
packages/scene-graph/tests/plugin-data/fields.test.ts
Normal file
83
packages/scene-graph/tests/plugin-data/fields.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue