refactor: Reorganize layout system into new renderer architecture (#5071)

- Move layout system to renderer/core/layout/
  - Store, operations, adapters, and sync modules organized clearly
  - Merged layoutTypes.ts and layoutOperations.ts into single types.ts
- Move canvas rendering to renderer/core/canvas/
  - LiteGraph-specific code in litegraph/ subdirectory
  - PathRenderer at canvas level
- Move spatial indexing to renderer/core/spatial/
- Move Vue node composables to renderer/extensions/vue-nodes/
- Update all import paths throughout codebase
- Apply consistent naming (renderer vs rendering)

This establishes clearer separation between core rendering concerns
and optional extensions, making the architecture more maintainable.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Christian Byrne
2025-08-17 20:19:00 -07:00
committed by Benjamin Lu
parent 889d136154
commit 0dd4ff2087
22 changed files with 390 additions and 36 deletions

View File

@@ -0,0 +1,820 @@
/**
* Path Renderer
*
* Pure canvas2D rendering utility with no framework dependencies.
* Renders bezier curves, straight lines, and linear connections between points.
* Supports arrows, flow animations, and returns Path2D objects for hit detection.
* Can be reused in any canvas-based project without modification.
*/
export interface Point {
x: number
y: number
}
export type Direction = 'left' | 'right' | 'up' | 'down'
export type RenderMode = 'spline' | 'straight' | 'linear'
export type ArrowShape = 'triangle' | 'circle' | 'square'
export interface LinkRenderData {
id: string
startPoint: Point
endPoint: Point
startDirection: Direction
endDirection: Direction
color?: string
type?: string
controlPoints?: Point[]
flow?: boolean
disabled?: boolean
// Optional multi-segment support
segments?: Array<{
start: Point
end: Point
controlPoints?: Point[]
}>
// Center point storage (for hit detection and menu)
centerPos?: Point
centerAngle?: number
}
export interface RenderStyle {
mode: RenderMode
connectionWidth: number
borderWidth?: number
arrowShape?: ArrowShape
showArrows?: boolean
lowQuality?: boolean
// Center marker properties
showCenterMarker?: boolean
centerMarkerShape?: 'circle' | 'arrow'
highQuality?: boolean
}
export interface RenderColors {
default: string
byType: Record<string, string>
highlighted: string
}
export interface RenderContext {
style: RenderStyle
colors: RenderColors
patterns?: {
disabled?: CanvasPattern | null
}
animation?: {
time: number // Seconds for flow animation
}
scale?: number // Canvas scale for quality adjustments
highlightedIds?: Set<string>
}
export interface DragLinkData {
/** Fixed end - the slot being dragged from */
fixedPoint: Point
fixedDirection: Direction
/** Moving end - follows mouse */
dragPoint: Point
dragDirection?: Direction
/** Visual properties */
color?: string
type?: string
disabled?: boolean
/** Whether dragging from input (reverse direction) */
fromInput?: boolean
}
export class CanvasPathRenderer {
/**
* Draw a link between two points
* Returns a Path2D object for hit detection
*/
drawLink(
ctx: CanvasRenderingContext2D,
link: LinkRenderData,
context: RenderContext
): Path2D {
const path = new Path2D()
// Determine final color
const isHighlighted = context.highlightedIds?.has(link.id) ?? false
const color = this.determineLinkColor(link, context, isHighlighted)
// Save context state
ctx.save()
// Apply disabled pattern if needed
if (link.disabled && context.patterns?.disabled) {
ctx.strokeStyle = context.patterns.disabled
} else {
ctx.strokeStyle = color
}
// Set line properties
ctx.lineWidth = context.style.connectionWidth
ctx.lineJoin = 'round'
// Draw border if needed
if (context.style.borderWidth && !context.style.lowQuality) {
this.drawLinkPath(
ctx,
path,
link,
context,
context.style.connectionWidth + context.style.borderWidth,
'rgba(0,0,0,0.5)'
)
}
// Draw main link
this.drawLinkPath(
ctx,
path,
link,
context,
context.style.connectionWidth,
color
)
// Calculate and store center position
this.calculateCenterPoint(link, context)
// Draw arrows if needed
if (context.style.showArrows) {
this.drawArrows(ctx, link, context, color)
}
// Draw center marker if needed (for link menu interaction)
if (
context.style.showCenterMarker &&
context.scale &&
context.scale >= 0.6 &&
context.style.highQuality
) {
this.drawCenterMarker(ctx, link, context, color)
}
// Draw flow animation if needed
if (link.flow && context.animation) {
this.drawFlowAnimation(ctx, path, link, context)
}
ctx.restore()
return path
}
private determineLinkColor(
link: LinkRenderData,
context: RenderContext,
isHighlighted: boolean
): string {
if (isHighlighted) {
return context.colors.highlighted
}
if (link.color) {
return link.color
}
if (link.type && context.colors.byType[link.type]) {
return context.colors.byType[link.type]
}
return context.colors.default
}
private drawLinkPath(
ctx: CanvasRenderingContext2D,
path: Path2D,
link: LinkRenderData,
context: RenderContext,
lineWidth: number,
color: string
): void {
ctx.strokeStyle = color
ctx.lineWidth = lineWidth
const start = link.startPoint
const end = link.endPoint
// Build the path based on render mode
if (context.style.mode === 'linear') {
this.buildLinearPath(
path,
start,
end,
link.startDirection,
link.endDirection
)
} else if (context.style.mode === 'straight') {
this.buildStraightPath(
path,
start,
end,
link.startDirection,
link.endDirection
)
} else {
// Spline mode (default)
this.buildSplinePath(
path,
start,
end,
link.startDirection,
link.endDirection,
link.controlPoints
)
}
ctx.stroke(path)
}
private buildLinearPath(
path: Path2D,
start: Point,
end: Point,
startDir: Direction,
endDir: Direction
): void {
// Match original litegraph LINEAR_LINK mode with 4-point path
const l = 15 // offset distance for control points
const innerA = { x: start.x, y: start.y }
const innerB = { x: end.x, y: end.y }
// Apply directional offsets to create control points
switch (startDir) {
case 'left':
innerA.x -= l
break
case 'right':
innerA.x += l
break
case 'up':
innerA.y -= l
break
case 'down':
innerA.y += l
break
}
switch (endDir) {
case 'left':
innerB.x -= l
break
case 'right':
innerB.x += l
break
case 'up':
innerB.y -= l
break
case 'down':
innerB.y += l
break
}
// Draw 4-point path: start -> innerA -> innerB -> end
path.moveTo(start.x, start.y)
path.lineTo(innerA.x, innerA.y)
path.lineTo(innerB.x, innerB.y)
path.lineTo(end.x, end.y)
}
private buildStraightPath(
path: Path2D,
start: Point,
end: Point,
startDir: Direction,
endDir: Direction
): void {
// Match original STRAIGHT_LINK implementation with l=10 offset
const l = 10 // offset distance matching original
const innerA = { x: start.x, y: start.y }
const innerB = { x: end.x, y: end.y }
// Apply directional offsets to match original behavior
switch (startDir) {
case 'left':
innerA.x -= l
break
case 'right':
innerA.x += l
break
case 'up':
innerA.y -= l
break
case 'down':
innerA.y += l
break
}
switch (endDir) {
case 'left':
innerB.x -= l
break
case 'right':
innerB.x += l
break
case 'up':
innerB.y -= l
break
case 'down':
innerB.y += l
break
}
// Calculate midpoint using innerA/innerB positions (matching original)
const midX = (innerA.x + innerB.x) * 0.5
// Build path: start -> innerA -> (midX, innerA.y) -> (midX, innerB.y) -> innerB -> end
path.moveTo(start.x, start.y)
path.lineTo(innerA.x, innerA.y)
path.lineTo(midX, innerA.y)
path.lineTo(midX, innerB.y)
path.lineTo(innerB.x, innerB.y)
path.lineTo(end.x, end.y)
}
private buildSplinePath(
path: Path2D,
start: Point,
end: Point,
startDir: Direction,
endDir: Direction,
controlPoints?: Point[]
): void {
path.moveTo(start.x, start.y)
// Calculate control points if not provided
const controls =
controlPoints || this.calculateControlPoints(start, end, startDir, endDir)
if (controls.length >= 2) {
// Cubic bezier
path.bezierCurveTo(
controls[0].x,
controls[0].y,
controls[1].x,
controls[1].y,
end.x,
end.y
)
} else if (controls.length === 1) {
// Quadratic bezier
path.quadraticCurveTo(controls[0].x, controls[0].y, end.x, end.y)
} else {
// Fallback to linear
path.lineTo(end.x, end.y)
}
}
private calculateControlPoints(
start: Point,
end: Point,
startDir: Direction,
endDir: Direction
): Point[] {
const dist = Math.sqrt(
Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2)
)
const controlDist = Math.max(30, dist * 0.25)
// Calculate control point offsets based on direction
const startControl = this.getDirectionOffset(startDir, controlDist)
const endControl = this.getDirectionOffset(endDir, controlDist)
return [
{ x: start.x + startControl.x, y: start.y + startControl.y },
{ x: end.x + endControl.x, y: end.y + endControl.y }
]
}
private getDirectionOffset(direction: Direction, distance: number): Point {
switch (direction) {
case 'left':
return { x: -distance, y: 0 }
case 'right':
return { x: distance, y: 0 }
case 'up':
return { x: 0, y: -distance }
case 'down':
return { x: 0, y: distance }
}
}
private drawArrows(
ctx: CanvasRenderingContext2D,
link: LinkRenderData,
context: RenderContext,
color: string
): void {
if (!context.style.showArrows) return
// Render arrows at 0.25 and 0.75 positions along the path (matching original)
const positions = [0.25, 0.75]
for (const t of positions) {
// Compute arrow position and angle
const posA = this.computeConnectionPoint(link, t, context)
const posB = this.computeConnectionPoint(link, t + 0.01, context) // slightly ahead for angle
const angle = Math.atan2(posB.y - posA.y, posB.x - posA.x)
// Draw arrow triangle (matching original shape)
const transform = ctx.getTransform()
ctx.translate(posA.x, posA.y)
ctx.rotate(angle)
ctx.fillStyle = color
ctx.beginPath()
ctx.moveTo(-5, -3)
ctx.lineTo(0, +7)
ctx.lineTo(+5, -3)
ctx.fill()
ctx.setTransform(transform)
}
}
/**
* Compute a point along the link path at position t (0 to 1)
* For backward compatibility with original litegraph, this always uses
* bezier calculation with spline offsets, regardless of render mode.
* This ensures arrow positions match the original implementation.
*/
private computeConnectionPoint(
link: LinkRenderData,
t: number,
_context: RenderContext
): Point {
const { startPoint, endPoint, startDirection, endDirection } = link
// Match original behavior: always use bezier math with spline offsets
// regardless of render mode (for arrow position compatibility)
const dist = Math.sqrt(
Math.pow(endPoint.x - startPoint.x, 2) +
Math.pow(endPoint.y - startPoint.y, 2)
)
const factor = 0.25
// Create control points with spline offsets (matching original #addSplineOffset)
const pa = { x: startPoint.x, y: startPoint.y }
const pb = { x: endPoint.x, y: endPoint.y }
// Apply spline offsets based on direction
switch (startDirection) {
case 'left':
pa.x -= dist * factor
break
case 'right':
pa.x += dist * factor
break
case 'up':
pa.y -= dist * factor
break
case 'down':
pa.y += dist * factor
break
}
switch (endDirection) {
case 'left':
pb.x -= dist * factor
break
case 'right':
pb.x += dist * factor
break
case 'up':
pb.y -= dist * factor
break
case 'down':
pb.y += dist * factor
break
}
// Calculate bezier point (matching original computeConnectionPoint)
const c1 = (1 - t) * (1 - t) * (1 - t)
const c2 = 3 * ((1 - t) * (1 - t)) * t
const c3 = 3 * (1 - t) * (t * t)
const c4 = t * t * t
return {
x: c1 * startPoint.x + c2 * pa.x + c3 * pb.x + c4 * endPoint.x,
y: c1 * startPoint.y + c2 * pa.y + c3 * pb.y + c4 * endPoint.y
}
}
private drawFlowAnimation(
ctx: CanvasRenderingContext2D,
_path: Path2D,
link: LinkRenderData,
context: RenderContext
): void {
if (!context.animation) return
// Match original implementation: render 5 moving circles along the path
const time = context.animation.time
const linkColor = this.determineLinkColor(link, context, false)
ctx.save()
ctx.fillStyle = linkColor
// Draw 5 circles at different positions along the path
for (let i = 0; i < 5; ++i) {
// Calculate position along path (0 to 1), with time-based animation
const f = (time + i * 0.2) % 1
const flowPos = this.computeConnectionPoint(link, f, context)
// Draw circle at this position
ctx.beginPath()
ctx.arc(flowPos.x, flowPos.y, 5, 0, 2 * Math.PI)
ctx.fill()
}
ctx.restore()
}
/**
* Utility to find a point on a bezier curve (for hit detection)
*/
findPointOnBezier(
t: number,
p0: Point,
p1: Point,
p2: Point,
p3: Point
): Point {
const mt = 1 - t
const mt2 = mt * mt
const mt3 = mt2 * mt
const t2 = t * t
const t3 = t2 * t
return {
x: mt3 * p0.x + 3 * mt2 * t * p1.x + 3 * mt * t2 * p2.x + t3 * p3.x,
y: mt3 * p0.y + 3 * mt2 * t * p1.y + 3 * mt * t2 * p2.y + t3 * p3.y
}
}
/**
* Draw a link being dragged from a slot to the mouse position
* Returns a Path2D object for potential hit detection
*/
drawDraggingLink(
ctx: CanvasRenderingContext2D,
dragData: DragLinkData,
context: RenderContext
): Path2D {
// Create LinkRenderData from drag data
// When dragging from input, swap the points/directions
const linkData: LinkRenderData = dragData.fromInput
? {
id: 'dragging',
startPoint: dragData.dragPoint,
endPoint: dragData.fixedPoint,
startDirection:
dragData.dragDirection ||
this.getOppositeDirection(dragData.fixedDirection),
endDirection: dragData.fixedDirection,
color: dragData.color,
type: dragData.type,
disabled: dragData.disabled
}
: {
id: 'dragging',
startPoint: dragData.fixedPoint,
endPoint: dragData.dragPoint,
startDirection: dragData.fixedDirection,
endDirection:
dragData.dragDirection ||
this.getOppositeDirection(dragData.fixedDirection),
color: dragData.color,
type: dragData.type,
disabled: dragData.disabled
}
// Use standard link drawing
return this.drawLink(ctx, linkData, context)
}
/**
* Get the opposite direction (for drag preview)
*/
private getOppositeDirection(direction: Direction): Direction {
switch (direction) {
case 'left':
return 'right'
case 'right':
return 'left'
case 'up':
return 'down'
case 'down':
return 'up'
}
}
/**
* Get the center point of a link (useful for labels, debugging)
*/
getLinkCenter(link: LinkRenderData): Point {
// For now, simple midpoint
// Could be enhanced to find actual curve midpoint
return {
x: (link.startPoint.x + link.endPoint.x) / 2,
y: (link.startPoint.y + link.endPoint.y) / 2
}
}
/**
* Calculate and store the center point and angle of a link
* Mimics the original litegraph center point calculation
*/
private calculateCenterPoint(
link: LinkRenderData,
context: RenderContext
): void {
const { startPoint, endPoint, controlPoints } = link
if (
context.style.mode === 'spline' &&
controlPoints &&
controlPoints.length >= 2
) {
// For spline mode, find point at t=0.5 on the bezier curve
const centerPos = this.findPointOnBezier(
0.5,
startPoint,
controlPoints[0],
controlPoints[1],
endPoint
)
link.centerPos = centerPos
// Calculate angle for arrow marker (point slightly past center)
if (context.style.centerMarkerShape === 'arrow') {
const justPastCenter = this.findPointOnBezier(
0.51,
startPoint,
controlPoints[0],
controlPoints[1],
endPoint
)
link.centerAngle = Math.atan2(
justPastCenter.y - centerPos.y,
justPastCenter.x - centerPos.x
)
}
} else if (context.style.mode === 'linear') {
// For linear mode, calculate midpoint between control points (matching original)
const l = 15 // Same offset as buildLinearPath
const innerA = { x: startPoint.x, y: startPoint.y }
const innerB = { x: endPoint.x, y: endPoint.y }
// Apply same directional offsets as buildLinearPath
switch (link.startDirection) {
case 'left':
innerA.x -= l
break
case 'right':
innerA.x += l
break
case 'up':
innerA.y -= l
break
case 'down':
innerA.y += l
break
}
switch (link.endDirection) {
case 'left':
innerB.x -= l
break
case 'right':
innerB.x += l
break
case 'up':
innerB.y -= l
break
case 'down':
innerB.y += l
break
}
link.centerPos = {
x: (innerA.x + innerB.x) * 0.5,
y: (innerA.y + innerB.y) * 0.5
}
if (context.style.centerMarkerShape === 'arrow') {
link.centerAngle = Math.atan2(innerB.y - innerA.y, innerB.x - innerA.x)
}
} else if (context.style.mode === 'straight') {
// For straight mode, match original STRAIGHT_LINK center calculation
const l = 10 // Same offset as buildStraightPath
const innerA = { x: startPoint.x, y: startPoint.y }
const innerB = { x: endPoint.x, y: endPoint.y }
// Apply same directional offsets as buildStraightPath
switch (link.startDirection) {
case 'left':
innerA.x -= l
break
case 'right':
innerA.x += l
break
case 'up':
innerA.y -= l
break
case 'down':
innerA.y += l
break
}
switch (link.endDirection) {
case 'left':
innerB.x -= l
break
case 'right':
innerB.x += l
break
case 'up':
innerB.y -= l
break
case 'down':
innerB.y += l
break
}
// Calculate center using midX and average of innerA/innerB y positions
const midX = (innerA.x + innerB.x) * 0.5
link.centerPos = {
x: midX,
y: (innerA.y + innerB.y) * 0.5
}
if (context.style.centerMarkerShape === 'arrow') {
const diff = innerB.y - innerA.y
if (Math.abs(diff) < 4) {
link.centerAngle = 0
} else if (diff > 0) {
link.centerAngle = Math.PI * 0.5
} else {
link.centerAngle = -(Math.PI * 0.5)
}
}
} else {
// Fallback to simple midpoint
link.centerPos = this.getLinkCenter(link)
if (context.style.centerMarkerShape === 'arrow') {
link.centerAngle = Math.atan2(
endPoint.y - startPoint.y,
endPoint.x - startPoint.x
)
}
}
}
/**
* Draw the center marker on a link (for menu interaction)
* Matches the original litegraph center marker rendering
*/
private drawCenterMarker(
ctx: CanvasRenderingContext2D,
link: LinkRenderData,
context: RenderContext,
color: string
): void {
if (!link.centerPos) return
ctx.beginPath()
if (
context.style.centerMarkerShape === 'arrow' &&
link.centerAngle !== undefined
) {
const transform = ctx.getTransform()
ctx.translate(link.centerPos.x, link.centerPos.y)
ctx.rotate(link.centerAngle)
// The math is off, but it currently looks better in chromium (from original)
ctx.moveTo(-3.2, -5)
ctx.lineTo(7, 0)
ctx.lineTo(-3.2, 5)
ctx.setTransform(transform)
} else {
// Default to circle
ctx.arc(link.centerPos.x, link.centerPos.y, 5, 0, Math.PI * 2)
}
// Apply disabled pattern or color
if (link.disabled && context.patterns?.disabled) {
const { fillStyle, globalAlpha } = ctx
ctx.fillStyle = context.patterns.disabled
ctx.globalAlpha = 0.75
ctx.fill()
ctx.globalAlpha = globalAlpha
ctx.fillStyle = fillStyle
} else {
ctx.fillStyle = color
ctx.fill()
}
}
}

