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:
parent
1d8ddd951a
commit
c96f87bf9b
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)[]
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
20
packages/scene-graph/src/bindings.ts
Normal file
20
packages/scene-graph/src/bindings.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
72
packages/scene-graph/src/shared-styles.ts
Normal file
72
packages/scene-graph/src/shared-styles.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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 = {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ These are the main APIs most SDK consumers should start with.
|
|||
- `useLayout()`
|
||||
- `useConstraints()`
|
||||
- `useAppearance()`
|
||||
- `useSharedStyleBinding()`
|
||||
- `useColorModel()`
|
||||
- `useMask()`
|
||||
- `useTypography()`
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
2
packages/vue/src/controls/shared-style/index.ts
Normal file
2
packages/vue/src/controls/shared-style/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { sharedStyleDetachPatch, sharedStylePatch } from '#vue/controls/shared-style/model'
|
||||
export { useSharedStyleBinding } from '#vue/controls/shared-style/use'
|
||||
63
packages/vue/src/controls/shared-style/model.ts
Normal file
63
packages/vue/src/controls/shared-style/model.ts
Normal 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 }
|
||||
}
|
||||
62
packages/vue/src/controls/shared-style/use.ts
Normal file
62
packages/vue/src/controls/shared-style/use.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -72,6 +72,13 @@
|
|||
"addAutoLayout": "オートレイアウトを追加",
|
||||
"removeAutoLayout": "オートレイアウトを削除",
|
||||
"mixed": "複数選択",
|
||||
"none": "なし",
|
||||
"fillStyle": "塗りスタイル",
|
||||
"strokeStyle": "線スタイル",
|
||||
"textStyle": "テキストスタイル",
|
||||
"effectStyle": "エフェクトスタイル",
|
||||
"gridStyle": "グリッドスタイル",
|
||||
"missingStyle": "不明なスタイル ({id})",
|
||||
"layersCount": "{count} 個のレイヤー",
|
||||
"goToMainComponent": "メインコンポーネントに移動",
|
||||
"detachInstance": "インスタンスの切り離し",
|
||||
|
|
|
|||
|
|
@ -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ę",
|
||||
|
|
|
|||
|
|
@ -71,6 +71,13 @@
|
|||
"mixedEffectsHelp": "Нажмите +, чтобы заменить смешанные эффекты",
|
||||
"strokeSides": "Стороны обводки",
|
||||
"mixed": "Смешанное",
|
||||
"none": "Нет",
|
||||
"fillStyle": "Стиль заливки",
|
||||
"strokeStyle": "Стиль обводки",
|
||||
"textStyle": "Стиль текста",
|
||||
"effectStyle": "Стиль эффекта",
|
||||
"gridStyle": "Стиль сетки",
|
||||
"missingStyle": "Недоступный стиль ({id})",
|
||||
"layersCount": "{count} слоёв",
|
||||
"goToMainComponent": "Перейти к главному компоненту",
|
||||
"detachInstance": "Отвязать экземпляр",
|
||||
|
|
|
|||
|
|
@ -71,6 +71,13 @@
|
|||
"mixedEffectsHelp": "点击 + 替换混合效果",
|
||||
"strokeSides": "描边边",
|
||||
"mixed": "混合",
|
||||
"none": "无",
|
||||
"fillStyle": "填充样式",
|
||||
"strokeStyle": "描边样式",
|
||||
"textStyle": "文本样式",
|
||||
"effectStyle": "效果样式",
|
||||
"gridStyle": "网格样式",
|
||||
"missingStyle": "缺失样式 ({id})",
|
||||
"layersCount": "{count} 个图层",
|
||||
"goToMainComponent": "转到主组件",
|
||||
"detachInstance": "分离实例",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
59
src/components/properties/shared-style/SharedStyleField.vue
Normal file
59
src/components/properties/shared-style/SharedStyleField.vue
Normal 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>
|
||||
182
tests/e2e/properties/shared-styles.spec.ts
Normal file
182
tests/e2e/properties/shared-styles.spec.ts
Normal 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()
|
||||
})
|
||||
79
tests/engine/io/fig/export/shared-styles.test.ts
Normal file
79
tests/engine/io/fig/export/shared-styles.test.ts
Normal 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 } })
|
||||
})
|
||||
})
|
||||
82
tests/engine/io/fig/import/legacy/shared-styles.test.ts
Normal file
82
tests/engine/io/fig/import/legacy/shared-styles.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
138
tests/engine/vue/controls/shared-style.test.ts
Normal file
138
tests/engine/vue/controls/shared-style.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue