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
This commit is contained in:
Danila Poyarkov 2026-07-17 20:28:22 +03:00
parent 1d8ddd951a
commit c96f87bf9b
54 changed files with 1236 additions and 77 deletions

View file

@ -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.

View file

@ -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<SceneNode>).layoutGrids ?? []
if (modeledGrids.length > 0) return modeledGrids
const source = (node as Partial<SceneNode>).source
const grids = source?.fig.rawNodeFields.layoutGrids
if (!Array.isArray(grids)) return []

View file

@ -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

View file

@ -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<SceneNode>) {
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<SceneNode>, 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)[]

View file

@ -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<string, GUID>
fontDigestMap: Map<string, Uint8Array>
varIdToGuid: Map<string, GUID>
modeIdToGuid: Map<string, GUID>
glyphBlobMap: Map<string, number>
blobIndexByHex: Map<string, number>
assignedGuidValues: Set<string>
}
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<string, unknown> = {
type: 'NODE_CHANGES',

View file

@ -8,6 +8,28 @@ export interface ExtractedGraph {
nodeIds: string[]
}
function includeReferencedStyles(source: SceneGraph, ids: Set<string>): void {
const referencedStyleIds = new Set<string>()
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<string>): SceneGraph {
const graph = new SceneGraph()
graph.rootId = source.rootId
@ -20,6 +42,8 @@ function cloneIntoGraph(source: SceneGraph, ids: Set<string>): 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

View file

@ -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'

View file

@ -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',

View file

@ -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
}

View file

@ -14,6 +14,8 @@ type StyleRefFields = Record<string, unknown> & {
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<string, Partial<StyleSource>>
function referencedStyle(
changeMap: StyleChangeMap,
reference: { guid?: GUID } | undefined
): Partial<StyleSource> | 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<string, Partial<StyleSource>>,
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)
}

View file

@ -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' },

View file

@ -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. |

View file

@ -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)

View file

@ -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)

View file

@ -110,6 +110,13 @@ Every node carries these fields (subset of `NodeChange`):
- `opacity` — 01
- `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

View file

@ -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<SceneNode>
): 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 }
}

View file

@ -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.

View file

@ -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

View file

@ -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<SceneNode>
): 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.

View file

@ -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,

View file

@ -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<SharedStyleKind, keyof SceneNode>
const STYLE_TYPES = {
fill: 'FILL',
stroke: 'FILL',
text: 'TEXT',
effect: 'EFFECT',
grid: 'GRID'
} as const satisfies Record<SharedStyleKind, SharedStyleType>
const TEXT_STYLE_KEYS = new Set<keyof SceneNode>([
'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<SceneNode>
): Partial<SceneNode> {
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
}

View file

@ -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<Record<string, string>> = {
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 = {}

View file

@ -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

View file

@ -136,6 +136,7 @@ These are the main APIs most SDK consumers should start with.
- `useLayout()`
- `useConstraints()`
- `useAppearance()`
- `useSharedStyleBinding()`
- `useColorModel()`
- `useMask()`
- `useTypography()`

View file

@ -39,15 +39,26 @@ export function createDefaultEffect(): Effect {
}
}
export function createEffectEditActions(editor: Editor, effectsBeforeScrub: Ref<Effect[] | null>) {
export interface EffectEditSnapshot {
effects: Effect[]
effectStyleId: string | null
}
export function createEffectEditActions(
editor: Editor,
effectsBeforeScrub: Ref<EffectEditSnapshot | null>
) {
function scrubEffect(node: SceneNode | null, index: number, changes: Partial<Effect>) {
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'
)
}
}

View file

@ -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<number | null>(null)
const effectsBeforeScrub = ref<Effect[] | null>(null)
const effectsBeforeScrub = ref<EffectEditSnapshot | null>(null)
const editActions = createEffectEditActions(editor, effectsBeforeScrub)
const controlActions = createEffectControlActions(expandedIndex)

View file

@ -0,0 +1,2 @@
export { sharedStyleDetachPatch, sharedStylePatch } from '#vue/controls/shared-style/model'
export { useSharedStyleBinding } from '#vue/controls/shared-style/use'

View file

@ -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<SceneNode> {
const refKey = sharedStyleRefKey(kind)
const patch: Partial<SceneNode> = { [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<SceneNode> {
return { [sharedStyleRefKey(kind)]: null }
}

View file

@ -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<SceneNode>) {
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 }
}

View file

@ -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<SceneNode>,
`Change ${key}`
)
}
if (!node.value) return
editor.commitNodeUpdate(
node.value.id,
{
[key]: previous,
...(textStyleBeforePreview !== undefined ? { textStyleId: textStyleBeforePreview } : {})
} as Partial<SceneNode>,
`Change ${key}`
)
textStyleBeforePreview = undefined
}
return {

View file

@ -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",

View file

@ -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",

View file

@ -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 deffet",
"gridStyle": "Style de grille",
"missingStyle": "Style manquant ({id})",
"layersCount": "{count} calques",
"goToMainComponent": "Aller au composant principal",
"detachInstance": "Détacher l'instance",

View file

@ -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",

View file

@ -72,6 +72,13 @@
"addAutoLayout": "オートレイアウトを追加",
"removeAutoLayout": "オートレイアウトを削除",
"mixed": "複数選択",
"none": "なし",
"fillStyle": "塗りスタイル",
"strokeStyle": "線スタイル",
"textStyle": "テキストスタイル",
"effectStyle": "エフェクトスタイル",
"gridStyle": "グリッドスタイル",
"missingStyle": "不明なスタイル ({id})",
"layersCount": "{count} 個のレイヤー",
"goToMainComponent": "メインコンポーネントに移動",
"detachInstance": "インスタンスの切り離し",

View file

@ -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ę",

View file

@ -71,6 +71,13 @@
"mixedEffectsHelp": "Нажмите +, чтобы заменить смешанные эффекты",
"strokeSides": "Стороны обводки",
"mixed": "Смешанное",
"none": "Нет",
"fillStyle": "Стиль заливки",
"strokeStyle": "Стиль обводки",
"textStyle": "Стиль текста",
"effectStyle": "Стиль эффекта",
"gridStyle": "Стиль сетки",
"missingStyle": "Недоступный стиль ({id})",
"layersCount": "{count} слоёв",
"goToMainComponent": "Перейти к главному компоненту",
"detachInstance": "Отвязать экземпляр",

View file

@ -71,6 +71,13 @@
"mixedEffectsHelp": "点击 + 替换混合效果",
"strokeSides": "描边边",
"mixed": "混合",
"none": "无",
"fillStyle": "填充样式",
"strokeStyle": "描边样式",
"textStyle": "文本样式",
"effectStyle": "效果样式",
"gridStyle": "网格样式",
"missingStyle": "缺失样式 ({id})",
"layersCount": "{count} 个图层",
"goToMainComponent": "转到主组件",
"detachInstance": "分离实例",

View file

@ -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',

View file

@ -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,

View file

@ -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)

View file

@ -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 {
</IconButton>
</template>
<SharedStyleField kind="effect" :label="panels.effectStyle" />
<p v-if="isMixed" class="text-[11px] text-muted">{{ panels.mixedEffectsHelp }}</p>
<div

View file

@ -20,6 +20,7 @@ import {
import { fillLabel } from '@/components/properties/fill-label'
import { createFillOkhclAdapter } from '@/components/properties/paint/okhcl'
import PropertyListRoot from '@/components/properties/PropertyListRoot.vue'
import SharedStyleField from '@/components/properties/shared-style/SharedStyleField.vue'
import VariableBindingPicker from '@/components/properties/binding/VariableBindingPicker.vue'
import IconButton from '@/components/ui/IconButton.vue'
import PanelSection from '@/components/ui/panel/PanelSection.vue'
@ -73,6 +74,8 @@ function updateSolidColor(
</IconButton>
</template>
<SharedStyleField kind="fill" :label="panels.fillStyle" />
<p v-if="isMixed" class="text-[11px] text-muted">{{ panels.mixedFillsHelp }}</p>
<PropertyItemRow

View file

@ -7,6 +7,7 @@ import FlexControls from '@/components/properties/LayoutSection/FlexControls.vue
import GridControls from '@/components/properties/LayoutSection/GridControls.vue'
import PaddingControls from '@/components/properties/LayoutSection/PaddingControls.vue'
import SizeControls from '@/components/properties/LayoutSection/size/SizeControls.vue'
import SharedStyleField from '@/components/properties/shared-style/SharedStyleField.vue'
import PanelSection from '@/components/ui/panel/PanelSection.vue'
const { panels } = useI18n()
@ -18,6 +19,7 @@ const CONTAINER_TYPES = ['FRAME', 'COMPONENT', 'COMPONENT_SET', 'INSTANCE']
<LayoutControlsRoot v-slot="ctx">
<template v-if="ctx.node">
<PanelSection :label="panels.layout">
<SharedStyleField kind="grid" :label="panels.gridStyle" />
<SizeControls />
</PanelSection>

View file

@ -24,6 +24,7 @@ import {
} from '@/components/properties/paint/binding'
import { createStrokeOkhclAdapter } from '@/components/properties/paint/okhcl'
import PropertyListRoot from '@/components/properties/PropertyListRoot.vue'
import SharedStyleField from '@/components/properties/shared-style/SharedStyleField.vue'
import VariableBindingPicker from '@/components/properties/binding/VariableBindingPicker.vue'
import AppSelect from '@/components/ui/AppSelect.vue'
import FillSwatch from '@/components/ui/FillSwatch.vue'
@ -109,6 +110,8 @@ function onToggleSides(activeNode: SceneNode | null) {
</IconButton>
</template>
<SharedStyleField kind="stroke" :label="panels.strokeStyle" />
<p v-if="isMixed" class="text-[11px] text-muted">{{ panels.mixedStrokesHelp }}</p>
<PropertyItemRow

View file

@ -5,6 +5,7 @@ import { TypographyControlsRoot, useI18n } from '@open-pencil/vue'
import FontPicker from '@/components/font-picker/FontPicker.vue'
import FontSettingsPopover from '@/components/FontSettings/FontSettingsPopover.vue'
import SharedStyleField from '@/components/properties/shared-style/SharedStyleField.vue'
import VariableNumberField from '@/components/properties/VariableNumberField.vue'
import AppSelect from '@/components/ui/AppSelect.vue'
import IconButton from '@/components/ui/IconButton.vue'
@ -28,6 +29,8 @@ const alignmentOptions = computed(() => [
<template>
<TypographyControlsRoot v-slot="ctx" :font-loader="fontLoader">
<PanelSection v-if="ctx.node.value" :label="panels.typography">
<SharedStyleField kind="text" :label="panels.textStyle" />
<div class="mb-panel flex min-w-0 items-center gap-panel">
<FontPicker
class="min-w-0 flex-1"

View file

@ -0,0 +1,59 @@
<script setup lang="ts">
import { computed } from 'vue'
import { MIXED, useI18n, useSharedStyleBinding } from '@open-pencil/vue'
import AppSelect from '@/components/ui/AppSelect.vue'
import PanelFieldGroup from '@/components/ui/panel/PanelFieldGroup.vue'
import PanelGrid from '@/components/ui/panel/PanelGrid.vue'
import type { SharedStyleKind } from '@open-pencil/scene-graph'
interface SharedStyleFieldProps {
kind: SharedStyleKind
label: string
}
const { kind, label } = defineProps<SharedStyleFieldProps>()
const { panels } = useI18n()
const binding = useSharedStyleBinding(kind)
const { active, styleId, styles } = binding
const visible = computed(
() =>
active.value && (styles.value.length > 0 || styleId.value === MIXED || styleId.value !== null)
)
const options = computed(() => {
const result: Array<{ value: string; label: string }> = [
{ value: 'NONE', label: panels.value.none }
]
if (styleId.value === MIXED) result.unshift({ value: 'MIXED', label: panels.value.mixed })
for (const style of styles.value) result.push({ value: style.id, label: style.name })
if (
typeof styleId.value === 'string' &&
!styles.value.some((style) => style.id === styleId.value)
) {
result.push({ value: styleId.value, label: panels.value.missingStyle({ id: styleId.value }) })
}
return result
})
function update(value: string) {
if (value === 'MIXED') return
if (value === 'NONE') binding.unbind()
else binding.bind(value)
}
</script>
<template>
<PanelGrid v-if="visible" columns="fill" class="mb-panel">
<PanelFieldGroup :label="label">
<AppSelect
:model-value="styleId === MIXED ? 'MIXED' : (styleId ?? 'NONE')"
:options="options"
:label="label"
:data-property="`${kind}-style`"
@update:model-value="update"
/>
</PanelFieldGroup>
</PanelGrid>
</template>

View file

@ -0,0 +1,182 @@
import { expect, test, type Page } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
import { propertySection } from '#tests/helpers/properties'
let page: Page
let canvas: CanvasHelper
let targetId = ''
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ browser }) => {
page = await browser.newPage()
await page.goto('/')
canvas = new CanvasHelper(page)
await canvas.waitForInit()
targetId = await page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const pageId = store.state.currentPageId
const fill = store.graph.createNode('RECTANGLE', pageId, {
name: 'Brand/Primary',
fills: [
{
type: 'SOLID',
color: { r: 0.9, g: 0.2, b: 0.15, a: 1 },
opacity: 1,
visible: true
}
],
sharedStyleType: 'FILL',
internalOnly: true
})
fill.source.id = '1:100'
const text = store.graph.createNode('TEXT', pageId, {
name: 'Type/Display',
fontFamily: 'Inter',
fontSize: 30,
fontWeight: 700,
lineHeight: 38,
sharedStyleType: 'TEXT',
internalOnly: true
})
text.source.id = '1:101'
const effect = store.graph.createNode('RECTANGLE', pageId, {
name: 'Effects/Card',
effects: [
{
type: 'DROP_SHADOW',
color: { r: 0, g: 0, b: 0, a: 0.25 },
offset: { x: 0, y: 6 },
radius: 12,
spread: 0,
visible: true
}
],
sharedStyleType: 'EFFECT',
internalOnly: true
})
effect.source.id = '1:102'
const grid = store.graph.createNode('FRAME', pageId, {
name: 'Grid/12 columns',
layoutGrids: [{ pattern: 'COLUMNS', count: 12, gutterSize: 16, visible: true }],
sharedStyleType: 'GRID',
internalOnly: true
})
grid.source.id = '1:103'
const target = store.graph.createNode('FRAME', pageId, {
name: 'Style target',
x: 120,
y: 100,
width: 240,
height: 160
})
store.select([target.id])
store.requestRender()
return target.id
})
await canvas.waitForRender()
})
test.afterAll(async () => {
await page.close()
})
async function chooseStyle(section: string, label: string, option: string) {
await propertySection(page, section).getByRole('combobox', { name: label }).click()
await page.getByRole('option', { name: option, exact: true }).click()
await canvas.waitForRender()
}
async function targetStyles(id = targetId) {
return page.evaluate((nodeId) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const node = store.graph.getNode(nodeId)
return node
? {
fillStyleId: node.fillStyleId,
strokeStyleId: node.strokeStyleId,
textStyleId: node.textStyleId,
effectStyleId: node.effectStyleId,
gridStyleId: node.gridStyleId,
fillColor: node.fills[0]?.color,
effects: node.effects.length,
grids: node.layoutGrids.length,
fontSize: node.fontSize
}
: null
}, id)
}
test('applies fill, stroke, effect, and grid styles from local definitions', async () => {
await chooseStyle('Fill', 'Fill style', 'Brand/Primary')
await chooseStyle('Stroke', 'Stroke style', 'Brand/Primary')
await chooseStyle('Effects', 'Effect style', 'Effects/Card')
await chooseStyle('Layout', 'Grid style', 'Grid/12 columns')
expect(await targetStyles()).toMatchObject({
fillStyleId: '1:100',
strokeStyleId: '1:100',
effectStyleId: '1:102',
gridStyleId: '1:103',
fillColor: { r: 0.9, g: 0.2, b: 0.15, a: 1 },
effects: 1,
grids: 1
})
})
test('manual paint edits detach the style and undo restores the reference', async () => {
await propertySection(page, 'Fill').getByRole('button', { name: 'Remove fill' }).click()
await canvas.waitForRender()
expect((await targetStyles())?.fillStyleId).toBeNull()
await canvas.pressKey('Meta+z')
await canvas.waitForRender()
expect(await targetStyles()).toMatchObject({ fillStyleId: '1:100', fillColor: { r: 0.9 } })
})
test('applies text styles and batches mixed selection binding', async () => {
const textId = await page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const text = store.graph.createNode('TEXT', store.state.currentPageId, {
name: 'Text target',
text: 'Shared style',
x: 420,
y: 120
})
store.select([text.id])
return text.id
})
await canvas.waitForRender()
await chooseStyle('Typography', 'Text style', 'Type/Display')
expect(await targetStyles(textId)).toMatchObject({
textStyleId: '1:101',
fontSize: 30
})
const secondId = await page.evaluate((firstId) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const second = store.graph.createNode('FRAME', store.state.currentPageId, {
name: 'Second style target',
x: 120,
y: 320,
width: 240,
height: 120
})
store.select([firstId, second.id])
return second.id
}, targetId)
await canvas.waitForRender()
await chooseStyle('Fill', 'Fill style', 'Brand/Primary')
expect((await targetStyles(targetId))?.fillStyleId).toBe('1:100')
expect((await targetStyles(secondId))?.fillStyleId).toBe('1:100')
await canvas.pressKey('Meta+z')
await canvas.waitForRender()
expect((await targetStyles(targetId))?.fillStyleId).toBe('1:100')
expect((await targetStyles(secondId))?.fillStyleId).toBeNull()
})

View file

@ -0,0 +1,79 @@
import { beforeAll, describe, expect, test } from 'bun:test'
import { exportFigFile, initCodec } from '@open-pencil/core'
import { parseFigBuffer } from '@open-pencil/kiwi/fig/parse'
import { SceneGraph } from '@open-pencil/scene-graph'
import { sceneNodeToKiwi } from '#core/kiwi/fig/node-change/serialize'
function serialize(graph: SceneGraph, nodeId: string) {
const node = graph.getNode(nodeId)
if (!node) throw new Error('Expected scene node')
return sceneNodeToKiwi(node, { sessionID: 1, localID: 1 }, 0, { value: 20 }, graph, [])[0]
}
describe('Figma shared style export', () => {
beforeAll(async () => {
await initCodec()
})
test('exports every modeled style reference and promoted layout grids', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const node = graph.createNode('FRAME', page.id, {
fillStyleId: '1:10',
strokeStyleId: '1:11',
textStyleId: '1:12',
effectStyleId: '1:13',
gridStyleId: '1:14',
layoutGrids: [{ pattern: 'COLUMNS', count: 12, gutterSize: 16, visible: true }]
})
expect(serialize(graph, node.id)).toMatchObject({
styleIdForFill: { guid: { sessionID: 1, localID: 10 } },
styleIdForStrokeFill: { guid: { sessionID: 1, localID: 11 } },
styleIdForText: { guid: { sessionID: 1, localID: 12 } },
styleIdForEffect: { guid: { sessionID: 1, localID: 13 } },
styleIdForGrid: { guid: { sessionID: 1, localID: 14 } },
layoutGrids: [{ pattern: 'COLUMNS', count: 12, gutterSize: 16, visible: true }]
})
})
test('exports internal style definitions with their style type', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const style = graph.createNode('RECTANGLE', page.id, {
name: 'Brand/Primary',
sharedStyleType: 'FILL',
internalOnly: true
})
style.source.id = '1:10'
expect(serialize(graph, style.id)).toMatchObject({
guid: { sessionID: 1, localID: 10 },
name: 'Brand/Primary',
styleType: 'FILL'
})
})
test('includes referenced definitions in a complete .fig export', async () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const style = graph.createNode('RECTANGLE', page.id, {
name: 'Brand/Primary',
sharedStyleType: 'FILL',
internalOnly: true
})
style.source.id = '1:10'
graph.createNode('RECTANGLE', page.id, { name: 'Styled target', fillStyleId: '1:10' })
const bytes = await exportFigFile(graph)
const parsed = parseFigBuffer(
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
)
const definition = parsed.nodeChanges.find((change) => change.styleType === 'FILL')
const target = parsed.nodeChanges.find((change) => change.name === 'Styled target')
expect(definition).toMatchObject({ name: 'Brand/Primary', styleType: 'FILL' })
expect(target?.styleIdForFill).toEqual({ guid: { sessionID: 1, localID: 10 } })
})
})

View file

@ -0,0 +1,82 @@
import { describe, expect, test } from 'bun:test'
import { importNodeChanges } from '@open-pencil/core'
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import { canvas, doc, node } from './helpers'
const paint = {
type: 'SOLID' as const,
color: { r: 0.2, g: 0.4, b: 0.9, a: 1 },
opacity: 1,
visible: true,
blendMode: 'NORMAL' as const
}
const effect = {
type: 'DROP_SHADOW' as const,
color: { r: 0, g: 0, b: 0, a: 0.25 },
offset: { x: 0, y: 4 },
radius: 8,
spread: 0,
visible: true
}
const grid = { pattern: 'COLUMNS', count: 12, gutterSize: 16, visible: true }
describe('fig-import: shared styles', () => {
test('models all style references and keeps definitions internal', () => {
const graph = importNodeChanges([
doc(),
canvas(),
node('ROUNDED_RECTANGLE', 20, 1, {
name: 'Brand/Fill',
styleType: 'FILL',
fillPaints: [paint]
} as Partial<NodeChange>),
node('TEXT', 21, 1, {
name: 'Type/Body',
styleType: 'TEXT',
fontSize: 18,
fontName: { family: 'Inter', style: 'Bold' },
lineHeight: { value: 26, units: 'PIXELS' }
} as Partial<NodeChange>),
node('ROUNDED_RECTANGLE', 22, 1, {
name: 'Effects/Card',
styleType: 'EFFECT',
effects: [effect]
} as Partial<NodeChange>),
node('FRAME', 23, 1, {
name: 'Grid/12 columns',
styleType: 'GRID',
layoutGrids: [grid]
} as Partial<NodeChange>),
node('FRAME', 10, 1, {
name: 'Styled target',
styleIdForFill: { guid: { sessionID: 1, localID: 20 } },
styleIdForStrokeFill: { guid: { sessionID: 1, localID: 20 } },
styleIdForText: { guid: { sessionID: 1, localID: 21 } },
styleIdForEffect: { guid: { sessionID: 1, localID: 22 } },
styleIdForGrid: { guid: { sessionID: 1, localID: 23 } }
} as Partial<NodeChange>)
])
const target = [...graph.getAllNodes()].find((item) => item.name === 'Styled target')
expect(target).toMatchObject({
fillStyleId: '1:20',
strokeStyleId: '1:20',
textStyleId: '1:21',
effectStyleId: '1:22',
gridStyleId: '1:23',
fontSize: 18,
fontWeight: 700,
lineHeight: 26,
fills: [paint],
effects: [effect],
layoutGrids: [grid]
})
const definitions = [...graph.getAllNodes()].filter((item) => item.sharedStyleType)
expect(definitions).toHaveLength(4)
expect(definitions.every((item) => item.internalOnly)).toBe(true)
})
})

View file

@ -115,9 +115,12 @@ describe('fig-import: text properties', () => {
.getChildren(graph.getPages()[0].id)
.find((node) => node.text === 'Styled text')
expect(styled?.fontSize).toBe(16)
expect(styled?.textStyleId).toBe('1:20')
expect(styled?.fontWeight).toBe(400)
expect(styled?.lineHeight).toBe(24)
expect(styled?.textDecoration).toBe('NONE')
const style = [...graph.getAllNodes()].find((node) => node.source.id === '1:20')
expect(style).toMatchObject({ name: 'Body style', sharedStyleType: 'TEXT', internalOnly: true })
})
test('applies shared fill style refs', () => {
@ -153,6 +156,7 @@ describe('fig-import: text properties', () => {
const styled = graph
.getChildren(graph.getPages()[0].id)
.find((node) => node.name === 'ROUNDED_RECTANGLE_10')
expect(styled?.fillStyleId).toBe('1:30')
expect(styled?.fills[0]?.color).toEqual({
r: 0.05882352963089943,
g: 0.09019608050584793,

View file

@ -5,6 +5,37 @@ import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import { applyStyleRefsToFields } from '#core/kiwi/fig/node-change/style-refs'
describe('fig import style refs', () => {
test('effect and grid styles replace stale direct payloads', () => {
const effectGuid = { sessionID: 4, localID: 5000 }
const gridGuid = { sessionID: 4, localID: 5001 }
const effect = {
type: 'DROP_SHADOW' as const,
color: { r: 0, g: 0, b: 0, a: 0.25 },
offset: { x: 0, y: 4 },
radius: 8,
spread: 0,
visible: true
}
const grid = { pattern: 'COLUMNS', count: 12, gutterSize: 16, visible: true }
const fields: Record<string, unknown> = {
styleIdForEffect: { guid: effectGuid },
styleIdForGrid: { guid: gridGuid },
effects: [],
layoutGrids: []
}
applyStyleRefsToFields(
new Map([
['4:5000', { styleType: 'EFFECT', effects: [effect] }],
['4:5001', { styleType: 'GRID', layoutGrids: [grid] }]
]),
fields
)
expect(fields.effects).toEqual([effect])
expect(fields.layoutGrids).toEqual([grid])
})
test('stroke fill style overrides stale direct stroke paint', () => {
const styleGuid = { sessionID: 4, localID: 4594 }
const stylePaint = {

View file

@ -0,0 +1,138 @@
import { describe, expect, test } from 'bun:test'
import { createEditor } from '@open-pencil/core/editor'
import { getSharedStyles, type Effect, type Fill, type SceneNode } from '@open-pencil/scene-graph'
import { sharedStyleDetachPatch, sharedStylePatch } from '#vue/controls/shared-style/model'
import { firstPageId, makeSceneGraph } from '#tests/helpers/scene'
const red: Fill = {
type: 'SOLID',
color: { r: 1, g: 0, b: 0, a: 1 },
opacity: 1,
visible: true
}
const blue: Fill = {
type: 'SOLID',
color: { r: 0, g: 0.3, b: 1, a: 1 },
opacity: 1,
visible: true
}
function setSourceId(node: SceneNode, id: string) {
node.source.id = id
node.source.format = 'fig'
}
describe('shared style model', () => {
test('lists internal definitions and applies canonical domain properties', () => {
const graph = makeSceneGraph()
const pageId = firstPageId(graph)
const fillStyle = graph.createNode('RECTANGLE', pageId, {
name: 'Brand/Primary',
fills: [red],
sharedStyleType: 'FILL',
internalOnly: true
})
setSourceId(fillStyle, '1:20')
const target = graph.createNode('RECTANGLE', pageId, { fills: [blue] })
expect(getSharedStyles(graph, 'fill')).toEqual([
{ id: '1:20', nodeId: fillStyle.id, name: 'Brand/Primary', type: 'FILL' }
])
expect(sharedStylePatch('fill', target, '1:20', fillStyle)).toMatchObject({
fillStyleId: '1:20',
fills: [red]
})
expect(sharedStyleDetachPatch('fill')).toEqual({ fillStyleId: null })
})
test('applies text, effect, and grid style payloads', () => {
const graph = makeSceneGraph()
const pageId = firstPageId(graph)
const target = graph.createNode('TEXT', pageId)
const textStyle = graph.createNode('TEXT', pageId, {
fontFamily: 'Inter',
fontSize: 24,
fontWeight: 700,
lineHeight: 32,
textCase: 'UPPER'
})
const effect: Effect = {
type: 'DROP_SHADOW',
color: { r: 0, g: 0, b: 0, a: 0.25 },
offset: { x: 0, y: 4 },
radius: 8,
spread: 0,
visible: true
}
const effectStyle = graph.createNode('RECTANGLE', pageId, { effects: [effect] })
const gridStyle = graph.createNode('FRAME', pageId, {
layoutGrids: [{ pattern: 'COLUMNS', count: 12, gutterSize: 16, visible: true }]
})
expect(sharedStylePatch('text', target, '1:21', textStyle)).toMatchObject({
textStyleId: '1:21',
fontSize: 24,
fontWeight: 700,
lineHeight: 32,
textCase: 'UPPER'
})
expect(sharedStylePatch('effect', target, '1:22', effectStyle)).toMatchObject({
effectStyleId: '1:22',
effects: [effect]
})
expect(sharedStylePatch('grid', target, '1:23', gridStyle)).toMatchObject({
gridStyleId: '1:23',
layoutGrids: [{ count: 12 }]
})
})
test('detaching a reference removes only its raw Figma fallback', () => {
const graph = makeSceneGraph()
const target = graph.createNode('RECTANGLE', firstPageId(graph), {
fillStyleId: '1:20',
effectStyleId: '1:22'
})
target.source.fig.rawNodeFields = {
styleIdForFill: { guid: { sessionID: 1, localID: 20 } },
styleIdForEffect: { guid: { sessionID: 1, localID: 22 } },
description: 'Preserve me'
}
graph.updateNode(target.id, { fillStyleId: null })
expect(target.source.fig.rawNodeFields).toEqual({
styleIdForEffect: { guid: { sessionID: 1, localID: 22 } },
description: 'Preserve me'
})
})
test('manual visual edits detach only the matching style and undo restores it', () => {
const graph = makeSceneGraph()
const pageId = firstPageId(graph)
const target = graph.createNode('RECTANGLE', pageId, {
fills: [red],
fillStyleId: '1:20',
effectStyleId: '1:22'
})
const editor = createEditor({ graph })
editor.updateNodeWithUndo(target.id, { fills: [blue] }, 'Change fills')
expect(graph.getNode(target.id)).toMatchObject({
fillStyleId: null,
effectStyleId: '1:22',
fills: [blue]
})
editor.undo.undo()
expect(graph.getNode(target.id)).toMatchObject({ fillStyleId: '1:20', fills: [red] })
const text = graph.createNode('TEXT', pageId, { fontSize: 16, textStyleId: '1:21' })
editor.updateNodeWithUndo(text.id, { fontSize: 24 }, 'Change font size')
expect(graph.getNode(text.id)).toMatchObject({ fontSize: 24, textStyleId: null })
editor.undo.undo()
expect(graph.getNode(text.id)).toMatchObject({ fontSize: 16, textStyleId: '1:21' })
})
})

View file

@ -16,12 +16,17 @@ describe('layer tree model', () => {
const frame = graph.createNode('FRAME', pageId, { name: 'Frame' })
const child = createRect(graph, frame.id, { name: 'Child' })
const sibling = createRect(graph, pageId, { name: 'Sibling' })
const internal = graph.createNode('RECTANGLE', pageId, {
name: 'Internal style',
internalOnly: true
})
const model = buildLayerTreeModel(graph, pageId)
expect(model.items.map((node) => node.id)).toEqual([frame.id, sibling.id])
expect(model.items[0]?.children?.map((node) => node.id)).toEqual([child.id])
expect(model.byId.get(child.id)?.name).toBe('Child')
expect(model.byId.has(internal.id)).toBe(false)
})
test('derives only rows made visible by expansion', () => {