View File

@@ -0,0 +1,525 @@
/**
* Litegraph Link Adapter
*
* Bridges the gap between litegraph's data model and the pure canvas renderer.
* Converts litegraph-specific types (LLink, LGraphNode, slots) into generic
* rendering data that can be consumed by the PathRenderer.
* Maintains backward compatibility with existing litegraph integration.
*/
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import type { Reroute } from '@/lib/litegraph/src/Reroute'
import type {
CanvasColour,
INodeInputSlot,
INodeOutputSlot,
ReadOnlyPoint
} from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import {
LinkDirection,
LinkMarkerShape,
LinkRenderType
} from '@/lib/litegraph/src/types/globalEnums'
import {
type ArrowShape,
CanvasPathRenderer,
type Direction,
type DragLinkData,
type LinkRenderData,
type RenderContext as PathRenderContext,
type Point,
type RenderMode
} from '@/renderer/core/canvas/PathRenderer'
import {
type SlotPositionContext,
calculateInputSlotPos,
calculateOutputSlotPos
} from '@/renderer/core/canvas/litegraph/SlotCalculations'
import { layoutStore } from '@/renderer/core/layout/store/LayoutStore'
export interface LinkRenderContext {
// Canvas settings
renderMode: LinkRenderType
connectionWidth: number
renderBorder: boolean
lowQuality: boolean
highQualityRender: boolean
scale: number
linkMarkerShape: LinkMarkerShape
renderConnectionArrows: boolean
// State
highlightedLinks: Set<string | number>
// Colors
defaultLinkColor: CanvasColour
linkTypeColors: Record<string, CanvasColour>
// Pattern for disabled links (optional)
disabledPattern?: CanvasPattern | null
}
export interface LinkRenderOptions {
color?: CanvasColour
flow?: boolean
skipBorder?: boolean
disabled?: boolean
}
export class LitegraphLinkAdapter {
private graph: LGraph
private pathRenderer: CanvasPathRenderer
constructor(graph: LGraph) {
this.graph = graph
this.pathRenderer = new CanvasPathRenderer()
}
/**
* Render a single link with all necessary data properly fetched
* Populates link.path for hit detection
*/
renderLink(
ctx: CanvasRenderingContext2D,
link: LLink,
context: LinkRenderContext,
options: LinkRenderOptions = {}
): void {
// Get nodes from graph
const sourceNode = this.graph.getNodeById(link.origin_id)
const targetNode = this.graph.getNodeById(link.target_id)
if (!sourceNode || !targetNode) {
console.warn(`Cannot render link ${link.id}: missing nodes`)
return
}
// Get slots from nodes
const sourceSlot = sourceNode.outputs?.[link.origin_slot]
const targetSlot = targetNode.inputs?.[link.target_slot]
if (!sourceSlot || !targetSlot) {
console.warn(`Cannot render link ${link.id}: missing slots`)
return
}
// Get positions using layout tree data if available
const startPos = this.getSlotPosition(
sourceNode,
link.origin_slot,
false // output
)
const endPos = this.getSlotPosition(
targetNode,
link.target_slot,
true // input
)
// Get directions from slots
const startDir = sourceSlot.dir || LinkDirection.RIGHT
const endDir = targetSlot.dir || LinkDirection.LEFT
// Convert to pure render data
const linkData = this.convertToLinkRenderData(
link,
{ x: startPos[0], y: startPos[1] },
{ x: endPos[0], y: endPos[1] },
startDir,
endDir,
options
)
// Convert context
const pathContext = this.convertToPathRenderContext(context)
// Render using pure renderer
const path = this.pathRenderer.drawLink(ctx, linkData, pathContext)
// Store path for hit detection
link.path = path
}
/**
* Convert litegraph link data to pure render format
*/
private convertToLinkRenderData(
link: LLink,
startPoint: Point,
endPoint: Point,
startDir: LinkDirection,
endDir: LinkDirection,
options: LinkRenderOptions
): LinkRenderData {
return {
id: String(link.id),
startPoint,
endPoint,
startDirection: this.convertDirection(startDir),
endDirection: this.convertDirection(endDir),
color: options.color
? String(options.color)
: link.color
? String(link.color)
: undefined,
type: link.type !== undefined ? String(link.type) : undefined,
flow: options.flow || false,
disabled: options.disabled || false
}
}
/**
* Convert LinkDirection enum to Direction string
*/
private convertDirection(dir: LinkDirection): Direction {
switch (dir) {
case LinkDirection.LEFT:
return 'left'
case LinkDirection.RIGHT:
return 'right'
case LinkDirection.UP:
return 'up'
case LinkDirection.DOWN:
return 'down'
default:
return 'right'
}
}
/**
* Convert LinkRenderContext to PathRenderContext
*/
private convertToPathRenderContext(
context: LinkRenderContext
): PathRenderContext {
// Match original arrow rendering conditions:
// Arrows only render when scale >= 0.6 AND highquality_render AND render_connection_arrows
const shouldShowArrows =
context.scale >= 0.6 &&
context.highQualityRender &&
context.renderConnectionArrows
// Only show center marker when not set to None
const shouldShowCenterMarker =
context.linkMarkerShape !== LinkMarkerShape.None
return {
style: {
mode: this.convertRenderMode(context.renderMode),
connectionWidth: context.connectionWidth,
borderWidth: context.renderBorder ? 4 : undefined,
arrowShape: this.convertArrowShape(context.linkMarkerShape),
showArrows: shouldShowArrows,
lowQuality: context.lowQuality,
// Center marker settings (matches original litegraph behavior)
showCenterMarker: shouldShowCenterMarker,
centerMarkerShape:
context.linkMarkerShape === LinkMarkerShape.Arrow
? 'arrow'
: 'circle',
highQuality: context.highQualityRender
},
colors: {
default: String(context.defaultLinkColor),
byType: this.convertColorMap(context.linkTypeColors),
highlighted: '#FFF'
},
patterns: {
disabled: context.disabledPattern
},
animation: {
time: LiteGraph.getTime() * 0.001
},
scale: context.scale,
highlightedIds: new Set(Array.from(context.highlightedLinks).map(String))
}
}
/**
* Convert LinkRenderType to RenderMode
*/
private convertRenderMode(mode: LinkRenderType): RenderMode {
switch (mode) {
case LinkRenderType.LINEAR_LINK:
return 'linear'
case LinkRenderType.STRAIGHT_LINK:
return 'straight'
case LinkRenderType.SPLINE_LINK:
default:
return 'spline'
}
}
/**
* Convert LinkMarkerShape to ArrowShape
*/
private convertArrowShape(shape: LinkMarkerShape): ArrowShape {
switch (shape) {
case LinkMarkerShape.Circle:
return 'circle'
case LinkMarkerShape.Arrow:
default:
return 'triangle'
}
}
/**
* Convert color map to ensure all values are strings
*/
private convertColorMap(
colors: Record<string, CanvasColour>
): Record<string, string> {
const result: Record<string, string> = {}
for (const [key, value] of Object.entries(colors)) {
result[key] = String(value)
}
return result
}
/**
* Apply spline offset to a point, mimicking original #addSplineOffset behavior
* Critically: does nothing for CENTER/NONE directions (no case for them)
*/
private applySplineOffset(
point: Point,
direction: LinkDirection,
distance: number
): void {
switch (direction) {
case LinkDirection.LEFT:
point.x -= distance
break
case LinkDirection.RIGHT:
point.x += distance
break
case LinkDirection.UP:
point.y -= distance
break
case LinkDirection.DOWN:
point.y += distance
break
// CENTER and NONE: no offset applied (original behavior)
}
}
/**
* Direct rendering method compatible with LGraphCanvas
* Converts data and delegates to pure renderer
*/
renderLinkDirect(
ctx: CanvasRenderingContext2D,
a: ReadOnlyPoint,
b: ReadOnlyPoint,
link: LLink | null,
skip_border: boolean,
flow: number | boolean | null,
color: CanvasColour | null,
start_dir: LinkDirection,
end_dir: LinkDirection,
context: LinkRenderContext,
extras: {
reroute?: Reroute
startControl?: ReadOnlyPoint
endControl?: ReadOnlyPoint
num_sublines?: number
disabled?: boolean
} = {}
): void {
// Apply same defaults as original renderLink
const startDir = start_dir || LinkDirection.RIGHT
const endDir = end_dir || LinkDirection.LEFT
// Convert flow to boolean
const flowBool = flow === true || (typeof flow === 'number' && flow > 0)
// Create LinkRenderData from direct parameters
const linkData: LinkRenderData = {
id: link ? String(link.id) : 'temp',
startPoint: { x: a[0], y: a[1] },
endPoint: { x: b[0], y: b[1] },
startDirection: this.convertDirection(startDir),
endDirection: this.convertDirection(endDir),
color: color !== null && color !== undefined ? String(color) : undefined,
type: link?.type !== undefined ? String(link.type) : undefined,
flow: flowBool,
disabled: extras.disabled || false
}
// Control points handling (spline mode):
// - Pre-refactor, the old renderLink honored a single provided control and
// derived the missing side via #addSplineOffset (CENTER => no offset).
// - Restore that behavior here so reroute segments render identically.
if (context.renderMode === LinkRenderType.SPLINE_LINK) {
const hasStartCtrl = !!extras.startControl
const hasEndCtrl = !!extras.endControl
// Compute distance once for offsets
const dist = Math.sqrt(
(b[0] - a[0]) * (b[0] - a[0]) + (b[1] - a[1]) * (b[1] - a[1])
)
const factor = 0.25
const cps: Point[] = []
if (hasStartCtrl && hasEndCtrl) {
// Both provided explicitly
cps.push(
{
x: a[0] + (extras.startControl![0] || 0),
y: a[1] + (extras.startControl![1] || 0)
},
{
x: b[0] + (extras.endControl![0] || 0),
y: b[1] + (extras.endControl![1] || 0)
}
)
linkData.controlPoints = cps
} else if (hasStartCtrl && !hasEndCtrl) {
// Start provided, derive end via direction offset (CENTER => no offset)
const start = {
x: a[0] + (extras.startControl![0] || 0),
y: a[1] + (extras.startControl![1] || 0)
}
const end = { x: b[0], y: b[1] }
this.applySplineOffset(end, endDir, dist * factor)
cps.push(start, end)
linkData.controlPoints = cps
} else if (!hasStartCtrl && hasEndCtrl) {
// End provided, derive start via direction offset (CENTER => no offset)
const start = { x: a[0], y: a[1] }
this.applySplineOffset(start, startDir, dist * factor)
const end = {
x: b[0] + (extras.endControl![0] || 0),
y: b[1] + (extras.endControl![1] || 0)
}
cps.push(start, end)
linkData.controlPoints = cps
} else {
// Neither provided: derive both from directions (CENTER => no offset)
const start = { x: a[0], y: a[1] }
const end = { x: b[0], y: b[1] }
this.applySplineOffset(start, startDir, dist * factor)
this.applySplineOffset(end, endDir, dist * factor)
cps.push(start, end)
linkData.controlPoints = cps
}
}
// Convert context
const pathContext = this.convertToPathRenderContext(context)
// Override skip_border if needed
if (skip_border) {
pathContext.style.borderWidth = undefined
}
// Render using pure renderer
const path = this.pathRenderer.drawLink(ctx, linkData, pathContext)
// Store path for hit detection
const linkSegment = extras.reroute ?? link
if (linkSegment) {
linkSegment.path = path
// Copy calculated center position back to litegraph object
// This is needed for hit detection and menu interaction
if (linkData.centerPos) {
linkSegment._pos = linkSegment._pos || new Float32Array(2)
linkSegment._pos[0] = linkData.centerPos.x
linkSegment._pos[1] = linkData.centerPos.y
// Store center angle if calculated (for arrow markers)
if (linkData.centerAngle !== undefined) {
linkSegment._centreAngle = linkData.centerAngle
}
}
}
}
/**
* Get slot position using layout tree if available, fallback to node's position
*/
private getSlotPosition(
node: LGraphNode,
slotIndex: number,
isInput: boolean
): ReadOnlyPoint {
// Try to get position from layout tree
const nodeLayout = layoutStore.getNodeLayoutRef(String(node.id)).value
if (nodeLayout) {
// Create context from layout tree data
const context: SlotPositionContext = {
nodeX: nodeLayout.position.x,
nodeY: nodeLayout.position.y,
nodeWidth: nodeLayout.size.width,
nodeHeight: nodeLayout.size.height,
collapsed: node.flags.collapsed || false,
collapsedWidth: node._collapsed_width,
slotStartY: node.constructor.slot_start_y,
inputs: node.inputs,
outputs: node.outputs,
widgets: node.widgets
}
// Use helper to calculate position
return isInput
? calculateInputSlotPos(context, slotIndex)
: calculateOutputSlotPos(context, slotIndex)
}
// Fallback to node's own methods if layout not available
return isInput ? node.getInputPos(slotIndex) : node.getOutputPos(slotIndex)
}
/**
* Render a link being dragged from a slot to mouse position
* Used during link creation/reconnection
*/
renderDraggingLink(
ctx: CanvasRenderingContext2D,
fromNode: LGraphNode | null,
fromSlot: INodeOutputSlot | INodeInputSlot,
fromSlotIndex: number,
toPosition: ReadOnlyPoint,
context: LinkRenderContext,
options: {
fromInput?: boolean
color?: CanvasColour
disabled?: boolean
} = {}
): void {
if (!fromNode) return
// Get slot position using layout tree if available
const slotPos = this.getSlotPosition(
fromNode,
fromSlotIndex,
options.fromInput || false
)
if (!slotPos) return
// Get slot direction
const slotDir =
fromSlot.dir ||
(options.fromInput ? LinkDirection.LEFT : LinkDirection.RIGHT)
// Create drag data
const dragData: DragLinkData = {
fixedPoint: { x: slotPos[0], y: slotPos[1] },
fixedDirection: this.convertDirection(slotDir),
dragPoint: { x: toPosition[0], y: toPosition[1] },
color: options.color ? String(options.color) : undefined,
type: fromSlot.type !== undefined ? String(fromSlot.type) : undefined,
disabled: options.disabled || false,
fromInput: options.fromInput || false
}
// Convert context
const pathContext = this.convertToPathRenderContext(context)
// Render using pure renderer
this.pathRenderer.drawDraggingLink(ctx, dragData, pathContext)
}
}

