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 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 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.
|
- 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`.
|
- 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 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.
|
- 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'
|
import type { SkiaRenderer } from './renderer'
|
||||||
|
|
||||||
interface RawLayoutGrid {
|
type RawLayoutGrid = SceneNode['layoutGrids'][number] & {
|
||||||
visible?: boolean
|
visible?: boolean
|
||||||
color?: Color
|
color?: Color
|
||||||
pattern?: string
|
pattern?: string
|
||||||
|
|
@ -32,6 +32,8 @@ interface GridGeometry {
|
||||||
}
|
}
|
||||||
|
|
||||||
function rawLayoutGrids(node: SceneNode): RawLayoutGrid[] {
|
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 source = (node as Partial<SceneNode>).source
|
||||||
const grids = source?.fig.rawNodeFields.layoutGrids
|
const grids = source?.fig.rawNodeFields.layoutGrids
|
||||||
if (!Array.isArray(grids)) return []
|
if (!Array.isArray(grids)) return []
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,15 @@ export function renderNode(
|
||||||
parentAbsY = 0
|
parentAbsY = 0
|
||||||
): void {
|
): void {
|
||||||
const node = graph.getNode(nodeId)
|
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)
|
// Hide the node being edited in node-edit mode (overlay draws it live)
|
||||||
if (overlays.nodeEditState?.nodeId === nodeId) return
|
if (overlays.nodeEditState?.nodeId === nodeId) return
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { pick } from 'es-toolkit/object'
|
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 { createLayoutModeActions } from './layout-mode'
|
||||||
import { createNudgeActions } from './nudge'
|
import { createNudgeActions } from './nudge'
|
||||||
|
|
@ -15,7 +15,11 @@ export function createNodeActions(ctx: EditorContext) {
|
||||||
|
|
||||||
function updateNode(id: string, changes: Partial<SceneNode>) {
|
function updateNode(id: string, changes: Partial<SceneNode>) {
|
||||||
const node = ctx.graph.getNode(id)
|
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.graph.updateNode(id, nextChanges)
|
||||||
ctx.runLayoutForNode(id)
|
ctx.runLayoutForNode(id)
|
||||||
}
|
}
|
||||||
|
|
@ -23,7 +27,10 @@ export function createNodeActions(ctx: EditorContext) {
|
||||||
function updateNodeWithUndo(id: string, changes: Partial<SceneNode>, label = 'Update') {
|
function updateNodeWithUndo(id: string, changes: Partial<SceneNode>, label = 'Update') {
|
||||||
const node = ctx.graph.getNode(id)
|
const node = ctx.graph.getNode(id)
|
||||||
if (!node) return
|
if (!node) return
|
||||||
const nextChanges = { ...changes, ...textAutoResizeChanges(node, changes) }
|
const nextChanges = styleDetachmentChanges(node, {
|
||||||
|
...changes,
|
||||||
|
...textAutoResizeChanges(node, changes)
|
||||||
|
})
|
||||||
const previous = pick(
|
const previous = pick(
|
||||||
node,
|
node,
|
||||||
Object.keys(nextChanges) as (keyof SceneNode)[]
|
Object.keys(nextChanges) as (keyof SceneNode)[]
|
||||||
|
|
|
||||||
|
|
@ -280,7 +280,8 @@ function buildCanvasEntries(
|
||||||
canvasEntries.push({ page, canvasGuid, canvasNc })
|
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++ }
|
internalCanvasGuid = { sessionID: 0, localID: localIdCounter.value++ }
|
||||||
assignedGuidValues.add(`${internalCanvasGuid.sessionID}:${internalCanvasGuid.localID}`)
|
assignedGuidValues.add(`${internalCanvasGuid.sessionID}:${internalCanvasGuid.localID}`)
|
||||||
canvasEntries.push({
|
canvasEntries.push({
|
||||||
|
|
@ -299,6 +300,54 @@ function buildCanvasEntries(
|
||||||
return { canvasEntries, internalCanvasGuid }
|
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(
|
export async function exportFigFile(
|
||||||
graph: SceneGraph,
|
graph: SceneGraph,
|
||||||
ck?: CanvasKit,
|
ck?: CanvasKit,
|
||||||
|
|
@ -408,9 +457,20 @@ export async function exportFigFile(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (graph.variableCollections.size > 0 && internalCanvasGuid) {
|
appendInternalResources({
|
||||||
appendVariableNodeChanges(graph, nodeChanges, internalCanvasGuid, varIdToGuid, modeIdToGuid)
|
graph,
|
||||||
}
|
nodeChanges,
|
||||||
|
internalCanvasGuid,
|
||||||
|
localIdCounter,
|
||||||
|
blobs,
|
||||||
|
nodeIdToGuid,
|
||||||
|
fontDigestMap,
|
||||||
|
varIdToGuid,
|
||||||
|
modeIdToGuid,
|
||||||
|
glyphBlobMap,
|
||||||
|
blobIndexByHex,
|
||||||
|
assignedGuidValues
|
||||||
|
})
|
||||||
|
|
||||||
const msg: Record<string, unknown> = {
|
const msg: Record<string, unknown> = {
|
||||||
type: 'NODE_CHANGES',
|
type: 'NODE_CHANGES',
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,28 @@ export interface ExtractedGraph {
|
||||||
nodeIds: string[]
|
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 {
|
function cloneIntoGraph(source: SceneGraph, ids: Set<string>): SceneGraph {
|
||||||
const graph = new SceneGraph()
|
const graph = new SceneGraph()
|
||||||
graph.rootId = source.rootId
|
graph.rootId = source.rootId
|
||||||
|
|
@ -20,6 +42,8 @@ function cloneIntoGraph(source: SceneGraph, ids: Set<string>): SceneGraph {
|
||||||
graph.figSchemaDeflated = source.figSchemaDeflated
|
graph.figSchemaDeflated = source.figSchemaDeflated
|
||||||
graph.documentColorSpace = source.documentColorSpace
|
graph.documentColorSpace = source.documentColorSpace
|
||||||
|
|
||||||
|
includeReferencedStyles(source, ids)
|
||||||
|
|
||||||
const sortedIds = [...ids].sort((a, b) => {
|
const sortedIds = [...ids].sort((a, b) => {
|
||||||
if (a === source.rootId) return -1
|
if (a === source.rootId) return -1
|
||||||
if (b === source.rootId) return 1
|
if (b === source.rootId) return 1
|
||||||
|
|
|
||||||
|
|
@ -445,6 +445,7 @@ export function importNodeChanges(
|
||||||
if (!nc) return
|
if (!nc) return
|
||||||
|
|
||||||
const { nodeType, ...props } = nodeChangeToProps(nc, blobs)
|
const { nodeType, ...props } = nodeChangeToProps(nc, blobs)
|
||||||
|
if (props.sharedStyleType) props.internalOnly = true
|
||||||
if (nodeType === 'DOCUMENT' || nodeType === 'VARIABLE' || nc.type === 'VARIABLE_SET') return
|
if (nodeType === 'DOCUMENT' || nodeType === 'VARIABLE' || nc.type === 'VARIABLE_SET') return
|
||||||
if (shouldImportTextAsAutoSize(nc, changeMap.get(parentMap.get(ncId) ?? ''))) {
|
if (shouldImportTextAsAutoSize(nc, changeMap.get(parentMap.get(ncId) ?? ''))) {
|
||||||
props.textAutoResize = 'WIDTH_AND_HEIGHT'
|
props.textAutoResize = 'WIDTH_AND_HEIGHT'
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ import type {
|
||||||
TextAlignVertical,
|
TextAlignVertical,
|
||||||
TextCase,
|
TextCase,
|
||||||
ArcData,
|
ArcData,
|
||||||
|
LayoutGrid,
|
||||||
|
SharedStyleType,
|
||||||
VectorNetwork,
|
VectorNetwork,
|
||||||
ComponentPropertyDefinition,
|
ComponentPropertyDefinition,
|
||||||
ComponentPropertyType,
|
ComponentPropertyType,
|
||||||
|
|
@ -500,6 +502,22 @@ function getVectorStrokeJoin(nc: NodeChange, vectorNetwork: VectorNetwork | null
|
||||||
'MITER') as StrokeJoin
|
'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[]) {
|
function convertVectorAndStrokeProps(nc: NodeChange, blobs: Uint8Array[]) {
|
||||||
const vectorNetwork = resolveVectorNetwork(nc, blobs)
|
const vectorNetwork = resolveVectorNetwork(nc, blobs)
|
||||||
const strokeCap = getVectorStrokeCap(nc, vectorNetwork)
|
const strokeCap = getVectorStrokeCap(nc, vectorNetwork)
|
||||||
|
|
@ -588,6 +606,13 @@ export function nodeChangeToProps(
|
||||||
nc.dashPattern ?? []
|
nc.dashPattern ?? []
|
||||||
),
|
),
|
||||||
effects: convertEffects(nc.effects),
|
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),
|
...convertCornerProps(nc),
|
||||||
...convertTextProps(nc, blobs),
|
...convertTextProps(nc, blobs),
|
||||||
horizontalConstraint: mapConstraint(nc.horizontalConstraint as string),
|
horizontalConstraint: mapConstraint(nc.horizontalConstraint as string),
|
||||||
|
|
@ -855,6 +880,7 @@ export const FIGMA_RAW_NODE_FIELD_KEYS = [
|
||||||
'styleIdForText',
|
'styleIdForText',
|
||||||
'styleIdForEffect',
|
'styleIdForEffect',
|
||||||
'styleIdForGrid',
|
'styleIdForGrid',
|
||||||
|
'styleType',
|
||||||
'backgroundPaints',
|
'backgroundPaints',
|
||||||
'layoutGrids',
|
'layoutGrids',
|
||||||
'exportSettings',
|
'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(
|
function applyNodeVisualProps(
|
||||||
context: SceneNodeToKiwiContext,
|
context: SceneNodeToKiwiContext,
|
||||||
node: SceneNode,
|
node: SceneNode,
|
||||||
|
|
@ -648,6 +659,7 @@ function applyNodeVisualProps(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.type !== 'VECTOR') nc.frameMaskDisabled = !node.clipsContent
|
if (node.type !== 'VECTOR') nc.frameMaskDisabled = !node.clipsContent
|
||||||
|
applySharedStyleProps(node, nc)
|
||||||
if (node.horizontalConstraint !== 'MIN') nc.horizontalConstraint = node.horizontalConstraint
|
if (node.horizontalConstraint !== 'MIN') nc.horizontalConstraint = node.horizontalConstraint
|
||||||
if (node.verticalConstraint !== 'MIN') nc.verticalConstraint = node.verticalConstraint
|
if (node.verticalConstraint !== 'MIN') nc.verticalConstraint = node.verticalConstraint
|
||||||
if (node.strokeCap !== 'NONE') nc.strokeCap = node.strokeCap
|
if (node.strokeCap !== 'NONE') nc.strokeCap = node.strokeCap
|
||||||
|
|
@ -699,6 +711,7 @@ export function sceneNodeToKiwiWithContext(
|
||||||
size: exportNodeSize(node),
|
size: exportNodeSize(node),
|
||||||
transform: exportNodeTransform(context, node)
|
transform: exportNodeTransform(context, node)
|
||||||
}
|
}
|
||||||
|
if (node.sharedStyleType) nc.styleType = node.sharedStyleType
|
||||||
if (node.type === 'GROUP') {
|
if (node.type === 'GROUP') {
|
||||||
nc.resizeToFit = true
|
nc.resizeToFit = true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ type StyleRefFields = Record<string, unknown> & {
|
||||||
styleIdForFill?: { guid?: GUID }
|
styleIdForFill?: { guid?: GUID }
|
||||||
styleIdForStrokeFill?: { guid?: GUID }
|
styleIdForStrokeFill?: { guid?: GUID }
|
||||||
styleIdForText?: { guid?: GUID }
|
styleIdForText?: { guid?: GUID }
|
||||||
|
styleIdForEffect?: { guid?: GUID }
|
||||||
|
styleIdForGrid?: { guid?: GUID }
|
||||||
}
|
}
|
||||||
|
|
||||||
type StyleSource = Pick<
|
type StyleSource = Pick<
|
||||||
|
|
@ -21,6 +23,8 @@ type StyleSource = Pick<
|
||||||
| 'type'
|
| 'type'
|
||||||
| 'styleType'
|
| 'styleType'
|
||||||
| 'fillPaints'
|
| 'fillPaints'
|
||||||
|
| 'effects'
|
||||||
|
| 'layoutGrids'
|
||||||
| 'fontSize'
|
| 'fontSize'
|
||||||
| 'fontName'
|
| 'fontName'
|
||||||
| 'lineHeight'
|
| 'lineHeight'
|
||||||
|
|
@ -29,33 +33,50 @@ type StyleSource = Pick<
|
||||||
| 'textCase'
|
| '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(
|
export function applyStyleRefsToFields(
|
||||||
changeMap: ReadonlyMap<string, Partial<StyleSource>>,
|
changeMap: ReadonlyMap<string, Partial<StyleSource>>,
|
||||||
fields: StyleRefFields
|
fields: StyleRefFields
|
||||||
): void {
|
): void {
|
||||||
const fillStyleGuid = fields.styleIdForFill?.guid
|
applyPaintStyleRefs(changeMap, fields)
|
||||||
if (fillStyleGuid) {
|
applyEffectAndGridStyleRefs(changeMap, fields)
|
||||||
const style = changeMap.get(guidToString(fillStyleGuid))
|
applyTextStyleRef(changeMap, fields)
|
||||||
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]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ const SDK_COMPOSABLE_PAGES = [
|
||||||
{ text: 'useLayout', slug: 'use-layout' },
|
{ text: 'useLayout', slug: 'use-layout' },
|
||||||
{ text: 'useConstraints', slug: 'use-constraints', canonical: true },
|
{ text: 'useConstraints', slug: 'use-constraints', canonical: true },
|
||||||
{ text: 'useAppearance', slug: 'use-appearance' },
|
{ text: 'useAppearance', slug: 'use-appearance' },
|
||||||
|
{ text: 'useSharedStyleBinding', slug: 'use-shared-style-binding', canonical: true },
|
||||||
{ text: 'useColorModel', slug: 'use-color-model', canonical: true },
|
{ text: 'useColorModel', slug: 'use-color-model', canonical: true },
|
||||||
{ text: 'useTypography', slug: 'use-typography' },
|
{ text: 'useTypography', slug: 'use-typography' },
|
||||||
{ text: 'useExport', slug: 'use-export' },
|
{ 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. |
|
| 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. |
|
| 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. |
|
| 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 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| Reverse z-index / align-content | ✅ | ◐ | — | ✅ | ✅ | Modeled and exported; UI is limited. |
|
||||||
| Constraints | ✅ | ◐ | — | ✅ | ✅ | Tools/API expose constraints; main 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. |
|
| Layout grids / guides | ✅ | ✅ | ◐ | ✅ | ✅ | Layout grids are modeled and grid styles are selectable; full grid geometry editing and guide management remain incomplete. |
|
||||||
| 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. |
|
| 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. |
|
| 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 auto resize | ✅ | ✅ | ◐ | ✅ | ✅ | Used by renderer/layout; UI does not expose every mode. |
|
||||||
| Text truncation / max lines | ✅ | ✅ | — | ✅ | ✅ | Renderer supports ending truncation; no inspector control. |
|
| 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)
|
- [useLayout](./use-layout)
|
||||||
- [useConstraints](./use-constraints)
|
- [useConstraints](./use-constraints)
|
||||||
- [useAppearance](./use-appearance)
|
- [useAppearance](./use-appearance)
|
||||||
|
- [useSharedStyleBinding](./use-shared-style-binding)
|
||||||
- [useColorModel](./use-color-model)
|
- [useColorModel](./use-color-model)
|
||||||
- [useMask](./use-mask)
|
- [useMask](./use-mask)
|
||||||
- [useTypography](./use-typography)
|
- [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
|
- `opacity` — 0–1
|
||||||
- `blendMode` — `NORMAL`, `MULTIPLY`, `SCREEN`, etc.
|
- `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
|
### Stroke
|
||||||
|
|
||||||
- `strokeWeight` — stroke thickness
|
- `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,
|
Fill,
|
||||||
GeometryPath,
|
GeometryPath,
|
||||||
GradientStop,
|
GradientStop,
|
||||||
|
LayoutGrid,
|
||||||
SceneNode,
|
SceneNode,
|
||||||
Stroke,
|
Stroke,
|
||||||
StyleRun
|
StyleRun
|
||||||
|
|
@ -81,6 +82,10 @@ export function copyEffects(effects: Effect[]): Effect[] {
|
||||||
return effects.map(copyEffect)
|
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[] {
|
export function copyStyleRuns(runs: StyleRun[]): StyleRun[] {
|
||||||
return runs.map(copyStyleRun)
|
return runs.map(copyStyleRun)
|
||||||
}
|
}
|
||||||
|
|
@ -149,6 +154,7 @@ export function cloneNodeProps(src: SceneNode, componentId: string | null): Part
|
||||||
fills: copyOpt(src.fills, copyFills),
|
fills: copyOpt(src.fills, copyFills),
|
||||||
strokes: copyOpt(src.strokes, copyStrokes),
|
strokes: copyOpt(src.strokes, copyStrokes),
|
||||||
effects: copyOpt(src.effects, copyEffects),
|
effects: copyOpt(src.effects, copyEffects),
|
||||||
|
layoutGrids: copyOpt(src.layoutGrids, copyLayoutGrids),
|
||||||
styleRuns: copyOpt(src.styleRuns, copyStyleRuns),
|
styleRuns: copyOpt(src.styleRuns, copyStyleRuns),
|
||||||
// Source metadata preserves opaque raw Figma payloads; use structuredClone instead of
|
// Source metadata preserves opaque raw Figma payloads; use structuredClone instead of
|
||||||
// hand-copying partial known shapes and accidentally sharing nested raw Figma data.
|
// 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--) {
|
for (let i = parent.childIds.length - 1; i >= 0; i--) {
|
||||||
const childId = parent.childIds[i]
|
const childId = parent.childIds[i]
|
||||||
const child = graph.nodes.get(childId)
|
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 (CONTAINER_TYPES.has(child.type)) {
|
||||||
if (OPAQUE_CONTAINER_TYPES.has(child.type) && !deep) {
|
if (OPAQUE_CONTAINER_TYPES.has(child.type) && !deep) {
|
||||||
const hit = hitTestOpaqueContainer(graph, px, py, child, childId, deep)
|
const hit = hitTestOpaqueContainer(graph, px, py, child, childId, deep)
|
||||||
|
|
@ -140,7 +140,7 @@ function hitTestFrameChildren(
|
||||||
for (const childId of parent.childIds) {
|
for (const childId of parent.childIds) {
|
||||||
if (excludeIds.has(childId)) continue
|
if (excludeIds.has(childId)) continue
|
||||||
const child = graph.nodes.get(childId)
|
const child = graph.nodes.get(childId)
|
||||||
if (!child || !child.visible) continue
|
if (!child || child.internalOnly || !child.visible) continue
|
||||||
|
|
||||||
const ax = offsetX + child.x
|
const ax = offsetX + child.x
|
||||||
const ay = offsetY + child.y
|
const ay = offsetY + child.y
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,24 @@
|
||||||
export * from './images'
|
export * from './images'
|
||||||
|
export * from './copy'
|
||||||
export * from './snap'
|
export * from './snap'
|
||||||
export * from './export-scale'
|
export * from './export-scale'
|
||||||
export * from './coordinate'
|
export * from './coordinate'
|
||||||
export * from './geometry'
|
export * from './geometry'
|
||||||
|
export * from './shared-styles'
|
||||||
export { default as TransformMatrix } from './matrix'
|
export { default as TransformMatrix } from './matrix'
|
||||||
export type { Mat3 } from './matrix'
|
export type { Mat3 } from './matrix'
|
||||||
export { UndoManager, type UndoEntry, type UndoManagerOptions } from './undo'
|
export { UndoManager, type UndoEntry, type UndoManagerOptions } from './undo'
|
||||||
|
|
||||||
import { omit } from 'es-toolkit/object'
|
|
||||||
import { createNanoEvents } from 'nanoevents'
|
import { createNanoEvents } from 'nanoevents'
|
||||||
|
|
||||||
|
import { removeStaleBindings } from './bindings'
|
||||||
import { cloneNodeProps } from './copy'
|
import { cloneNodeProps } from './copy'
|
||||||
import { bindNodeEvents } from './events'
|
import { bindNodeEvents } from './events'
|
||||||
import * as HitTest from './hit-test'
|
import * as HitTest from './hit-test'
|
||||||
import * as Instances from './instances'
|
import * as Instances from './instances'
|
||||||
import { CONTAINER_TYPES, createDefaultNode } from './node-defaults'
|
import { CONTAINER_TYPES, createDefaultNode } from './node-defaults'
|
||||||
import { updateNodePreview } from './preview'
|
import { updateNodePreview } from './preview'
|
||||||
|
import { styleDetachmentChanges } from './shared-styles'
|
||||||
import { clearEditedSourceMetadata } from './source-metadata'
|
import { clearEditedSourceMetadata } from './source-metadata'
|
||||||
import { TEXT_PICTURE_KEYS } from './text-picture'
|
import { TEXT_PICTURE_KEYS } from './text-picture'
|
||||||
import * as Variables from './variables'
|
import * as Variables from './variables'
|
||||||
|
|
@ -43,23 +46,6 @@ import type {
|
||||||
|
|
||||||
export { cloneVectorNetwork, normalizeVectorNetwork, validateVectorNetwork } from './vector-network'
|
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
|
let nextLocalID = 1
|
||||||
|
|
||||||
export function generateId(): string {
|
export function generateId(): string {
|
||||||
|
|
@ -371,6 +357,7 @@ export class SceneGraph {
|
||||||
|
|
||||||
const node = this.nodes.get(id)
|
const node = this.nodes.get(id)
|
||||||
if (!node) return
|
if (!node) return
|
||||||
|
changes = styleDetachmentChanges(node, changes)
|
||||||
|
|
||||||
// Only clear absPosCache when layout-affecting properties change.
|
// Only clear absPosCache when layout-affecting properties change.
|
||||||
// Fills, strokes, effects, plugin data changes do NOT affect absolute position.
|
// 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 }] : [],
|
type === 'TEXT' ? [{ type: 'SOLID' as const, color: BLACK, opacity: 1, visible: true }] : [],
|
||||||
strokes: [],
|
strokes: [],
|
||||||
effects: [],
|
effects: [],
|
||||||
|
layoutGrids: [],
|
||||||
|
fillStyleId: null,
|
||||||
|
strokeStyleId: null,
|
||||||
|
textStyleId: null,
|
||||||
|
effectStyleId: null,
|
||||||
|
gridStyleId: null,
|
||||||
|
sharedStyleType: null,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
cornerRadius: 0,
|
cornerRadius: 0,
|
||||||
topLeftRadius: 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'
|
import type { SceneNode } from './types'
|
||||||
|
|
||||||
const RAW_SIZE_KEYS = new Set(['width', 'height'])
|
const RAW_SIZE_KEYS = new Set(['width', 'height'])
|
||||||
|
|
||||||
const RAW_TRANSFORM_KEYS = new Set(['x', 'y', 'rotation', 'flipX', 'flipY'])
|
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([
|
const RAW_NODE_FIELD_KEYS = new Set([
|
||||||
'visible',
|
'visible',
|
||||||
'opacity',
|
'opacity',
|
||||||
|
|
@ -16,6 +26,7 @@ const RAW_NODE_FIELD_KEYS = new Set([
|
||||||
'borderLeftWeight',
|
'borderLeftWeight',
|
||||||
'independentStrokeWeights',
|
'independentStrokeWeights',
|
||||||
'effects',
|
'effects',
|
||||||
|
'layoutGrids',
|
||||||
'cornerRadius',
|
'cornerRadius',
|
||||||
'topLeftRadius',
|
'topLeftRadius',
|
||||||
'topRightRadius',
|
'topRightRadius',
|
||||||
|
|
@ -82,6 +93,12 @@ const RAW_NODE_FIELD_KEYS = new Set([
|
||||||
])
|
])
|
||||||
|
|
||||||
export function clearEditedSourceMetadata(node: SceneNode, changeKeys: string[]): void {
|
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_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_TRANSFORM_KEYS.has(key))) node.source.fig.rawTransform = null
|
||||||
if (changeKeys.some((key) => RAW_NODE_FIELD_KEYS.has(key))) node.source.fig.rawNodeFields = {}
|
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 StrokeCap = 'NONE' | 'ROUND' | 'SQUARE' | 'ARROW_LINES' | 'ARROW_EQUILATERAL'
|
||||||
export type StrokeJoin = 'MITER' | 'BEVEL' | 'ROUND'
|
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 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 {
|
export interface Stroke {
|
||||||
color: Color
|
color: Color
|
||||||
weight: number
|
weight: number
|
||||||
|
|
@ -341,6 +364,13 @@ export interface SceneNode {
|
||||||
fills: Fill[]
|
fills: Fill[]
|
||||||
strokes: Stroke[]
|
strokes: Stroke[]
|
||||||
effects: Effect[]
|
effects: Effect[]
|
||||||
|
layoutGrids: LayoutGrid[]
|
||||||
|
fillStyleId: string | null
|
||||||
|
strokeStyleId: string | null
|
||||||
|
textStyleId: string | null
|
||||||
|
effectStyleId: string | null
|
||||||
|
gridStyleId: string | null
|
||||||
|
sharedStyleType: SharedStyleType | null
|
||||||
opacity: number
|
opacity: number
|
||||||
|
|
||||||
cornerRadius: number
|
cornerRadius: number
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,7 @@ These are the main APIs most SDK consumers should start with.
|
||||||
- `useLayout()`
|
- `useLayout()`
|
||||||
- `useConstraints()`
|
- `useConstraints()`
|
||||||
- `useAppearance()`
|
- `useAppearance()`
|
||||||
|
- `useSharedStyleBinding()`
|
||||||
- `useColorModel()`
|
- `useColorModel()`
|
||||||
- `useMask()`
|
- `useMask()`
|
||||||
- `useTypography()`
|
- `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>) {
|
function scrubEffect(node: SceneNode | null, index: number, changes: Partial<Effect>) {
|
||||||
if (!node) return
|
if (!node) return
|
||||||
if (!effectsBeforeScrub.value) {
|
if (!effectsBeforeScrub.value) {
|
||||||
effectsBeforeScrub.value = node.effects.map((e) => ({
|
effectsBeforeScrub.value = {
|
||||||
...e,
|
effects: node.effects.map((e) => ({
|
||||||
color: { ...e.color },
|
...e,
|
||||||
offset: { ...e.offset }
|
color: { ...e.color },
|
||||||
}))
|
offset: { ...e.offset }
|
||||||
|
})),
|
||||||
|
effectStyleId: node.effectStyleId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const effects = [...node.effects]
|
const effects = [...node.effects]
|
||||||
effects[index] = { ...effects[index], ...changes }
|
effects[index] = { ...effects[index], ...changes }
|
||||||
|
|
@ -64,7 +75,11 @@ export function createEffectEditActions(editor: Editor, effectsBeforeScrub: Ref<
|
||||||
editor.updateNode(node.id, { effects })
|
editor.updateNode(node.id, { effects })
|
||||||
editor.requestRender()
|
editor.requestRender()
|
||||||
if (previous) {
|
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 { ref } from 'vue'
|
||||||
|
|
||||||
import type { Effect } from '@open-pencil/scene-graph'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EFFECT_OPTIONS,
|
EFFECT_OPTIONS,
|
||||||
createDefaultEffect,
|
createDefaultEffect,
|
||||||
createEffectControlActions,
|
createEffectControlActions,
|
||||||
createEffectEditActions,
|
createEffectEditActions,
|
||||||
isShadow
|
isShadow,
|
||||||
|
type EffectEditSnapshot
|
||||||
} from '#vue/controls/effects/helpers'
|
} from '#vue/controls/effects/helpers'
|
||||||
import { useEditor } from '#vue/editor/context'
|
import { useEditor } from '#vue/editor/context'
|
||||||
|
|
||||||
|
|
@ -21,7 +20,7 @@ export function useEffectsControls() {
|
||||||
const editor = useEditor()
|
const editor = useEditor()
|
||||||
|
|
||||||
const expandedIndex = ref<number | null>(null)
|
const expandedIndex = ref<number | null>(null)
|
||||||
const effectsBeforeScrub = ref<Effect[] | null>(null)
|
const effectsBeforeScrub = ref<EffectEditSnapshot | null>(null)
|
||||||
const editActions = createEffectEditActions(editor, effectsBeforeScrub)
|
const editActions = createEffectEditActions(editor, effectsBeforeScrub)
|
||||||
const controlActions = createEffectControlActions(expandedIndex)
|
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,
|
activeFormatting,
|
||||||
options
|
options
|
||||||
}: TypographyActionOptions) {
|
}: TypographyActionOptions) {
|
||||||
|
let textStyleBeforePreview: string | null | undefined
|
||||||
|
|
||||||
async function doLoadFont(family: string, style: string) {
|
async function doLoadFont(family: string, style: string) {
|
||||||
await options.fontLoader?.load(family, style)
|
await options.fontLoader?.load(family, style)
|
||||||
}
|
}
|
||||||
|
|
@ -130,17 +132,22 @@ export function createTypographyActions({
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateProp(key: string, value: number | string) {
|
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) {
|
function commitProp(key: string, _value: number | string, previous: number | string) {
|
||||||
if (node.value) {
|
if (!node.value) return
|
||||||
editor.commitNodeUpdate(
|
editor.commitNodeUpdate(
|
||||||
node.value.id,
|
node.value.id,
|
||||||
{ [key]: previous } as Partial<SceneNode>,
|
{
|
||||||
`Change ${key}`
|
[key]: previous,
|
||||||
)
|
...(textStyleBeforePreview !== undefined ? { textStyleId: textStyleBeforePreview } : {})
|
||||||
}
|
} as Partial<SceneNode>,
|
||||||
|
`Change ${key}`
|
||||||
|
)
|
||||||
|
textStyleBeforePreview = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,13 @@
|
||||||
"addAutoLayout": "Auto-Layout hinzufügen",
|
"addAutoLayout": "Auto-Layout hinzufügen",
|
||||||
"removeAutoLayout": "Auto-Layout entfernen",
|
"removeAutoLayout": "Auto-Layout entfernen",
|
||||||
"mixed": "Gemischt",
|
"mixed": "Gemischt",
|
||||||
|
"none": "Keine",
|
||||||
|
"fillStyle": "Füllstil",
|
||||||
|
"strokeStyle": "Konturstil",
|
||||||
|
"textStyle": "Textstil",
|
||||||
|
"effectStyle": "Effektstil",
|
||||||
|
"gridStyle": "Rasterstil",
|
||||||
|
"missingStyle": "Fehlender Stil ({id})",
|
||||||
"layersCount": "{count} Ebenen",
|
"layersCount": "{count} Ebenen",
|
||||||
"goToMainComponent": "Zur Hauptkomponente",
|
"goToMainComponent": "Zur Hauptkomponente",
|
||||||
"detachInstance": "Instanz lösen",
|
"detachInstance": "Instanz lösen",
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,13 @@
|
||||||
"addAutoLayout": "Añadir auto-layout",
|
"addAutoLayout": "Añadir auto-layout",
|
||||||
"removeAutoLayout": "Quitar auto-layout",
|
"removeAutoLayout": "Quitar auto-layout",
|
||||||
"mixed": "Mixto",
|
"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",
|
"layersCount": "{count} capas",
|
||||||
"goToMainComponent": "Ir al componente principal",
|
"goToMainComponent": "Ir al componente principal",
|
||||||
"detachInstance": "Separar instancia",
|
"detachInstance": "Separar instancia",
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,13 @@
|
||||||
"addAutoLayout": "Ajouter un auto-layout",
|
"addAutoLayout": "Ajouter un auto-layout",
|
||||||
"removeAutoLayout": "Retirer l'auto-layout",
|
"removeAutoLayout": "Retirer l'auto-layout",
|
||||||
"mixed": "Mixte",
|
"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",
|
"layersCount": "{count} calques",
|
||||||
"goToMainComponent": "Aller au composant principal",
|
"goToMainComponent": "Aller au composant principal",
|
||||||
"detachInstance": "Détacher l'instance",
|
"detachInstance": "Détacher l'instance",
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,13 @@
|
||||||
"addAutoLayout": "Aggiungi auto-layout",
|
"addAutoLayout": "Aggiungi auto-layout",
|
||||||
"removeAutoLayout": "Rimuovi auto-layout",
|
"removeAutoLayout": "Rimuovi auto-layout",
|
||||||
"mixed": "Misto",
|
"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",
|
"layersCount": "{count} livelli",
|
||||||
"goToMainComponent": "Vai al componente principale",
|
"goToMainComponent": "Vai al componente principale",
|
||||||
"detachInstance": "Scollega istanza",
|
"detachInstance": "Scollega istanza",
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,13 @@
|
||||||
"addAutoLayout": "オートレイアウトを追加",
|
"addAutoLayout": "オートレイアウトを追加",
|
||||||
"removeAutoLayout": "オートレイアウトを削除",
|
"removeAutoLayout": "オートレイアウトを削除",
|
||||||
"mixed": "複数選択",
|
"mixed": "複数選択",
|
||||||
|
"none": "なし",
|
||||||
|
"fillStyle": "塗りスタイル",
|
||||||
|
"strokeStyle": "線スタイル",
|
||||||
|
"textStyle": "テキストスタイル",
|
||||||
|
"effectStyle": "エフェクトスタイル",
|
||||||
|
"gridStyle": "グリッドスタイル",
|
||||||
|
"missingStyle": "不明なスタイル ({id})",
|
||||||
"layersCount": "{count} 個のレイヤー",
|
"layersCount": "{count} 個のレイヤー",
|
||||||
"goToMainComponent": "メインコンポーネントに移動",
|
"goToMainComponent": "メインコンポーネントに移動",
|
||||||
"detachInstance": "インスタンスの切り離し",
|
"detachInstance": "インスタンスの切り離し",
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,13 @@
|
||||||
"addAutoLayout": "Dodaj auto-layout",
|
"addAutoLayout": "Dodaj auto-layout",
|
||||||
"removeAutoLayout": "Usuń auto-layout",
|
"removeAutoLayout": "Usuń auto-layout",
|
||||||
"mixed": "Mieszane",
|
"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",
|
"layersCount": "{count} warstw",
|
||||||
"goToMainComponent": "Przejdź do głównego komponentu",
|
"goToMainComponent": "Przejdź do głównego komponentu",
|
||||||
"detachInstance": "Odłącz instancję",
|
"detachInstance": "Odłącz instancję",
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,13 @@
|
||||||
"mixedEffectsHelp": "Нажмите +, чтобы заменить смешанные эффекты",
|
"mixedEffectsHelp": "Нажмите +, чтобы заменить смешанные эффекты",
|
||||||
"strokeSides": "Стороны обводки",
|
"strokeSides": "Стороны обводки",
|
||||||
"mixed": "Смешанное",
|
"mixed": "Смешанное",
|
||||||
|
"none": "Нет",
|
||||||
|
"fillStyle": "Стиль заливки",
|
||||||
|
"strokeStyle": "Стиль обводки",
|
||||||
|
"textStyle": "Стиль текста",
|
||||||
|
"effectStyle": "Стиль эффекта",
|
||||||
|
"gridStyle": "Стиль сетки",
|
||||||
|
"missingStyle": "Недоступный стиль ({id})",
|
||||||
"layersCount": "{count} слоёв",
|
"layersCount": "{count} слоёв",
|
||||||
"goToMainComponent": "Перейти к главному компоненту",
|
"goToMainComponent": "Перейти к главному компоненту",
|
||||||
"detachInstance": "Отвязать экземпляр",
|
"detachInstance": "Отвязать экземпляр",
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,13 @@
|
||||||
"mixedEffectsHelp": "点击 + 替换混合效果",
|
"mixedEffectsHelp": "点击 + 替换混合效果",
|
||||||
"strokeSides": "描边边",
|
"strokeSides": "描边边",
|
||||||
"mixed": "混合",
|
"mixed": "混合",
|
||||||
|
"none": "无",
|
||||||
|
"fillStyle": "填充样式",
|
||||||
|
"strokeStyle": "描边样式",
|
||||||
|
"textStyle": "文本样式",
|
||||||
|
"effectStyle": "效果样式",
|
||||||
|
"gridStyle": "网格样式",
|
||||||
|
"missingStyle": "缺失样式 ({id})",
|
||||||
"layersCount": "{count} 个图层",
|
"layersCount": "{count} 个图层",
|
||||||
"goToMainComponent": "转到主组件",
|
"goToMainComponent": "转到主组件",
|
||||||
"detachInstance": "分离实例",
|
"detachInstance": "分离实例",
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,13 @@ export const panelMessageDefaults = {
|
||||||
createNumberVariable: params('Create number variable from {value}'),
|
createNumberVariable: params('Create number variable from {value}'),
|
||||||
variableName: 'Variable name',
|
variableName: 'Variable name',
|
||||||
mixed: 'Mixed',
|
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'),
|
layersCount: params('{count} layers'),
|
||||||
goToMainComponent: 'Go to Main Component',
|
goToMainComponent: 'Go to Main Component',
|
||||||
detachInstance: 'Detach Instance',
|
detachInstance: 'Detach Instance',
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@ export type {
|
||||||
UseVariableBindingOptions
|
UseVariableBindingOptions
|
||||||
} from '#vue/controls/variable-binding/use'
|
} from '#vue/controls/variable-binding/use'
|
||||||
export { useEffectsControls } from '#vue/controls/effects/use'
|
export { useEffectsControls } from '#vue/controls/effects/use'
|
||||||
|
export { useSharedStyleBinding } from '#vue/controls/shared-style/use'
|
||||||
export { useStrokeControls } from '#vue/controls/stroke/use'
|
export { useStrokeControls } from '#vue/controls/stroke/use'
|
||||||
export {
|
export {
|
||||||
applySolidFillColor,
|
applySolidFillColor,
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export function buildLayerTreeModel(graph: SceneGraph, parentId: string): LayerT
|
||||||
const children: LayerNode[] = []
|
const children: LayerNode[] = []
|
||||||
for (const childId of parent.childIds) {
|
for (const childId of parent.childIds) {
|
||||||
const sceneNode = graph.getNode(childId)
|
const sceneNode = graph.getNode(childId)
|
||||||
if (!sceneNode) continue
|
if (!sceneNode || sceneNode.internalOnly) continue
|
||||||
const node = nodeToLayerNode(sceneNode)
|
const node = nodeToLayerNode(sceneNode)
|
||||||
byId.set(node.id, node)
|
byId.set(node.id, node)
|
||||||
if (sceneNode.childIds.length > 0) node.children = buildChildren(node.id)
|
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 NumberField from '@/components/inputs/NumberField.vue'
|
||||||
import PropertyItemRow from '@/components/properties/item-list/PropertyItemRow.vue'
|
import PropertyItemRow from '@/components/properties/item-list/PropertyItemRow.vue'
|
||||||
import PropertyListRoot from '@/components/properties/PropertyListRoot.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 AppSelect from '@/components/ui/AppSelect.vue'
|
||||||
import FillSwatch from '@/components/ui/FillSwatch.vue'
|
import FillSwatch from '@/components/ui/FillSwatch.vue'
|
||||||
import IconButton from '@/components/ui/IconButton.vue'
|
import IconButton from '@/components/ui/IconButton.vue'
|
||||||
|
|
@ -42,6 +43,8 @@ function effectPreview(effect: Effect): Fill {
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<SharedStyleField kind="effect" :label="panels.effectStyle" />
|
||||||
|
|
||||||
<p v-if="isMixed" class="text-[11px] text-muted">{{ panels.mixedEffectsHelp }}</p>
|
<p v-if="isMixed" class="text-[11px] text-muted">{{ panels.mixedEffectsHelp }}</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
import { fillLabel } from '@/components/properties/fill-label'
|
import { fillLabel } from '@/components/properties/fill-label'
|
||||||
import { createFillOkhclAdapter } from '@/components/properties/paint/okhcl'
|
import { createFillOkhclAdapter } from '@/components/properties/paint/okhcl'
|
||||||
import PropertyListRoot from '@/components/properties/PropertyListRoot.vue'
|
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 VariableBindingPicker from '@/components/properties/binding/VariableBindingPicker.vue'
|
||||||
import IconButton from '@/components/ui/IconButton.vue'
|
import IconButton from '@/components/ui/IconButton.vue'
|
||||||
import PanelSection from '@/components/ui/panel/PanelSection.vue'
|
import PanelSection from '@/components/ui/panel/PanelSection.vue'
|
||||||
|
|
@ -73,6 +74,8 @@ function updateSolidColor(
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<SharedStyleField kind="fill" :label="panels.fillStyle" />
|
||||||
|
|
||||||
<p v-if="isMixed" class="text-[11px] text-muted">{{ panels.mixedFillsHelp }}</p>
|
<p v-if="isMixed" class="text-[11px] text-muted">{{ panels.mixedFillsHelp }}</p>
|
||||||
|
|
||||||
<PropertyItemRow
|
<PropertyItemRow
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import FlexControls from '@/components/properties/LayoutSection/FlexControls.vue
|
||||||
import GridControls from '@/components/properties/LayoutSection/GridControls.vue'
|
import GridControls from '@/components/properties/LayoutSection/GridControls.vue'
|
||||||
import PaddingControls from '@/components/properties/LayoutSection/PaddingControls.vue'
|
import PaddingControls from '@/components/properties/LayoutSection/PaddingControls.vue'
|
||||||
import SizeControls from '@/components/properties/LayoutSection/size/SizeControls.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'
|
import PanelSection from '@/components/ui/panel/PanelSection.vue'
|
||||||
|
|
||||||
const { panels } = useI18n()
|
const { panels } = useI18n()
|
||||||
|
|
@ -18,6 +19,7 @@ const CONTAINER_TYPES = ['FRAME', 'COMPONENT', 'COMPONENT_SET', 'INSTANCE']
|
||||||
<LayoutControlsRoot v-slot="ctx">
|
<LayoutControlsRoot v-slot="ctx">
|
||||||
<template v-if="ctx.node">
|
<template v-if="ctx.node">
|
||||||
<PanelSection :label="panels.layout">
|
<PanelSection :label="panels.layout">
|
||||||
|
<SharedStyleField kind="grid" :label="panels.gridStyle" />
|
||||||
<SizeControls />
|
<SizeControls />
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
} from '@/components/properties/paint/binding'
|
} from '@/components/properties/paint/binding'
|
||||||
import { createStrokeOkhclAdapter } from '@/components/properties/paint/okhcl'
|
import { createStrokeOkhclAdapter } from '@/components/properties/paint/okhcl'
|
||||||
import PropertyListRoot from '@/components/properties/PropertyListRoot.vue'
|
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 VariableBindingPicker from '@/components/properties/binding/VariableBindingPicker.vue'
|
||||||
import AppSelect from '@/components/ui/AppSelect.vue'
|
import AppSelect from '@/components/ui/AppSelect.vue'
|
||||||
import FillSwatch from '@/components/ui/FillSwatch.vue'
|
import FillSwatch from '@/components/ui/FillSwatch.vue'
|
||||||
|
|
@ -109,6 +110,8 @@ function onToggleSides(activeNode: SceneNode | null) {
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<SharedStyleField kind="stroke" :label="panels.strokeStyle" />
|
||||||
|
|
||||||
<p v-if="isMixed" class="text-[11px] text-muted">{{ panels.mixedStrokesHelp }}</p>
|
<p v-if="isMixed" class="text-[11px] text-muted">{{ panels.mixedStrokesHelp }}</p>
|
||||||
|
|
||||||
<PropertyItemRow
|
<PropertyItemRow
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { TypographyControlsRoot, useI18n } from '@open-pencil/vue'
|
||||||
|
|
||||||
import FontPicker from '@/components/font-picker/FontPicker.vue'
|
import FontPicker from '@/components/font-picker/FontPicker.vue'
|
||||||
import FontSettingsPopover from '@/components/FontSettings/FontSettingsPopover.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 VariableNumberField from '@/components/properties/VariableNumberField.vue'
|
||||||
import AppSelect from '@/components/ui/AppSelect.vue'
|
import AppSelect from '@/components/ui/AppSelect.vue'
|
||||||
import IconButton from '@/components/ui/IconButton.vue'
|
import IconButton from '@/components/ui/IconButton.vue'
|
||||||
|
|
@ -28,6 +29,8 @@ const alignmentOptions = computed(() => [
|
||||||
<template>
|
<template>
|
||||||
<TypographyControlsRoot v-slot="ctx" :font-loader="fontLoader">
|
<TypographyControlsRoot v-slot="ctx" :font-loader="fontLoader">
|
||||||
<PanelSection v-if="ctx.node.value" :label="panels.typography">
|
<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">
|
<div class="mb-panel flex min-w-0 items-center gap-panel">
|
||||||
<FontPicker
|
<FontPicker
|
||||||
class="min-w-0 flex-1"
|
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)
|
.getChildren(graph.getPages()[0].id)
|
||||||
.find((node) => node.text === 'Styled text')
|
.find((node) => node.text === 'Styled text')
|
||||||
expect(styled?.fontSize).toBe(16)
|
expect(styled?.fontSize).toBe(16)
|
||||||
|
expect(styled?.textStyleId).toBe('1:20')
|
||||||
expect(styled?.fontWeight).toBe(400)
|
expect(styled?.fontWeight).toBe(400)
|
||||||
expect(styled?.lineHeight).toBe(24)
|
expect(styled?.lineHeight).toBe(24)
|
||||||
expect(styled?.textDecoration).toBe('NONE')
|
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', () => {
|
test('applies shared fill style refs', () => {
|
||||||
|
|
@ -153,6 +156,7 @@ describe('fig-import: text properties', () => {
|
||||||
const styled = graph
|
const styled = graph
|
||||||
.getChildren(graph.getPages()[0].id)
|
.getChildren(graph.getPages()[0].id)
|
||||||
.find((node) => node.name === 'ROUNDED_RECTANGLE_10')
|
.find((node) => node.name === 'ROUNDED_RECTANGLE_10')
|
||||||
|
expect(styled?.fillStyleId).toBe('1:30')
|
||||||
expect(styled?.fills[0]?.color).toEqual({
|
expect(styled?.fills[0]?.color).toEqual({
|
||||||
r: 0.05882352963089943,
|
r: 0.05882352963089943,
|
||||||
g: 0.09019608050584793,
|
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'
|
import { applyStyleRefsToFields } from '#core/kiwi/fig/node-change/style-refs'
|
||||||
|
|
||||||
describe('fig import 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', () => {
|
test('stroke fill style overrides stale direct stroke paint', () => {
|
||||||
const styleGuid = { sessionID: 4, localID: 4594 }
|
const styleGuid = { sessionID: 4, localID: 4594 }
|
||||||
const stylePaint = {
|
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 frame = graph.createNode('FRAME', pageId, { name: 'Frame' })
|
||||||
const child = createRect(graph, frame.id, { name: 'Child' })
|
const child = createRect(graph, frame.id, { name: 'Child' })
|
||||||
const sibling = createRect(graph, pageId, { name: 'Sibling' })
|
const sibling = createRect(graph, pageId, { name: 'Sibling' })
|
||||||
|
const internal = graph.createNode('RECTANGLE', pageId, {
|
||||||
|
name: 'Internal style',
|
||||||
|
internalOnly: true
|
||||||
|
})
|
||||||
|
|
||||||
const model = buildLayerTreeModel(graph, pageId)
|
const model = buildLayerTreeModel(graph, pageId)
|
||||||
|
|
||||||
expect(model.items.map((node) => node.id)).toEqual([frame.id, sibling.id])
|
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.items[0]?.children?.map((node) => node.id)).toEqual([child.id])
|
||||||
expect(model.byId.get(child.id)?.name).toBe('Child')
|
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', () => {
|
test('derives only rows made visible by expansion', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue