Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 156 additions & 1 deletion src/components/graph/DomWidgets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@
graph,
low_quality: false,
read_only: false,
isNodeVisible: vi.fn(() => true)
isNodeVisible: vi.fn(() => true),
ds: { offset: [0, 0], scale: 1 },
selected_nodes: {}
})
}

Expand Down Expand Up @@ -139,4 +141,157 @@

expect(widgetState.visible).toBe(false)
})

it('forces pos reassignment on viewport pan even when canvas-space pos is unchanged', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()

const graph = new LGraph()
const node = createNode(graph, 1, 'node', [100, 200])
const widget = createWidget('viewport-widget', node, 12)
domWidgetStore.registerWidget(widget)

const canvas = createCanvas(graph)
canvasStore.canvas = canvas

render(DomWidgets, {
global: { stubs: { DomWidget: true } }
})

drawFrame(canvas)
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) throw new Error('Widget state not registered')
const posAfterFirstFrame = widgetState.pos
expect(posAfterFirstFrame).toEqual([110, 222])

// Canvas pan: ds.offset is non-reactive, so the downstream watcher only
// fires if widgetState.pos is reassigned (a new array identity).
canvas.ds.offset[0] = 50
canvas.ds.offset[1] = 60
drawFrame(canvas)

expect(widgetState.pos).not.toBe(posAfterFirstFrame)
})

it('skips pos reassignment when viewport and canvas-space pos are both stable', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()

const graph = new LGraph()
const node = createNode(graph, 1, 'node', [100, 200])
const widget = createWidget('idle-widget', node, 12)
domWidgetStore.registerWidget(widget)

const canvas = createCanvas(graph)
canvasStore.canvas = canvas

render(DomWidgets, {
global: { stubs: { DomWidget: true } }
})

drawFrame(canvas)
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) throw new Error('Widget state not registered')
const posAfterFirstFrame = widgetState.pos

// No pan, no node movement — pos array identity must be preserved
// (this is the perf optimization being protected).
drawFrame(canvas)
expect(widgetState.pos).toBe(posAfterFirstFrame)
})

it('mirrors widget.computedDisabled into widgetState each frame', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()

const graph = new LGraph()
const node = createNode(graph, 1, 'node', [100, 200])
const widget = createWidget('disabled-widget', node, 12)
Object.assign(widget, { computedDisabled: false })
domWidgetStore.registerWidget(widget)

const canvas = createCanvas(graph)
canvasStore.canvas = canvas

render(DomWidgets, {
global: { stubs: { DomWidget: true } }
})

drawFrame(canvas)
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) throw new Error('Widget state not registered')
expect(widgetState.computedDisabled).toBe(false)

// Simulate litegraph connecting an input -> widget.computedDisabled flips.
Object.assign(widget, { computedDisabled: true })
drawFrame(canvas)
expect(widgetState.computedDisabled).toBe(true)
})

it('forces pos reassignment for widgets when the selected node moves', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()

const graph = new LGraph()
const movingNode = createNode(graph, 1, 'moving', [100, 100])
const otherNode = createNode(graph, 2, 'other', [400, 100])
const widget = createWidget('clipped-widget', otherNode, 12)
domWidgetStore.registerWidget(widget)

const canvas = createCanvas(graph)
// movingNode is the selected node — its renderArea drives clipping for
// widgets owned by other nodes.
canvas.selected_nodes = { 1: movingNode }
canvasStore.canvas = canvas

render(DomWidgets, {
global: { stubs: { DomWidget: true } }
})

drawFrame(canvas)
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) throw new Error('Widget state not registered')
const posAfterFirstFrame = widgetState.pos

// Drag the selected node — otherNode (and its widget) hasn't moved, but
// the widget's clip-path depends on movingNode.renderArea, so the
// downstream pos watcher must re-fire.
movingNode.pos[0] = 150
drawFrame(canvas)
expect(widgetState.pos).not.toBe(posAfterFirstFrame)

Check failure on line 261 in src/components/graph/DomWidgets.test.ts

View workflow job for this annotation

GitHub Actions / setup

Cannot find name 'createTestingPinia'.

Check failure on line 261 in src/components/graph/DomWidgets.test.ts

View workflow job for this annotation

GitHub Actions / setup

Cannot find name 'setActivePinia'.

Check failure on line 261 in src/components/graph/DomWidgets.test.ts

View workflow job for this annotation

GitHub Actions / collect

Cannot find name 'createTestingPinia'.

Check failure on line 261 in src/components/graph/DomWidgets.test.ts

View workflow job for this annotation

GitHub Actions / collect

Cannot find name 'setActivePinia'.

Check failure on line 261 in src/components/graph/DomWidgets.test.ts

View workflow job for this annotation

GitHub Actions / perf-tests

Cannot find name 'createTestingPinia'.