View File

@@ -0,0 +1,231 @@
/**
* Slot Position Calculations
*
* Centralized utility for calculating input/output slot positions on nodes.
* This allows both litegraph nodes and the layout system to use the same
* calculation logic while providing their own position data.
*/
import type {
INodeInputSlot,
INodeOutputSlot,
INodeSlot,
Point
} from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { isWidgetInputSlot } from '@/lib/litegraph/src/node/slotUtils'
export interface SlotPositionContext {
/** Node's X position in graph coordinates */
nodeX: number
/** Node's Y position in graph coordinates */
nodeY: number
/** Node's width */
nodeWidth: number
/** Node's height */
nodeHeight: number
/** Whether the node is collapsed */
collapsed: boolean
/** Collapsed width (if applicable) */
collapsedWidth?: number
/** Node constructor's slot_start_y offset */
slotStartY?: number
/** Node's input slots */
inputs: INodeInputSlot[]
/** Node's output slots */
outputs: INodeOutputSlot[]
/** Node's widgets (for widget slot detection) */
widgets?: Array<{ name?: string }>
}
/**
* Calculate the position of an input slot in graph coordinates
* @param context Node context containing position and slot data
* @param slot The input slot index
* @returns Position of the input slot center in graph coordinates
*/
export function calculateInputSlotPos(
context: SlotPositionContext,
slot: number
): Point {
const input = context.inputs[slot]
if (!input) return [context.nodeX, context.nodeY]
return calculateInputSlotPosFromSlot(context, input)
}
/**
* Calculate the position of an input slot in graph coordinates
* @param context Node context containing position and slot data
* @param input The input slot object
* @returns Position of the input slot center in graph coordinates
*/
export function calculateInputSlotPosFromSlot(
context: SlotPositionContext,
input: INodeInputSlot
): Point {
const { nodeX, nodeY, collapsed } = context
// Handle collapsed nodes
if (collapsed) {
const halfTitle = LiteGraph.NODE_TITLE_HEIGHT * 0.5
return [nodeX, nodeY - halfTitle]
}
// Handle hard-coded positions
const { pos } = input
if (pos) return [nodeX + pos[0], nodeY + pos[1]]
// Check if we should use Vue positioning
if (LiteGraph.vueNodesMode) {
if (isWidgetInputSlot(input)) {
// Widget slot - pass the slot object
return calculateVueSlotPosition(context, true, input, -1)
} else {
// Regular slot - find its index in default vertical inputs
const defaultVerticalInputs = getDefaultVerticalInputs(context)
const slotIndex = defaultVerticalInputs.indexOf(input)
if (slotIndex !== -1) {
return calculateVueSlotPosition(context, true, input, slotIndex)
}
}
}
// Default vertical slots
const offsetX = LiteGraph.NODE_SLOT_HEIGHT * 0.5
const nodeOffsetY = context.slotStartY || 0
const defaultVerticalInputs = getDefaultVerticalInputs(context)
const slotIndex = defaultVerticalInputs.indexOf(input)
const slotY = (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT
return [nodeX + offsetX, nodeY + slotY + nodeOffsetY]
}
/**
* Calculate the position of an output slot in graph coordinates
* @param context Node context containing position and slot data
* @param slot The output slot index
* @returns Position of the output slot center in graph coordinates
*/
export function calculateOutputSlotPos(
context: SlotPositionContext,
slot: number
): Point {
const { nodeX, nodeY, nodeWidth, collapsed, collapsedWidth, outputs } =
context
// Handle collapsed nodes
if (collapsed) {
const width = collapsedWidth || LiteGraph.NODE_COLLAPSED_WIDTH
const halfTitle = LiteGraph.NODE_TITLE_HEIGHT * 0.5
return [nodeX + width, nodeY - halfTitle]
}
const outputSlot = outputs[slot]
if (!outputSlot) return [nodeX + nodeWidth, nodeY]
// Handle hard-coded positions
const outputPos = outputSlot.pos
if (outputPos) return [nodeX + outputPos[0], nodeY + outputPos[1]]
// Check if we should use Vue positioning
if (LiteGraph.vueNodesMode) {
const defaultVerticalOutputs = getDefaultVerticalOutputs(context)
const slotIndex = defaultVerticalOutputs.indexOf(outputSlot)
if (slotIndex !== -1) {
return calculateVueSlotPosition(context, false, outputSlot, slotIndex)
}
}
// Default vertical slots
const offsetX = LiteGraph.NODE_SLOT_HEIGHT * 0.5
const nodeOffsetY = context.slotStartY || 0
const defaultVerticalOutputs = getDefaultVerticalOutputs(context)
const slotIndex = defaultVerticalOutputs.indexOf(outputSlot)
const slotY = (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT
// TODO: Why +1?
return [nodeX + nodeWidth + 1 - offsetX, nodeY + slotY + nodeOffsetY]
}
/**
* Get the inputs that are not positioned with absolute coordinates
*/
function getDefaultVerticalInputs(
context: SlotPositionContext
): INodeInputSlot[] {
return context.inputs.filter(
(slot) => !slot.pos && !(context.widgets?.length && isWidgetInputSlot(slot))
)
}
/**
* Get the outputs that are not positioned with absolute coordinates
*/
function getDefaultVerticalOutputs(
context: SlotPositionContext
): INodeOutputSlot[] {
return context.outputs.filter((slot) => !slot.pos)
}
/**
* Calculate slot position using Vue node dimensions.
* This method uses the COMFY_VUE_NODE_DIMENSIONS constants to match Vue component rendering.
* @param context Node context
* @param isInput Whether this is an input slot (true) or output slot (false)
* @param slot The slot object (for widget detection)
* @param slotIndex The index of the slot in the appropriate array
* @returns The [x, y] position of the slot center in graph coordinates
*/
function calculateVueSlotPosition(
context: SlotPositionContext,
isInput: boolean,
slot: INodeSlot,
slotIndex: number
): Point {
const { nodeX, nodeY, nodeWidth, widgets } = context
const dimensions = LiteGraph.COMFY_VUE_NODE_DIMENSIONS.components
const spacing = LiteGraph.COMFY_VUE_NODE_DIMENSIONS.spacing
let slotCenterY: number
// IMPORTANT: LiteGraph's node position (nodeY) is at the TOP of the body (below the header)
// The header is rendered ABOVE this position at negative Y coordinates
// So we need to adjust for the difference between LiteGraph's header (30px) and Vue's header (34px)
const headerDifference =
dimensions.HEADER_HEIGHT - LiteGraph.NODE_TITLE_HEIGHT
if (isInput && isWidgetInputSlot(slot as INodeInputSlot)) {
// Widget input slot - calculate based on widget position
// Count regular (non-widget) input slots
const regularInputCount = getDefaultVerticalInputs(context).length
// Find widget index
const widgetIndex =
widgets?.findIndex(
(w) => w.name === (slot as INodeInputSlot).widget?.name
) ?? 0
// Y position relative to the node body top (not the header)
slotCenterY =
headerDifference +
regularInputCount * dimensions.SLOT_HEIGHT +
(regularInputCount > 0 ? spacing.BETWEEN_SLOTS_AND_BODY : 0) +
widgetIndex *
(dimensions.STANDARD_WIDGET_HEIGHT + spacing.BETWEEN_WIDGETS) +
dimensions.STANDARD_WIDGET_HEIGHT / 2
} else {
// Regular slot (input or output)
// Slots start at the top of the body, but we need to account for Vue's larger header
slotCenterY =
headerDifference +
slotIndex * dimensions.SLOT_HEIGHT +
dimensions.SLOT_HEIGHT / 2
}
// Calculate X position
// Input slots: 10px from left edge (center of 20x20 connector)
// Output slots: 10px from right edge (center of 20x20 connector)
const slotCenterX = isInput ? 10 : nodeWidth - 10
return [nodeX + slotCenterX, nodeY + slotCenterY]
}

View File

@@ -0,0 +1,137 @@
/**
* Mock Layout Adapter
*
* Simple in-memory implementation for testing without CRDT overhead.
*/
import type { LayoutOperation } from '@/renderer/core/layout/types'
import type { NodeId, NodeLayout } from '@/renderer/core/layout/types'
import type { AdapterChange, LayoutAdapter } from './layoutAdapter'
/**
* Mock implementation for testing
*/
export class MockLayoutAdapter implements LayoutAdapter {
private nodes = new Map<NodeId, NodeLayout>()
private operations: LayoutOperation[] = []
private changeCallbacks = new Set<(change: AdapterChange) => void>()
private currentActor?: string
setNode(nodeId: NodeId, layout: NodeLayout): void {
this.nodes.set(nodeId, { ...layout })
this.notifyChange({
type: 'set',
nodeIds: [nodeId],
actor: this.currentActor
})
}
getNode(nodeId: NodeId): NodeLayout | null {
const layout = this.nodes.get(nodeId)
return layout ? { ...layout } : null
}
deleteNode(nodeId: NodeId): void {
const existed = this.nodes.delete(nodeId)
if (existed) {
this.notifyChange({
type: 'delete',
nodeIds: [nodeId],
actor: this.currentActor
})
}
}
getAllNodes(): Map<NodeId, NodeLayout> {
// Return a copy to prevent external mutations
const copy = new Map<NodeId, NodeLayout>()
for (const [id, layout] of this.nodes) {
copy.set(id, { ...layout })
}
return copy
}
clear(): void {
const nodeIds = Array.from(this.nodes.keys())
this.nodes.clear()
this.operations = []
if (nodeIds.length > 0) {
this.notifyChange({
type: 'clear',
nodeIds,
actor: this.currentActor
})
}
}
addOperation(operation: LayoutOperation): void {
this.operations.push({ ...operation })
}
getOperationsSince(timestamp: number): LayoutOperation[] {
return this.operations
.filter((op) => op.timestamp > timestamp)
.map((op) => ({ ...op }))
}
getOperationsByActor(actor: string): LayoutOperation[] {
return this.operations
.filter((op) => op.actor === actor)
.map((op) => ({ ...op }))
}
subscribe(callback: (change: AdapterChange) => void): () => void {
this.changeCallbacks.add(callback)
return () => this.changeCallbacks.delete(callback)
}
transaction(fn: () => void, actor?: string): void {
const previousActor = this.currentActor
this.currentActor = actor
try {
fn()
} finally {
this.currentActor = previousActor
}
}
// Mock network sync methods
getStateVector(): Uint8Array {
return new Uint8Array([1, 2, 3]) // Mock data
}
getStateAsUpdate(): Uint8Array {
// Simple serialization for testing
const json = JSON.stringify({
nodes: Array.from(this.nodes.entries()),
operations: this.operations
})
return new TextEncoder().encode(json)
}
applyUpdate(update: Uint8Array): void {
// Simple deserialization for testing
const json = new TextDecoder().decode(update)
const data = JSON.parse(json) as {
nodes: Array<[NodeId, NodeLayout]>
operations: LayoutOperation[]
}
this.nodes.clear()
for (const [id, layout] of data.nodes) {
this.nodes.set(id, layout)
}
this.operations = data.operations
}
private notifyChange(change: AdapterChange): void {
this.changeCallbacks.forEach((callback) => {
try {
callback(change)
} catch (error) {
console.error('Error in mock adapter change callback:', error)
}
})
}
}

View File

@@ -0,0 +1,207 @@
/**
* Yjs Layout Adapter
*
* Implements the LayoutAdapter interface using Yjs as the CRDT backend.
* Provides efficient local state management with future collaboration support.
*/
import * as Y from 'yjs'
import type { LayoutOperation } from '@/renderer/core/layout/types'
import type {
Bounds,
NodeId,
NodeLayout,
Point
} from '@/renderer/core/layout/types'
import type { AdapterChange, LayoutAdapter } from './layoutAdapter'
/**
* Yjs implementation of the layout adapter
*/
export class YjsLayoutAdapter implements LayoutAdapter {
private ydoc: Y.Doc
private ynodes: Y.Map<Y.Map<unknown>>
private yoperations: Y.Array<LayoutOperation>
private changeCallbacks = new Set<(change: AdapterChange) => void>()
constructor() {
this.ydoc = new Y.Doc()
this.ynodes = this.ydoc.getMap('nodes')
this.yoperations = this.ydoc.getArray('operations')
// Set up change observation
this.ynodes.observe((event, transaction) => {
const change: AdapterChange = {
type: 'set', // Yjs doesn't distinguish set/delete in observe
nodeIds: [],
actor: transaction.origin as string | undefined
}
// Collect affected node IDs
event.changes.keys.forEach((changeType, key) => {
change.nodeIds.push(key)
if (changeType.action === 'delete') {
change.type = 'delete'
}
})
// Notify subscribers
this.notifyChange(change)
})
}
/**
* Set a node's layout data
*/
setNode(nodeId: NodeId, layout: NodeLayout): void {
const ynode = this.layoutToYNode(layout)
this.ynodes.set(nodeId, ynode)
}
/**
* Get a node's layout data
*/
getNode(nodeId: NodeId): NodeLayout | null {
const ynode = this.ynodes.get(nodeId)
return ynode ? this.yNodeToLayout(ynode) : null
}
/**
* Delete a node
*/
deleteNode(nodeId: NodeId): void {
this.ynodes.delete(nodeId)
}
/**
* Get all nodes
*/
getAllNodes(): Map<NodeId, NodeLayout> {
const result = new Map<NodeId, NodeLayout>()
for (const [nodeId] of this.ynodes) {
const ynode = this.ynodes.get(nodeId)
if (ynode) {
result.set(nodeId, this.yNodeToLayout(ynode))
}
}
return result
}
/**
* Clear all nodes
*/
clear(): void {
this.ynodes.clear()
}
/**
* Add an operation to the log
*/
addOperation(operation: LayoutOperation): void {
this.yoperations.push([operation])
}
/**
* Get operations since a timestamp
*/
getOperationsSince(timestamp: number): LayoutOperation[] {
const operations: LayoutOperation[] = []
this.yoperations.forEach((op) => {
if (op && op.timestamp > timestamp) {
operations.push(op)
}
})
return operations
}
/**
* Get operations by a specific actor
*/
getOperationsByActor(actor: string): LayoutOperation[] {
const operations: LayoutOperation[] = []
this.yoperations.forEach((op) => {
if (op && op.actor === actor) {
operations.push(op)
}
})
return operations
}
/**
* Subscribe to changes
*/
subscribe(callback: (change: AdapterChange) => void): () => void {
this.changeCallbacks.add(callback)
return () => this.changeCallbacks.delete(callback)
}
/**
* Transaction support for atomic updates
*/
transaction(fn: () => void, actor?: string): void {
this.ydoc.transact(fn, actor)
}
/**
* Get the current state vector for sync
*/
getStateVector(): Uint8Array {
return Y.encodeStateVector(this.ydoc)
}
/**
* Get state as update for sending to peers
*/
getStateAsUpdate(): Uint8Array {
return Y.encodeStateAsUpdate(this.ydoc)
}
/**
* Apply updates from remote peers
*/
applyUpdate(update: Uint8Array): void {
Y.applyUpdate(this.ydoc, update)
}
/**
* Convert layout to Yjs structure
*/
private layoutToYNode(layout: NodeLayout): Y.Map<unknown> {
const ynode = new Y.Map<unknown>()
ynode.set('id', layout.id)
ynode.set('position', layout.position)
ynode.set('size', layout.size)
ynode.set('zIndex', layout.zIndex)
ynode.set('visible', layout.visible)
ynode.set('bounds', layout.bounds)
return ynode
}
/**
* Convert Yjs structure to layout
*/
private yNodeToLayout(ynode: Y.Map<unknown>): NodeLayout {
return {
id: ynode.get('id') as string,
position: ynode.get('position') as Point,
size: ynode.get('size') as { width: number; height: number },
zIndex: ynode.get('zIndex') as number,
visible: ynode.get('visible') as boolean,
bounds: ynode.get('bounds') as Bounds
}
}
/**
* Notify all change subscribers
*/
private notifyChange(change: AdapterChange): void {
this.changeCallbacks.forEach((callback) => {
try {
callback(change)
} catch (error) {
console.error('Error in adapter change callback:', error)
}
})
}
}

View File

@@ -0,0 +1,82 @@
/**
* Layout Adapter Interface
*
* Abstracts the underlying CRDT implementation to allow for different
* backends (Yjs, Automerge, etc.) and easier testing.
*/
import type { LayoutOperation } from '@/renderer/core/layout/types'
import type { NodeId, NodeLayout } from '@/renderer/core/layout/types'
/**
* Change event emitted by the adapter
*/
export interface AdapterChange {
/** Type of change */
type: 'set' | 'delete' | 'clear'
/** Affected node IDs */
nodeIds: NodeId[]
/** Actor who made the change */
actor?: string
}
/**
* Layout adapter interface for CRDT abstraction
*/
export interface LayoutAdapter {
/**
* Set a node's layout data
*/
setNode(nodeId: NodeId, layout: NodeLayout): void
/**
* Get a node's layout data
*/
getNode(nodeId: NodeId): NodeLayout | null
/**
* Delete a node
*/
deleteNode(nodeId: NodeId): void
/**
* Get all nodes
*/
getAllNodes(): Map<NodeId, NodeLayout>
/**
* Clear all nodes
*/
clear(): void
/**
* Add an operation to the log
*/
addOperation(operation: LayoutOperation): void
/**
* Get operations since a timestamp
*/
getOperationsSince(timestamp: number): LayoutOperation[]
/**
* Get operations by a specific actor
*/
getOperationsByActor(actor: string): LayoutOperation[]
/**
* Subscribe to changes
*/
subscribe(callback: (change: AdapterChange) => void): () => void
/**
* Transaction support for atomic updates
*/
transaction(fn: () => void, actor?: string): void
/**
* Network sync methods (for future use)
*/
getStateVector(): Uint8Array
getStateAsUpdate(): Uint8Array
applyUpdate(update: Uint8Array): void
}

View File

@@ -0,0 +1,73 @@
/**
* Layout System Constants
*
* Centralized configuration values for the layout system.
* These values control spatial indexing, performance, and behavior.
*/
/**
* QuadTree configuration for spatial indexing
*/
export const QUADTREE_CONFIG = {
/** Default bounds for the QuadTree - covers a large canvas area */
DEFAULT_BOUNDS: {
x: -10000,
y: -10000,
width: 20000,
height: 20000
},
/** Maximum tree depth to prevent excessive subdivision */
MAX_DEPTH: 6,
/** Maximum items per node before subdivision */
MAX_ITEMS_PER_NODE: 4
} as const
/**
* Performance and optimization settings
*/
export const PERFORMANCE_CONFIG = {
/** RAF-based change detection interval (roughly 60fps) */
CHANGE_DETECTION_INTERVAL: 16,
/** Spatial query cache TTL in milliseconds */
SPATIAL_CACHE_TTL: 1000,
/** Maximum cache size for spatial queries */
SPATIAL_CACHE_MAX_SIZE: 100,
/** Batch update delay in milliseconds */
BATCH_UPDATE_DELAY: 4
} as const
/**
* Default values for node layout
*/
export const NODE_DEFAULTS = {
/** Default node size when not specified */
SIZE: { width: 200, height: 100 },
/** Default z-index for new nodes */
Z_INDEX: 0,
/** Default visibility state */
VISIBLE: true
} as const
/**
* Debug and development settings
*/
export const DEBUG_CONFIG = {
/** LocalStorage key for enabling layout debug mode */
LAYOUT_DEBUG_KEY: 'layout-debug',
/** Logger name for layout system */
LOGGER_NAME: 'layout',
/** Logger name for layout store */
STORE_LOGGER_NAME: 'layout-store'
} as const
/**
* Actor and source identifiers
*/
export const ACTOR_CONFIG = {
/** Prefix for auto-generated actor IDs */
USER_PREFIX: 'user-',
/** Length of random suffix for actor IDs */
ID_LENGTH: 9,
/** Default source when not specified */
DEFAULT_SOURCE: 'external' as const
} as const

View File

@@ -0,0 +1,150 @@
/**
* Layout Mutations - Simplified Direct Operations
*
* Provides a clean API for layout operations that are CRDT-ready.
* Operations are synchronous and applied directly to the store.
*/
import { layoutStore } from '@/renderer/core/layout/store/LayoutStore'
import type {
LayoutMutations,
NodeId,
NodeLayout,
Point,
Size
} from '@/renderer/core/layout/types'
class LayoutMutationsImpl implements LayoutMutations {
/**
* Set the current mutation source
*/
setSource(source: 'canvas' | 'vue' | 'external'): void {
layoutStore.setSource(source)
}
/**
* Set the current actor (for CRDT)
*/
setActor(actor: string): void {
layoutStore.setActor(actor)
}
/**
* Move a node to a new position
*/
moveNode(nodeId: NodeId, position: Point): void {
const existing = layoutStore.getNodeLayoutRef(nodeId).value
if (!existing) return
layoutStore.applyOperation({
type: 'moveNode',
nodeId,
position,
previousPosition: existing.position,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
}
/**
* Resize a node
*/
resizeNode(nodeId: NodeId, size: Size): void {
const existing = layoutStore.getNodeLayoutRef(nodeId).value
if (!existing) return
layoutStore.applyOperation({
type: 'resizeNode',
nodeId,
size,
previousSize: existing.size,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
}
/**
* Set node z-index
*/
setNodeZIndex(nodeId: NodeId, zIndex: number): void {
const existing = layoutStore.getNodeLayoutRef(nodeId).value
if (!existing) return
layoutStore.applyOperation({
type: 'setNodeZIndex',
nodeId,
zIndex,
previousZIndex: existing.zIndex,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
}
/**
* Create a new node
*/
createNode(nodeId: NodeId, layout: Partial<NodeLayout>): void {
const fullLayout: NodeLayout = {
id: nodeId,
position: layout.position ?? { x: 0, y: 0 },
size: layout.size ?? { width: 200, height: 100 },
zIndex: layout.zIndex ?? 0,
visible: layout.visible ?? true,
bounds: {
x: layout.position?.x ?? 0,
y: layout.position?.y ?? 0,
width: layout.size?.width ?? 200,
height: layout.size?.height ?? 100
}
}
layoutStore.applyOperation({
type: 'createNode',
nodeId,
layout: fullLayout,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
}
/**
* Delete a node
*/
deleteNode(nodeId: NodeId): void {
const existing = layoutStore.getNodeLayoutRef(nodeId).value
if (!existing) return
layoutStore.applyOperation({
type: 'deleteNode',
nodeId,
previousLayout: existing,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
}
/**
* Bring a node to the front (highest z-index)
*/
bringNodeToFront(nodeId: NodeId): void {
// Get all nodes to find the highest z-index
const allNodes = layoutStore.getAllNodes().value
let maxZIndex = 0
for (const [, layout] of allNodes) {
if (layout.zIndex > maxZIndex) {
maxZIndex = layout.zIndex
}
}
// Set this node's z-index to be one higher than the current max
this.setNodeZIndex(nodeId, maxZIndex + 1)
}
}
// Create singleton instance
export const layoutMutations = new LayoutMutationsImpl()

View File

@@ -0,0 +1,665 @@
/**
* Layout Store - Single Source of Truth
*
* Uses Yjs for efficient local state management and future collaboration.
* CRDT ensures conflict-free operations for both single and multi-user scenarios.
*/
import log from 'loglevel'
import { type ComputedRef, type Ref, computed, customRef } from 'vue'
import * as Y from 'yjs'
import { ACTOR_CONFIG, DEBUG_CONFIG } from '@/renderer/core/layout/constants'
import type {
CreateNodeOperation,
DeleteNodeOperation,
LayoutOperation,
MoveNodeOperation,
ResizeNodeOperation,
SetNodeZIndexOperation
} from '@/renderer/core/layout/types'
import type {
Bounds,
LayoutChange,
LayoutStore,
NodeId,
NodeLayout,
Point
} from '@/renderer/core/layout/types'
import { SpatialIndexManager } from '@/renderer/core/spatial/SpatialIndex'
// Create logger for layout store
const logger = log.getLogger(DEBUG_CONFIG.STORE_LOGGER_NAME)
// In dev mode, always show debug logs
if (import.meta.env.DEV) {
logger.setLevel('debug')
}
class LayoutStoreImpl implements LayoutStore {
// Yjs document and shared data structures
private ydoc = new Y.Doc()
private ynodes: Y.Map<Y.Map<unknown>> // Maps nodeId -> Y.Map containing NodeLayout data
private yoperations: Y.Array<LayoutOperation> // Operation log
// Vue reactivity layer
private version = 0
private currentSource: 'canvas' | 'vue' | 'external' =
ACTOR_CONFIG.DEFAULT_SOURCE
private currentActor = `${ACTOR_CONFIG.USER_PREFIX}${Math.random()
.toString(36)
.substr(2, ACTOR_CONFIG.ID_LENGTH)}`
// Change listeners
private changeListeners = new Set<(change: LayoutChange) => void>()
// CustomRef cache and trigger functions
private nodeRefs = new Map<NodeId, Ref<NodeLayout | null>>()
private nodeTriggers = new Map<NodeId, () => void>()
// Spatial index manager
private spatialIndex: SpatialIndexManager
constructor() {
// Initialize Yjs data structures
this.ynodes = this.ydoc.getMap('nodes')
this.yoperations = this.ydoc.getArray('operations')
// Initialize spatial index manager
this.spatialIndex = new SpatialIndexManager()
// Listen for Yjs changes and trigger Vue reactivity
this.ynodes.observe((event) => {
this.version++
// Trigger all affected node refs
event.changes.keys.forEach((_change, key) => {
const trigger = this.nodeTriggers.get(key)
if (trigger) {
logger.debug(`Yjs change detected for node ${key}, triggering ref`)
trigger()
}
})
})
// Debug: Log layout operations
if (localStorage.getItem(DEBUG_CONFIG.LAYOUT_DEBUG_KEY) === 'true') {
this.yoperations.observe((event) => {
const operations: LayoutOperation[] = []
event.changes.added.forEach((item) => {
const content = item.content.getContent()
if (Array.isArray(content) && content.length > 0) {
operations.push(content[0] as LayoutOperation)
}
})
console.log('Layout Operation:', operations)
})
}
}
/**
* Get or create a customRef for a node layout
*/
getNodeLayoutRef(nodeId: NodeId): Ref<NodeLayout | null> {
let nodeRef = this.nodeRefs.get(nodeId)
if (!nodeRef) {
logger.debug(`Creating new layout ref for node ${nodeId}`)
nodeRef = customRef<NodeLayout | null>((track, trigger) => {
// Store the trigger so we can call it when Yjs changes
this.nodeTriggers.set(nodeId, trigger)
return {
get: () => {
track()
const ynode = this.ynodes.get(nodeId)
const layout = ynode ? this.yNodeToLayout(ynode) : null
logger.debug(`Layout ref GET for node ${nodeId}:`, {
position: layout?.position,
hasYnode: !!ynode,
version: this.version
})
return layout
},
set: (newLayout: NodeLayout | null) => {
if (newLayout === null) {
// Delete operation
const existing = this.ynodes.get(nodeId)
if (existing) {
this.applyOperation({
type: 'deleteNode',
nodeId,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor,
previousLayout: this.yNodeToLayout(existing)
})
}
} else {
// Update operation - detect what changed
const existing = this.ynodes.get(nodeId)
if (!existing) {
// Create operation
this.applyOperation({
type: 'createNode',
nodeId,
layout: newLayout,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
} else {
const existingLayout = this.yNodeToLayout(existing)
// Check what properties changed
if (
existingLayout.position.x !== newLayout.position.x ||
existingLayout.position.y !== newLayout.position.y
) {
this.applyOperation({
type: 'moveNode',
nodeId,
position: newLayout.position,
previousPosition: existingLayout.position,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
}
if (
existingLayout.size.width !== newLayout.size.width ||
existingLayout.size.height !== newLayout.size.height
) {
this.applyOperation({
type: 'resizeNode',
nodeId,
size: newLayout.size,
previousSize: existingLayout.size,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
}
if (existingLayout.zIndex !== newLayout.zIndex) {
this.applyOperation({
type: 'setNodeZIndex',
nodeId,
zIndex: newLayout.zIndex,
previousZIndex: existingLayout.zIndex,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
}
}
}
logger.debug(`Layout ref SET triggering for node ${nodeId}`)
trigger()
}
}
})
this.nodeRefs.set(nodeId, nodeRef)
}
return nodeRef
}
/**
* Get nodes within bounds (reactive)
*/
getNodesInBounds(bounds: Bounds): ComputedRef<NodeId[]> {
return computed(() => {
// Touch version for reactivity
void this.version
const result: NodeId[] = []
for (const [nodeId] of this.ynodes) {
const ynode = this.ynodes.get(nodeId)
if (ynode) {
const layout = this.yNodeToLayout(ynode)
if (layout && this.boundsIntersect(layout.bounds, bounds)) {
result.push(nodeId)
}
}
}
return result
})
}
/**
* Get all nodes as a reactive map
*/
getAllNodes(): ComputedRef<ReadonlyMap<NodeId, NodeLayout>> {
return computed(() => {
// Touch version for reactivity
void this.version
const result = new Map<NodeId, NodeLayout>()
for (const [nodeId] of this.ynodes) {
const ynode = this.ynodes.get(nodeId)
if (ynode) {
const layout = this.yNodeToLayout(ynode)
if (layout) {
result.set(nodeId, layout)
}
}
}
return result
})
}
/**
* Get current version for change detection
*/
getVersion(): ComputedRef<number> {
return computed(() => this.version)
}
/**
* Query node at point (non-reactive for performance)
*/
queryNodeAtPoint(point: Point): NodeId | null {
const nodes: Array<[NodeId, NodeLayout]> = []
for (const [nodeId] of this.ynodes) {
const ynode = this.ynodes.get(nodeId)
if (ynode) {
const layout = this.yNodeToLayout(ynode)
if (layout) {
nodes.push([nodeId, layout])
}
}
}
// Sort by zIndex (top to bottom)
nodes.sort(([, a], [, b]) => b.zIndex - a.zIndex)
for (const [nodeId, layout] of nodes) {
if (this.pointInBounds(point, layout.bounds)) {
return nodeId
}
}
return null
}
/**
* Query nodes in bounds (non-reactive for performance)
*/
queryNodesInBounds(bounds: Bounds): NodeId[] {
return this.spatialIndex.query(bounds)
}
/**
* Apply a layout operation using Yjs transactions
*/
applyOperation(operation: LayoutOperation): void {
logger.debug(`applyOperation called:`, {
type: operation.type,
nodeId: operation.nodeId,
operation
})
// Create change object outside transaction so we can use it after
const change: LayoutChange = {
type: 'update',
nodeIds: [],
timestamp: operation.timestamp,
source: operation.source,
operation
}
// Use Yjs transaction for atomic updates
this.ydoc.transact(() => {
// Add operation to log
this.yoperations.push([operation])
// Apply the operation
this.applyOperationInTransaction(operation, change)
}, this.currentActor)
// Post-transaction updates
this.finalizeOperation(change)
}
/**
* Apply operation within a transaction
*/
private applyOperationInTransaction(
operation: LayoutOperation,
change: LayoutChange
): void {
switch (operation.type) {
case 'moveNode':
this.handleMoveNode(operation as MoveNodeOperation, change)
break
case 'resizeNode':
this.handleResizeNode(operation as ResizeNodeOperation, change)
break
case 'setNodeZIndex':
this.handleSetNodeZIndex(operation as SetNodeZIndexOperation, change)
break
case 'createNode':
this.handleCreateNode(operation as CreateNodeOperation, change)
break
case 'deleteNode':
this.handleDeleteNode(operation as DeleteNodeOperation, change)
break
}
}
/**
* Finalize operation after transaction
*/
private finalizeOperation(change: LayoutChange): void {
// Update version
this.version++
// Manually trigger affected node refs after transaction
// This is needed because Yjs observers don't fire for property changes
change.nodeIds.forEach((nodeId) => {
const trigger = this.nodeTriggers.get(nodeId)
if (trigger) {
logger.debug(
`Manually triggering ref for node ${nodeId} after operation`
)
trigger()
}
})
// Notify listeners (after transaction completes)
setTimeout(() => this.notifyChange(change), 0)
}
/**
* Subscribe to layout changes
*/
onChange(callback: (change: LayoutChange) => void): () => void {
this.changeListeners.add(callback)
return () => this.changeListeners.delete(callback)
}
/**
* Set the current operation source
*/
setSource(source: 'canvas' | 'vue' | 'external'): void {
this.currentSource = source
}
/**
* Set the current actor (for CRDT)
*/
setActor(actor: string): void {
this.currentActor = actor
}
/**
* Get the current operation source
*/
getCurrentSource(): 'canvas' | 'vue' | 'external' {
return this.currentSource
}
/**
* Get the current actor
*/
getCurrentActor(): string {
return this.currentActor
}
/**
* Initialize store with existing nodes
*/
initializeFromLiteGraph(
nodes: Array<{ id: string; pos: [number, number]; size: [number, number] }>
): void {
logger.debug('Initializing layout store from LiteGraph', {
nodeCount: nodes.length,
nodes: nodes.map((n) => ({ id: n.id, pos: n.pos }))
})
this.ydoc.transact(() => {
this.ynodes.clear()
this.nodeRefs.clear()
this.nodeTriggers.clear()
this.spatialIndex.clear()
nodes.forEach((node, index) => {
const layout: NodeLayout = {
id: node.id.toString(),
position: { x: node.pos[0], y: node.pos[1] },
size: { width: node.size[0], height: node.size[1] },
zIndex: index,
visible: true,
bounds: {
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1]
}
}
this.ynodes.set(layout.id, this.layoutToYNode(layout))
// Add to spatial index
this.spatialIndex.insert(layout.id, layout.bounds)
logger.debug(
`Initialized node ${layout.id} at position:`,
layout.position
)
})
}, 'initialization')
logger.debug('Layout store initialization complete', {
totalNodes: this.ynodes.size
})
}
// Operation handlers
private handleMoveNode(
operation: MoveNodeOperation,
change: LayoutChange
): void {
const ynode = this.ynodes.get(operation.nodeId)
if (!ynode) {
logger.warn(`No ynode found for ${operation.nodeId}`)
return
}
logger.debug(`Moving node ${operation.nodeId}`, operation.position)
const size = ynode.get('size') as { width: number; height: number }
ynode.set('position', operation.position)
this.updateNodeBounds(ynode, operation.position, size)
// Update spatial index
this.spatialIndex.update(operation.nodeId, {
x: operation.position.x,
y: operation.position.y,
width: size.width,
height: size.height
})
change.nodeIds.push(operation.nodeId)
}
private handleResizeNode(
operation: ResizeNodeOperation,
change: LayoutChange
): void {
const ynode = this.ynodes.get(operation.nodeId)
if (!ynode) return
const position = ynode.get('position') as Point
ynode.set('size', operation.size)
this.updateNodeBounds(ynode, position, operation.size)
// Update spatial index
this.spatialIndex.update(operation.nodeId, {
x: position.x,
y: position.y,
width: operation.size.width,
height: operation.size.height
})
change.nodeIds.push(operation.nodeId)
}
private handleSetNodeZIndex(
operation: SetNodeZIndexOperation,
change: LayoutChange
): void {
const ynode = this.ynodes.get(operation.nodeId)
if (!ynode) return
ynode.set('zIndex', operation.zIndex)
change.nodeIds.push(operation.nodeId)
}
private handleCreateNode(
operation: CreateNodeOperation,
change: LayoutChange
): void {
const ynode = this.layoutToYNode(operation.layout)
this.ynodes.set(operation.nodeId, ynode)
// Add to spatial index
this.spatialIndex.insert(operation.nodeId, operation.layout.bounds)
change.type = 'create'
change.nodeIds.push(operation.nodeId)
}
private handleDeleteNode(
operation: DeleteNodeOperation,
change: LayoutChange
): void {
if (!this.ynodes.has(operation.nodeId)) return
this.ynodes.delete(operation.nodeId)
this.nodeRefs.delete(operation.nodeId)
this.nodeTriggers.delete(operation.nodeId)
// Remove from spatial index
this.spatialIndex.remove(operation.nodeId)
change.type = 'delete'
change.nodeIds.push(operation.nodeId)
}
/**
* Update node bounds helper
*/
private updateNodeBounds(
ynode: Y.Map<unknown>,
position: Point,
size: { width: number; height: number }
): void {
ynode.set('bounds', {
x: position.x,
y: position.y,
width: size.width,
height: size.height
})
}
// Helper methods
private layoutToYNode(layout: NodeLayout): Y.Map<unknown> {
const ynode = new Y.Map<unknown>()
ynode.set('id', layout.id)
ynode.set('position', layout.position)
ynode.set('size', layout.size)
ynode.set('zIndex', layout.zIndex)
ynode.set('visible', layout.visible)
ynode.set('bounds', layout.bounds)
return ynode
}
private yNodeToLayout(ynode: Y.Map<unknown>): NodeLayout {
return {
id: ynode.get('id') as string,
position: ynode.get('position') as Point,
size: ynode.get('size') as { width: number; height: number },
zIndex: ynode.get('zIndex') as number,
visible: ynode.get('visible') as boolean,
bounds: ynode.get('bounds') as Bounds
}
}
private notifyChange(change: LayoutChange): void {
this.changeListeners.forEach((listener) => {
try {
listener(change)
} catch (error) {
console.error('Error in layout change listener:', error)
}
})
}
private pointInBounds(point: Point, bounds: Bounds): boolean {
return (
point.x >= bounds.x &&
point.x <= bounds.x + bounds.width &&
point.y >= bounds.y &&
point.y <= bounds.y + bounds.height
)
}
private boundsIntersect(a: Bounds, b: Bounds): boolean {
return !(
a.x + a.width < b.x ||
b.x + b.width < a.x ||
a.y + a.height < b.y ||
b.y + b.height < a.y
)
}
// CRDT-specific methods
getOperationsSince(timestamp: number): LayoutOperation[] {
const operations: LayoutOperation[] = []
this.yoperations.forEach((op) => {
if (op && op.timestamp > timestamp) {
operations.push(op)
}
})
return operations
}
getOperationsByActor(actor: string): LayoutOperation[] {
const operations: LayoutOperation[] = []
this.yoperations.forEach((op) => {
if (op && op.actor === actor) {
operations.push(op)
}
})
return operations
}
/**
* Get the Yjs document for network sync (future feature)
*/
getYDoc(): Y.Doc {
return this.ydoc
}
/**
* Apply updates from remote peers (future feature)
*/
applyUpdate(update: Uint8Array): void {
Y.applyUpdate(this.ydoc, update)
}
/**
* Get state as update for sending to peers (future feature)
*/
getStateAsUpdate(): Uint8Array {
return Y.encodeStateAsUpdate(this.ydoc)
}
}
// Create singleton instance
export const layoutStore = new LayoutStoreImpl()
// Export types for convenience
export type { LayoutStore } from '@/renderer/core/layout/types'

View File

@@ -0,0 +1,31 @@
/**
* Main composable for accessing the layout system
*
* Provides unified access to the layout store and mutation API.
*/
import { layoutMutations } from '@/renderer/core/layout/operations/LayoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/LayoutStore'
import type { Bounds, NodeId, Point } from '@/renderer/core/layout/types'
/**
* Main composable for accessing the layout system
*/
export function useLayout() {
return {
// Store access
store: layoutStore,
// Mutation API
mutations: layoutMutations,
// Reactive accessors
getNodeLayoutRef: (nodeId: NodeId) => layoutStore.getNodeLayoutRef(nodeId),
getAllNodes: () => layoutStore.getAllNodes(),
getNodesInBounds: (bounds: Bounds) => layoutStore.getNodesInBounds(bounds),
// Non-reactive queries (for performance)
queryNodeAtPoint: (point: Point) => layoutStore.queryNodeAtPoint(point),
queryNodesInBounds: (bounds: Bounds) =>
layoutStore.queryNodesInBounds(bounds)
}
}

View File

@@ -0,0 +1,97 @@
/**
* Composable for syncing LiteGraph with the Layout system
*
* Implements one-way sync from Layout Store to LiteGraph.
* The layout store is the single source of truth.
*/
import log from 'loglevel'
import { onUnmounted } from 'vue'
import { layoutStore } from '@/renderer/core/layout/store/LayoutStore'
// Create a logger for layout debugging
const logger = log.getLogger('layout')
// In dev mode, always show debug logs
if (import.meta.env.DEV) {
logger.setLevel('debug')
}
/**
* Composable for syncing LiteGraph with the Layout system
* This replaces the bidirectional sync with a one-way sync
*/
export function useLayoutSync() {
let unsubscribe: (() => void) | null = null
/**
* Start syncing from Layout system to LiteGraph
* This is one-way: Layout → LiteGraph only
*/
function startSync(canvas: any) {
if (!canvas?.graph) return
// Subscribe to layout changes
unsubscribe = layoutStore.onChange((change) => {
logger.debug('Layout sync received change:', {
source: change.source,
nodeIds: change.nodeIds,
type: change.type
})
// Apply changes to LiteGraph regardless of source
// The layout store is the single source of truth
for (const nodeId of change.nodeIds) {
const layout = layoutStore.getNodeLayoutRef(nodeId).value
if (!layout) continue
const liteNode = canvas.graph.getNodeById(parseInt(nodeId))
if (!liteNode) continue
// Update position if changed
if (
liteNode.pos[0] !== layout.position.x ||
liteNode.pos[1] !== layout.position.y
) {
logger.debug(`Updating LiteGraph node ${nodeId} position:`, {
from: { x: liteNode.pos[0], y: liteNode.pos[1] },
to: layout.position
})
liteNode.pos[0] = layout.position.x
liteNode.pos[1] = layout.position.y
}
// Update size if changed
if (
liteNode.size[0] !== layout.size.width ||
liteNode.size[1] !== layout.size.height
) {
liteNode.size[0] = layout.size.width
liteNode.size[1] = layout.size.height
}
}
// Trigger single redraw for all changes
canvas.setDirty(true, true)
})
}
/**
* Stop syncing
*/
function stopSync() {
if (unsubscribe) {
unsubscribe()
unsubscribe = null
}
}
// Auto-cleanup on unmount
onUnmounted(() => {
stopSync()
})
return {
startSync,
stopSync
}
}

View File

@@ -0,0 +1,346 @@
/**
* Layout System - Type Definitions
*
* This file contains all type definitions for the layout system
* that manages node positions, bounds, spatial data, and operations.
*/
import type { ComputedRef, Ref } from 'vue'
// Basic geometric types
export interface Point {
x: number
y: number
}
export interface Size {
width: number
height: number
}
export interface Bounds {
x: number
y: number
width: number
height: number
}
// ID types for type safety
export type NodeId = string
export type SlotId = string
export type ConnectionId = string
// Layout data structures
export interface NodeLayout {
id: NodeId
position: Point
size: Size
zIndex: number
visible: boolean
// Computed bounds for hit testing
bounds: Bounds
}
export interface SlotLayout {
id: SlotId
nodeId: NodeId
position: Point // Relative to node
type: 'input' | 'output'
index: number
}
export interface ConnectionLayout {
id: ConnectionId
sourceSlot: SlotId
targetSlot: SlotId
// Control points for curved connections
controlPoints?: Point[]
}
// Mutation types (legacy - for compatibility)
export type LayoutMutationType =
| 'moveNode'
| 'resizeNode'
| 'setNodeZIndex'
| 'createNode'
| 'deleteNode'
| 'batch'
export interface LayoutMutation {
type: LayoutMutationType
timestamp: number
source: 'canvas' | 'vue' | 'external'
}
export interface MoveNodeMutation extends LayoutMutation {
type: 'moveNode'
nodeId: NodeId
position: Point
previousPosition?: Point
}
export interface ResizeNodeMutation extends LayoutMutation {
type: 'resizeNode'
nodeId: NodeId
size: Size
previousSize?: Size
}
export interface SetNodeZIndexMutation extends LayoutMutation {
type: 'setNodeZIndex'
nodeId: NodeId
zIndex: number
previousZIndex?: number
}
export interface CreateNodeMutation extends LayoutMutation {
type: 'createNode'
nodeId: NodeId
layout: NodeLayout
}
export interface DeleteNodeMutation extends LayoutMutation {
type: 'deleteNode'
nodeId: NodeId
previousLayout?: NodeLayout
}
export interface BatchMutation extends LayoutMutation {
type: 'batch'
mutations: AnyLayoutMutation[]
}
// Union type for all mutations
export type AnyLayoutMutation =
| MoveNodeMutation
| ResizeNodeMutation
| SetNodeZIndexMutation
| CreateNodeMutation
| DeleteNodeMutation
| BatchMutation
// CRDT Operation Types
/**
* Base operation interface that all operations extend
*/
export interface BaseOperation {
/** Unique operation ID for deduplication */
id?: string
/** Timestamp for ordering operations */
timestamp: number
/** Actor who performed the operation (for CRDT) */
actor: string
/** Source system that initiated the operation */
source: 'canvas' | 'vue' | 'external'
/** Node this operation affects */
nodeId: NodeId
}
/**
* Operation type discriminator for type narrowing
*/
export type OperationType =
| 'moveNode'
| 'resizeNode'
| 'setNodeZIndex'
| 'createNode'
| 'deleteNode'
| 'setNodeVisibility'
| 'batchUpdate'
/**
* Move node operation
*/
export interface MoveNodeOperation extends BaseOperation {
type: 'moveNode'
position: Point
previousPosition: Point
}
/**
* Resize node operation
*/
export interface ResizeNodeOperation extends BaseOperation {
type: 'resizeNode'
size: { width: number; height: number }
previousSize: { width: number; height: number }
}
/**
* Set node z-index operation
*/
export interface SetNodeZIndexOperation extends BaseOperation {
type: 'setNodeZIndex'
zIndex: number
previousZIndex: number
}
/**
* Create node operation
*/
export interface CreateNodeOperation extends BaseOperation {
type: 'createNode'
layout: NodeLayout
}
/**
* Delete node operation
*/
export interface DeleteNodeOperation extends BaseOperation {
type: 'deleteNode'
previousLayout: NodeLayout
}
/**
* Set node visibility operation
*/
export interface SetNodeVisibilityOperation extends BaseOperation {
type: 'setNodeVisibility'
visible: boolean
previousVisible: boolean
}
/**
* Batch update operation for atomic multi-property changes
*/
export interface BatchUpdateOperation extends BaseOperation {
type: 'batchUpdate'
updates: Partial<NodeLayout>
previousValues: Partial<NodeLayout>
}
/**
* Union of all operation types
*/
export type LayoutOperation =
| MoveNodeOperation
| ResizeNodeOperation
| SetNodeZIndexOperation
| CreateNodeOperation
| DeleteNodeOperation
| SetNodeVisibilityOperation
| BatchUpdateOperation
// Legacy alias for compatibility
export type AnyLayoutOperation = LayoutOperation
/**
* Type guards for operations
*/
export const isBaseOperation = (op: unknown): op is BaseOperation => {
return (
typeof op === 'object' &&
op !== null &&
'timestamp' in op &&
'actor' in op &&
'source' in op &&
'nodeId' in op
)
}
export const isMoveNodeOperation = (
op: LayoutOperation
): op is MoveNodeOperation => op.type === 'moveNode'
export const isResizeNodeOperation = (
op: LayoutOperation
): op is ResizeNodeOperation => op.type === 'resizeNode'
export const isCreateNodeOperation = (
op: LayoutOperation
): op is CreateNodeOperation => op.type === 'createNode'
export const isDeleteNodeOperation = (
op: LayoutOperation
): op is DeleteNodeOperation => op.type === 'deleteNode'
/**
* Operation application interface
*/
export interface OperationApplicator<
T extends LayoutOperation = LayoutOperation
> {
canApply(operation: T): boolean
apply(operation: T): void
reverse(operation: T): void
}
/**
* Operation serialization for network/storage
*/
export interface OperationSerializer {
serialize(operation: LayoutOperation): string
deserialize(data: string): LayoutOperation
}
/**
* Conflict resolution strategy
*/
export interface ConflictResolver {
resolve(op1: LayoutOperation, op2: LayoutOperation): LayoutOperation[]
}
// Change notification types
export interface LayoutChange {
type: 'create' | 'update' | 'delete'
nodeIds: NodeId[]
timestamp: number
source: 'canvas' | 'vue' | 'external'
operation: LayoutOperation
}
// Store interfaces
export interface LayoutStore {
// CustomRef accessors for shared write access
getNodeLayoutRef(nodeId: NodeId): Ref<NodeLayout | null>
getNodesInBounds(bounds: Bounds): ComputedRef<NodeId[]>
getAllNodes(): ComputedRef<ReadonlyMap<NodeId, NodeLayout>>
getVersion(): ComputedRef<number>
// Spatial queries (non-reactive)
queryNodeAtPoint(point: Point): NodeId | null
queryNodesInBounds(bounds: Bounds): NodeId[]
// Direct mutation API (CRDT-ready)
applyOperation(operation: LayoutOperation): void
// Change subscription
onChange(callback: (change: LayoutChange) => void): () => void
// Initialization
initializeFromLiteGraph(
nodes: Array<{ id: string; pos: [number, number]; size: [number, number] }>
): void
// Source and actor management
setSource(source: 'canvas' | 'vue' | 'external'): void
setActor(actor: string): void
getCurrentSource(): 'canvas' | 'vue' | 'external'
getCurrentActor(): string
}
// Simplified mutation API
export interface LayoutMutations {
// Single node operations (synchronous, CRDT-ready)
moveNode(nodeId: NodeId, position: Point): void
resizeNode(nodeId: NodeId, size: Size): void
setNodeZIndex(nodeId: NodeId, zIndex: number): void
// Lifecycle operations
createNode(nodeId: NodeId, layout: Partial<NodeLayout>): void
deleteNode(nodeId: NodeId): void
// Stacking operations
bringNodeToFront(nodeId: NodeId): void
// Source tracking
setSource(source: 'canvas' | 'vue' | 'external'): void
setActor(actor: string): void // For CRDT
}
// CRDT-ready operation log (for future CRDT integration)
export interface OperationLog {
operations: LayoutOperation[]
addOperation(operation: LayoutOperation): void
getOperationsSince(timestamp: number): LayoutOperation[]
getOperationsByActor(actor: string): LayoutOperation[]
}

View File

@@ -0,0 +1,169 @@
/**
* Spatial Index Manager
*
* Manages spatial indexing for efficient node queries based on bounds.
* Uses QuadTree for fast spatial lookups with caching for performance.
*/
import {
PERFORMANCE_CONFIG,
QUADTREE_CONFIG
} from '@/renderer/core/layout/constants'
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
import { QuadTree } from '@/utils/spatial/QuadTree'
/**
* Cache entry for spatial queries
*/
interface CacheEntry {
result: NodeId[]
timestamp: number
}
/**
* Spatial index manager using QuadTree
*/
export class SpatialIndexManager {
private quadTree: QuadTree<NodeId>
private queryCache: Map<string, CacheEntry>
private cacheSize = 0
constructor(bounds?: Bounds) {
this.quadTree = new QuadTree<NodeId>(
bounds ?? QUADTREE_CONFIG.DEFAULT_BOUNDS,
{
maxDepth: QUADTREE_CONFIG.MAX_DEPTH,
maxItemsPerNode: QUADTREE_CONFIG.MAX_ITEMS_PER_NODE
}
)
this.queryCache = new Map()
}
/**
* Insert a node into the spatial index
*/
insert(nodeId: NodeId, bounds: Bounds): void {
this.quadTree.insert(nodeId, bounds, nodeId)
this.invalidateCache()
}
/**
* Update a node's bounds in the spatial index
*/
update(nodeId: NodeId, bounds: Bounds): void {
this.quadTree.update(nodeId, bounds)
this.invalidateCache()
}
/**
* Remove a node from the spatial index
*/
remove(nodeId: NodeId): void {
this.quadTree.remove(nodeId)
this.invalidateCache()
}
/**
* Query nodes within the given bounds
*/
query(bounds: Bounds): NodeId[] {
const cacheKey = this.getCacheKey(bounds)
const cached = this.queryCache.get(cacheKey)
// Check cache validity
if (cached) {
const age = Date.now() - cached.timestamp
if (age < PERFORMANCE_CONFIG.SPATIAL_CACHE_TTL) {
return cached.result
}
// Remove stale entry
this.queryCache.delete(cacheKey)
this.cacheSize--
}
// Perform query
const result = this.quadTree.query(bounds)
// Cache result
this.addToCache(cacheKey, result)
return result
}
/**
* Clear all nodes from the spatial index
*/
clear(): void {
this.quadTree.clear()
this.invalidateCache()
}
/**
* Get the current size of the index
*/
get size(): number {
return this.quadTree.size
}
/**
* Get debug information about the spatial index
*/
getDebugInfo() {
return {
quadTreeInfo: this.quadTree.getDebugInfo(),
cacheSize: this.cacheSize,
cacheEntries: this.queryCache.size
}
}
/**
* Generate cache key for bounds
*/
private getCacheKey(bounds: Bounds): string {
return `${bounds.x},${bounds.y},${bounds.width},${bounds.height}`
}
/**
* Add result to cache with LRU eviction
*/
private addToCache(key: string, result: NodeId[]): void {
// Evict oldest entries if cache is full
if (this.cacheSize >= PERFORMANCE_CONFIG.SPATIAL_CACHE_MAX_SIZE) {
const oldestKey = this.findOldestCacheEntry()
if (oldestKey) {
this.queryCache.delete(oldestKey)
this.cacheSize--
}
}
this.queryCache.set(key, {
result,
timestamp: Date.now()
})
this.cacheSize++
}
/**
* Find oldest cache entry for LRU eviction
*/
private findOldestCacheEntry(): string | null {
let oldestKey: string | null = null
let oldestTime = Infinity
for (const [key, entry] of this.queryCache) {
if (entry.timestamp < oldestTime) {
oldestTime = entry.timestamp
oldestKey = key
}
}
return oldestKey
}
/**
* Invalidate all cached queries
*/
private invalidateCache(): void {
this.queryCache.clear()
this.cacheSize = 0
}
}

View File

@@ -0,0 +1,199 @@
/**
* Composable for individual Vue node components
*
* Uses customRef for shared write access with Canvas renderer.
* Provides dragging functionality and reactive layout state.
*/
import log from 'loglevel'
import { computed, inject } from 'vue'
import { layoutMutations } from '@/renderer/core/layout/operations/LayoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/LayoutStore'
import type { Point } from '@/renderer/core/layout/types'
// Create a logger for layout debugging
const logger = log.getLogger('layout')
// In dev mode, always show debug logs
if (import.meta.env.DEV) {
logger.setLevel('debug')
}
/**
* Composable for individual Vue node components
* Uses customRef for shared write access with Canvas renderer
*/
export function useNodeLayout(nodeId: string) {
const store = layoutStore
const mutations = layoutMutations
// Get transform utilities from TransformPane if available
const transformState = inject('transformState') as
| {
canvasToScreen: (point: Point) => Point
screenToCanvas: (point: Point) => Point
}
| undefined
// Get the customRef for this node (shared write access)
const layoutRef = store.getNodeLayoutRef(nodeId)
logger.debug(`useNodeLayout initialized for node ${nodeId}`, {
hasLayout: !!layoutRef.value,
initialPosition: layoutRef.value?.position
})
// Computed properties for easy access
const position = computed(() => {
const layout = layoutRef.value
const pos = layout?.position ?? { x: 0, y: 0 }
logger.debug(`Node ${nodeId} position computed:`, {
pos,
hasLayout: !!layout,
layoutRefValue: layout
})
return pos
})
const size = computed(
() => layoutRef.value?.size ?? { width: 200, height: 100 }
)
const bounds = computed(
() =>
layoutRef.value?.bounds ?? {
x: position.value.x,
y: position.value.y,
width: size.value.width,
height: size.value.height
}
)
const isVisible = computed(() => layoutRef.value?.visible ?? true)
const zIndex = computed(() => layoutRef.value?.zIndex ?? 0)
// Drag state
let isDragging = false
let dragStartPos: Point | null = null
let dragStartMouse: Point | null = null
/**
* Start dragging the node
*/
function startDrag(event: PointerEvent) {
if (!layoutRef.value) return
isDragging = true
dragStartPos = { ...position.value }
dragStartMouse = { x: event.clientX, y: event.clientY }
// Set mutation source
mutations.setSource('vue')
// Capture pointer
const target = event.target as HTMLElement
target.setPointerCapture(event.pointerId)
}
/**
* Handle drag movement
*/
const handleDrag = (event: PointerEvent) => {
if (!isDragging || !dragStartPos || !dragStartMouse || !transformState) {
logger.debug(`Drag skipped for node ${nodeId}:`, {
isDragging,
hasDragStartPos: !!dragStartPos,
hasDragStartMouse: !!dragStartMouse,
hasTransformState: !!transformState
})
return
}
// Calculate mouse delta in screen coordinates
const mouseDelta = {
x: event.clientX - dragStartMouse.x,
y: event.clientY - dragStartMouse.y
}
// Convert to canvas coordinates
const canvasOrigin = transformState.screenToCanvas({ x: 0, y: 0 })
const canvasWithDelta = transformState.screenToCanvas(mouseDelta)
const canvasDelta = {
x: canvasWithDelta.x - canvasOrigin.x,
y: canvasWithDelta.y - canvasOrigin.y
}
// Calculate new position
const newPosition = {
x: dragStartPos.x + canvasDelta.x,
y: dragStartPos.y + canvasDelta.y
}
logger.debug(`Dragging node ${nodeId}:`, {
mouseDelta,
canvasDelta,
newPosition,
currentLayoutPos: layoutRef.value?.position
})
// Apply mutation through the layout system
mutations.moveNode(nodeId, newPosition)
}
/**
* End dragging
*/
function endDrag(event: PointerEvent) {
if (!isDragging) return
isDragging = false
dragStartPos = null
dragStartMouse = null
// Release pointer
const target = event.target as HTMLElement
target.releasePointerCapture(event.pointerId)
}
/**
* Update node position directly (without drag)
*/
function moveTo(position: Point) {
mutations.setSource('vue')
mutations.moveNode(nodeId, position)
}
/**
* Update node size
*/
function resize(newSize: { width: number; height: number }) {
mutations.setSource('vue')
mutations.resizeNode(nodeId, newSize)
}
return {
// Reactive state (via customRef)
layoutRef,
position,
size,
bounds,
isVisible,
zIndex,
// Mutations
moveTo,
resize,
// Drag handlers
startDrag,
handleDrag,
endDrag,
// Computed styles for Vue templates
nodeStyle: computed(() => ({
position: 'absolute' as const,
left: `${position.value.x}px`,
top: `${position.value.y}px`,
width: `${size.value.width}px`,
height: `${size.value.height}px`,
zIndex: zIndex.value,
cursor: isDragging ? 'grabbing' : 'grab'
}))
}
}