mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 06:35:10 +00:00
fix: detach DOM widget event listeners on widget removal (#11724)
## Summary Fixes leaked event listeners ## Changes - **What**: - update all listeners to use AbortController to signal removal on widget remove ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11724-fix-detach-DOM-widget-event-listeners-on-widget-removal-3506d73d3650811dae81c034c1098759) by [Unito](https://www.unito.io) --------- Co-authored-by: Alexander Brown <drjkl@comfy.org>
This commit is contained in:
70
src/composables/node/useNodeAnimatedImage.test.ts
Normal file
70
src/composables/node/useNodeAnimatedImage.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
93
src/composables/node/useNodeImage.test.ts
Normal file
93
src/composables/node/useNodeImage.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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<string, unknown> & { widgets?: never }
|
||||
|
||||
export function createMockDOMWidgetNode(overrides: NodeOverrides = {}) {
|
||||
const widgets: FakeDOMWidget[] = []
|
||||
return fromAny<LGraphNode & { widgets: FakeDOMWidget[] }, unknown>({
|
||||
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<LGraphNode & { widgets: FakeMediaWidget[] }, unknown>({
|
||||
widgets,
|
||||
addDOMWidget: vi.fn((name: string, _type: string, element: HTMLElement) => {
|
||||
const widget: FakeMediaWidget = { name, element }
|
||||
widgets.push(widget)
|
||||
return widget
|
||||
}),
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
@@ -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<typeof Litegraph>()
|
||||
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<HTMLElement, string>
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof Litegraph>()
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user