Dragging inside auto-layout frames: - 8px dead zone before breaking out of auto-layout flow - Blue insertion indicator line between children, computed from cursor position relative to child midpoints - Drop reorders children in the parent's childIds array and recomputes layout via Yoga - Dragging from outside into an auto-layout frame shows the indicator and inserts at the correct index SceneGraph.reorderChild() added for index-based child insertion. Renderer draws the indicator as a 2px blue line (horizontal for vertical layout, vertical for horizontal layout).
414 lines
10 KiB
TypeScript
414 lines
10 KiB
TypeScript
export type NodeType =
|
|
| 'FRAME'
|
|
| 'RECTANGLE'
|
|
| 'ELLIPSE'
|
|
| 'TEXT'
|
|
| 'LINE'
|
|
| 'STAR'
|
|
| 'POLYGON'
|
|
| 'VECTOR'
|
|
| 'GROUP'
|
|
| 'SECTION'
|
|
|
|
export interface GUID {
|
|
sessionID: number
|
|
localID: number
|
|
}
|
|
|
|
export interface Color {
|
|
r: number
|
|
g: number
|
|
b: number
|
|
a: number
|
|
}
|
|
|
|
export interface Fill {
|
|
type: 'SOLID'
|
|
color: Color
|
|
opacity: number
|
|
visible: boolean
|
|
}
|
|
|
|
export interface Stroke {
|
|
color: Color
|
|
weight: number
|
|
opacity: number
|
|
visible: boolean
|
|
align: 'INSIDE' | 'CENTER' | 'OUTSIDE'
|
|
}
|
|
|
|
export interface Effect {
|
|
type: 'DROP_SHADOW' | 'INNER_SHADOW' | 'LAYER_BLUR' | 'BACKGROUND_BLUR'
|
|
color: Color
|
|
offset: { x: number; y: number }
|
|
radius: number
|
|
spread: number
|
|
visible: boolean
|
|
}
|
|
|
|
export type LayoutMode = 'NONE' | 'HORIZONTAL' | 'VERTICAL'
|
|
export type LayoutSizing = 'FIXED' | 'HUG' | 'FILL'
|
|
export type LayoutAlign = 'MIN' | 'CENTER' | 'MAX' | 'SPACE_BETWEEN'
|
|
export type LayoutCounterAlign = '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
|
|
|
|
text: string
|
|
fontSize: number
|
|
fontFamily: string
|
|
fontWeight: number
|
|
textAlignHorizontal: 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED'
|
|
lineHeight: number | null
|
|
letterSpacing: number
|
|
|
|
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: 'AUTO' | 'STRETCH'
|
|
}
|
|
|
|
let nextLocalID = 1
|
|
|
|
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,
|
|
text: '',
|
|
fontSize: 14,
|
|
fontFamily: 'Inter',
|
|
fontWeight: 400,
|
|
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,
|
|
layoutPositioning: 'AUTO',
|
|
layoutGrow: 0,
|
|
layoutAlignSelf: 'AUTO',
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
const CONTAINER_TYPES = new Set<NodeType>(['FRAME', 'GROUP', 'SECTION'])
|
|
|
|
export class SceneGraph {
|
|
nodes = new Map<string, SceneNode>()
|
|
rootId: string
|
|
|
|
constructor() {
|
|
const root = createDefaultNode('FRAME', {
|
|
name: 'Document',
|
|
width: 0,
|
|
height: 0
|
|
})
|
|
this.rootId = root.id
|
|
this.nodes.set(root.id, root)
|
|
}
|
|
|
|
getNode(id: string): SceneNode | undefined {
|
|
return this.nodes.get(id)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
getAbsolutePosition(id: string): { x: number; y: number } {
|
|
let ax = 0
|
|
let ay = 0
|
|
let current = this.nodes.get(id)
|
|
while (current && current.id !== this.rootId) {
|
|
ax += current.x
|
|
ay += current.y
|
|
current = current.parentId ? this.nodes.get(current.parentId) : undefined
|
|
}
|
|
return { x: ax, y: ay }
|
|
}
|
|
|
|
getAbsoluteBounds(id: string): { x: number; y: number; width: number; height: number } {
|
|
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)
|
|
}
|
|
|
|
return node
|
|
}
|
|
|
|
updateNode(id: string, changes: Partial<SceneNode>): void {
|
|
const node = this.nodes.get(id)
|
|
if (!node) return
|
|
Object.assign(node, 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
|
|
|
|
// Convert absolute position
|
|
const absPos = this.getAbsolutePosition(nodeId)
|
|
const newParentAbs =
|
|
newParentId === this.rootId ? { 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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
this.nodes.delete(id)
|
|
}
|
|
|
|
hitTest(px: number, py: number, scopeId?: string): SceneNode | null {
|
|
const scope = scopeId ?? this.rootId
|
|
return this.hitTestChildren(px, py, scope, 0, 0)
|
|
}
|
|
|
|
private hitTestChildren(
|
|
px: number,
|
|
py: number,
|
|
parentId: string,
|
|
offsetX: number,
|
|
offsetY: number
|
|
): SceneNode | null {
|
|
const parent = this.nodes.get(parentId)
|
|
if (!parent) return null
|
|
|
|
// Reverse order = topmost first
|
|
for (let i = parent.childIds.length - 1; i >= 0; i--) {
|
|
const childId = parent.childIds[i]
|
|
const child = this.nodes.get(childId)
|
|
if (!child || !child.visible) continue
|
|
|
|
const ax = offsetX + child.x
|
|
const ay = offsetY + child.y
|
|
|
|
// Check children first (deeper hit)
|
|
if (CONTAINER_TYPES.has(child.type)) {
|
|
const deepHit = this.hitTestChildren(px, py, childId, ax, ay)
|
|
if (deepHit) return deepHit
|
|
}
|
|
|
|
if (px >= ax && px <= ax + child.width && py >= ay && py <= ay + child.height) {
|
|
return child
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
hitTestFrame(px: number, py: number, excludeIds: Set<string>): SceneNode | null {
|
|
return this.hitTestFrameChildren(px, py, this.rootId, 0, 0, excludeIds)
|
|
}
|
|
|
|
private hitTestFrameChildren(
|
|
px: number,
|
|
py: number,
|
|
parentId: string,
|
|
offsetX: number,
|
|
offsetY: number,
|
|
excludeIds: Set<string>
|
|
): SceneNode | null {
|
|
const parent = this.nodes.get(parentId)
|
|
if (!parent) return null
|
|
|
|
// Deepest matching frame wins
|
|
let best: SceneNode | null = null
|
|
|
|
for (const childId of parent.childIds) {
|
|
if (excludeIds.has(childId)) continue
|
|
const child = this.nodes.get(childId)
|
|
if (!child || !child.visible) continue
|
|
|
|
const ax = offsetX + child.x
|
|
const ay = offsetY + child.y
|
|
|
|
if (!CONTAINER_TYPES.has(child.type)) continue
|
|
if (px < ax || px > ax + child.width || py < ay || py > ay + child.height) continue
|
|
|
|
best = child
|
|
|
|
const deeper = this.hitTestFrameChildren(px, py, childId, ax, ay, excludeIds)
|
|
if (deeper) best = deeper
|
|
}
|
|
|
|
return best
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|