From c96f87bf9b5e6f14bb5e33c7c497aa2466bead23 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 20:28:22 +0300 Subject: [PATCH] feat(editor): add shared style bindings - Model imported fill, stroke, text, effect, and grid style definitions and references - Add SDK and app selectors with mixed-selection batching, detach-on-edit, and undo restoration - Preserve style definitions through .fig export and extracted subgraphs - Document the shared-style composable and update compatibility coverage --- CHANGELOG.md | 1 + packages/core/src/canvas/layout-grids.ts | 4 +- packages/core/src/canvas/scene.ts | 10 +- packages/core/src/editor/nodes.ts | 13 +- packages/core/src/io/formats/fig/export.ts | 68 ++++++- packages/core/src/io/subgraph.ts | 24 +++ packages/core/src/kiwi/fig/import.ts | 1 + .../core/src/kiwi/fig/node-change/convert.ts | 26 +++ .../src/kiwi/fig/node-change/export-node.ts | 13 ++ .../src/kiwi/fig/node-change/style-refs.ts | 71 ++++--- packages/docs/.vitepress/sdk-sidebar.ts | 1 + packages/docs/development/roadmap.md | 6 +- .../programmable/sdk/api/composables/index.md | 1 + .../composables/use-shared-style-binding.md | 41 ++++ packages/docs/reference/node-types.md | 7 + packages/scene-graph/src/bindings.ts | 20 ++ packages/scene-graph/src/copy.ts | 6 + packages/scene-graph/src/hit-test.ts | 4 +- packages/scene-graph/src/index.ts | 23 +-- packages/scene-graph/src/node-defaults.ts | 7 + packages/scene-graph/src/shared-styles.ts | 72 +++++++ packages/scene-graph/src/source-metadata.ts | 17 ++ packages/scene-graph/src/types.ts | 30 +++ packages/vue/README.md | 1 + packages/vue/src/controls/effects/helpers.ts | 29 ++- packages/vue/src/controls/effects/use.ts | 7 +- .../vue/src/controls/shared-style/index.ts | 2 + .../vue/src/controls/shared-style/model.ts | 63 ++++++ packages/vue/src/controls/shared-style/use.ts | 62 ++++++ .../vue/src/controls/typography/actions.ts | 23 ++- packages/vue/src/i18n/locales/de/panels.json | 7 + packages/vue/src/i18n/locales/es/panels.json | 7 + packages/vue/src/i18n/locales/fr/panels.json | 7 + packages/vue/src/i18n/locales/it/panels.json | 7 + packages/vue/src/i18n/locales/ja/panels.json | 7 + packages/vue/src/i18n/locales/pl/panels.json | 7 + packages/vue/src/i18n/locales/ru/panels.json | 7 + .../vue/src/i18n/locales/zh-cn/panels.json | 7 + packages/vue/src/i18n/messages/panels.ts | 7 + packages/vue/src/index.ts | 1 + .../vue/src/primitives/LayerTree/model.ts | 2 +- src/components/properties/EffectsSection.vue | 3 + src/components/properties/FillSection.vue | 3 + .../LayoutSection/LayoutSection.vue | 2 + src/components/properties/StrokeSection.vue | 3 + .../properties/TypographySection.vue | 3 + .../shared-style/SharedStyleField.vue | 59 ++++++ tests/e2e/properties/shared-styles.spec.ts | 182 ++++++++++++++++++ .../io/fig/export/shared-styles.test.ts | 79 ++++++++ .../fig/import/legacy/shared-styles.test.ts | 82 ++++++++ .../engine/io/fig/import/legacy/text.test.ts | 4 + tests/engine/io/fig/import/style-refs.test.ts | 31 +++ .../engine/vue/controls/shared-style.test.ts | 138 +++++++++++++ tests/engine/vue/layer-tree/model.test.ts | 5 + 54 files changed, 1236 insertions(+), 77 deletions(-) create mode 100644 packages/docs/programmable/sdk/api/composables/use-shared-style-binding.md create mode 100644 packages/scene-graph/src/bindings.ts create mode 100644 packages/scene-graph/src/shared-styles.ts create mode 100644 packages/vue/src/controls/shared-style/index.ts create mode 100644 packages/vue/src/controls/shared-style/model.ts create mode 100644 packages/vue/src/controls/shared-style/use.ts create mode 100644 src/components/properties/shared-style/SharedStyleField.vue create mode 100644 tests/e2e/properties/shared-styles.spec.ts create mode 100644 tests/engine/io/fig/export/shared-styles.test.ts create mode 100644 tests/engine/io/fig/import/legacy/shared-styles.test.ts create mode 100644 tests/engine/vue/controls/shared-style.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 384a6e3ce..4f5afad1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Add Figma-style horizontal and vertical constraint controls with pin interactions, mixed-selection editing, undo, and responsive frame resizing. - Add mixed-selection stroke cap, join, and miter-limit controls with CanvasKit rendering and `.fig` roundtrip support. - Add a mixed-selection corner-smoothing percentage control with live preview, per-node undo restoration, and `.fig` roundtrip coverage. +- Model imported fill, stroke, text, effect, and grid styles with reusable SDK/app selectors, automatic detach-on-edit, undo, and `.fig` definition roundtrips. - Standardize Vue SDK and app override type names on the `UI` acronym, including `FontPickerUI`. - Add a headless Vue SDK NumberField with pointer scrubbing, keyboard stepping, safe arithmetic expressions, and mixed/bound states; remove the superseded ScrubInput API. - Add provider-driven BindableValue primitives for variable and token binding, including detach-on-edit, read-only, edit-variable, mixed-value, and undo-batched interactions. diff --git a/packages/core/src/canvas/layout-grids.ts b/packages/core/src/canvas/layout-grids.ts index 645a3383d..8ed6abe24 100644 --- a/packages/core/src/canvas/layout-grids.ts +++ b/packages/core/src/canvas/layout-grids.ts @@ -7,7 +7,7 @@ import { SELECTION_COLOR } from '#core/constants' import type { SkiaRenderer } from './renderer' -interface RawLayoutGrid { +type RawLayoutGrid = SceneNode['layoutGrids'][number] & { visible?: boolean color?: Color pattern?: string @@ -32,6 +32,8 @@ interface GridGeometry { } function rawLayoutGrids(node: SceneNode): RawLayoutGrid[] { + const modeledGrids = (node as Partial).layoutGrids ?? [] + if (modeledGrids.length > 0) return modeledGrids const source = (node as Partial).source const grids = source?.fig.rawNodeFields.layoutGrids if (!Array.isArray(grids)) return [] diff --git a/packages/core/src/canvas/scene.ts b/packages/core/src/canvas/scene.ts index 2b05a4faa..ed71428a8 100644 --- a/packages/core/src/canvas/scene.ts +++ b/packages/core/src/canvas/scene.ts @@ -198,7 +198,15 @@ export function renderNode( parentAbsY = 0 ): void { const node = graph.getNode(nodeId) - if (!node || !node.visible || node.isMask || fontManager.isNodeBlocked(nodeId)) return + if ( + !node || + node.internalOnly || + !node.visible || + node.isMask || + fontManager.isNodeBlocked(nodeId) + ) { + return + } // Hide the node being edited in node-edit mode (overlay draws it live) if (overlays.nodeEditState?.nodeId === nodeId) return diff --git a/packages/core/src/editor/nodes.ts b/packages/core/src/editor/nodes.ts index b7a80ded5..f00fa443c 100644 --- a/packages/core/src/editor/nodes.ts +++ b/packages/core/src/editor/nodes.ts @@ -1,6 +1,6 @@ import { pick } from 'es-toolkit/object' -import type { SceneNode } from '@open-pencil/scene-graph' +import { styleDetachmentChanges, type SceneNode } from '@open-pencil/scene-graph' import { createLayoutModeActions } from './layout-mode' import { createNudgeActions } from './nudge' @@ -15,7 +15,11 @@ export function createNodeActions(ctx: EditorContext) { function updateNode(id: string, changes: Partial) { const node = ctx.graph.getNode(id) - const nextChanges = { ...changes, ...textAutoResizeChanges(node, changes) } + if (!node) return + const nextChanges = styleDetachmentChanges(node, { + ...changes, + ...textAutoResizeChanges(node, changes) + }) ctx.graph.updateNode(id, nextChanges) ctx.runLayoutForNode(id) } @@ -23,7 +27,10 @@ export function createNodeActions(ctx: EditorContext) { function updateNodeWithUndo(id: string, changes: Partial, label = 'Update') { const node = ctx.graph.getNode(id) if (!node) return - const nextChanges = { ...changes, ...textAutoResizeChanges(node, changes) } + const nextChanges = styleDetachmentChanges(node, { + ...changes, + ...textAutoResizeChanges(node, changes) + }) const previous = pick( node, Object.keys(nextChanges) as (keyof SceneNode)[] diff --git a/packages/core/src/io/formats/fig/export.ts b/packages/core/src/io/formats/fig/export.ts index b3495ac9e..d2e001762 100644 --- a/packages/core/src/io/formats/fig/export.ts +++ b/packages/core/src/io/formats/fig/export.ts @@ -280,7 +280,8 @@ function buildCanvasEntries( canvasEntries.push({ page, canvasGuid, canvasNc }) } - if (graph.variableCollections.size > 0 && internalCanvasGuid === null) { + const hasSharedStyles = [...graph.nodes.values()].some((node) => node.sharedStyleType !== null) + if ((graph.variableCollections.size > 0 || hasSharedStyles) && internalCanvasGuid === null) { internalCanvasGuid = { sessionID: 0, localID: localIdCounter.value++ } assignedGuidValues.add(`${internalCanvasGuid.sessionID}:${internalCanvasGuid.localID}`) canvasEntries.push({ @@ -299,6 +300,54 @@ function buildCanvasEntries( return { canvasEntries, internalCanvasGuid } } +interface InternalResourceContext { + graph: SceneGraph + nodeChanges: KiwiNodeChange[] + internalCanvasGuid: GUID | null + localIdCounter: { value: number } + blobs: Uint8Array[] + nodeIdToGuid: Map + fontDigestMap: Map + varIdToGuid: Map + modeIdToGuid: Map + glyphBlobMap: Map + blobIndexByHex: Map + assignedGuidValues: Set +} + +function appendInternalResources(context: InternalResourceContext): void { + const { graph, internalCanvasGuid, nodeChanges } = context + if (!internalCanvasGuid) return + const sharedStyleNodes = [...graph.nodes.values()].filter((node) => node.sharedStyleType !== null) + for (let index = 0; index < sharedStyleNodes.length; index++) { + nodeChanges.push( + ...sceneNodeToKiwi( + sharedStyleNodes[index], + internalCanvasGuid, + index, + context.localIdCounter, + graph, + context.blobs, + context.nodeIdToGuid, + context.fontDigestMap, + context.varIdToGuid, + context.glyphBlobMap, + context.blobIndexByHex, + context.assignedGuidValues + ) + ) + } + if (graph.variableCollections.size > 0) { + appendVariableNodeChanges( + graph, + nodeChanges, + internalCanvasGuid, + context.varIdToGuid, + context.modeIdToGuid + ) + } +} + export async function exportFigFile( graph: SceneGraph, ck?: CanvasKit, @@ -408,9 +457,20 @@ export async function exportFigFile( } } - if (graph.variableCollections.size > 0 && internalCanvasGuid) { - appendVariableNodeChanges(graph, nodeChanges, internalCanvasGuid, varIdToGuid, modeIdToGuid) - } + appendInternalResources({ + graph, + nodeChanges, + internalCanvasGuid, + localIdCounter, + blobs, + nodeIdToGuid, + fontDigestMap, + varIdToGuid, + modeIdToGuid, + glyphBlobMap, + blobIndexByHex, + assignedGuidValues + }) const msg: Record = { type: 'NODE_CHANGES', diff --git a/packages/core/src/io/subgraph.ts b/packages/core/src/io/subgraph.ts index b57ed0a81..3daf0c722 100644 --- a/packages/core/src/io/subgraph.ts +++ b/packages/core/src/io/subgraph.ts @@ -8,6 +8,28 @@ export interface ExtractedGraph { nodeIds: string[] } +function includeReferencedStyles(source: SceneGraph, ids: Set): void { + const referencedStyleIds = new Set() + for (const id of ids) { + const node = source.getNode(id) + if (!node) continue + for (const styleId of [ + node.fillStyleId, + node.strokeStyleId, + node.textStyleId, + node.effectStyleId, + node.gridStyleId + ]) { + if (styleId) referencedStyleIds.add(styleId) + } + } + for (const node of source.getAllNodes()) { + if (node.sharedStyleType && node.source.id && referencedStyleIds.has(node.source.id)) { + ids.add(node.id) + } + } +} + function cloneIntoGraph(source: SceneGraph, ids: Set): SceneGraph { const graph = new SceneGraph() graph.rootId = source.rootId @@ -20,6 +42,8 @@ function cloneIntoGraph(source: SceneGraph, ids: Set): SceneGraph { graph.figSchemaDeflated = source.figSchemaDeflated graph.documentColorSpace = source.documentColorSpace + includeReferencedStyles(source, ids) + const sortedIds = [...ids].sort((a, b) => { if (a === source.rootId) return -1 if (b === source.rootId) return 1 diff --git a/packages/core/src/kiwi/fig/import.ts b/packages/core/src/kiwi/fig/import.ts index 13d922371..aa8a6c8c1 100644 --- a/packages/core/src/kiwi/fig/import.ts +++ b/packages/core/src/kiwi/fig/import.ts @@ -445,6 +445,7 @@ export function importNodeChanges( if (!nc) return const { nodeType, ...props } = nodeChangeToProps(nc, blobs) + if (props.sharedStyleType) props.internalOnly = true if (nodeType === 'DOCUMENT' || nodeType === 'VARIABLE' || nc.type === 'VARIABLE_SET') return if (shouldImportTextAsAutoSize(nc, changeMap.get(parentMap.get(ncId) ?? ''))) { props.textAutoResize = 'WIDTH_AND_HEIGHT' diff --git a/packages/core/src/kiwi/fig/node-change/convert.ts b/packages/core/src/kiwi/fig/node-change/convert.ts index f0ee07ab2..cdde94c86 100644 --- a/packages/core/src/kiwi/fig/node-change/convert.ts +++ b/packages/core/src/kiwi/fig/node-change/convert.ts @@ -44,6 +44,8 @@ import type { TextAlignVertical, TextCase, ArcData, + LayoutGrid, + SharedStyleType, VectorNetwork, ComponentPropertyDefinition, ComponentPropertyType, @@ -500,6 +502,22 @@ function getVectorStrokeJoin(nc: NodeChange, vectorNetwork: VectorNetwork | null 'MITER') as StrokeJoin } +function styleRefId(value: unknown): string | null { + if (!value || typeof value !== 'object' || !('guid' in value)) return null + const guid = value.guid + if (!guid || typeof guid !== 'object') return null + return guidToString(guid as GUID) +} + +function sharedStyleType(value: string | undefined): SharedStyleType | null { + if (value === 'FILL' || value === 'TEXT' || value === 'EFFECT' || value === 'GRID') return value + return null +} + +function convertLayoutGrids(value: unknown): LayoutGrid[] { + return Array.isArray(value) ? structuredClone(value as LayoutGrid[]) : [] +} + function convertVectorAndStrokeProps(nc: NodeChange, blobs: Uint8Array[]) { const vectorNetwork = resolveVectorNetwork(nc, blobs) const strokeCap = getVectorStrokeCap(nc, vectorNetwork) @@ -588,6 +606,13 @@ export function nodeChangeToProps( nc.dashPattern ?? [] ), effects: convertEffects(nc.effects), + layoutGrids: convertLayoutGrids(nc.layoutGrids), + fillStyleId: styleRefId(nc.styleIdForFill), + strokeStyleId: styleRefId(nc.styleIdForStrokeFill), + textStyleId: styleRefId(nc.styleIdForText), + effectStyleId: styleRefId(nc.styleIdForEffect), + gridStyleId: styleRefId(nc.styleIdForGrid), + sharedStyleType: sharedStyleType(nc.styleType), ...convertCornerProps(nc), ...convertTextProps(nc, blobs), horizontalConstraint: mapConstraint(nc.horizontalConstraint as string), @@ -855,6 +880,7 @@ export const FIGMA_RAW_NODE_FIELD_KEYS = [ 'styleIdForText', 'styleIdForEffect', 'styleIdForGrid', + 'styleType', 'backgroundPaints', 'layoutGrids', 'exportSettings', diff --git a/packages/core/src/kiwi/fig/node-change/export-node.ts b/packages/core/src/kiwi/fig/node-change/export-node.ts index a240581bc..7c6842349 100644 --- a/packages/core/src/kiwi/fig/node-change/export-node.ts +++ b/packages/core/src/kiwi/fig/node-change/export-node.ts @@ -597,6 +597,17 @@ function nodeForGeometryExport(node: SceneNode): SceneNode { } } +function applySharedStyleProps(node: SceneNode, nc: KiwiNodeChange): void { + if (node.fillStyleId) nc.styleIdForFill = { guid: stringToGuid(node.fillStyleId) } + if (node.strokeStyleId) nc.styleIdForStrokeFill = { guid: stringToGuid(node.strokeStyleId) } + if (node.textStyleId) nc.styleIdForText = { guid: stringToGuid(node.textStyleId) } + if (node.effectStyleId) nc.styleIdForEffect = { guid: stringToGuid(node.effectStyleId) } + if (node.gridStyleId) nc.styleIdForGrid = { guid: stringToGuid(node.gridStyleId) } + if (node.layoutGrids.length > 0 || 'layoutGrids' in node.source.fig.rawNodeFields) { + nc.layoutGrids = structuredClone(node.layoutGrids) + } +} + function applyNodeVisualProps( context: SceneNodeToKiwiContext, node: SceneNode, @@ -648,6 +659,7 @@ function applyNodeVisualProps( } if (node.type !== 'VECTOR') nc.frameMaskDisabled = !node.clipsContent + applySharedStyleProps(node, nc) if (node.horizontalConstraint !== 'MIN') nc.horizontalConstraint = node.horizontalConstraint if (node.verticalConstraint !== 'MIN') nc.verticalConstraint = node.verticalConstraint if (node.strokeCap !== 'NONE') nc.strokeCap = node.strokeCap @@ -699,6 +711,7 @@ export function sceneNodeToKiwiWithContext( size: exportNodeSize(node), transform: exportNodeTransform(context, node) } + if (node.sharedStyleType) nc.styleType = node.sharedStyleType if (node.type === 'GROUP') { nc.resizeToFit = true } diff --git a/packages/core/src/kiwi/fig/node-change/style-refs.ts b/packages/core/src/kiwi/fig/node-change/style-refs.ts index 5436f73ca..0345c4d3e 100644 --- a/packages/core/src/kiwi/fig/node-change/style-refs.ts +++ b/packages/core/src/kiwi/fig/node-change/style-refs.ts @@ -14,6 +14,8 @@ type StyleRefFields = Record & { styleIdForFill?: { guid?: GUID } styleIdForStrokeFill?: { guid?: GUID } styleIdForText?: { guid?: GUID } + styleIdForEffect?: { guid?: GUID } + styleIdForGrid?: { guid?: GUID } } type StyleSource = Pick< @@ -21,6 +23,8 @@ type StyleSource = Pick< | 'type' | 'styleType' | 'fillPaints' + | 'effects' + | 'layoutGrids' | 'fontSize' | 'fontName' | 'lineHeight' @@ -29,33 +33,50 @@ type StyleSource = Pick< | 'textCase' > +type StyleChangeMap = ReadonlyMap> + +function referencedStyle( + changeMap: StyleChangeMap, + reference: { guid?: GUID } | undefined +): Partial | undefined { + return reference?.guid ? changeMap.get(guidToString(reference.guid)) : undefined +} + +function applyPaintStyleRefs(changeMap: StyleChangeMap, fields: StyleRefFields): void { + const fillStyle = referencedStyle(changeMap, fields.styleIdForFill) + if (fillStyle?.styleType === 'FILL' && fillStyle.fillPaints) { + fields.fillPaints = fillStyle.fillPaints + } + const strokeStyle = referencedStyle(changeMap, fields.styleIdForStrokeFill) + if (strokeStyle?.styleType === 'FILL' && strokeStyle.fillPaints) { + fields.strokePaints = strokeStyle.fillPaints + } +} + +function applyEffectAndGridStyleRefs(changeMap: StyleChangeMap, fields: StyleRefFields): void { + const effectStyle = referencedStyle(changeMap, fields.styleIdForEffect) + if (effectStyle?.styleType === 'EFFECT' && effectStyle.effects) + fields.effects = effectStyle.effects + const gridStyle = referencedStyle(changeMap, fields.styleIdForGrid) + if (gridStyle?.styleType === 'GRID' && gridStyle.layoutGrids) { + fields.layoutGrids = gridStyle.layoutGrids + } +} + +function applyTextStyleRef(changeMap: StyleChangeMap, fields: StyleRefFields): void { + const style = referencedStyle(changeMap, fields.styleIdForText) + if (style?.type !== 'TEXT' || style.styleType !== 'TEXT') return + for (const field of TEXT_STYLE_FIELDS) { + if (field === 'textDecoration') fields.textDecoration = style.textDecoration + else if (style[field] !== undefined) fields[field] = style[field] + } +} + export function applyStyleRefsToFields( changeMap: ReadonlyMap>, fields: StyleRefFields ): void { - const fillStyleGuid = fields.styleIdForFill?.guid - if (fillStyleGuid) { - const style = changeMap.get(guidToString(fillStyleGuid)) - if (style?.styleType === 'FILL' && style.fillPaints) fields.fillPaints = style.fillPaints - } - - const strokeFillStyleGuid = fields.styleIdForStrokeFill?.guid - if (strokeFillStyleGuid) { - const style = changeMap.get(guidToString(strokeFillStyleGuid)) - if (style?.styleType === 'FILL' && style.fillPaints) fields.strokePaints = style.fillPaints - } - - const textStyleGuid = fields.styleIdForText?.guid - if (!textStyleGuid) return - - const style = changeMap.get(guidToString(textStyleGuid)) - if (style?.type !== 'TEXT' || style.styleType !== 'TEXT') return - - for (const field of TEXT_STYLE_FIELDS) { - if (field === 'textDecoration') { - fields.textDecoration = style.textDecoration - } else if (style[field] !== undefined) { - fields[field] = style[field] - } - } + applyPaintStyleRefs(changeMap, fields) + applyEffectAndGridStyleRefs(changeMap, fields) + applyTextStyleRef(changeMap, fields) } diff --git a/packages/docs/.vitepress/sdk-sidebar.ts b/packages/docs/.vitepress/sdk-sidebar.ts index d92872b8a..f0c1148a7 100644 --- a/packages/docs/.vitepress/sdk-sidebar.ts +++ b/packages/docs/.vitepress/sdk-sidebar.ts @@ -44,6 +44,7 @@ const SDK_COMPOSABLE_PAGES = [ { text: 'useLayout', slug: 'use-layout' }, { text: 'useConstraints', slug: 'use-constraints', canonical: true }, { text: 'useAppearance', slug: 'use-appearance' }, + { text: 'useSharedStyleBinding', slug: 'use-shared-style-binding', canonical: true }, { text: 'useColorModel', slug: 'use-color-model', canonical: true }, { text: 'useTypography', slug: 'use-typography' }, { text: 'useExport', slug: 'use-export' }, diff --git a/packages/docs/development/roadmap.md b/packages/docs/development/roadmap.md index a623441b0..816ec1bd4 100644 --- a/packages/docs/development/roadmap.md +++ b/packages/docs/development/roadmap.md @@ -143,7 +143,7 @@ Figma's design documentation groups features into these areas: | Strokes | ✅ | ✅ | ✅ | ✅ | ✅ | Weight, alignment, dashes, and side weights are supported. | | Stroke caps / joins / miter limit | ✅ | ✅ | ✅ | ✅ | ✅ | Inspector controls support mixed cap/join/miter editing; CanvasKit rendering and `.fig` roundtrips preserve miter limits. | | Effects: shadows and blurs | ✅ | ✅ | ✅ | ✅ | ✅ | `showShadowBehindNode` is rendered but not exposed in UI. | -| Effect styles | ↩ | — | — | ↩ | — | Style IDs round-trip; no style manager. | +| Fill / stroke / effect styles | ✅ | ✅ | ◐ | ✅ | ✅ | Imported local definitions are modeled and selectable with undo-safe detach; creating and publishing styles still needs a style manager. | | Corner radius | ✅ | ✅ | ✅ | ✅ | ✅ | Uniform and independent radii supported. | | Corner smoothing | ✅ | ✅ | ✅ | ✅ | ✅ | The inspector supports mixed smoothing percentages with undo; uniform and independent-radius corners render, while exact Figma parity still needs broader fixture tuning. | | Masks | ✅ | ◐ | — | ✅ | ✅ | Figma schema `mask`, `maskType`, and `maskIsOutline` fields import and export; common sibling alpha/vector/luminance mask stacks render, including consecutive mask layers. UI controls and deeper Figma edge cases remain incomplete. | @@ -156,8 +156,8 @@ Figma's design documentation groups features into these areas: | Strokes included in layout | ✅ | ◐ | — | ✅ | ✅ | Stored/exported and used in layout paths, but no obvious panel control. | | Reverse z-index / align-content | ✅ | ◐ | — | ✅ | ✅ | Modeled and exported; UI is limited. | | Constraints | ✅ | ◐ | — | ✅ | ✅ | Tools/API expose constraints; main UI is limited. | -| Layout grids / guides | ↩ | ◐ | — | ↩ | — | Imported layout grids and page guides render from preserved Figma metadata; style IDs round-trip, but editing UI is not exposed. | -| Text styles | ↩ | ◐ | — | ↩ | — | Style IDs round-trip; no style management UI. Rich schema metadata such as derived text data, leading trim, decoration style/thickness/fill, and semantic font style/weight is preserved for round-trip. | +| Layout grids / guides | ✅ | ✅ | ◐ | ✅ | ✅ | Layout grids are modeled and grid styles are selectable; full grid geometry editing and guide management remain incomplete. | +| Text styles | ✅ | ✅ | ◐ | ✅ | ✅ | Imported local text styles are modeled, selectable, and detachable; authoring and publishing style definitions still needs a style manager. | | Rich style runs | ✅ | ✅ | ◐ | ✅ | ✅ | Import/render/export support; editing mixed runs is partial. | | Text auto resize | ✅ | ✅ | ◐ | ✅ | ✅ | Used by renderer/layout; UI does not expose every mode. | | Text truncation / max lines | ✅ | ✅ | — | ✅ | ✅ | Renderer supports ending truncation; no inspector control. | diff --git a/packages/docs/programmable/sdk/api/composables/index.md b/packages/docs/programmable/sdk/api/composables/index.md index 0c68c7f7d..c919bc5b6 100644 --- a/packages/docs/programmable/sdk/api/composables/index.md +++ b/packages/docs/programmable/sdk/api/composables/index.md @@ -28,6 +28,7 @@ These are the main composables most `@open-pencil/vue` consumers will use. - [useLayout](./use-layout) - [useConstraints](./use-constraints) - [useAppearance](./use-appearance) +- [useSharedStyleBinding](./use-shared-style-binding) - [useColorModel](./use-color-model) - [useMask](./use-mask) - [useTypography](./use-typography) diff --git a/packages/docs/programmable/sdk/api/composables/use-shared-style-binding.md b/packages/docs/programmable/sdk/api/composables/use-shared-style-binding.md new file mode 100644 index 000000000..c261c3e45 --- /dev/null +++ b/packages/docs/programmable/sdk/api/composables/use-shared-style-binding.md @@ -0,0 +1,41 @@ +--- +title: useSharedStyleBinding +description: Apply and detach local fill, stroke, text, effect, and grid styles. +--- + +# useSharedStyleBinding + +`useSharedStyleBinding(kind)` exposes the selected nodes' shared-style reference, compatible local +style definitions, and undo-aware bind/detach actions. + +```ts +import { useSharedStyleBinding } from '@open-pencil/vue' + +const fillStyle = useSharedStyleBinding('fill') + +fillStyle.bind('1:120') +fillStyle.unbind() +``` + +Supported kinds are `fill`, `stroke`, `text`, `effect`, and `grid`. + +- `active` requires every selected node to support the requested style domain. +- `styleId` is the shared ID, `null`, or `MIXED`. +- `styles` lists compatible local definitions imported with the document. +- `bind(id)` applies supported style properties and the reference in one undo step. +- `unbind()` keeps the resolved properties and removes only the reference. +- Multi-selection changes are grouped into one undo entry. + +Manual edits to fills, strokes, supported text properties, effects, or layout grids automatically +detach the matching style reference. Other style domains remain bound. + +OpenPencil currently consumes styles already present in a document. Creating, renaming, publishing, +and synchronizing style libraries is outside this composable's scope. + +## Related APIs + +- [useFillControls](./use-fill-controls) +- [useStrokeControls](./use-stroke-controls) +- [useTypography](./use-typography) +- [useEffectsControls](./use-effects-controls) +- [Property Panels guide](../../guides/property-panels) diff --git a/packages/docs/reference/node-types.md b/packages/docs/reference/node-types.md index 89ec38c23..3413d7d7d 100644 --- a/packages/docs/reference/node-types.md +++ b/packages/docs/reference/node-types.md @@ -110,6 +110,13 @@ Every node carries these fields (subset of `NodeChange`): - `opacity` — 0–1 - `blendMode` — `NORMAL`, `MULTIPLY`, `SCREEN`, etc. +### Shared styles + +Scene nodes model `fillStyleId`, `strokeStyleId`, `textStyleId`, `effectStyleId`, and +`gridStyleId` as nullable Figma GUID strings. Imported local definitions are internal nodes with a +`sharedStyleType` of `FILL`, `TEXT`, `EFFECT`, or `GRID`; `layoutGrids[]` contains promoted grid +geometry. Manual property edits detach only the matching reference. + ### Stroke - `strokeWeight` — stroke thickness diff --git a/packages/scene-graph/src/bindings.ts b/packages/scene-graph/src/bindings.ts new file mode 100644 index 000000000..2780dca8f --- /dev/null +++ b/packages/scene-graph/src/bindings.ts @@ -0,0 +1,20 @@ +import { omit } from 'es-toolkit/object' + +import type { SceneNode } from './types' + +export function removeStaleBindings( + node: SceneNode, + field: 'fills' | 'strokes', + changes: Partial +): void { + const length = node[field].length + const stale = Object.keys(node.boundVariables).filter((key) => { + if (key === field) return true + if (!key.startsWith(`${field}/`)) return false + const index = Number.parseInt(key.split('/')[1] ?? '', 10) + return Number.isNaN(index) || index < 0 || index >= length + }) + if (stale.length === 0) return + node.boundVariables = omit(node.boundVariables, stale) + changes.boundVariables = { ...node.boundVariables } +} diff --git a/packages/scene-graph/src/copy.ts b/packages/scene-graph/src/copy.ts index faca4530f..c391cdebf 100644 --- a/packages/scene-graph/src/copy.ts +++ b/packages/scene-graph/src/copy.ts @@ -16,6 +16,7 @@ import type { Fill, GeometryPath, GradientStop, + LayoutGrid, SceneNode, Stroke, StyleRun @@ -81,6 +82,10 @@ export function copyEffects(effects: Effect[]): Effect[] { return effects.map(copyEffect) } +export function copyLayoutGrids(grids: LayoutGrid[]): LayoutGrid[] { + return grids.map((grid) => ({ ...grid, color: grid.color ? { ...grid.color } : undefined })) +} + export function copyStyleRuns(runs: StyleRun[]): StyleRun[] { return runs.map(copyStyleRun) } @@ -149,6 +154,7 @@ export function cloneNodeProps(src: SceneNode, componentId: string | null): Part fills: copyOpt(src.fills, copyFills), strokes: copyOpt(src.strokes, copyStrokes), effects: copyOpt(src.effects, copyEffects), + layoutGrids: copyOpt(src.layoutGrids, copyLayoutGrids), styleRuns: copyOpt(src.styleRuns, copyStyleRuns), // Source metadata preserves opaque raw Figma payloads; use structuredClone instead of // hand-copying partial known shapes and accidentally sharing nested raw Figma data. diff --git a/packages/scene-graph/src/hit-test.ts b/packages/scene-graph/src/hit-test.ts index 11bb7ebb2..3c263fabc 100644 --- a/packages/scene-graph/src/hit-test.ts +++ b/packages/scene-graph/src/hit-test.ts @@ -84,7 +84,7 @@ function hitTestChildren( for (let i = parent.childIds.length - 1; i >= 0; i--) { const childId = parent.childIds[i] const child = graph.nodes.get(childId) - if (!child || !child.visible) continue + if (!child || child.internalOnly || !child.visible) continue if (CONTAINER_TYPES.has(child.type)) { if (OPAQUE_CONTAINER_TYPES.has(child.type) && !deep) { const hit = hitTestOpaqueContainer(graph, px, py, child, childId, deep) @@ -140,7 +140,7 @@ function hitTestFrameChildren( for (const childId of parent.childIds) { if (excludeIds.has(childId)) continue const child = graph.nodes.get(childId) - if (!child || !child.visible) continue + if (!child || child.internalOnly || !child.visible) continue const ax = offsetX + child.x const ay = offsetY + child.y diff --git a/packages/scene-graph/src/index.ts b/packages/scene-graph/src/index.ts index 18c448073..05debf90e 100644 --- a/packages/scene-graph/src/index.ts +++ b/packages/scene-graph/src/index.ts @@ -1,21 +1,24 @@ export * from './images' +export * from './copy' export * from './snap' export * from './export-scale' export * from './coordinate' export * from './geometry' +export * from './shared-styles' export { default as TransformMatrix } from './matrix' export type { Mat3 } from './matrix' export { UndoManager, type UndoEntry, type UndoManagerOptions } from './undo' -import { omit } from 'es-toolkit/object' import { createNanoEvents } from 'nanoevents' +import { removeStaleBindings } from './bindings' import { cloneNodeProps } from './copy' import { bindNodeEvents } from './events' import * as HitTest from './hit-test' import * as Instances from './instances' import { CONTAINER_TYPES, createDefaultNode } from './node-defaults' import { updateNodePreview } from './preview' +import { styleDetachmentChanges } from './shared-styles' import { clearEditedSourceMetadata } from './source-metadata' import { TEXT_PICTURE_KEYS } from './text-picture' import * as Variables from './variables' @@ -43,23 +46,6 @@ import type { export { cloneVectorNetwork, normalizeVectorNetwork, validateVectorNetwork } from './vector-network' -function removeStaleBindings( - node: SceneNode, - field: 'fills' | 'strokes', - changes: Partial -): void { - const len = node[field].length - const stale = Object.keys(node.boundVariables).filter((k) => { - if (k === field) return true - if (!k.startsWith(`${field}/`)) return false - const i = Number.parseInt(k.split('/')[1] ?? '', 10) - return Number.isNaN(i) || i < 0 || i >= len - }) - if (stale.length > 0) { - node.boundVariables = omit(node.boundVariables, stale) - changes.boundVariables = { ...node.boundVariables } - } -} let nextLocalID = 1 export function generateId(): string { @@ -371,6 +357,7 @@ export class SceneGraph { const node = this.nodes.get(id) if (!node) return + changes = styleDetachmentChanges(node, changes) // Only clear absPosCache when layout-affecting properties change. // Fills, strokes, effects, plugin data changes do NOT affect absolute position. diff --git a/packages/scene-graph/src/node-defaults.ts b/packages/scene-graph/src/node-defaults.ts index aa79033ae..9cb699993 100644 --- a/packages/scene-graph/src/node-defaults.ts +++ b/packages/scene-graph/src/node-defaults.ts @@ -38,6 +38,13 @@ export function createDefaultNode( type === 'TEXT' ? [{ type: 'SOLID' as const, color: BLACK, opacity: 1, visible: true }] : [], strokes: [], effects: [], + layoutGrids: [], + fillStyleId: null, + strokeStyleId: null, + textStyleId: null, + effectStyleId: null, + gridStyleId: null, + sharedStyleType: null, opacity: 1, cornerRadius: 0, topLeftRadius: 0, diff --git a/packages/scene-graph/src/shared-styles.ts b/packages/scene-graph/src/shared-styles.ts new file mode 100644 index 000000000..8cb3d5209 --- /dev/null +++ b/packages/scene-graph/src/shared-styles.ts @@ -0,0 +1,72 @@ +import type { SceneGraph, SceneNode, SharedStyle, SharedStyleKind, SharedStyleType } from './index' + +const STYLE_REF_KEYS = { + fill: 'fillStyleId', + stroke: 'strokeStyleId', + text: 'textStyleId', + effect: 'effectStyleId', + grid: 'gridStyleId' +} as const satisfies Record + +const STYLE_TYPES = { + fill: 'FILL', + stroke: 'FILL', + text: 'TEXT', + effect: 'EFFECT', + grid: 'GRID' +} as const satisfies Record + +const TEXT_STYLE_KEYS = new Set([ + 'fontFamily', + 'fontWeight', + 'italic', + 'fontSize', + 'lineHeight', + 'letterSpacing', + 'textDecoration', + 'textCase' +]) + +export function sharedStyleRefKey(kind: SharedStyleKind): (typeof STYLE_REF_KEYS)[SharedStyleKind] { + return STYLE_REF_KEYS[kind] +} + +export function sharedStyleTypeForKind(kind: SharedStyleKind): SharedStyleType { + return STYLE_TYPES[kind] +} + +export function getSharedStyles(graph: SceneGraph, kind: SharedStyleKind): SharedStyle[] { + const type = sharedStyleTypeForKind(kind) + const styles: SharedStyle[] = [] + for (const node of graph.getAllNodes()) { + if (node.sharedStyleType !== type || !node.source.id) continue + styles.push({ id: node.source.id, nodeId: node.id, name: node.name, type }) + } + return styles.sort((left, right) => left.name.localeCompare(right.name)) +} + +export function styleDetachmentChanges( + node: SceneNode, + changes: Partial +): Partial { + const next = { ...changes } + if ('fills' in changes && !('fillStyleId' in changes) && node.fillStyleId) { + next.fillStyleId = null + } + if ('strokes' in changes && !('strokeStyleId' in changes) && node.strokeStyleId) { + next.strokeStyleId = null + } + if ('effects' in changes && !('effectStyleId' in changes) && node.effectStyleId) { + next.effectStyleId = null + } + if ('layoutGrids' in changes && !('gridStyleId' in changes) && node.gridStyleId) { + next.gridStyleId = null + } + const changesTextStyle = (Object.keys(changes) as (keyof SceneNode)[]).some((key) => + TEXT_STYLE_KEYS.has(key) + ) + if (changesTextStyle && !('textStyleId' in changes) && node.textStyleId) { + next.textStyleId = null + } + return next +} diff --git a/packages/scene-graph/src/source-metadata.ts b/packages/scene-graph/src/source-metadata.ts index 419536aa9..b86095098 100644 --- a/packages/scene-graph/src/source-metadata.ts +++ b/packages/scene-graph/src/source-metadata.ts @@ -1,9 +1,19 @@ +import { omit } from 'es-toolkit/object' + import type { SceneNode } from './types' const RAW_SIZE_KEYS = new Set(['width', 'height']) const RAW_TRANSFORM_KEYS = new Set(['x', 'y', 'rotation', 'flipX', 'flipY']) +const STYLE_RAW_FIELDS: Partial> = { + fillStyleId: 'styleIdForFill', + strokeStyleId: 'styleIdForStrokeFill', + textStyleId: 'styleIdForText', + effectStyleId: 'styleIdForEffect', + gridStyleId: 'styleIdForGrid' +} + const RAW_NODE_FIELD_KEYS = new Set([ 'visible', 'opacity', @@ -16,6 +26,7 @@ const RAW_NODE_FIELD_KEYS = new Set([ 'borderLeftWeight', 'independentStrokeWeights', 'effects', + 'layoutGrids', 'cornerRadius', 'topLeftRadius', 'topRightRadius', @@ -82,6 +93,12 @@ const RAW_NODE_FIELD_KEYS = new Set([ ]) export function clearEditedSourceMetadata(node: SceneNode, changeKeys: string[]): void { + const styleRawFields = changeKeys + .map((key) => STYLE_RAW_FIELDS[key]) + .filter((field): field is string => field !== undefined) + if (styleRawFields.length > 0) { + node.source.fig.rawNodeFields = omit(node.source.fig.rawNodeFields, styleRawFields) + } if (changeKeys.some((key) => RAW_SIZE_KEYS.has(key))) node.source.fig.rawSize = null if (changeKeys.some((key) => RAW_TRANSFORM_KEYS.has(key))) node.source.fig.rawTransform = null if (changeKeys.some((key) => RAW_NODE_FIELD_KEYS.has(key))) node.source.fig.rawNodeFields = {} diff --git a/packages/scene-graph/src/types.ts b/packages/scene-graph/src/types.ts index 975d7acda..2e297fa43 100644 --- a/packages/scene-graph/src/types.ts +++ b/packages/scene-graph/src/types.ts @@ -161,8 +161,31 @@ export interface Fill { export type StrokeCap = 'NONE' | 'ROUND' | 'SQUARE' | 'ARROW_LINES' | 'ARROW_EQUILATERAL' export type StrokeJoin = 'MITER' | 'BEVEL' | 'ROUND' +export type SharedStyleType = 'FILL' | 'TEXT' | 'EFFECT' | 'GRID' +export type SharedStyleKind = 'fill' | 'stroke' | 'text' | 'effect' | 'grid' export type MaskType = 'ALPHA' | 'VECTOR' | 'LUMINANCE' +export interface LayoutGrid { + visible?: boolean + color?: Color + pattern?: 'COLUMNS' | 'ROWS' | 'GRID' + axis?: 'X' | 'Y' + type?: 'MIN' | 'CENTER' | 'MAX' | 'STRETCH' + alignment?: 'MIN' | 'CENTER' | 'MAX' | 'STRETCH' + numSections?: number + count?: number + offset?: number + sectionSize?: number + gutterSize?: number +} + +export interface SharedStyle { + id: string + nodeId: string + name: string + type: SharedStyleType +} + export interface Stroke { color: Color weight: number @@ -341,6 +364,13 @@ export interface SceneNode { fills: Fill[] strokes: Stroke[] effects: Effect[] + layoutGrids: LayoutGrid[] + fillStyleId: string | null + strokeStyleId: string | null + textStyleId: string | null + effectStyleId: string | null + gridStyleId: string | null + sharedStyleType: SharedStyleType | null opacity: number cornerRadius: number diff --git a/packages/vue/README.md b/packages/vue/README.md index 49b2e492c..3f266bcab 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -136,6 +136,7 @@ These are the main APIs most SDK consumers should start with. - `useLayout()` - `useConstraints()` - `useAppearance()` +- `useSharedStyleBinding()` - `useColorModel()` - `useMask()` - `useTypography()` diff --git a/packages/vue/src/controls/effects/helpers.ts b/packages/vue/src/controls/effects/helpers.ts index 935acc4f2..76e354235 100644 --- a/packages/vue/src/controls/effects/helpers.ts +++ b/packages/vue/src/controls/effects/helpers.ts @@ -39,15 +39,26 @@ export function createDefaultEffect(): Effect { } } -export function createEffectEditActions(editor: Editor, effectsBeforeScrub: Ref) { +export interface EffectEditSnapshot { + effects: Effect[] + effectStyleId: string | null +} + +export function createEffectEditActions( + editor: Editor, + effectsBeforeScrub: Ref +) { function scrubEffect(node: SceneNode | null, index: number, changes: Partial) { if (!node) return if (!effectsBeforeScrub.value) { - effectsBeforeScrub.value = node.effects.map((e) => ({ - ...e, - color: { ...e.color }, - offset: { ...e.offset } - })) + effectsBeforeScrub.value = { + effects: node.effects.map((e) => ({ + ...e, + color: { ...e.color }, + offset: { ...e.offset } + })), + effectStyleId: node.effectStyleId + } } const effects = [...node.effects] effects[index] = { ...effects[index], ...changes } @@ -64,7 +75,11 @@ export function createEffectEditActions(editor: Editor, effectsBeforeScrub: Ref< editor.updateNode(node.id, { effects }) editor.requestRender() if (previous) { - editor.commitNodeUpdate(node.id, { effects: previous }, 'Change effect') + editor.commitNodeUpdate( + node.id, + { effects: previous.effects, effectStyleId: previous.effectStyleId }, + 'Change effect' + ) } } diff --git a/packages/vue/src/controls/effects/use.ts b/packages/vue/src/controls/effects/use.ts index e7911b842..d38db744e 100644 --- a/packages/vue/src/controls/effects/use.ts +++ b/packages/vue/src/controls/effects/use.ts @@ -1,13 +1,12 @@ import { ref } from 'vue' -import type { Effect } from '@open-pencil/scene-graph' - import { EFFECT_OPTIONS, createDefaultEffect, createEffectControlActions, createEffectEditActions, - isShadow + isShadow, + type EffectEditSnapshot } from '#vue/controls/effects/helpers' import { useEditor } from '#vue/editor/context' @@ -21,7 +20,7 @@ export function useEffectsControls() { const editor = useEditor() const expandedIndex = ref(null) - const effectsBeforeScrub = ref(null) + const effectsBeforeScrub = ref(null) const editActions = createEffectEditActions(editor, effectsBeforeScrub) const controlActions = createEffectControlActions(expandedIndex) diff --git a/packages/vue/src/controls/shared-style/index.ts b/packages/vue/src/controls/shared-style/index.ts new file mode 100644 index 000000000..80d1604d9 --- /dev/null +++ b/packages/vue/src/controls/shared-style/index.ts @@ -0,0 +1,2 @@ +export { sharedStyleDetachPatch, sharedStylePatch } from '#vue/controls/shared-style/model' +export { useSharedStyleBinding } from '#vue/controls/shared-style/use' diff --git a/packages/vue/src/controls/shared-style/model.ts b/packages/vue/src/controls/shared-style/model.ts new file mode 100644 index 000000000..2442ec482 --- /dev/null +++ b/packages/vue/src/controls/shared-style/model.ts @@ -0,0 +1,63 @@ +import { BLACK } from '@open-pencil/core/constants' +import { + copyEffects, + copyFills, + copyLayoutGrids, + sharedStyleRefKey, + type SceneNode, + type SharedStyleKind +} from '@open-pencil/scene-graph' + +function strokePaintsFromStyle(target: SceneNode, style: SceneNode): SceneNode['strokes'] { + const fills = style.fills.filter((fill) => fill.type === 'SOLID') + if (fills.length === 0) return target.strokes + const fallback = target.strokes[0] ?? { + color: BLACK, + weight: 1, + opacity: 1, + visible: true, + align: 'CENTER' as const + } + return fills.map((fill, index) => { + const current = target.strokes[index] ?? fallback + return { + ...current, + color: { ...fill.color }, + opacity: fill.opacity, + visible: fill.visible + } + }) +} + +export function sharedStylePatch( + kind: SharedStyleKind, + target: SceneNode, + styleId: string, + style: SceneNode | null +): Partial { + const refKey = sharedStyleRefKey(kind) + const patch: Partial = { [refKey]: styleId } + if (!style) return patch + + if (kind === 'fill') patch.fills = copyFills(style.fills) + else if (kind === 'stroke') patch.strokes = strokePaintsFromStyle(target, style) + else if (kind === 'effect') patch.effects = copyEffects(style.effects) + else if (kind === 'grid') patch.layoutGrids = copyLayoutGrids(style.layoutGrids) + else { + Object.assign(patch, { + fontFamily: style.fontFamily, + fontWeight: style.fontWeight, + italic: style.italic, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + letterSpacing: style.letterSpacing, + textDecoration: style.textDecoration, + textCase: style.textCase + }) + } + return patch +} + +export function sharedStyleDetachPatch(kind: SharedStyleKind): Partial { + return { [sharedStyleRefKey(kind)]: null } +} diff --git a/packages/vue/src/controls/shared-style/use.ts b/packages/vue/src/controls/shared-style/use.ts new file mode 100644 index 000000000..dab0ac92d --- /dev/null +++ b/packages/vue/src/controls/shared-style/use.ts @@ -0,0 +1,62 @@ +import { computed } from 'vue' + +import { + getSharedStyles, + sharedStyleRefKey, + sharedStyleTypeForKind, + type SceneNode, + type SharedStyleKind +} from '@open-pencil/scene-graph' + +import { useNodeProps } from '#vue/controls/node-props/use' +import { sharedStyleDetachPatch, sharedStylePatch } from '#vue/controls/shared-style/model' +import { useSceneComputed } from '#vue/internal/scene-computed/use' + +function supportsStyle(node: SceneNode, kind: SharedStyleKind): boolean { + if (kind === 'text') return node.type === 'TEXT' + if (kind === 'grid') { + return ( + node.type === 'FRAME' || + node.type === 'COMPONENT' || + node.type === 'COMPONENT_SET' || + node.type === 'INSTANCE' + ) + } + return node.type !== 'CANVAS' +} + +export function useSharedStyleBinding(kind: SharedStyleKind) { + const { store, nodes, merged } = useNodeProps() + const refKey = sharedStyleRefKey(kind) + const active = computed( + () => nodes.value.length > 0 && nodes.value.every((node) => supportsStyle(node, kind)) + ) + const styleId = computed(() => merged(refKey)) + const styles = useSceneComputed(() => { + void store.state.sceneVersion + return getSharedStyles(store.graph, kind) + }) + + function update(label: string, apply: (node: SceneNode) => Partial) { + if (!active.value) return + const targets = nodes.value + const run = () => { + for (const node of targets) store.updateNodeWithUndo(node.id, apply(node), label) + } + if (targets.length > 1) store.undo.runBatch(label, run) + else run() + } + + function bind(nextStyleId: string) { + const styleInfo = styles.value.find((style) => style.id === nextStyleId) + const styleNode = styleInfo ? (store.graph.getNode(styleInfo.nodeId) ?? null) : null + if (styleNode?.sharedStyleType !== sharedStyleTypeForKind(kind)) return + update(`Apply ${kind} style`, (node) => sharedStylePatch(kind, node, nextStyleId, styleNode)) + } + + function unbind() { + update(`Detach ${kind} style`, () => sharedStyleDetachPatch(kind)) + } + + return { kind, active, styleId, styles, bind, unbind } +} diff --git a/packages/vue/src/controls/typography/actions.ts b/packages/vue/src/controls/typography/actions.ts index 3fa9c23f6..6a0c135ce 100644 --- a/packages/vue/src/controls/typography/actions.ts +++ b/packages/vue/src/controls/typography/actions.ts @@ -64,6 +64,8 @@ export function createTypographyActions({ activeFormatting, options }: TypographyActionOptions) { + let textStyleBeforePreview: string | null | undefined + async function doLoadFont(family: string, style: string) { await options.fontLoader?.load(family, style) } @@ -130,17 +132,22 @@ export function createTypographyActions({ } function updateProp(key: string, value: number | string) { - if (node.value) editor.updateNode(node.value.id, { [key]: value }) + if (!node.value) return + if (textStyleBeforePreview === undefined) textStyleBeforePreview = node.value.textStyleId + editor.updateNode(node.value.id, { [key]: value, textStyleId: null }) } function commitProp(key: string, _value: number | string, previous: number | string) { - if (node.value) { - editor.commitNodeUpdate( - node.value.id, - { [key]: previous } as Partial, - `Change ${key}` - ) - } + if (!node.value) return + editor.commitNodeUpdate( + node.value.id, + { + [key]: previous, + ...(textStyleBeforePreview !== undefined ? { textStyleId: textStyleBeforePreview } : {}) + } as Partial, + `Change ${key}` + ) + textStyleBeforePreview = undefined } return { diff --git a/packages/vue/src/i18n/locales/de/panels.json b/packages/vue/src/i18n/locales/de/panels.json index df8bf0da9..340d3fbe7 100644 --- a/packages/vue/src/i18n/locales/de/panels.json +++ b/packages/vue/src/i18n/locales/de/panels.json @@ -58,6 +58,13 @@ "addAutoLayout": "Auto-Layout hinzufügen", "removeAutoLayout": "Auto-Layout entfernen", "mixed": "Gemischt", + "none": "Keine", + "fillStyle": "Füllstil", + "strokeStyle": "Konturstil", + "textStyle": "Textstil", + "effectStyle": "Effektstil", + "gridStyle": "Rasterstil", + "missingStyle": "Fehlender Stil ({id})", "layersCount": "{count} Ebenen", "goToMainComponent": "Zur Hauptkomponente", "detachInstance": "Instanz lösen", diff --git a/packages/vue/src/i18n/locales/es/panels.json b/packages/vue/src/i18n/locales/es/panels.json index b1935c15c..db8805d63 100644 --- a/packages/vue/src/i18n/locales/es/panels.json +++ b/packages/vue/src/i18n/locales/es/panels.json @@ -72,6 +72,13 @@ "addAutoLayout": "Añadir auto-layout", "removeAutoLayout": "Quitar auto-layout", "mixed": "Mixto", + "none": "Ninguno", + "fillStyle": "Estilo de relleno", + "strokeStyle": "Estilo de trazo", + "textStyle": "Estilo de texto", + "effectStyle": "Estilo de efecto", + "gridStyle": "Estilo de cuadrícula", + "missingStyle": "Estilo no disponible ({id})", "layersCount": "{count} capas", "goToMainComponent": "Ir al componente principal", "detachInstance": "Separar instancia", diff --git a/packages/vue/src/i18n/locales/fr/panels.json b/packages/vue/src/i18n/locales/fr/panels.json index e96853a7c..64d268d76 100644 --- a/packages/vue/src/i18n/locales/fr/panels.json +++ b/packages/vue/src/i18n/locales/fr/panels.json @@ -58,6 +58,13 @@ "addAutoLayout": "Ajouter un auto-layout", "removeAutoLayout": "Retirer l'auto-layout", "mixed": "Mixte", + "none": "Aucun", + "fillStyle": "Style de remplissage", + "strokeStyle": "Style de contour", + "textStyle": "Style de texte", + "effectStyle": "Style d’effet", + "gridStyle": "Style de grille", + "missingStyle": "Style manquant ({id})", "layersCount": "{count} calques", "goToMainComponent": "Aller au composant principal", "detachInstance": "Détacher l'instance", diff --git a/packages/vue/src/i18n/locales/it/panels.json b/packages/vue/src/i18n/locales/it/panels.json index 8edb6d364..602b1f10b 100644 --- a/packages/vue/src/i18n/locales/it/panels.json +++ b/packages/vue/src/i18n/locales/it/panels.json @@ -58,6 +58,13 @@ "addAutoLayout": "Aggiungi auto-layout", "removeAutoLayout": "Rimuovi auto-layout", "mixed": "Misto", + "none": "Nessuno", + "fillStyle": "Stile riempimento", + "strokeStyle": "Stile contorno", + "textStyle": "Stile testo", + "effectStyle": "Stile effetto", + "gridStyle": "Stile griglia", + "missingStyle": "Stile mancante ({id})", "layersCount": "{count} livelli", "goToMainComponent": "Vai al componente principale", "detachInstance": "Scollega istanza", diff --git a/packages/vue/src/i18n/locales/ja/panels.json b/packages/vue/src/i18n/locales/ja/panels.json index 966354541..bbcb094d1 100644 --- a/packages/vue/src/i18n/locales/ja/panels.json +++ b/packages/vue/src/i18n/locales/ja/panels.json @@ -72,6 +72,13 @@ "addAutoLayout": "オートレイアウトを追加", "removeAutoLayout": "オートレイアウトを削除", "mixed": "複数選択", + "none": "なし", + "fillStyle": "塗りスタイル", + "strokeStyle": "線スタイル", + "textStyle": "テキストスタイル", + "effectStyle": "エフェクトスタイル", + "gridStyle": "グリッドスタイル", + "missingStyle": "不明なスタイル ({id})", "layersCount": "{count} 個のレイヤー", "goToMainComponent": "メインコンポーネントに移動", "detachInstance": "インスタンスの切り離し", diff --git a/packages/vue/src/i18n/locales/pl/panels.json b/packages/vue/src/i18n/locales/pl/panels.json index 5e7ed26d3..32303f268 100644 --- a/packages/vue/src/i18n/locales/pl/panels.json +++ b/packages/vue/src/i18n/locales/pl/panels.json @@ -58,6 +58,13 @@ "addAutoLayout": "Dodaj auto-layout", "removeAutoLayout": "Usuń auto-layout", "mixed": "Mieszane", + "none": "Brak", + "fillStyle": "Styl wypełnienia", + "strokeStyle": "Styl obrysu", + "textStyle": "Styl tekstu", + "effectStyle": "Styl efektu", + "gridStyle": "Styl siatki", + "missingStyle": "Brakujący styl ({id})", "layersCount": "{count} warstw", "goToMainComponent": "Przejdź do głównego komponentu", "detachInstance": "Odłącz instancję", diff --git a/packages/vue/src/i18n/locales/ru/panels.json b/packages/vue/src/i18n/locales/ru/panels.json index d7fda4e9c..7d1194d84 100644 --- a/packages/vue/src/i18n/locales/ru/panels.json +++ b/packages/vue/src/i18n/locales/ru/panels.json @@ -71,6 +71,13 @@ "mixedEffectsHelp": "Нажмите +, чтобы заменить смешанные эффекты", "strokeSides": "Стороны обводки", "mixed": "Смешанное", + "none": "Нет", + "fillStyle": "Стиль заливки", + "strokeStyle": "Стиль обводки", + "textStyle": "Стиль текста", + "effectStyle": "Стиль эффекта", + "gridStyle": "Стиль сетки", + "missingStyle": "Недоступный стиль ({id})", "layersCount": "{count} слоёв", "goToMainComponent": "Перейти к главному компоненту", "detachInstance": "Отвязать экземпляр", diff --git a/packages/vue/src/i18n/locales/zh-cn/panels.json b/packages/vue/src/i18n/locales/zh-cn/panels.json index dfd7fde71..b1eaed683 100644 --- a/packages/vue/src/i18n/locales/zh-cn/panels.json +++ b/packages/vue/src/i18n/locales/zh-cn/panels.json @@ -71,6 +71,13 @@ "mixedEffectsHelp": "点击 + 替换混合效果", "strokeSides": "描边边", "mixed": "混合", + "none": "无", + "fillStyle": "填充样式", + "strokeStyle": "描边样式", + "textStyle": "文本样式", + "effectStyle": "效果样式", + "gridStyle": "网格样式", + "missingStyle": "缺失样式 ({id})", "layersCount": "{count} 个图层", "goToMainComponent": "转到主组件", "detachInstance": "分离实例", diff --git a/packages/vue/src/i18n/messages/panels.ts b/packages/vue/src/i18n/messages/panels.ts index 85b7d6d47..f4af289ac 100644 --- a/packages/vue/src/i18n/messages/panels.ts +++ b/packages/vue/src/i18n/messages/panels.ts @@ -165,6 +165,13 @@ export const panelMessageDefaults = { createNumberVariable: params('Create number variable from {value}'), variableName: 'Variable name', mixed: 'Mixed', + none: 'None', + fillStyle: 'Fill style', + strokeStyle: 'Stroke style', + textStyle: 'Text style', + effectStyle: 'Effect style', + gridStyle: 'Grid style', + missingStyle: params('Missing style ({id})'), layersCount: params('{count} layers'), goToMainComponent: 'Go to Main Component', detachInstance: 'Detach Instance', diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 3f60e0d9d..b06880fe0 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -90,6 +90,7 @@ export type { UseVariableBindingOptions } from '#vue/controls/variable-binding/use' export { useEffectsControls } from '#vue/controls/effects/use' +export { useSharedStyleBinding } from '#vue/controls/shared-style/use' export { useStrokeControls } from '#vue/controls/stroke/use' export { applySolidFillColor, diff --git a/packages/vue/src/primitives/LayerTree/model.ts b/packages/vue/src/primitives/LayerTree/model.ts index daf8c524a..ecf3a5fbd 100644 --- a/packages/vue/src/primitives/LayerTree/model.ts +++ b/packages/vue/src/primitives/LayerTree/model.ts @@ -27,7 +27,7 @@ export function buildLayerTreeModel(graph: SceneGraph, parentId: string): LayerT const children: LayerNode[] = [] for (const childId of parent.childIds) { const sceneNode = graph.getNode(childId) - if (!sceneNode) continue + if (!sceneNode || sceneNode.internalOnly) continue const node = nodeToLayerNode(sceneNode) byId.set(node.id, node) if (sceneNode.childIds.length > 0) node.children = buildChildren(node.id) diff --git a/src/components/properties/EffectsSection.vue b/src/components/properties/EffectsSection.vue index 1b59793a1..897b19f09 100644 --- a/src/components/properties/EffectsSection.vue +++ b/src/components/properties/EffectsSection.vue @@ -5,6 +5,7 @@ import ColorInput from '@/components/ColorPicker/ColorInput.vue' import NumberField from '@/components/inputs/NumberField.vue' import PropertyItemRow from '@/components/properties/item-list/PropertyItemRow.vue' import PropertyListRoot from '@/components/properties/PropertyListRoot.vue' +import SharedStyleField from '@/components/properties/shared-style/SharedStyleField.vue' import AppSelect from '@/components/ui/AppSelect.vue' import FillSwatch from '@/components/ui/FillSwatch.vue' import IconButton from '@/components/ui/IconButton.vue' @@ -42,6 +43,8 @@ function effectPreview(effect: Effect): Fill { + +

{{ panels.mixedEffectsHelp }}

+ +

{{ panels.mixedFillsHelp }}

+ +

{{ panels.mixedStrokesHelp }}

[