diff --git a/src/composables/node/useNodeAnimatedImage.test.ts b/src/composables/node/useNodeAnimatedImage.test.ts new file mode 100644 index 0000000000..4b5b421cd8 --- /dev/null +++ b/src/composables/node/useNodeAnimatedImage.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, onTestFinished, vi } from 'vitest' + +import { useNodeAnimatedImage } from '@/composables/node/useNodeAnimatedImage' +import { createMockMediaNode } from '@/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils' + +const { canvasInteractionsMock } = vi.hoisted(() => ({ + canvasInteractionsMock: { + handleWheel: vi.fn(), + handlePointer: vi.fn(), + forwardEventToCanvas: vi.fn() + } +})) + +vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({ + useCanvasInteractions: () => canvasInteractionsMock +})) +// `@/scripts/app` has a heavy import graph (pinia stores, LGraphCanvas, etc.) +// that we cannot pull in here, so we stub only the constant we need. +vi.mock('@/scripts/app', () => ({ + ANIM_PREVIEW_WIDGET: '$$comfy_animation_preview' +})) + +describe('useNodeAnimatedImage', () => { + function setup() { + vi.clearAllMocks() + const node = createMockMediaNode({ imgs: [document.createElement('img')] }) + const { showAnimatedPreview, removeAnimatedPreview } = + useNodeAnimatedImage() + showAnimatedPreview(node) + const element = node.widgets[0].element + document.body.append(element) + onTestFinished(() => element.remove()) + return { node, element, showAnimatedPreview, removeAnimatedPreview } + } + + it('forwards non-right-click pointer events and wheel to the canvas while alive', () => { + const { element } = setup() + element.dispatchEvent(new WheelEvent('wheel')) + element.dispatchEvent(new PointerEvent('pointermove')) + element.dispatchEvent(new PointerEvent('pointerup')) + element.dispatchEvent(new PointerEvent('pointerdown', { button: 0 })) + + expect(canvasInteractionsMock.handleWheel).toHaveBeenCalledTimes(1) + expect(canvasInteractionsMock.handlePointer).toHaveBeenCalledTimes(3) + expect(canvasInteractionsMock.forwardEventToCanvas).not.toHaveBeenCalled() + }) + + it('routes right-click pointerdown through forwardEventToCanvas, not handlePointer', () => { + const { element } = setup() + element.dispatchEvent(new PointerEvent('pointerdown', { button: 2 })) + + expect(canvasInteractionsMock.forwardEventToCanvas).toHaveBeenCalledTimes(1) + expect(canvasInteractionsMock.handlePointer).not.toHaveBeenCalled() + }) + + it('detaches every listener when the preview is removed', () => { + const { node, element, removeAnimatedPreview } = setup() + removeAnimatedPreview(node) + + element.dispatchEvent(new WheelEvent('wheel')) + element.dispatchEvent(new PointerEvent('pointermove')) + element.dispatchEvent(new PointerEvent('pointerup')) + element.dispatchEvent(new PointerEvent('pointerdown', { button: 0 })) + element.dispatchEvent(new PointerEvent('pointerdown', { button: 2 })) + + expect(canvasInteractionsMock.handleWheel).not.toHaveBeenCalled() + expect(canvasInteractionsMock.handlePointer).not.toHaveBeenCalled() + expect(canvasInteractionsMock.forwardEventToCanvas).not.toHaveBeenCalled() + }) +}) diff --git a/src/composables/node/useNodeAnimatedImage.ts b/src/composables/node/useNodeAnimatedImage.ts index 962396b0e9..39a5eb9ff8 100644 --- a/src/composables/node/useNodeAnimatedImage.ts +++ b/src/composables/node/useNodeAnimatedImage.ts @@ -1,3 +1,4 @@ +import { useChainCallback } from '@/composables/functional/useChainCallback' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' import { ANIM_PREVIEW_WIDGET } from '@/scripts/app' @@ -39,17 +40,20 @@ export function useNodeAnimatedImage() { const { handleWheel, handlePointer, forwardEventToCanvas } = useCanvasInteractions() node.imgs[0].style.pointerEvents = 'none' - element.addEventListener('wheel', handleWheel) - element.addEventListener('pointermove', handlePointer) - element.addEventListener('pointerup', handlePointer) + const controller = new AbortController() + const { signal } = controller + element.addEventListener('wheel', handleWheel, { signal }) + element.addEventListener('pointermove', handlePointer, { signal }) + element.addEventListener('pointerup', handlePointer, { signal }) element.addEventListener( 'pointerdown', - (e) => { - return e.button !== 2 ? handlePointer(e) : forwardEventToCanvas(e) - }, - true + (e) => (e.button !== 2 ? handlePointer(e) : forwardEventToCanvas(e)), + { capture: true, signal } ) + widget.onRemove = useChainCallback(widget.onRemove, () => { + controller.abort() + }) widget.serialize = false widget.serializeValue = () => undefined } diff --git a/src/composables/node/useNodeImage.test.ts b/src/composables/node/useNodeImage.test.ts new file mode 100644 index 0000000000..eb417afe21 --- /dev/null +++ b/src/composables/node/useNodeImage.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' + +import { useNodeVideo } from '@/composables/node/useNodeImage' +import { createMockMediaNode } from '@/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils' + +const { canvasInteractionsMock, nodeOutputStoreMock } = vi.hoisted(() => ({ + canvasInteractionsMock: { + handleWheel: vi.fn(), + handlePointer: vi.fn() + }, + nodeOutputStoreMock: { + getNodeImageUrls: vi.fn<(node: unknown) => string[] | undefined>() + } +})) + +vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({ + useCanvasInteractions: () => canvasInteractionsMock +})) +vi.mock('@/stores/nodeOutputStore', () => ({ + useNodeOutputStore: () => nodeOutputStoreMock +})) +vi.mock('@/utils/imageUtil', () => ({ + fitDimensionsToNodeWidth: () => ({ minHeight: 256, minWidth: 256 }) +})) + +describe('useNodeVideo', () => { + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + async function setup() { + vi.clearAllMocks() + vi.useFakeTimers() + + nodeOutputStoreMock.getNodeImageUrls.mockReturnValue(['http://video/1.mp4']) + const node = createMockMediaNode({ + size: [400, 400], + graph: { setDirtyCanvas: vi.fn() } + }) + + const createdVideos: HTMLVideoElement[] = [] + const realCreateElement = document.createElement.bind(document) + vi.spyOn(document, 'createElement').mockImplementation( + (tag: string, opts?: ElementCreationOptions) => { + const el = realCreateElement(tag, opts) + if (tag === 'video') createdVideos.push(el as HTMLVideoElement) + return el + } + ) + + const { showPreview } = useNodeVideo(node) + showPreview() + + // happy-dom does not auto-fire onloadeddata for src assignment, so we + // manually trigger it, then drain the resulting promise chain. + const video = createdVideos[0] + video.onloadeddata?.(new Event('loadeddata')) + await vi.runAllTimersAsync() + + onTestFinished(() => { + node.widgets[0]?.onRemove?.() + }) + + return { node, video } + } + + it('creates a video-preview widget and forwards canvas events while alive', async () => { + const { node, video } = await setup() + + expect(node.widgets[0]?.name).toBe('video-preview') + + video.dispatchEvent(new WheelEvent('wheel', { bubbles: true })) + video.dispatchEvent(new PointerEvent('pointermove', { bubbles: true })) + video.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) + + expect(canvasInteractionsMock.handleWheel).toHaveBeenCalledTimes(1) + expect(canvasInteractionsMock.handlePointer).toHaveBeenCalledTimes(2) + }) + + it('detaches every listener when the widget is removed', async () => { + const { node, video } = await setup() + + node.widgets[0]?.onRemove?.() + + video.dispatchEvent(new WheelEvent('wheel', { bubbles: true })) + video.dispatchEvent(new PointerEvent('pointermove', { bubbles: true })) + video.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) + + expect(canvasInteractionsMock.handleWheel).not.toHaveBeenCalled() + expect(canvasInteractionsMock.handlePointer).not.toHaveBeenCalled() + }) +}) diff --git a/src/composables/node/useNodeImage.ts b/src/composables/node/useNodeImage.ts index ea3d1d0099..86c5e5ac68 100644 --- a/src/composables/node/useNodeImage.ts +++ b/src/composables/node/useNodeImage.ts @@ -1,3 +1,4 @@ +import { useChainCallback } from '@/composables/functional/useChainCallback' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' import { useNodeOutputStore } from '@/stores/nodeOutputStore' @@ -151,11 +152,6 @@ export const useNodeVideo = (node: LGraphNode, callback?: () => void) => { const video = document.createElement('video') Object.assign(video, VIDEO_DEFAULT_OPTIONS) - // Add event listeners for canvas interactions - video.addEventListener('wheel', handleWheel) - video.addEventListener('pointermove', handlePointer) - video.addEventListener('pointerdown', handlePointer) - video.onloadeddata = () => { setMinDimensions(video) resolve(video) @@ -176,6 +172,16 @@ export const useNodeVideo = (node: LGraphNode, callback?: () => void) => { minHeight, minWidth }) + + const controller = new AbortController() + const { signal } = controller + container.addEventListener('wheel', handleWheel, { signal }) + container.addEventListener('pointermove', handlePointer, { signal }) + container.addEventListener('pointerdown', handlePointer, { signal }) + + widget.onRemove = useChainCallback(widget.onRemove, () => { + controller.abort() + }) } } diff --git a/src/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils.ts b/src/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils.ts new file mode 100644 index 0000000000..b7736eee9e --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils.ts @@ -0,0 +1,60 @@ +import { fromAny } from '@total-typescript/shoehorn' +import { vi } from 'vitest' + +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' + +interface FakeDOMWidget { + name: string + type: string + element: HTMLElement + options: Record + value: string + callback?: (value: string) => void + onRemove?: () => void + serialize?: boolean + serializeValue?: () => unknown +} + +interface FakeMediaWidget { + name: string + element: HTMLElement + onRemove?: () => void + serialize?: boolean + serializeValue?: () => unknown + computeLayoutSize?: () => { minHeight: number; minWidth: number } +} + +type NodeOverrides = Record & { widgets?: never } + +export function createMockDOMWidgetNode(overrides: NodeOverrides = {}) { + const widgets: FakeDOMWidget[] = [] + return fromAny({ + id: 1, + widgets, + addDOMWidget: vi.fn((name: string, type: string, element: HTMLElement) => { + const widget: FakeDOMWidget = { + name, + type, + element, + options: {}, + value: '' + } + widgets.push(widget) + return widget + }), + ...overrides + }) +} + +export function createMockMediaNode(overrides: NodeOverrides = {}) { + const widgets: FakeMediaWidget[] = [] + return fromAny({ + widgets, + addDOMWidget: vi.fn((name: string, _type: string, element: HTMLElement) => { + const widget: FakeMediaWidget = { name, element } + widgets.push(widget) + return widget + }), + ...overrides + }) +} diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.test.ts b/src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.test.ts new file mode 100644 index 0000000000..b5d5796f0d --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, onTestFinished, vi } from 'vitest' + +import type * as Litegraph from '@/lib/litegraph/src/litegraph' +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' +import type { DOMWidget } from '@/scripts/domWidget' +import { useMarkdownWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget' +import { createMockDOMWidgetNode } from '@/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils' + +const { canvasMock } = vi.hoisted(() => ({ + canvasMock: { + processMouseDown: vi.fn(), + processMouseMove: vi.fn(), + processMouseUp: vi.fn() + } +})) + +vi.mock('@/scripts/app', () => ({ + app: { rootGraph: { id: 'root' }, canvas: canvasMock } +})) +vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, resolveNodeRootGraphId: vi.fn(() => 'root') } +}) +vi.mock('@/stores/widgetValueStore', () => ({ + useWidgetValueStore: () => ({ getWidget: () => undefined }) +})) + +function createMarkdownWidget(node: LGraphNode) { + const inputSpec: InputSpec = { + type: 'MARKDOWN', + name: 'note', + default: '' + } + return useMarkdownWidget()(node, inputSpec) as DOMWidget +} + +describe('useMarkdownWidget', () => { + function setup() { + vi.clearAllMocks() + const node = createMockDOMWidgetNode() + const widget = createMarkdownWidget(node) + const callback = vi.fn<(value: string) => void>() + widget.callback = callback + const inputEl = widget.element + const textarea = inputEl.querySelector('textarea')! + const parentKeydown = vi.fn<(ev: KeyboardEvent) => void>() + document.body.append(inputEl) + document.body.addEventListener('keydown', parentKeydown) + onTestFinished(() => { + document.body.removeEventListener('keydown', parentKeydown) + inputEl.remove() + }) + return { widget, inputEl, textarea, callback, parentKeydown } + } + + it('fires the widget callback on textarea input and change', () => { + const { textarea, callback } = setup() + textarea.value = 'hello' + textarea.dispatchEvent(new Event('input', { bubbles: true })) + textarea.dispatchEvent(new Event('change', { bubbles: true })) + expect(callback).toHaveBeenCalledTimes(2) + }) + + it('toggles editing on dblclick/blur and stops keydown propagation', () => { + const { inputEl, textarea, parentKeydown } = setup() + inputEl.dispatchEvent(new Event('dblclick', { bubbles: true })) + expect(inputEl.classList.contains('editing')).toBe(true) + + textarea.dispatchEvent(new Event('blur')) + expect(inputEl.classList.contains('editing')).toBe(false) + + inputEl.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true })) + expect(parentKeydown).not.toHaveBeenCalled() + }) + + it('forwards middle-click pointer events to the canvas while alive', () => { + const { inputEl } = setup() + inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 1 })) + inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 4 })) + inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 1 })) + + expect(canvasMock.processMouseDown).toHaveBeenCalledTimes(1) + expect(canvasMock.processMouseMove).toHaveBeenCalledTimes(1) + expect(canvasMock.processMouseUp).toHaveBeenCalledTimes(1) + }) + + it('detaches every listener and lets keydown bubble after removal', () => { + const { widget, inputEl, textarea, callback, parentKeydown } = setup() + widget.onRemove?.() + + textarea.value = 'after' + textarea.dispatchEvent(new Event('input', { bubbles: true })) + textarea.dispatchEvent(new Event('change', { bubbles: true })) + inputEl.dispatchEvent(new Event('dblclick', { bubbles: true })) + inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 1 })) + inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 4 })) + inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 1 })) + inputEl.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true })) + + expect(callback).not.toHaveBeenCalled() + expect(canvasMock.processMouseDown).not.toHaveBeenCalled() + expect(canvasMock.processMouseMove).not.toHaveBeenCalled() + expect(canvasMock.processMouseUp).not.toHaveBeenCalled() + expect(inputEl.classList.contains('editing')).toBe(false) + // keydown listener (which called stopPropagation) is gone, so the event + // now bubbles to the parent. + expect(parentKeydown).toHaveBeenCalledTimes(1) + }) + + it('survives onRemove being invoked twice', () => { + const { widget } = setup() + widget.onRemove?.() + expect(() => widget.onRemove?.()).not.toThrow() + }) +}) diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.ts index 694baf86e4..a04323770b 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.ts @@ -7,6 +7,7 @@ import TiptapTableRow from '@tiptap/extension-table-row' import TiptapStarterKit from '@tiptap/starter-kit' import { Markdown as TiptapMarkdown } from 'tiptap-markdown' +import { useChainCallback } from '@/composables/functional/useChainCallback' import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' @@ -64,49 +65,73 @@ function addMarkdownWidget( widget.element = inputEl widget.options.minNodeSize = [400, 200] - inputEl.addEventListener('input', (event) => { - if (event.target instanceof HTMLTextAreaElement) { - widget.value = event.target.value - } - widget.callback?.(widget.value) + const controller = new AbortController() + const { signal } = controller + + inputEl.addEventListener( + 'input', + (event) => { + if (event.target instanceof HTMLTextAreaElement) { + widget.value = event.target.value + } + widget.callback?.(widget.value) + }, + { signal } + ) + + inputEl.addEventListener( + 'dblclick', + () => { + inputEl.classList.add('editing') + setTimeout(() => textarea.focus(), 0) + }, + { signal } + ) + + textarea.addEventListener('blur', () => inputEl.classList.remove('editing'), { + signal }) - inputEl.addEventListener('dblclick', () => { - inputEl.classList.add('editing') - setTimeout(() => { - textarea.focus() - }, 0) + textarea.addEventListener( + 'change', + () => { + editor.commands.setContent(textarea.value) + widget.callback?.(widget.value) + }, + { signal } + ) + + inputEl.addEventListener('keydown', (event) => event.stopPropagation(), { + signal }) - textarea.addEventListener('blur', () => { - inputEl.classList.remove('editing') - }) + inputEl.addEventListener( + 'pointerdown', + (event) => { + if (event.button === 1) app.canvas.processMouseDown(event) + }, + { signal } + ) - textarea.addEventListener('change', () => { - editor.commands.setContent(textarea.value) - widget.callback?.(widget.value) - }) + inputEl.addEventListener( + 'pointermove', + (event) => { + if ((event.buttons & 4) === 4) app.canvas.processMouseMove(event) + }, + { signal } + ) - inputEl.addEventListener('keydown', (event: KeyboardEvent) => { - event.stopPropagation() - }) + inputEl.addEventListener( + 'pointerup', + (event) => { + if (event.button === 1) app.canvas.processMouseUp(event) + }, + { signal } + ) - inputEl.addEventListener('pointerdown', (event: PointerEvent) => { - if (event.button === 1) { - app.canvas.processMouseDown(event) - } - }) - - inputEl.addEventListener('pointermove', (event: PointerEvent) => { - if ((event.buttons & 4) === 4) { - app.canvas.processMouseMove(event) - } - }) - - inputEl.addEventListener('pointerup', (event: PointerEvent) => { - if (event.button === 1) { - app.canvas.processMouseUp(event) - } + widget.onRemove = useChainCallback(widget.onRemove, () => { + controller.abort() + if (!editor.isDestroyed) editor.destroy() }) return widget diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.test.ts b/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.test.ts new file mode 100644 index 0000000000..2c7258f37c --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, onTestFinished, vi } from 'vitest' + +import type * as Litegraph from '@/lib/litegraph/src/litegraph' +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' +import type { DOMWidget } from '@/scripts/domWidget' +import { useStringWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useStringWidget' +import { createMockDOMWidgetNode } from '@/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils' + +const { canvasMock } = vi.hoisted(() => ({ + canvasMock: { + processMouseDown: vi.fn(), + processMouseMove: vi.fn(), + processMouseUp: vi.fn(), + processMouseWheel: vi.fn() + } +})) + +vi.mock('@/scripts/app', () => ({ + app: { rootGraph: { id: 'root' }, canvas: canvasMock } +})) +vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, resolveNodeRootGraphId: vi.fn(() => 'root') } +}) +vi.mock('@/stores/widgetValueStore', () => ({ + useWidgetValueStore: () => ({ getWidget: () => undefined }) +})) +vi.mock('@/platform/settings/settingStore', () => ({ + useSettingStore: () => ({ get: () => false }) +})) + +function createStringWidget(node: LGraphNode) { + const inputSpec: InputSpec = { + type: 'STRING', + name: 'prompt', + default: '', + multiline: true + } + return useStringWidget()(node, inputSpec) as DOMWidget< + HTMLTextAreaElement, + string + > +} + +describe('useStringWidget (multiline)', () => { + function setup() { + vi.clearAllMocks() + const node = createMockDOMWidgetNode() + const widget = createStringWidget(node) + const callback = vi.fn<(value: string) => void>() + widget.callback = callback + const inputEl = widget.element + document.body.append(inputEl) + onTestFinished(() => inputEl.remove()) + return { widget, inputEl, callback } + } + + it('fires the widget callback on input', () => { + const { inputEl, callback } = setup() + inputEl.value = 'hello' + inputEl.dispatchEvent(new Event('input', { bubbles: true })) + expect(callback).toHaveBeenCalledTimes(1) + }) + + it('forwards middle-click pointer events and ctrl+wheel to the canvas while alive', () => { + const { inputEl } = setup() + inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 1 })) + inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 4 })) + inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 1 })) + inputEl.dispatchEvent(new WheelEvent('wheel', { ctrlKey: true })) + + expect(canvasMock.processMouseDown).toHaveBeenCalledTimes(1) + expect(canvasMock.processMouseMove).toHaveBeenCalledTimes(1) + expect(canvasMock.processMouseUp).toHaveBeenCalledTimes(1) + expect(canvasMock.processMouseWheel).toHaveBeenCalledTimes(1) + }) + + it('detaches every listener when the widget is removed', () => { + const { widget, inputEl, callback } = setup() + widget.onRemove?.() + + inputEl.value = 'after' + inputEl.dispatchEvent(new Event('input', { bubbles: true })) + inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 1 })) + inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 4 })) + inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 1 })) + inputEl.dispatchEvent(new WheelEvent('wheel', { ctrlKey: true })) + + expect(callback).not.toHaveBeenCalled() + expect(canvasMock.processMouseDown).not.toHaveBeenCalled() + expect(canvasMock.processMouseMove).not.toHaveBeenCalled() + expect(canvasMock.processMouseUp).not.toHaveBeenCalled() + expect(canvasMock.processMouseWheel).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts index d7f914dac6..74de999997 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts @@ -1,3 +1,4 @@ +import { useChainCallback } from '@/composables/functional/useChainCallback' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph' import { defineDeprecatedProperty } from '@/lib/litegraph/src/utils/feedback' @@ -51,81 +52,102 @@ function addMultilineWidget( ) widget.options.minNodeSize = [400, 200] - inputEl.addEventListener('input', (event) => { - if (event.target instanceof HTMLTextAreaElement) { - widget.value = event.target.value - } - widget.callback?.(widget.value) - }) + const controller = new AbortController() + const { signal } = controller + + inputEl.addEventListener( + 'input', + (event) => { + if (event.target instanceof HTMLTextAreaElement) { + widget.value = event.target.value + } + widget.callback?.(widget.value) + }, + { signal } + ) // Allow middle mouse button panning - inputEl.addEventListener('pointerdown', (event: PointerEvent) => { - if (event.button === 1) { - app.canvas.processMouseDown(event) - } - }) + inputEl.addEventListener( + 'pointerdown', + (event: PointerEvent) => { + if (event.button === 1) app.canvas.processMouseDown(event) + }, + { signal } + ) - inputEl.addEventListener('pointermove', (event: PointerEvent) => { - if ((event.buttons & 4) === 4) { - app.canvas.processMouseMove(event) - } - }) + inputEl.addEventListener( + 'pointermove', + (event: PointerEvent) => { + if ((event.buttons & 4) === 4) app.canvas.processMouseMove(event) + }, + { signal } + ) - inputEl.addEventListener('pointerup', (event: PointerEvent) => { - if (event.button === 1) { - app.canvas.processMouseUp(event) - } - }) + inputEl.addEventListener( + 'pointerup', + (event: PointerEvent) => { + if (event.button === 1) app.canvas.processMouseUp(event) + }, + { signal } + ) - inputEl.addEventListener('wheel', (event: WheelEvent) => { - const gesturesEnabled = useSettingStore().get( - 'LiteGraph.Pointer.TrackpadGestures' - ) - const deltaX = event.deltaX - const deltaY = event.deltaY + inputEl.addEventListener( + 'wheel', + (event: WheelEvent) => { + const gesturesEnabled = useSettingStore().get( + 'LiteGraph.Pointer.TrackpadGestures' + ) + const deltaX = event.deltaX + const deltaY = event.deltaY - const canScrollY = inputEl.scrollHeight > inputEl.clientHeight - const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY) + const canScrollY = inputEl.scrollHeight > inputEl.clientHeight + const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY) - // Prevent pinch zoom from zooming the page - if (event.ctrlKey) { + // Prevent pinch zoom from zooming the page + if (event.ctrlKey) { + event.preventDefault() + event.stopPropagation() + app.canvas.processMouseWheel(event) + return + } + + // Detect if this is likely a trackpad gesture vs mouse wheel + // Trackpads usually have deltaX or smaller deltaY values (< TRACKPAD_DETECTION_THRESHOLD) + // Mouse wheels typically have larger discrete deltaY values (>= TRACKPAD_DETECTION_THRESHOLD) + const isLikelyTrackpad = + Math.abs(deltaX) > 0 || Math.abs(deltaY) < TRACKPAD_DETECTION_THRESHOLD + + // Trackpad gestures: when enabled, trackpad panning goes to canvas + if (gesturesEnabled && isLikelyTrackpad) { + event.preventDefault() + event.stopPropagation() + app.canvas.processMouseWheel(event) + return + } + + // When gestures disabled: horizontal always goes to canvas (no horizontal scroll in textarea) + if (isHorizontal) { + event.preventDefault() + event.stopPropagation() + app.canvas.processMouseWheel(event) + return + } + + // Vertical scrolling when gestures disabled: let textarea scroll if scrollable + if (canScrollY) { + event.stopPropagation() + return + } + + // If textarea can't scroll vertically, pass to canvas event.preventDefault() - event.stopPropagation() app.canvas.processMouseWheel(event) - return - } + }, + { signal } + ) - // Detect if this is likely a trackpad gesture vs mouse wheel - // Trackpads usually have deltaX or smaller deltaY values (< TRACKPAD_DETECTION_THRESHOLD) - // Mouse wheels typically have larger discrete deltaY values (>= TRACKPAD_DETECTION_THRESHOLD) - const isLikelyTrackpad = - Math.abs(deltaX) > 0 || Math.abs(deltaY) < TRACKPAD_DETECTION_THRESHOLD - - // Trackpad gestures: when enabled, trackpad panning goes to canvas - if (gesturesEnabled && isLikelyTrackpad) { - event.preventDefault() - event.stopPropagation() - app.canvas.processMouseWheel(event) - return - } - - // When gestures disabled: horizontal always goes to canvas (no horizontal scroll in textarea) - if (isHorizontal) { - event.preventDefault() - event.stopPropagation() - app.canvas.processMouseWheel(event) - return - } - - // Vertical scrolling when gestures disabled: let textarea scroll if scrollable - if (canScrollY) { - event.stopPropagation() - return - } - - // If textarea can't scroll vertically, pass to canvas - event.preventDefault() - app.canvas.processMouseWheel(event) + widget.onRemove = useChainCallback(widget.onRemove, () => { + controller.abort() }) return widget