Check failure on line 261 in src/components/graph/DomWidgets.test.ts

View workflow job for this annotation

GitHub Actions / perf-tests

Cannot find name 'setActivePinia'.

Check failure on line 261 in src/components/graph/DomWidgets.test.ts

View workflow job for this annotation

GitHub Actions / scan

Cannot find name 'createTestingPinia'.

Check failure on line 261 in src/components/graph/DomWidgets.test.ts

View workflow job for this annotation

GitHub Actions / scan

Cannot find name 'setActivePinia'.

Check failure on line 261 in src/components/graph/DomWidgets.test.ts

View workflow job for this annotation

GitHub Actions / validate-fonts

Cannot find name 'createTestingPinia'.

Check failure on line 261 in src/components/graph/DomWidgets.test.ts

View workflow job for this annotation

GitHub Actions / validate-fonts

Cannot find name 'setActivePinia'.

Check failure on line 261 in src/components/graph/DomWidgets.test.ts

View workflow job for this annotation

GitHub Actions / test

src/components/graph/DomWidgets.test.ts > DomWidgets reactive-write budget > pos array identity is preserved across N idle frames (perf invariant)

ReferenceError: setActivePinia is not defined ❯ src/components/graph/DomWidgets.test.ts:261:5

Check failure on line 261 in src/components/graph/DomWidgets.test.ts

View workflow job for this annotation

GitHub Actions / test

src/components/graph/DomWidgets.test.ts > DomWidgets reactive-write budget > pos array identity is preserved across N idle frames (perf invariant)

ReferenceError: setActivePinia is not defined ❯ src/components/graph/DomWidgets.test.ts:261:5

Check failure on line 261 in src/components/graph/DomWidgets.test.ts

View workflow job for this annotation

GitHub Actions / test

src/components/graph/DomWidgets.test.ts > DomWidgets reactive-write budget > pos array identity is preserved across N idle frames (perf invariant)

ReferenceError: setActivePinia is not defined ❯ src/components/graph/DomWidgets.test.ts:261:5
})
})

describe('DomWidgets reactive-write budget', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})

it('pos array identity is preserved across N idle frames (perf invariant)', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()

const graph = new LGraph()
const node = createNode(graph, 1, 'node', [100, 200])
const widget = createWidget('perf-widget', node, 12)
domWidgetStore.registerWidget(widget)

const canvas = createCanvas(graph)
canvasStore.canvas = canvas

render(DomWidgets, { global: { stubs: { DomWidget: true } } })

// Warm-up: first frame writes initial pos.
drawFrame(canvas)

const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) throw new Error('Widget state not registered')
const posAfterWarmup = widgetState.pos

// 20 idle frames: canvas stationary, no node movement, no pan.
// The pos array must not be reassigned (same reference = no reactive write).
for (let i = 0; i < 20; i++) drawFrame(canvas)

expect(widgetState.pos).toBe(posAfterWarmup)
})
})
92 changes: 82 additions & 10 deletions src/components/graph/DomWidgets.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,61 @@ const domWidgetStore = useDomWidgetStore()

const widgetStates = computed(() => [...domWidgetStore.widgetStates.values()])

// Track canvas viewport and selected-node bounds between frames.
// lgCanvas.ds.offset, ds.scale, and node.pos/size are non-reactive plain
// props — Vue watchers in DomWidget won't fire unless widgetState.pos gets
// a new array identity. We force reassignment whenever these change so the
// downstream watcher re-runs updatePosition / updateDomClipping.
const lastViewport = {
offsetX: Number.NaN,
offsetY: Number.NaN,
scale: Number.NaN
}
const lastSelected = {
id: undefined as string | number | undefined,
posX: 0,
posY: 0,
width: 0,
height: 0
}

const updateWidgets = () => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas) return

const lowQuality = lgCanvas.low_quality
const currentGraph = lgCanvas.graph

const viewportOffsetX = lgCanvas.ds.offset[0]
const viewportOffsetY = lgCanvas.ds.offset[1]
const viewportScale = lgCanvas.ds.scale
const viewportChanged =
lastViewport.offsetX !== viewportOffsetX ||
lastViewport.offsetY !== viewportOffsetY ||
lastViewport.scale !== viewportScale
lastViewport.offsetX = viewportOffsetX
lastViewport.offsetY = viewportOffsetY
lastViewport.scale = viewportScale

