openpencil/packages/core/src/scene-graph.ts
Anton Soldatov 7305dbe634
AI chat polishing: skeleton workflow, batched tools, visual feedback, stock photos (#111)
* Fix text rendering: always use paragraph wrapping, font fallback, batch tools, severity levels

Rendering:
- renderText: always use paragraph when fontProvider available, not just
  when node-specific font loaded — fixes single-line overflow for Georgia/
  non-Inter fonts
- buildParagraph: add Inter as fallback fontFamily so paragraph wraps text
  even when requested font not yet loaded
- measureTextNode/buildTextPicture: remove isNodeFontLoaded gate — measure
  with fallback font for consistent layout↔rendering
- TEXT_PICTURE_KEYS: add width/height so textPicture invalidates on resize
- renderText drawText fallback: clip to node bounds
- onAfterExecute: invalidate textPictures after font load

Layout:
- estimateTextSize: accept maxWidth, compute multi-line height
- toJSON: depth parameter with childCount fallback

AI tools:
- batch_update tool: multiple set_layout/set_layout_child in one call
- describe: accept ids array for multi-node inspection
- Issue severity (error/warning/info) with pattern classification
- checkNestedFlexWithoutFill: cross-axis fill tolerance
- CORE_TOOLS trimmed 34→27, rest moved to EXTENDED
- System prompt: batch_update, describe ids, severity rules, font/text
  wrapping guidance, card grid patterns

* Add Pexels stock photo integration for AI chat

- stock_photo tool: search Pexels by query, download image, apply as
  IMAGE fill on any node. Picks resolution based on node dimensions.
- Pexels API key stored in localStorage, synced to core via
  setPexelsApiKey on change
- ProviderSettings UI: Pexels API key input with link to get free key
- System prompt: stock photo usage guidance
- Added to CORE_TOOLS so AI can use it in every session

* Batch stock_photo + calc, 4-phase workflow, fix wrap false positives

- stock_photo: accepts JSON array of requests, all fetched in parallel.
  Rejects nodes with children (leaf shapes only). One call replaces 14.
- calc: accepts JSON array of expressions or single string. One call
  replaces 3 separate calc invocations.
- System prompt: 4-phase workflow (plan → skeleton → content → polish).
  Batch stock_photo and calc examples. Updated step budget.
- describe-layout-issues: skip 'nested flex may collapse' for wrap
  containers (pill/tag frames are intentionally HUG). Extract
  childNeedsFill helper to reduce complexity.

* Fix nested flex false positive for pills, single-root render rule

- describe-layout-issues: skip 'nested flex may collapse' when a sibling
  already has grow or fill — pill/badge frames next to flex-grow content
  are intentionally small (e.g. BreakingLabel next to ticker text)
- System prompt: render must have exactly one root element. Stock_photo
  401 fallback: tell user, don't eval gradients.

* Rework workflow: render complete sections, not empty frames

Phase 2 now prescribes 5-7 renders, each producing a complete section
(nav, hero, stories, opinions, sidebar, footer) with placeholder images,
real text, proper auto-layout. No more empty frames to fill later.
Removes the fill content phase — skeleton IS the content, just without
real photos. Phase 3 is now just polish (batch stock_photo + fixes).
Step budget: 12-15 steps instead of 15-30.

* Pexels key input: match AI key behavior

Same pattern as the AI provider key — intermediate ref, 'Key saved —
enter new to replace' placeholder, Clear button, save on change. No
longer exposes the raw key in the input field.

* No empty frames in skeleton, select nodes after each tool

- System prompt: explicit rules against empty frames, no pre-creating
  wrapper frames for future content. Each render = complete section with
  visible placeholders. Render 1 = only nav+ticker, not the whole page
  with empty containers.
- tools.ts: set selection to flash node IDs after each mutating tool,
  so user sees which section AI is working on (selection border persists
  vs flash which is transient).

* AI overlay: pulsing blue while working, green flash on done; skeleton workflow

Rendering:
- ai-overlays.ts: new module with pulsing blue border (sinusoidal
  opacity 0.3-0.8 at 1.5s period) for nodes AI is working on, green
  fade-out flash (800ms) when done
- renderer.ts: aiMarkActive/aiMarkDone/aiClearAll methods, drawn after
  regular flashes
- constants.ts: AI_ACTIVE_COLOR, AI_DONE_COLOR, timing constants

Integration:
- ai-adapter.ts: onBeforeExecute now receives args (for target ID)
- tools.ts: marks target node (id or parent_id) active before tool
  executes, marks done via onFlashNodes after completion
- editor.ts: aiMarkActive/aiMarkDone/aiClearAll forwarded to renderer

Workflow:
- System prompt: 3-phase skeleton workflow. Phase 2 renders gray
  placeholder blocks (Rectangle bg=#E2E8F0) for every section — page
  looks like wireframe before any real content. Phase 3 replaces
  skeletons with real content. Phase 4 batch stock_photo + polish.

* render replace_id: atomic skeleton-to-content swap, fix AI overlay lifecycle

render tool:
- New replace_id parameter — renders new content, places it at the
  skeleton's position in parent, then deletes the skeleton. No visual
  gap between skeleton removal and content appearance.

AI overlay fixes:
- Track lastActiveIds across before/after execute — aiMarkDone clears
  the exact nodes that were marked active, not just the result IDs
- aiFlashDone: direct green flash on result nodes (not gated by
  _aiActiveNodes membership)
- replace_id included in target ID detection for active marking

System prompt:
- Phase 3 uses render({ replace_id }) instead of delete_node + render

* Fix gradient fills, nested flex false positives, render insert_index, h=hug prompt

- set_fill: gradient support with color_end + direction (top-bottom,
  left-right, etc). Uses proper Matrix type. No more eval for gradients.
- describe-layout-issues: skip nested flex warning for all non-MIN
  alignments (SPACE_BETWEEN, CENTER, MAX) — not just SPACE_BETWEEN
- render: insert_index parameter for controlling child order
- system prompt: page frame h="hug" not fixed height
- AI overlay: mark active on any tool with target ID (not just
  mutating), clear previous active in onBeforeExecute

* Add desktop business news site example to system prompt

Full workflow example: skeleton with gray placeholders → replace_id
content fills → batch stock_photo. Shows 12-col grid pattern, card
components, section headers, sidebar widgets, h=hug page frame.
Complements the existing mobile app UI example.

* Drop blue active pulse, keep only green done flash

Blue active marking was invisible — tool execution is 20-80ms (one
frame), AI thinking happens between tool calls where no hooks fire.
Green done flash on result nodes works and is visible.

* Blue pulse on skeleton during AI tool streaming

Watch streaming message parts in ChatPanel — when a tool part appears
with state 'call'/'partial-call' and has replace_id or parent_id in
args, mark that node active (blue pulse). When tool transitions to
'output-available', mark done (green flash). This shows the pulsing
blue border on the skeleton wireframe frame while AI generates JSX,
and switches to green when the content replaces it.

* AI overlay debug log section, watch tool parts by toolCallId

- chat-debug.ts: aiOverlayLog array + AI OVERLAY LOG section in debug
  output showing every part/mark-active/mark-done event with timestamps
- ChatPanel.vue: check parts by toolCallId instead of isToolUIPart,
  log all overlay events to aiOverlayLog for debugging

* Fix AI overlay: use 'input' not 'args' for tool part params

Vercel AI SDK UIToolInvocation uses 'input' field (not 'args') for
tool call parameters. The watch was checking p.args which was always
undefined, so no overlay events ever fired. Also use p.type instead
of p.toolName for the log.

* Move AI overlay to onBeforeExecute/onFlashNodes, reorder render params

- onBeforeExecute: mark replace_id/parent_id active (blue pulse)
- onFlashNodes: clear active, flash done on new nodes (green)
- Reorder render tool params: replace_id first so streaming partial
  input reveals target ID before JSX body
- Remove ChatPanel watch — all overlay logic in tools.ts now
- Add aiClearActive() to renderer (clears active without touching
  done flashes)

* Wait 2 rAF frames after marking active before tool execute

onBeforeExecute marks the target node active (blue pulse), but
execute ran immediately after without yielding to the render loop.
The blue frame was never painted because markActive → execute →
clearActive all happened in the same JS task. Now await two
requestAnimationFrame callbacks so the canvas renders at least one
blue-pulse frame before execution starts.

* Add comprehensive AI overlay debug logging to chat debug log

Both streaming watch (ChatPanel) and tool execution hooks (tools.ts)
now log every event to aiOverlayLog with full state: renderer exists,
activeNodes count, hasFlashes, node existence. This will show exactly
what happens and why the blue pulse is or isn't visible.

* Filter partial node IDs in streaming overlay watch

During input-streaming, replace_id may be partially received (e.g.
'0:' without the number part). Guard with isValidNodeId check.

Also refactor watch callback into collectPendingTargets +
syncOverlayState to stay under complexity limit.

Analysis of last debug log:
- Hero: only 235ms active (streaming didn't deliver replace_id early)
- Stories: 14.8s active — should be clearly visible
- Opinions: 12.5s, LatestNews: 11.5s, Stocks: 9.8s — all long enough
- Hero was the only one too fast to notice

* Fix undo race, JSX pragma conflict, partial ID overlay, add deletion log

- Fix undo snapshot race condition: replace shared `let beforeSnapshot`
  with a per-tool stack. Vercel AI SDK runs tool calls from one step via
  Promise.all — concurrent mutating tools overwrote the shared snapshot,
  causing wrong undo entries and potential content loss.

- Fix 'h is not a function' JSX error: rename sucrase pragma from `h`
  to `__h`. When AI writes `.map((h, i) => ...)`, the loop variable
  shadowed the pragma, crashing render.

- Fix partial node ID `0:1` in overlay: strengthen `isValidNodeId` to
  require both parts ≥2 chars and verify node exists in graph. During
  streaming, `replace_id: "0:1811"` arrives as `0:1` → `0:18` → etc.

- Add deletion log to SceneGraph: `_deletionLog` records id, name,
  type, parentId, timestamp, and truncated stack for last 200 deletions.
  Included in debug log as NODE DELETION LOG section to diagnose the
  intermittent frame disappearance bug.

* Reduce find_nodes spam and describe bloat in system prompt

- Instruct AI to reuse IDs from render results and describe output
  instead of calling find_nodes to rediscover them (saves 8+ calls)
- Recommend depth=1 for full-page describe, depth=2 only for sections

* Auto-depth describe + validate-after-render workflow

Describe tool now auto-picks depth based on subtree size when depth
is omitted: ≤15 nodes → depth 4, ≤40 → 3, ≤100 → 2, larger → 1.
Max raised to 5 for explicit overrides.

Prompt updated: after each render replace_id, call describe on that
block (auto depth gives full detail on small sections). Every 2-3
content renders, describe root at depth=1 to catch cross-section
layout issues early.

* Enforce describe-after-every-render in system prompt

Show explicit render → describe → fix pattern with code example.
AI was skipping per-block validation and batching all describes
at the end, causing compounding layout errors.

* Add describe rules: nested text, missing padding, button padding, row centering

Four new layout issue detectors in describe-issues.ts:

- Nested Text (error): Text node inside Text causes overflow.
  Suggestion: split into separate Text elements in flex row.

- Zero padding container (warning): Frame with fill + text children
  but no padding — content touches edges.

- Button without padding (warning): Button-like frame with children
  but zero horizontal padding.

- Row without vertical centering (warning): Horizontal flex with
  items=start, tall container, and short children — suggests
  items=center.

All verified with bun -e tests including false positive checks.

* Fix gradient alpha loss, add undersize child detection

Gradient fix: set_fill with color_end was overwriting alpha to 1
on both gradient stops (`{ ...c, a: 1 }`). Now preserves original
alpha from parsed hex — #00000000 → a:0, #000000AA → a:0.667.
This caused transparent-to-dark overlays to render as solid black.

New describe rule: detect child significantly narrower than parent
in a container without auto-layout (e.g. 100px Rectangle inside
896px Frame). Suggests adding flex + w=fill. Caught the HeroImg
bug that describe previously missed.

* Auto-include issues in render result, no separate describe needed

Render tool now scans the created subtree for errors/warnings and
includes them in the response. AI gets issues automatically without
needing to call describe — eliminates the pattern where AI skips
describe on 'simple' sidebar blocks.

Example response:
  { id: '0:42', name: 'Card', issues: [
    { node: 'Text', id: '0:45', message: 'no color', severity: 'error' }
  ] }

* Run layout before collecting render issues

Issues were checked on raw node sizes (default 100px) before
computeAllLayouts ran in onAfterExecute. Now render calls
computeAllLayouts inline before collectSubtreeIssues, so issues
reflect actual computed dimensions.

This fixes hundreds of false 'X 100px overflows parent' warnings
that made AI chase nonexistent problems.

* Revert auto-issues in render and layout-before-issues

Reverts 09a9b89 and 70c116a. Auto-including issues in render
result caused false positives (100px default sizes before layout)
and made AI chase nonexistent problems, degrading output quality.

* Clean up AI chat PR: remove debug scaffolding, add stock photo provider adapter

- Delete _deletionLog (stack traces on every node deletion)
- Delete aiOverlayLog and all debug push() calls
- Delete succesful_prompt_diffucult_ux.md (raw debug dump) and tokens.md (empty)
- Remove double-rAF hack from ai-adapter (added 32ms to every tool call)
- Restore simple beforeSnapshot for undo (was matching by tool name)
- Remove 70-line overlay state tracker from ChatPanel.vue
- Remove Math.* regex hack from calc (expr-eval has builtins)
- Refactor stock-photo into provider adapter: Pexels + Unsplash
- Add Unsplash key management to settings UI and use-chat composable

---------

Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-03-15 15:42:41 +03:00

946 lines
24 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 {
createInstance as createInstanceFn,
populateInstanceChildren as populateInstanceChildrenFn,
syncInstances as syncInstancesFn,
detachInstance as detachInstanceFn,
getMainComponent as getMainComponentFn,
getInstances as getInstancesFn
} from './scene-graph-instances'
import {
hitTest as hitTestFn,
hitTestDeep as hitTestDeepFn,
hitTestFrame as hitTestFrameFn
} from './scene-graph-hit-test'
export type { GUID, Color } from './types'
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 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[]
}
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 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 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'
textAlignVertical: TextAlignVertical
textAutoResize: TextAutoResize
textCase: TextCase
textDecoration: TextDecoration
lineHeight: number | null
letterSpacing: number
maxLines: number | null
styleRuns: StyleRun[]
horizontalConstraint: ConstraintType
verticalConstraint: ConstraintType
layoutMode: LayoutMode
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>
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: [],
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',
lineHeight: null,
letterSpacing: 0,
layoutMode: 'NONE',
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: {},
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
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
let ax = 0
let ay = 0
let current = this.nodes.get(id)
while (current && current.id !== this.rootId && current.type !== 'CANVAS') {
ax += current.x
ay += current.y
current = current.parentId ? this.nodes.get(current.parentId) : undefined
}
const result = { x: ax, y: ay }
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', '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
}
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)
}
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
}
}