Compare commits

...

4 Commits

Author SHA1 Message Date
bymyself
3e3c53f1ed test: address review comments on pointer capture tests
- Add _finishDragZoom spy to verify drag zoom cleanup on non-primary pointerup
- Add hasPointerCapture assertions to verify guard is exercised
- Add pointercancel tests for useNodeResize and useNodeDrag to verify
  releasePointerCapture is called when gestures are interrupted
- Fix test isolation in useNodePointerInteractions by resetting
  isDraggingVueNodes state between tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 12:59:13 -07:00
bymyself
06a2a80bec Merge remote-tracking branch 'origin/main' into worktree-shift-drag-bug-investigation 2026-05-13 12:44:36 -07:00
bymyself
57f5b7f437 fix: also run _finishDragZoom on non-primary pointerup capture release
A drag-zoom session started with a primary pointer and ended by a non-primary
pointerup would leave _dragZoomStart set and read_only stuck, since the early
return previously skipped _finishDragZoom(). The method is a no-op when
_dragZoomStart is null, so calling it unconditionally is safe.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:25:30 -07:00
bymyself
0556244999 fix: harden pointer capture release and drag state for anomalous Mac events
Five pointer event handling bugs triggered by certain Mac hardware sending
isPrimary: false and spurious modifier keys (reproducible on specific MacBooks
across Safari and Chrome):

1. LGraphCanvas.processMouseUp: non-primary pointerup skipped pointer.reset()
   leaving canvas holding capture forever → UI frozen. Fix: release if pointerId
   matches the captured one.

2. useNodePointerInteractions.onPointermove: multiselect-drag branch fired on
   spurious shiftKey/metaKey even without a prior pointerdown on this node.
   Fix: add hasDraggingStarted guard.

3. useNodeResize cleanup(): pointercancel path was missing releasePointerCapture.
   Browser auto-releases on pointerup but not pointercancel. Fix: release via
   capturedPointerId in closure.

4. useNodeDrag.endDrag: never explicitly released capture; safe for pointerup
   (auto-release) but not for pointercancel. Fix: attempt release at top of endDrag.

5. useNodePointerInteractions.onPointercancel: only checked isDraggingVueNodes,
   missing hasDraggingStarted window before drag threshold. Fix: mirror onPointerup
   check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:08:49 -07:00
10 changed files with 424 additions and 19 deletions

View File