const selectedNode = Object.values(lgCanvas.selected_nodes ?? {})[0]
const selectedNodeId = selectedNode?.id
const selectedPosX = selectedNode ? selectedNode.pos[0] : 0
const selectedPosY = selectedNode ? selectedNode.pos[1] : 0
const selectedWidth = selectedNode ? selectedNode.size[0] : 0
const selectedHeight = selectedNode ? selectedNode.size[1] : 0
const selectionChanged =
lastSelected.id !== selectedNodeId ||
(!!selectedNode &&
(lastSelected.posX !== selectedPosX ||
lastSelected.posY !== selectedPosY ||
lastSelected.width !== selectedWidth ||
lastSelected.height !== selectedHeight))
lastSelected.id = selectedNodeId
lastSelected.posX = selectedPosX
lastSelected.posY = selectedPosY
lastSelected.width = selectedWidth
lastSelected.height = selectedHeight

for (const widgetState of widgetStates.value) {
const widget = widgetState.widget

Expand All @@ -51,16 +99,40 @@ const updateWidgets = () => {

if (widgetState.visible) {
const margin = widget.margin
widgetState.pos = [
posNode.pos[0] + margin,
posNode.pos[1] + margin + widget.y
]
widgetState.size = [
(widget.width ?? posNode.width) - margin * 2,
(widget.computedHeight ?? 50) - margin * 2
]
widgetState.zIndex = getDomWidgetZIndex(posNode, currentGraph)
widgetState.readonly = lgCanvas.read_only
const newPosX = posNode.pos[0] + margin
const newPosY = posNode.pos[1] + margin + widget.y
if (
viewportChanged ||
selectionChanged ||
widgetState.pos[0] !== newPosX ||
widgetState.pos[1] !== newPosY
) {
widgetState.pos = [newPosX, newPosY]
}

const newWidth = (widget.width ?? posNode.width) - margin * 2
const newHeight = (widget.computedHeight ?? 50) - margin * 2
if (
widgetState.size[0] !== newWidth ||
widgetState.size[1] !== newHeight
) {
widgetState.size = [newWidth, newHeight]
}

const newZIndex = getDomWidgetZIndex(posNode, currentGraph)
if (widgetState.zIndex !== newZIndex) {
widgetState.zIndex = newZIndex
}

const newReadonly = lgCanvas.read_only
if (widgetState.readonly !== newReadonly) {
widgetState.readonly = newReadonly
}

const newComputedDisabled = widget.computedDisabled ?? false
if (widgetState.computedDisabled !== newComputedDisabled) {
widgetState.computedDisabled = newComputedDisabled
}
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/components/graph/widgets/DomWidget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ function createWidgetState(disabled: boolean): DomWidgetState {

state.zIndex = 2
state.size = [100, 40]
// DomWidgets.vue snapshots widget.computedDisabled into widgetState each frame.
// In unit tests there's no draw loop, so set the snapshot directly.
state.computedDisabled = disabled

return reactive(state)
}
Expand Down
34 changes: 29 additions & 5 deletions src/components/graph/widgets/DomWidget.vue
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ const updateDomClipping = () => {
const { left, top } = useElementBounding(canvasStore.getCanvas().canvas)

function composeStyle() {
const isDisabled = widget.computedDisabled
const isDisabled = widgetState.computedDisabled

style.value = {
...positionStyle.value,
Expand All @@ -115,15 +115,39 @@ function composeStyle() {
}

watch(
[() => widgetState, left, top, enableDomClipping],
([widgetState]) => {
[
() => widgetState.pos,
() => widgetState.size,
// Visibility transitions (e.g. LOD low_quality flipping) must refresh
// style: while invisible, DomWidgets.vue does not update widgetState
// and ds.offset/ds.scale are non-reactive, so updatePosition must be
// re-run against the current viewport when the widget reappears.
() => widgetState.visible,
left,
top
],
() => {
updatePosition(widgetState)
if (enableDomClipping.value) {
updateDomClipping()
}
composeStyle()
},
{ deep: true }
}
)

watch(
[
() => widgetState.zIndex,
() => widgetState.readonly,
() => widgetState.computedDisabled,
enableDomClipping
],
() => {
if (enableDomClipping.value) {
updateDomClipping()
}
composeStyle()
}
)

// Recompose style when clippingStyle updates asynchronously via RAF.
Expand Down
8 changes: 8 additions & 0 deletions src/stores/domWidgetStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ export interface DomWidgetState extends PositionConfig {
widget: Raw<BaseDOMWidget<object | string>>
visible: boolean
readonly: boolean
/**
* Mirrors `widget.computedDisabled` (set by litegraph when a widget input
* is connected). The underlying property is non-reactive, so DomWidgets.vue
* snapshots it into widgetState each frame and DomWidget.vue watches the
* snapshot to refresh opacity/pointer-events.
*/
computedDisabled: boolean
zIndex: number
/** If the widget belongs to the current graph/subgraph. */
active: boolean
Expand All @@ -33,6 +40,7 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
widget: markRaw(widget) as unknown as Raw<BaseDOMWidget<object | string>>,
visible: true,
readonly: false,
computedDisabled: false,
zIndex: 0,
pos: [0, 0],
size: [0, 0],
Expand Down
Loading