create_vector now validates the path JSON before accepting it: checks vertices have numeric coordinates and segment indices are in range. Returns a clear error message instead of silently creating a malformed node that crashes on save. The scene graph also normalizes vectorNetwork on updateNode as a safety net for other code paths.
1089 lines
28 KiB
TypeScript
1089 lines
28 KiB
TypeScript
/* eslint-disable max-lines -- core class; instance, hit-test methods already extracted */
|
|
import { createNanoEvents } from 'nanoevents'
|
|
|
|
import { BLACK, DEFAULT_FONT_FAMILY, DEFAULT_STROKE_MITER_LIMIT } from '../constants'
|
|
import {
|
|
hitTest as hitTestFn,
|
|
hitTestDeep as hitTestDeepFn,
|
|
hitTestFrame as hitTestFrameFn
|
|
} from './hit-test'
|
|
import {
|
|
createInstance as createInstanceFn,
|
|
populateInstanceChildren as populateInstanceChildrenFn,
|
|
syncInstances as syncInstancesFn,
|
|
detachInstance as detachInstanceFn,
|
|
getMainComponent as getMainComponentFn,
|
|
getInstances as getInstancesFn
|
|
} from './instances'
|
|
|
|
export type { GUID, Color } from '../types'
|
|
import { getAbsolutePosition } from '@open-pencil/core/canvas/coordinate'
|
|
|
|
import type { Matrix, Vector, Color, Rect } from '../types'
|
|
import type { Emitter } from 'nanoevents'
|
|
|
|
export interface SceneGraphEvents {
|
|
'node:created': (node: SceneNode) => void
|
|
'node:updated': (id: string, changes: Partial<SceneNode>) => void
|
|
'node:deleted': (id: string) => void
|
|
'node:reparented': (nodeId: string, oldParentId: string | null, newParentId: string) => void
|
|
'node:reordered': (nodeId: string, parentId: string, index: number) => void
|
|
}
|
|
|
|
export type DocumentColorSpace = 'srgb' | 'display-p3'
|
|
|
|
export type HandleMirroring = 'NONE' | 'ANGLE' | 'ANGLE_AND_LENGTH'
|
|
export type WindingRule = 'NONZERO' | 'EVENODD'
|
|
|
|
export interface VectorVertex {
|
|
x: number
|
|
y: number
|
|
strokeCap?: string
|
|
strokeJoin?: string
|
|
cornerRadius?: number
|
|
handleMirroring?: HandleMirroring
|
|
}
|
|
|
|
export interface VectorSegment {
|
|
start: number
|
|
end: number
|
|
tangentStart: Vector
|
|
tangentEnd: Vector
|
|
}
|
|
|
|
export interface VectorRegion {
|
|
windingRule: WindingRule
|
|
loops: number[][]
|
|
}
|
|
|
|
export interface VectorNetwork {
|
|
vertices: VectorVertex[]
|
|
segments: VectorSegment[]
|
|
regions: VectorRegion[]
|
|
}
|
|
|
|
/** Deep-copy a VectorNetwork, stripping any Vue Proxy wrappers. */
|
|
export function cloneVectorNetwork(vn: VectorNetwork): VectorNetwork {
|
|
return {
|
|
vertices: vn.vertices.map((v) => ({ ...v })),
|
|
segments: vn.segments.map((s) => ({
|
|
...s,
|
|
tangentStart: { ...s.tangentStart },
|
|
tangentEnd: { ...s.tangentEnd }
|
|
})),
|
|
regions: vn.regions.map((r) => ({
|
|
windingRule: r.windingRule,
|
|
loops: r.loops.map((l) => [...l])
|
|
}))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate a VectorNetwork structure, returning an array of error messages.
|
|
* Empty array means the network is valid.
|
|
*/
|
|
export function validateVectorNetwork(vn: VectorNetwork): string[] {
|
|
const errors: string[] = []
|
|
if (!Array.isArray(vn.vertices)) {
|
|
errors.push('vertices must be an array')
|
|
return errors
|
|
}
|
|
if (!Array.isArray(vn.segments)) {
|
|
errors.push('segments must be an array')
|
|
return errors
|
|
}
|
|
if (!Array.isArray(vn.regions)) errors.push('regions must be an array')
|
|
const vertexCount = vn.vertices.length
|
|
for (let i = 0; i < vn.vertices.length; i++) {
|
|
const v = vn.vertices[i]
|
|
if (typeof v.x !== 'number' || typeof v.y !== 'number') {
|
|
errors.push(`vertex[${i}]: x and y must be numbers`)
|
|
}
|
|
}
|
|
for (let i = 0; i < vn.segments.length; i++) {
|
|
const s = vn.segments[i]
|
|
if (typeof s.start !== 'number' || typeof s.end !== 'number') {
|
|
errors.push(`segment[${i}]: start and end must be numbers`)
|
|
} else {
|
|
if (s.start < 0 || s.start >= vertexCount)
|
|
errors.push(`segment[${i}]: start index ${s.start} out of range`)
|
|
if (s.end < 0 || s.end >= vertexCount)
|
|
errors.push(`segment[${i}]: end index ${s.end} out of range`)
|
|
}
|
|
}
|
|
return errors
|
|
}
|
|
|
|
/**
|
|
* Ensure every segment has tangentStart/tangentEnd.
|
|
* Missing tangents default to {x:0, y:0} (straight line segments).
|
|
* Use at system boundaries where input may come from JSON/MCP.
|
|
*/
|
|
export function normalizeVectorNetwork(vn: VectorNetwork): VectorNetwork {
|
|
const ZERO: Vector = { x: 0, y: 0 }
|
|
return {
|
|
vertices: vn.vertices,
|
|
segments: vn.segments.map((s) => ({
|
|
start: s.start,
|
|
end: s.end,
|
|
tangentStart: (s as Partial<VectorSegment>).tangentStart ?? { ...ZERO },
|
|
tangentEnd: (s as Partial<VectorSegment>).tangentEnd ?? { ...ZERO }
|
|
})),
|
|
regions: vn.regions
|
|
}
|
|
}
|
|
|
|
export interface GeometryPath {
|
|
windingRule: WindingRule
|
|
commandsBlob: Uint8Array
|
|
}
|
|
|
|
export type NodeType =
|
|
| 'CANVAS'
|
|
| 'FRAME'
|
|
| 'RECTANGLE'
|
|
| 'ROUNDED_RECTANGLE'
|
|
| 'ELLIPSE'
|
|
| 'TEXT'
|
|
| 'LINE'
|
|
| 'STAR'
|
|
| 'POLYGON'
|
|
| 'VECTOR'
|
|
| 'GROUP'
|
|
| 'SECTION'
|
|
| 'COMPONENT'
|
|
| 'COMPONENT_SET'
|
|
| 'INSTANCE'
|
|
| 'CONNECTOR'
|
|
| 'SHAPE_WITH_TEXT'
|
|
|
|
export type FillType =
|
|
| 'SOLID'
|
|
| 'GRADIENT_LINEAR'
|
|
| 'GRADIENT_RADIAL'
|
|
| 'GRADIENT_ANGULAR'
|
|
| 'GRADIENT_DIAMOND'
|
|
| 'IMAGE'
|
|
export type BlendMode =
|
|
| 'NORMAL'
|
|
| 'DARKEN'
|
|
| 'MULTIPLY'
|
|
| 'COLOR_BURN'
|
|
| 'LIGHTEN'
|
|
| 'SCREEN'
|
|
| 'COLOR_DODGE'
|
|
| 'OVERLAY'
|
|
| 'SOFT_LIGHT'
|
|
| 'HARD_LIGHT'
|
|
| 'DIFFERENCE'
|
|
| 'EXCLUSION'
|
|
| 'HUE'
|
|
| 'SATURATION'
|
|
| 'COLOR'
|
|
| 'LUMINOSITY'
|
|
| 'PASS_THROUGH'
|
|
export type ImageScaleMode = 'FILL' | 'FIT' | 'CROP' | 'TILE'
|
|
|
|
export interface GradientStop {
|
|
color: Color
|
|
position: number
|
|
}
|
|
|
|
export type GradientTransform = Matrix
|
|
|
|
export interface Fill {
|
|
type: FillType
|
|
color: Color
|
|
opacity: number
|
|
visible: boolean
|
|
blendMode?: BlendMode
|
|
gradientStops?: GradientStop[]
|
|
gradientTransform?: GradientTransform
|
|
imageHash?: string
|
|
imageScaleMode?: ImageScaleMode
|
|
imageTransform?: GradientTransform
|
|
}
|
|
|
|
export type StrokeCap = 'NONE' | 'ROUND' | 'SQUARE' | 'ARROW_LINES' | 'ARROW_EQUILATERAL'
|
|
export type StrokeJoin = 'MITER' | 'BEVEL' | 'ROUND'
|
|
export type MaskType = 'ALPHA' | 'VECTOR' | 'LUMINANCE'
|
|
|
|
export interface Stroke {
|
|
color: Color
|
|
weight: number
|
|
opacity: number
|
|
visible: boolean
|
|
align: 'INSIDE' | 'CENTER' | 'OUTSIDE'
|
|
cap?: StrokeCap
|
|
join?: StrokeJoin
|
|
dashPattern?: number[]
|
|
}
|
|
|
|
export interface Effect {
|
|
type: 'DROP_SHADOW' | 'INNER_SHADOW' | 'LAYER_BLUR' | 'BACKGROUND_BLUR' | 'FOREGROUND_BLUR'
|
|
color: Color
|
|
offset: Vector
|
|
radius: number
|
|
spread: number
|
|
visible: boolean
|
|
blendMode?: BlendMode
|
|
}
|
|
|
|
export type ConstraintType = 'MIN' | 'CENTER' | 'MAX' | 'STRETCH' | 'SCALE'
|
|
export type TextAutoResize = 'NONE' | 'HEIGHT' | 'WIDTH_AND_HEIGHT' | 'TRUNCATE'
|
|
export type TextAlignVertical = 'TOP' | 'CENTER' | 'BOTTOM'
|
|
export type TextCase = 'ORIGINAL' | 'UPPER' | 'LOWER' | 'TITLE'
|
|
export type TextDecoration = 'NONE' | 'UNDERLINE' | 'STRIKETHROUGH'
|
|
export type TextDirection = 'AUTO' | 'LTR' | 'RTL'
|
|
export type LayoutDirection = 'AUTO' | 'LTR' | 'RTL'
|
|
|
|
export interface CharacterStyleOverride {
|
|
fontWeight?: number
|
|
italic?: boolean
|
|
textDecoration?: TextDecoration
|
|
fontSize?: number
|
|
fontFamily?: string
|
|
letterSpacing?: number
|
|
lineHeight?: number | null
|
|
fills?: Fill[]
|
|
}
|
|
|
|
export interface StyleRun {
|
|
start: number
|
|
length: number
|
|
style: CharacterStyleOverride
|
|
}
|
|
|
|
export interface ArcData {
|
|
startingAngle: number
|
|
endingAngle: number
|
|
innerRadius: number
|
|
}
|
|
|
|
export type LayoutMode = 'NONE' | 'HORIZONTAL' | 'VERTICAL' | 'GRID'
|
|
export type LayoutSizing = 'FIXED' | 'HUG' | 'FILL'
|
|
|
|
export type GridTrackSizing = 'FIXED' | 'FR' | 'AUTO'
|
|
|
|
export interface GridTrack {
|
|
sizing: GridTrackSizing
|
|
value: number
|
|
}
|
|
|
|
export interface GridPosition {
|
|
column: number
|
|
row: number
|
|
columnSpan: number
|
|
rowSpan: number
|
|
}
|
|
export type LayoutAlign = 'MIN' | 'CENTER' | 'MAX' | 'SPACE_BETWEEN'
|
|
export type LayoutCounterAlign = 'MIN' | 'CENTER' | 'MAX' | 'STRETCH' | 'BASELINE'
|
|
export type LayoutAlignSelf = 'AUTO' | 'MIN' | 'CENTER' | 'MAX' | 'STRETCH' | 'BASELINE'
|
|
export type LayoutWrap = 'NO_WRAP' | 'WRAP'
|
|
|
|
export interface PluginDataEntry {
|
|
pluginId: string
|
|
key: string
|
|
value: string
|
|
}
|
|
|
|
export interface SharedPluginDataEntry {
|
|
namespace: string
|
|
key: string
|
|
value: string
|
|
}
|
|
|
|
export interface PluginRelaunchDataEntry {
|
|
pluginId: string
|
|
command: string
|
|
message: string
|
|
isDeleted: boolean
|
|
}
|
|
|
|
export interface SceneNode {
|
|
id: string
|
|
type: NodeType
|
|
name: string
|
|
parentId: string | null
|
|
childIds: string[]
|
|
|
|
x: number
|
|
y: number
|
|
width: number
|
|
height: number
|
|
rotation: number
|
|
|
|
fills: Fill[]
|
|
strokes: Stroke[]
|
|
effects: Effect[]
|
|
opacity: number
|
|
|
|
cornerRadius: number
|
|
topLeftRadius: number
|
|
topRightRadius: number
|
|
bottomRightRadius: number
|
|
bottomLeftRadius: number
|
|
independentCorners: boolean
|
|
cornerSmoothing: number
|
|
|
|
visible: boolean
|
|
locked: boolean
|
|
clipsContent: boolean
|
|
|
|
blendMode: BlendMode
|
|
|
|
text: string
|
|
fontSize: number
|
|
fontFamily: string
|
|
fontWeight: number
|
|
italic: boolean
|
|
textAlignHorizontal: 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED'
|
|
textDirection: TextDirection
|
|
textAlignVertical: TextAlignVertical
|
|
textAutoResize: TextAutoResize
|
|
textCase: TextCase
|
|
textDecoration: TextDecoration
|
|
lineHeight: number | null
|
|
letterSpacing: number
|
|
maxLines: number | null
|
|
styleRuns: StyleRun[]
|
|
|
|
horizontalConstraint: ConstraintType
|
|
verticalConstraint: ConstraintType
|
|
|
|
layoutMode: LayoutMode
|
|
layoutDirection: LayoutDirection
|
|
layoutWrap: LayoutWrap
|
|
primaryAxisAlign: LayoutAlign
|
|
counterAxisAlign: LayoutCounterAlign
|
|
primaryAxisSizing: LayoutSizing
|
|
counterAxisSizing: LayoutSizing
|
|
itemSpacing: number
|
|
counterAxisSpacing: number
|
|
paddingTop: number
|
|
paddingRight: number
|
|
paddingBottom: number
|
|
paddingLeft: number
|
|
|
|
layoutPositioning: 'AUTO' | 'ABSOLUTE'
|
|
layoutGrow: number
|
|
layoutAlignSelf: LayoutAlignSelf
|
|
|
|
vectorNetwork: VectorNetwork | null
|
|
fillGeometry: GeometryPath[]
|
|
strokeGeometry: GeometryPath[]
|
|
|
|
arcData: ArcData | null
|
|
|
|
strokeCap: StrokeCap
|
|
strokeJoin: StrokeJoin
|
|
dashPattern: number[]
|
|
|
|
borderTopWeight: number
|
|
borderRightWeight: number
|
|
borderBottomWeight: number
|
|
borderLeftWeight: number
|
|
independentStrokeWeights: boolean
|
|
|
|
strokeMiterLimit: number
|
|
|
|
minWidth: number | null
|
|
maxWidth: number | null
|
|
minHeight: number | null
|
|
maxHeight: number | null
|
|
|
|
isMask: boolean
|
|
maskType: MaskType
|
|
|
|
gridTemplateColumns: GridTrack[]
|
|
gridTemplateRows: GridTrack[]
|
|
gridColumnGap: number
|
|
gridRowGap: number
|
|
gridPosition: GridPosition | null
|
|
|
|
counterAxisAlignContent: 'AUTO' | 'SPACE_BETWEEN'
|
|
itemReverseZIndex: boolean
|
|
strokesIncludedInLayout: boolean
|
|
|
|
expanded: boolean
|
|
textTruncation: 'DISABLED' | 'ENDING'
|
|
autoRename: boolean
|
|
|
|
pointCount: number
|
|
starInnerRadius: number
|
|
|
|
componentId: string | null
|
|
overrides: Record<string, unknown>
|
|
|
|
boundVariables: Record<string, string>
|
|
|
|
pluginData: PluginDataEntry[]
|
|
sharedPluginData: SharedPluginDataEntry[]
|
|
pluginRelaunchData: PluginRelaunchDataEntry[]
|
|
|
|
internalOnly: boolean
|
|
|
|
flipX: boolean
|
|
flipY: boolean
|
|
|
|
textPicture: Uint8Array | null
|
|
}
|
|
|
|
export type VariableType = 'COLOR' | 'FLOAT' | 'STRING' | 'BOOLEAN'
|
|
export type VariableValue = Color | number | string | boolean | { aliasId: string }
|
|
|
|
export interface Variable {
|
|
id: string
|
|
name: string
|
|
type: VariableType
|
|
collectionId: string
|
|
valuesByMode: Record<string, VariableValue>
|
|
description: string
|
|
hiddenFromPublishing: boolean
|
|
}
|
|
|
|
export interface VariableCollectionMode {
|
|
modeId: string
|
|
name: string
|
|
}
|
|
|
|
export interface VariableCollection {
|
|
id: string
|
|
name: string
|
|
modes: VariableCollectionMode[]
|
|
defaultModeId: string
|
|
variableIds: string[]
|
|
}
|
|
|
|
let nextLocalID = 1
|
|
|
|
export function generateId(): string {
|
|
return `0:${nextLocalID++}`
|
|
}
|
|
|
|
function createDefaultNode(type: NodeType, overrides: Partial<SceneNode> = {}): SceneNode {
|
|
return {
|
|
id: generateId(),
|
|
type,
|
|
name: type.charAt(0) + type.slice(1).toLowerCase(),
|
|
parentId: null,
|
|
childIds: [],
|
|
x: 0,
|
|
y: 0,
|
|
width: 100,
|
|
height: 100,
|
|
rotation: 0,
|
|
fills:
|
|
type === 'TEXT'
|
|
? [{ type: 'SOLID' as const, color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
|
|
: [],
|
|
strokes: [],
|
|
effects: [],
|
|
opacity: 1,
|
|
cornerRadius: 0,
|
|
topLeftRadius: 0,
|
|
topRightRadius: 0,
|
|
bottomRightRadius: 0,
|
|
bottomLeftRadius: 0,
|
|
independentCorners: false,
|
|
cornerSmoothing: 0,
|
|
visible: true,
|
|
locked: false,
|
|
clipsContent: false,
|
|
text: '',
|
|
fontSize: 14,
|
|
fontFamily: DEFAULT_FONT_FAMILY,
|
|
fontWeight: 400,
|
|
italic: false,
|
|
textAlignHorizontal: 'LEFT',
|
|
textDirection: 'AUTO',
|
|
lineHeight: null,
|
|
letterSpacing: 0,
|
|
layoutMode: 'NONE',
|
|
layoutDirection: 'AUTO',
|
|
layoutWrap: 'NO_WRAP',
|
|
primaryAxisAlign: 'MIN',
|
|
counterAxisAlign: 'MIN',
|
|
primaryAxisSizing: 'FIXED',
|
|
counterAxisSizing: 'FIXED',
|
|
itemSpacing: 0,
|
|
counterAxisSpacing: 0,
|
|
paddingTop: 0,
|
|
paddingRight: 0,
|
|
paddingBottom: 0,
|
|
paddingLeft: 0,
|
|
blendMode: 'PASS_THROUGH',
|
|
layoutPositioning: 'AUTO',
|
|
layoutGrow: 0,
|
|
layoutAlignSelf: 'AUTO',
|
|
vectorNetwork: null,
|
|
fillGeometry: [],
|
|
strokeGeometry: [],
|
|
arcData: null,
|
|
textAlignVertical: 'TOP',
|
|
textAutoResize: 'NONE',
|
|
textCase: 'ORIGINAL',
|
|
textDecoration: 'NONE',
|
|
maxLines: null,
|
|
styleRuns: [],
|
|
horizontalConstraint: 'MIN',
|
|
verticalConstraint: 'MIN',
|
|
strokeCap: 'NONE',
|
|
strokeJoin: 'MITER',
|
|
dashPattern: [],
|
|
borderTopWeight: 0,
|
|
borderRightWeight: 0,
|
|
borderBottomWeight: 0,
|
|
borderLeftWeight: 0,
|
|
independentStrokeWeights: false,
|
|
strokeMiterLimit: DEFAULT_STROKE_MITER_LIMIT,
|
|
minWidth: null,
|
|
maxWidth: null,
|
|
minHeight: null,
|
|
maxHeight: null,
|
|
isMask: false,
|
|
maskType: 'ALPHA',
|
|
gridTemplateColumns: [],
|
|
gridTemplateRows: [],
|
|
gridColumnGap: 0,
|
|
gridRowGap: 0,
|
|
gridPosition: null,
|
|
counterAxisAlignContent: 'AUTO',
|
|
itemReverseZIndex: false,
|
|
strokesIncludedInLayout: false,
|
|
expanded: true,
|
|
textTruncation: 'DISABLED',
|
|
autoRename: true,
|
|
pointCount: 5,
|
|
starInnerRadius: 0.38,
|
|
componentId: null,
|
|
overrides: {},
|
|
boundVariables: {},
|
|
pluginData: [],
|
|
sharedPluginData: [],
|
|
pluginRelaunchData: [],
|
|
internalOnly: false,
|
|
flipX: false,
|
|
flipY: false,
|
|
textPicture: null,
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
const CONTAINER_TYPES = new Set<NodeType>([
|
|
'CANVAS',
|
|
'FRAME',
|
|
'GROUP',
|
|
'SECTION',
|
|
'COMPONENT',
|
|
'COMPONENT_SET',
|
|
'INSTANCE'
|
|
])
|
|
|
|
export class SceneGraph {
|
|
nodes = new Map<string, SceneNode>()
|
|
images = new Map<string, Uint8Array>()
|
|
variables = new Map<string, Variable>()
|
|
variableCollections = new Map<string, VariableCollection>()
|
|
activeMode = new Map<string, string>()
|
|
rootId: string
|
|
figKiwiVersion: number | null = null
|
|
documentColorSpace: DocumentColorSpace = 'display-p3'
|
|
readonly emitter: Emitter<SceneGraphEvents> = createNanoEvents()
|
|
private absPosCache = new Map<string, Vector>()
|
|
instanceIndex = new Map<string, Set<string>>()
|
|
|
|
constructor() {
|
|
const root = createDefaultNode('FRAME', {
|
|
name: 'Document',
|
|
width: 0,
|
|
height: 0
|
|
})
|
|
this.rootId = root.id
|
|
this.nodes.set(root.id, root)
|
|
|
|
this.addPage('Page 1')
|
|
}
|
|
|
|
addPage(name: string): SceneNode {
|
|
return this.createNode('CANVAS', this.rootId, { name, width: 0, height: 0 })
|
|
}
|
|
|
|
getPages(includeInternal = false): SceneNode[] {
|
|
return this.getChildren(this.rootId).filter(
|
|
(n) => n.type === 'CANVAS' && (includeInternal || !n.internalOnly)
|
|
)
|
|
}
|
|
|
|
getAllNodes(): Iterable<SceneNode> {
|
|
return this.nodes.values()
|
|
}
|
|
|
|
getNode(id: string): SceneNode | undefined {
|
|
return this.nodes.get(id)
|
|
}
|
|
|
|
// --- Variables ---
|
|
|
|
addVariable(variable: Variable): void {
|
|
this.variables.set(variable.id, variable)
|
|
const collection = this.variableCollections.get(variable.collectionId)
|
|
if (collection && !collection.variableIds.includes(variable.id)) {
|
|
collection.variableIds.push(variable.id)
|
|
}
|
|
}
|
|
|
|
removeVariable(id: string): void {
|
|
const variable = this.variables.get(id)
|
|
if (!variable) return
|
|
this.variables.delete(id)
|
|
const collection = this.variableCollections.get(variable.collectionId)
|
|
if (collection) {
|
|
collection.variableIds = collection.variableIds.filter((vid) => vid !== id)
|
|
}
|
|
for (const node of this.nodes.values()) {
|
|
for (const [field, varId] of Object.entries(node.boundVariables)) {
|
|
if (varId === id) delete node.boundVariables[field]
|
|
}
|
|
}
|
|
}
|
|
|
|
addCollection(collection: VariableCollection): void {
|
|
this.variableCollections.set(collection.id, collection)
|
|
if (!this.activeMode.has(collection.id)) {
|
|
this.activeMode.set(collection.id, collection.defaultModeId)
|
|
}
|
|
}
|
|
|
|
createVariable(
|
|
name: string,
|
|
type: VariableType,
|
|
collectionId: string,
|
|
value?: VariableValue
|
|
): Variable {
|
|
const collection = this.variableCollections.get(collectionId)
|
|
if (!collection) throw new Error(`Collection "${collectionId}" not found`)
|
|
const id = generateId()
|
|
let defaultValue: VariableValue
|
|
if (value !== undefined) {
|
|
defaultValue = value
|
|
} else if (type === 'COLOR') {
|
|
defaultValue = { ...BLACK }
|
|
} else if (type === 'FLOAT') {
|
|
defaultValue = 0
|
|
} else if (type === 'BOOLEAN') {
|
|
defaultValue = false
|
|
} else {
|
|
defaultValue = ''
|
|
}
|
|
const valuesByMode: Record<string, VariableValue> = {}
|
|
for (const mode of collection.modes) {
|
|
valuesByMode[mode.modeId] = structuredClone(defaultValue)
|
|
}
|
|
const variable: Variable = {
|
|
id,
|
|
name,
|
|
type,
|
|
collectionId,
|
|
valuesByMode,
|
|
description: '',
|
|
hiddenFromPublishing: false
|
|
}
|
|
this.addVariable(variable)
|
|
return variable
|
|
}
|
|
|
|
createCollection(name: string): VariableCollection {
|
|
const id = generateId()
|
|
const modeId = generateId()
|
|
const collection: VariableCollection = {
|
|
id,
|
|
name,
|
|
modes: [{ modeId, name: 'Mode 1' }],
|
|
defaultModeId: modeId,
|
|
variableIds: []
|
|
}
|
|
this.addCollection(collection)
|
|
return collection
|
|
}
|
|
|
|
removeCollection(id: string): void {
|
|
const collection = this.variableCollections.get(id)
|
|
if (collection) {
|
|
for (const varId of Array.from(collection.variableIds)) {
|
|
this.removeVariable(varId)
|
|
}
|
|
}
|
|
this.variableCollections.delete(id)
|
|
this.activeMode.delete(id)
|
|
}
|
|
|
|
getActiveModeId(collectionId: string): string {
|
|
const mode = this.activeMode.get(collectionId)
|
|
if (mode) return mode
|
|
const collection = this.variableCollections.get(collectionId)
|
|
return collection?.defaultModeId ?? ''
|
|
}
|
|
|
|
setActiveMode(collectionId: string, modeId: string): void {
|
|
this.activeMode.set(collectionId, modeId)
|
|
}
|
|
|
|
resolveVariable(
|
|
variableId: string,
|
|
modeId?: string,
|
|
visited?: Set<string>
|
|
): VariableValue | undefined {
|
|
if (visited?.has(variableId)) return undefined
|
|
const variable = this.variables.get(variableId)
|
|
if (!variable) return undefined
|
|
const resolvedModeId = modeId ?? this.getActiveModeId(variable.collectionId)
|
|
const value = variable.valuesByMode[resolvedModeId]
|
|
if (typeof value === 'object' && 'aliasId' in value) {
|
|
const seen = visited ?? new Set<string>()
|
|
seen.add(variableId)
|
|
return this.resolveVariable(value.aliasId, undefined, seen)
|
|
}
|
|
return value
|
|
}
|
|
|
|
resolveColorVariable(variableId: string): Color | undefined {
|
|
const value = this.resolveVariable(variableId)
|
|
if (value && typeof value === 'object' && 'r' in value) return value
|
|
return undefined
|
|
}
|
|
|
|
resolveNumberVariable(variableId: string): number | undefined {
|
|
const value = this.resolveVariable(variableId)
|
|
return typeof value === 'number' ? value : undefined
|
|
}
|
|
|
|
getVariablesForCollection(collectionId: string): Variable[] {
|
|
const collection = this.variableCollections.get(collectionId)
|
|
if (!collection) return []
|
|
return collection.variableIds
|
|
.map((id) => this.variables.get(id))
|
|
.filter((v): v is Variable => v !== undefined)
|
|
}
|
|
|
|
getVariablesByType(type: VariableType): Variable[] {
|
|
return [...this.variables.values()].filter((v) => v.type === type)
|
|
}
|
|
|
|
bindVariable(nodeId: string, field: string, variableId: string): void {
|
|
const node = this.nodes.get(nodeId)
|
|
if (node) node.boundVariables[field] = variableId
|
|
}
|
|
|
|
unbindVariable(nodeId: string, field: string): void {
|
|
const node = this.nodes.get(nodeId)
|
|
if (node) delete node.boundVariables[field]
|
|
}
|
|
|
|
getChildren(id: string): SceneNode[] {
|
|
const node = this.nodes.get(id)
|
|
if (!node) return []
|
|
return node.childIds
|
|
.map((cid) => this.nodes.get(cid))
|
|
.filter((n): n is SceneNode => n !== undefined)
|
|
}
|
|
|
|
isContainer(id: string): boolean {
|
|
const node = this.nodes.get(id)
|
|
return node ? CONTAINER_TYPES.has(node.type) : false
|
|
}
|
|
|
|
isDescendant(childId: string, ancestorId: string): boolean {
|
|
let current = this.nodes.get(childId)
|
|
while (current) {
|
|
if (current.id === ancestorId) return true
|
|
current = current.parentId ? this.nodes.get(current.parentId) : undefined
|
|
}
|
|
return false
|
|
}
|
|
|
|
clearAbsPosCache(): void {
|
|
this.absPosCache.clear()
|
|
}
|
|
|
|
getAbsolutePosition(id: string): Vector {
|
|
const cached = this.absPosCache.get(id)
|
|
if (cached) return cached
|
|
|
|
const node = this.getNode(id)
|
|
|
|
const result = getAbsolutePosition(node!, this)
|
|
this.absPosCache.set(id, result)
|
|
return result
|
|
}
|
|
|
|
getAbsoluteBounds(id: string): Rect {
|
|
const pos = this.getAbsolutePosition(id)
|
|
const node = this.nodes.get(id)
|
|
return {
|
|
x: pos.x,
|
|
y: pos.y,
|
|
width: node?.width ?? 0,
|
|
height: node?.height ?? 0
|
|
}
|
|
}
|
|
|
|
createNode(type: NodeType, parentId: string, overrides: Partial<SceneNode> = {}): SceneNode {
|
|
const node = createDefaultNode(type, overrides)
|
|
node.parentId = parentId
|
|
this.nodes.set(node.id, node)
|
|
|
|
const parent = this.nodes.get(parentId)
|
|
if (parent) {
|
|
parent.childIds.push(node.id)
|
|
}
|
|
|
|
if (node.type === 'INSTANCE' && node.componentId) {
|
|
let set = this.instanceIndex.get(node.componentId)
|
|
if (!set) {
|
|
set = new Set()
|
|
this.instanceIndex.set(node.componentId, set)
|
|
}
|
|
set.add(node.id)
|
|
}
|
|
|
|
this.emitter.emit('node:created', node)
|
|
return node
|
|
}
|
|
|
|
static TEXT_PICTURE_KEYS: ReadonlySet<string> = new Set([
|
|
'text',
|
|
'fontSize',
|
|
'fontFamily',
|
|
'fontWeight',
|
|
'italic',
|
|
'textAlignHorizontal',
|
|
'textDirection',
|
|
'textAlignVertical',
|
|
'lineHeight',
|
|
'letterSpacing',
|
|
'textDecoration',
|
|
'textCase',
|
|
'styleRuns',
|
|
'fills',
|
|
'width',
|
|
'height'
|
|
])
|
|
|
|
updateNode(id: string, changes: Partial<SceneNode>): void {
|
|
const node = this.nodes.get(id)
|
|
if (!node) return
|
|
this.absPosCache.clear()
|
|
if (
|
|
node.type === 'INSTANCE' &&
|
|
'componentId' in changes &&
|
|
changes.componentId !== node.componentId
|
|
) {
|
|
if (node.componentId) this.instanceIndex.get(node.componentId)?.delete(id)
|
|
if (changes.componentId) {
|
|
let set = this.instanceIndex.get(changes.componentId)
|
|
if (!set) {
|
|
set = new Set()
|
|
this.instanceIndex.set(changes.componentId, set)
|
|
}
|
|
set.add(id)
|
|
}
|
|
}
|
|
if (
|
|
node.type === 'TEXT' &&
|
|
node.textPicture &&
|
|
Object.keys(changes).some((k) => SceneGraph.TEXT_PICTURE_KEYS.has(k))
|
|
) {
|
|
node.textPicture = null
|
|
}
|
|
if (changes.vectorNetwork) {
|
|
changes = { ...changes, vectorNetwork: normalizeVectorNetwork(changes.vectorNetwork) }
|
|
}
|
|
Object.assign(node, changes)
|
|
this.emitter.emit('node:updated', id, changes)
|
|
}
|
|
|
|
reparentNode(nodeId: string, newParentId: string): void {
|
|
const node = this.nodes.get(nodeId)
|
|
if (!node || nodeId === this.rootId) return
|
|
if (this.isDescendant(newParentId, nodeId)) return
|
|
|
|
const oldParent = node.parentId ? this.nodes.get(node.parentId) : undefined
|
|
const newParent = this.nodes.get(newParentId)
|
|
if (!newParent) return
|
|
if (node.parentId === newParentId) return
|
|
|
|
const oldParentId = node.parentId
|
|
this.absPosCache.clear()
|
|
|
|
// Convert absolute position
|
|
const absPos = this.getAbsolutePosition(nodeId)
|
|
const newParentNode = this.nodes.get(newParentId)
|
|
const newParentAbs =
|
|
newParentId === this.rootId || newParentNode?.type === 'CANVAS'
|
|
? { x: 0, y: 0 }
|
|
: this.getAbsolutePosition(newParentId)
|
|
|
|
// Remove from old parent
|
|
if (oldParent) {
|
|
oldParent.childIds = oldParent.childIds.filter((cid) => cid !== nodeId)
|
|
}
|
|
|
|
// Add to new parent
|
|
node.parentId = newParentId
|
|
newParent.childIds.push(nodeId)
|
|
|
|
// Adjust position so node stays in same visual place
|
|
node.x = absPos.x - newParentAbs.x
|
|
node.y = absPos.y - newParentAbs.y
|
|
|
|
this.emitter.emit('node:reparented', nodeId, oldParentId, newParentId)
|
|
}
|
|
|
|
reorderChild(nodeId: string, parentId: string, insertIndex: number): void {
|
|
const node = this.nodes.get(nodeId)
|
|
if (!node) return
|
|
|
|
const oldParent = node.parentId ? this.nodes.get(node.parentId) : undefined
|
|
const newParent = this.nodes.get(parentId)
|
|
if (!newParent) return
|
|
|
|
// Remove from old parent
|
|
if (oldParent) {
|
|
oldParent.childIds = oldParent.childIds.filter((cid) => cid !== nodeId)
|
|
}
|
|
|
|
// If same parent, adjust index since we removed the item
|
|
let idx = insertIndex
|
|
if (
|
|
oldParent === newParent &&
|
|
idx > (oldParent.childIds.indexOf(nodeId) === -1 ? idx : oldParent.childIds.length)
|
|
) {
|
|
// Already removed above, no adjustment needed
|
|
}
|
|
|
|
node.parentId = parentId
|
|
idx = Math.min(idx, newParent.childIds.length)
|
|
newParent.childIds.splice(idx, 0, nodeId)
|
|
|
|
this.emitter.emit('node:reordered', nodeId, parentId, idx)
|
|
}
|
|
|
|
insertChildAt(childId: string, parentId: string, index: number): void {
|
|
const oldParent = this.getNode(this.getNode(childId)?.parentId ?? '')
|
|
if (oldParent) {
|
|
oldParent.childIds = oldParent.childIds.filter((id) => id !== childId)
|
|
}
|
|
const newParent = this.getNode(parentId)
|
|
if (!newParent) return
|
|
newParent.childIds = newParent.childIds.filter((id) => id !== childId)
|
|
newParent.childIds.splice(index, 0, childId)
|
|
const node = this.getNode(childId)
|
|
if (node) node.parentId = parentId
|
|
this.clearAbsPosCache()
|
|
this.emitter.emit('node:reordered', childId, parentId, index)
|
|
}
|
|
|
|
deleteNode(id: string): void {
|
|
const node = this.nodes.get(id)
|
|
if (!node || id === this.rootId) return
|
|
|
|
if (node.parentId) {
|
|
const parent = this.nodes.get(node.parentId)
|
|
if (parent) {
|
|
parent.childIds = parent.childIds.filter((cid) => cid !== id)
|
|
}
|
|
}
|
|
|
|
for (const childId of Array.from(node.childIds)) {
|
|
this.deleteNode(childId)
|
|
}
|
|
|
|
if (node.type === 'INSTANCE' && node.componentId) {
|
|
this.instanceIndex.get(node.componentId)?.delete(id)
|
|
}
|
|
this.nodes.delete(id)
|
|
this.emitter.emit('node:deleted', id)
|
|
}
|
|
|
|
hitTest(px: number, py: number, scopeId?: string): SceneNode | null {
|
|
return hitTestFn(this, px, py, scopeId)
|
|
}
|
|
|
|
hitTestDeep(px: number, py: number, scopeId?: string): SceneNode | null {
|
|
return hitTestDeepFn(this, px, py, scopeId)
|
|
}
|
|
|
|
hitTestFrame(
|
|
px: number,
|
|
py: number,
|
|
excludeIds: Set<string>,
|
|
scopeId?: string
|
|
): SceneNode | null {
|
|
return hitTestFrameFn(this, px, py, excludeIds, scopeId)
|
|
}
|
|
|
|
cloneTree(
|
|
sourceId: string,
|
|
parentId: string,
|
|
overrides: Partial<SceneNode> = {}
|
|
): SceneNode | null {
|
|
const src = this.nodes.get(sourceId)
|
|
if (!src) return null
|
|
|
|
const { id: _srcId, parentId: _srcParent, childIds: _srcChildren, ...rest } = src
|
|
const clone = this.createNode(src.type, parentId, { ...rest, ...overrides })
|
|
|
|
for (const childId of src.childIds) {
|
|
this.cloneTree(childId, clone.id)
|
|
}
|
|
|
|
return clone
|
|
}
|
|
|
|
createInstance(
|
|
componentId: string,
|
|
parentId: string,
|
|
overrides: Partial<SceneNode> = {}
|
|
): SceneNode | null {
|
|
return createInstanceFn(this, componentId, parentId, overrides)
|
|
}
|
|
|
|
populateInstanceChildren(instanceId: string, componentId: string): void {
|
|
populateInstanceChildrenFn(this, instanceId, componentId)
|
|
}
|
|
|
|
syncInstances(componentId: string): void {
|
|
syncInstancesFn(this, componentId)
|
|
}
|
|
|
|
detachInstance(instanceId: string): void {
|
|
detachInstanceFn(this, instanceId)
|
|
}
|
|
|
|
getMainComponent(instanceId: string): SceneNode | undefined {
|
|
return getMainComponentFn(this, instanceId)
|
|
}
|
|
|
|
getInstances(componentId: string): SceneNode[] {
|
|
return getInstancesFn(this, componentId)
|
|
}
|
|
|
|
flattenTree(parentId?: string, depth = 0): Array<{ node: SceneNode; depth: number }> {
|
|
const id = parentId ?? this.rootId
|
|
const parent = this.nodes.get(id)
|
|
if (!parent) return []
|
|
|
|
const result: Array<{ node: SceneNode; depth: number }> = []
|
|
for (const childId of parent.childIds) {
|
|
const child = this.nodes.get(childId)
|
|
if (!child) continue
|
|
result.push({ node: child, depth })
|
|
if (child.childIds.length > 0) {
|
|
result.push(...this.flattenTree(childId, depth + 1))
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
}
|