@@ -27,7 +27,10 @@ const config: KnipConfig = {
project: ['src/**/*.{js,ts}']
},
'packages/registry-types': {
project: ['src/**/*.{js,ts}']
// Auto-generated API types — wrapper types (paths, webhooks, $defs) are
// consumed cross-workspace via src/types/comfyRegistryTypes.ts re-export.
// Exclude the generated file from per-workspace project analysis.
project: []
},
'packages/ingest-types': {
project: ['src/**/*.{js,ts}']

View File

@@ -39,6 +39,7 @@ export function appendJsonExt(path: string) {
return path
}
/** @knipIgnoreUnusedButUsedByCustomNodes */
export type WorkflowSuffix = typeof JSON_SUFFIX | typeof APP_JSON_SUFFIX
export function getWorkflowSuffix(
@@ -605,6 +606,7 @@ const TEXT_EXTENSIONS = [
] as const
const MEDIA_TYPES = ['image', 'video', 'audio', '3D', 'text', 'other'] as const
/** @knipIgnoreUnusedButUsedByCustomNodes */
export type MediaType = (typeof MEDIA_TYPES)[number]
// Type guard helper for checking array membership

View File

@@ -0,0 +1,219 @@
/**
* Tests for pointer capture release behavior in processMouseUp.
*
* Some Mac hardware (certain trackpads/mice) sends pointerup events with
* isPrimary=false even for the primary pointer. Without a fix, the canvas
* holds pointer capture indefinitely — making the entire UI unresponsive.
*
* @vitest-environment jsdom
*/
import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
querySlotAtPoint: vi.fn(),
queryRerouteAtPoint: vi.fn(),
queryLinkSegmentAtPoint: vi.fn(),
getNodeLayoutRef: vi.fn(() => ({ value: null })),
getSlotLayout: vi.fn(),
setSource: vi.fn(),
batchUpdateNodeBounds: vi.fn()
}
}))
function createCanvas(graph: LGraph): {
canvas: LGraphCanvas
el: HTMLCanvasElement
releasePointerCapture: ReturnType<typeof vi.fn>
hasPointerCapture: ReturnType<typeof vi.fn>
} {
const el = document.createElement('canvas')
el.width = 800
el.height = 600
// Track captured pointer IDs
const capturedPointers = new Set<number>()
const setPointerCapture = vi
.fn()
.mockImplementation((id: number) => capturedPointers.add(id))
const releasePointerCapture = vi
.fn()
.mockImplementation((id: number) => capturedPointers.delete(id))
const hasPointerCapture = vi
.fn()
.mockImplementation((id: number) => capturedPointers.has(id))
el.setPointerCapture = setPointerCapture
el.releasePointerCapture = releasePointerCapture
el.hasPointerCapture = hasPointerCapture
const ctx = {
save: vi.fn(),
restore: vi.fn(),
translate: vi.fn(),
scale: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn().mockReturnValue({ width: 50 }),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fill: vi.fn(),
closePath: vi.fn(),
arc: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
clearRect: vi.fn(),
setTransform: vi.fn(),
roundRect: vi.fn(),
getTransform: vi
.fn()
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
globalAlpha: 1,
textAlign: 'left' as CanvasTextAlign,
textBaseline: 'alphabetic' as CanvasTextBaseline
} satisfies Partial<CanvasRenderingContext2D>
el.getContext = vi
.fn()
.mockReturnValue(fromAny<CanvasRenderingContext2D, unknown>(ctx))
el.getBoundingClientRect = vi.fn().mockReturnValue({
left: 0,
top: 0,
width: 800,
height: 600
})
const canvas = new LGraphCanvas(el, graph, { skip_render: true })
return { canvas, el, releasePointerCapture, hasPointerCapture }
}
function makePointerEvent(
type: string,
init: Partial<PointerEventInit> & { isPrimary?: boolean }
): PointerEvent {
// jsdom PointerEvent doesn't support isPrimary override in constructor,
// so we patch it after creation
const e = new PointerEvent(type, {
pointerId: 1,
button: 0,
buttons: 1,
clientX: 100,
clientY: 100,
bubbles: true,
cancelable: true,
...init
})
if (init.isPrimary !== undefined) {
Object.defineProperty(e, 'isPrimary', { value: init.isPrimary })
}
return e
}
describe('LGraphCanvas processMouseUp — pointer capture release', () => {
let graph: LGraph
let canvas: LGraphCanvas
let releasePointerCapture: ReturnType<typeof vi.fn>
let hasPointerCapture: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
graph = new LGraph()
;({ canvas, releasePointerCapture, hasPointerCapture } =
createCanvas(graph))
})
it('releases capture and finishes drag zoom when non-primary pointerup arrives with captured pointerId', () => {
const finishDragZoomSpy = vi.spyOn(
canvas as unknown as { _finishDragZoom: () => void },
'_finishDragZoom'
)
// Simulate primary pointerdown to set up capture
const downEvent = makePointerEvent('pointerdown', {
pointerId: 1,
isPrimary: true
})
canvas.processMouseDown(downEvent as unknown as CanvasPointerEvent)
// Sanity: pointer.pointerId should now be set
expect(canvas.pointer.pointerId).toBe(1)
// Non-primary pointerup with matching pointerId (the Mac bug scenario)
const upEvent = makePointerEvent('pointerup', {
pointerId: 1,
isPrimary: false,
button: 0,
buttons: 0
})
canvas.processMouseUp(upEvent)
// _finishDragZoom must be called to clean up any drag-zoom state
expect(finishDragZoomSpy).toHaveBeenCalled()
// Capture must be released — otherwise the canvas holds it forever
expect(hasPointerCapture).toHaveBeenCalledWith(1)
expect(releasePointerCapture).toHaveBeenCalledWith(1)
expect(canvas.pointer.pointerId).toBeUndefined()
})
it('does NOT release capture when a non-primary pointerup has a different pointerId', () => {
// Simulate primary pointerdown capturing pointerId 1
const downEvent = makePointerEvent('pointerdown', {
pointerId: 1,
isPrimary: true
})
canvas.processMouseDown(downEvent as unknown as CanvasPointerEvent)
// Non-primary pointerup for a different pointer (e.g. pointerId 2)
const upEvent = makePointerEvent('pointerup', {
pointerId: 2,
isPrimary: false,
button: 0,
buttons: 0
})
canvas.processMouseUp(upEvent)
// Capture for pointerId 1 should still be held
expect(canvas.pointer.pointerId).toBe(1)
})
it('processes normally when a primary pointerup arrives', () => {
const finishDragZoomSpy = vi.spyOn(
canvas as unknown as { _finishDragZoom: () => void },
'_finishDragZoom'
)
const downEvent = makePointerEvent('pointerdown', {
pointerId: 1,
isPrimary: true
})
canvas.processMouseDown(downEvent as unknown as CanvasPointerEvent)
const upEvent = makePointerEvent('pointerup', {
pointerId: 1,
isPrimary: true,
button: 0,
buttons: 0
})
canvas.processMouseUp(upEvent)
// _finishDragZoom is called for cleanup
expect(finishDragZoomSpy).toHaveBeenCalled()
// Normal pointerup releases capture via the standard path
expect(hasPointerCapture).toHaveBeenCalledWith(1)
expect(releasePointerCapture).toHaveBeenCalledWith(1)
expect(canvas.pointer.pointerId).toBeUndefined()
})
})

View File

@@ -3796,8 +3796,20 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* Called when a mouse up event has to be processed
*/
processMouseUp(e: PointerEvent): void {
// early exit for extra pointer
if (e.isPrimary === false) return
// Release pointer capture for non-primary pointers that match the captured pointer ID.
// Some devices (e.g. certain Mac trackpads/mice) send pointerup with isPrimary=false,
// which would otherwise leave the canvas holding capture indefinitely.
if (e.isPrimary === false) {
const { pointer } = this
if (
typeof pointer.pointerId === 'number' &&
pointer.pointerId === e.pointerId
) {
this._finishDragZoom()
pointer.reset()
}
return
}
const { graph, pointer } = this
if (!graph) return

View File

@@ -133,8 +133,9 @@ const createMouseEvent = (
describe('useNodePointerInteractions', () => {
beforeEach(async () => {
vi.resetAllMocks()
vi.clearAllMocks()
selectedItemsState.items = []
layoutStore.isDraggingVueNodes.value = false
setActivePinia(createTestingPinia())
})
@@ -270,6 +271,79 @@ describe('useNodePointerInteractions', () => {
expect(handleNodeSelect).toHaveBeenCalledTimes(1)
})
describe('spurious modifier key on pointermove — Mac hardware bug', () => {
it('does NOT trigger multiselect-drag when shiftKey is true but pointerdown was never received', () => {
// This simulates a slot drag where a pointermove bubbles up to the node
// with spurious shiftKey=true, but this node never received pointerdown.
const { handleNodeSelect } = useNodeEventHandlers()
const { startDrag } = useNodeDrag()
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
// No pointerdown on this node — hasDraggingStarted remains false
// A pointermove arrives with shiftKey=true and LMB down (bubbling from a slot drag)
const moveEvent = createPointerEvent('pointermove', {
shiftKey: true,
buttons: 1,
clientX: 110,
clientY: 110
})
pointerHandlers.onPointermove(moveEvent)
// Should NOT have triggered multiselect-drag selection or drag start
expect(handleNodeSelect).not.toHaveBeenCalled()
expect(startDrag).not.toHaveBeenCalled()
expect(layoutStore.isDraggingVueNodes.value).toBe(false)
})
it('does NOT trigger multiselect-drag when metaKey is true but pointerdown was never received', () => {
const { handleNodeSelect } = useNodeEventHandlers()
const { startDrag } = useNodeDrag()
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
// No pointerdown on this node
const moveEvent = createPointerEvent('pointermove', {
metaKey: true,
buttons: 1,
clientX: 110,
clientY: 110
})
pointerHandlers.onPointermove(moveEvent)
expect(handleNodeSelect).not.toHaveBeenCalled()
expect(startDrag).not.toHaveBeenCalled()
})
it('DOES trigger multiselect-drag when shiftKey is true and pointerdown was received first', () => {
const { handleNodeSelect } = useNodeEventHandlers()
const { startDrag } = useNodeDrag()
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
// This node received pointerdown — hasDraggingStarted becomes true
pointerHandlers.onPointerdown(
createPointerEvent('pointerdown', { clientX: 100, clientY: 100 })
)
// pointermove with shiftKey (intentional multiselect drag)
const moveEvent = createPointerEvent('pointermove', {
shiftKey: true,
buttons: 1,
clientX: 110,
clientY: 110
})
pointerHandlers.onPointermove(moveEvent)
// Should trigger multiselect-drag selection and start drag
expect(handleNodeSelect).toHaveBeenCalledWith(moveEvent, 'test-node-123')
expect(startDrag).toHaveBeenCalled()
expect(layoutStore.isDraggingVueNodes.value).toBe(true)
})
})
it('on ctrl+click: calls toggleNodeSelectionAfterPointerUp on pointer up (not pointer down)', async () => {
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
const { toggleNodeSelectionAfterPointerUp } = useNodeEventHandlers()

View File

@@ -76,7 +76,16 @@ export function useNodePointerInteractions(
const multiSelect = isMultiSelectKey(event)
const lmbDown = event.buttons & 1
if (lmbDown && multiSelect && !layoutStore.isDraggingVueNodes.value) {
// Guard with hasDraggingStarted: only enter multiselect-drag branch if this node
// instance received the original pointerdown. Without this, a slot drag bubbling
// a pointermove with spurious modifier keys (observed on some Mac hardware) would
// incorrectly trigger node selection and a competing drag.
if (
lmbDown &&
multiSelect &&
hasDraggingStarted &&
!layoutStore.isDraggingVueNodes.value
) {
layoutStore.isDraggingVueNodes.value = true
handleNodeSelect(event, nodeId)
safeDragStart(event, nodeId)
@@ -149,8 +158,9 @@ export function useNodePointerInteractions(
}
function onPointercancel(event: PointerEvent) {
if (!layoutStore.isDraggingVueNodes.value) return
safeDragEnd(event)
if (hasDraggingStarted || layoutStore.isDraggingVueNodes.value) {
safeDragEnd(event)
}
}
/**

View File

@@ -11,10 +11,11 @@ type ResizeCallback = (
element: HTMLElement
) => void
// Capture pointermove/pointerup handlers registered via useEventListener
// Capture pointermove/pointerup/pointercancel handlers registered via useEventListener
const eventHandlers = vi.hoisted(() => ({
pointermove: null as ((e: PointerEvent) => void) | null,
pointerup: null as ((e: PointerEvent) => void) | null
pointerup: null as ((e: PointerEvent) => void) | null,
pointercancel: null as ((e?: PointerEvent) => void) | null
}))
vi.mock('@vueuse/core', () => ({
@@ -22,6 +23,8 @@ vi.mock('@vueuse/core', () => ({
(eventName: string, handler: (...args: unknown[]) => void) => {
if (eventName === 'pointermove' || eventName === 'pointerup') {
eventHandlers[eventName] = handler as (e: PointerEvent) => void
} else if (eventName === 'pointercancel') {
eventHandlers.pointercancel = handler as (e?: PointerEvent) => void
}
return vi.fn()
}
@@ -97,12 +100,21 @@ function createMockNodeElement(
return element
}
function createMockHandle(nodeElement: HTMLElement): HTMLElement {
function createMockHandle(nodeElement: HTMLElement) {
const handle = document.createElement('div')
nodeElement.appendChild(handle)
handle.setPointerCapture = vi.fn()
handle.releasePointerCapture = vi.fn()
return handle
const capturedPointerIds = new Set<number>()
handle.setPointerCapture = vi.fn((id: number) => capturedPointerIds.add(id))
handle.releasePointerCapture = vi.fn((id: number) =>
capturedPointerIds.delete(id)
)
handle.hasPointerCapture = vi.fn((id: number) => capturedPointerIds.has(id))
return Object.assign(handle, {
capturedPointerIds,
releasePointerCapture: handle.releasePointerCapture as ReturnType<
typeof vi.fn
>
})
}
function createPointerEvent(
@@ -159,6 +171,7 @@ describe('useNodeResize', () => {
vi.clearAllMocks()
eventHandlers.pointermove = null
eventHandlers.pointerup = null
eventHandlers.pointercancel = null
snapState.shouldSnap = false
snapState.applySnapToPosition = (pos) => pos
snapState.applySnapToSize = (size) => size
@@ -413,6 +426,28 @@ describe('useNodeResize', () => {
expect(el).toBeDefined()
})
it('releases pointer capture on pointercancel', async () => {
const { cb, handle: h, startResize } = await setupDynamic(() => 150)
startResizeAt(startResize, h, 'SE')
simulateMove(10, 10)
// Verify pointer is captured
expect(h.capturedPointerIds.has(1)).toBe(true)
// Fire pointercancel (e.g. OS gesture interrupt)
eventHandlers.pointercancel?.()
// Pointer capture should be released
expect(h.releasePointerCapture).toHaveBeenCalledWith(1)
expect(h.capturedPointerIds.has(1)).toBe(false)
// Further moves are ignored — cleanup ran
const callsAfterCancel = cb.mock.calls.length
simulateMove(50, 50)
expect(cb.mock.calls.length).toBe(callsAfterCancel)
})
it('applies snap-to-grid on SE (size only)', async () => {
snapState.shouldSnap = true
snapState.applySnapToSize = ({ width, height }) => ({

View File

@@ -203,6 +203,8 @@ export function useNodeResize(
}
}
const capturedPointerId = event.pointerId
const cleanup = () => {
if (!isResizing.value) return
isResizing.value = false
@@ -214,18 +216,22 @@ export function useNodeResize(
// Stop tracking shift key state
stopShiftSync()
// Release pointer capture — needed on pointercancel since browser doesn't auto-release
try {
if (target.hasPointerCapture(capturedPointerId)) {
target.releasePointerCapture(capturedPointerId)
}
} catch {
// Already released
}
stopMoveListen()
stopUpListen()
stopCancelListen()
}
const handlePointerUp = (upEvent: PointerEvent) => {
const handlePointerUp = () => {
if (isResizing.value) {
try {
target.releasePointerCapture(upEvent.pointerId)
} catch {
// Pointer capture may already be released
}
cleanup()
}
}

View File

@@ -125,6 +125,27 @@ function pointerEvent(clientX: number, clientY: number): PointerEvent {
return fromPartial<PointerEvent>({ clientX, clientY, target, pointerId: 1 })
}
function pointerCancelEvent(clientX: number, clientY: number) {
const target = document.createElement('div')
target.hasPointerCapture = vi.fn(() => true)
target.setPointerCapture = vi.fn()
target.releasePointerCapture = vi.fn()
const event = fromPartial<PointerEvent>({
type: 'pointercancel',
clientX,
clientY,
target,
pointerId: 1
})
return Object.assign(event, {
target: Object.assign(target, {
releasePointerCapture: target.releasePointerCapture as ReturnType<
typeof vi.fn
>
})
})
}
describe('useNodeDrag', () => {
beforeEach(() => {
testState.selectedNodeIds = ref(new Set<string>())
@@ -342,4 +363,17 @@ describe('useNodeDrag auto-pan', () => {
expect(testState.mutationFns.batchMoveNodes).not.toHaveBeenCalled()
})
it('releases pointer capture on pointercancel', () => {
const drag = useNodeDrag()
drag.startDrag(pointerEvent(400, 300), '1')
// Create pointercancel event with target that has pointer capture
const cancelEvent = pointerCancelEvent(400, 300)
drag.endDrag(cancelEvent, '1')
// releasePointerCapture should be called for pointercancel
expect(cancelEvent.target.releasePointerCapture).toHaveBeenCalledWith(1)
})
})

View File

@@ -215,6 +215,16 @@ function useNodeDragIndividual() {
}
function endDrag(event: PointerEvent, nodeId: NodeId | undefined) {
// Release pointer capture in case this is a pointercancel (browser doesn't auto-release on cancel)
const { target, pointerId } = event
if (target instanceof HTMLElement && target.hasPointerCapture(pointerId)) {
try {
target.releasePointerCapture(pointerId)
} catch {
// Already released
}
}
// Apply snap to final position if snap was active (matches LiteGraph behavior)
if (shouldSnap(event) && nodeId) {
const boundsUpdates: NodeBoundsUpdate[] = []