allow dragging out links and creating connections

This commit is contained in:
Benjamin Lu
2025-09-17 14:03:23 -07:00
parent 9203ed5311
commit 16ddd4d481
12 changed files with 543 additions and 150 deletions

View File

@@ -119,6 +119,7 @@ import { useWorkflowPersistence } from '@/platform/workflow/persistence/composab
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import { attachSlotLinkPreviewRenderer } from '@/renderer/core/linkInteractions/slotLinkPreviewRenderer'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
@@ -404,6 +405,7 @@ onMounted(async () => {
// @ts-expect-error fixme ts strict error
await comfyApp.setup(canvasRef.value)
attachSlotLinkPreviewRenderer(comfyApp.canvas)
canvasStore.canvas = comfyApp.canvas
canvasStore.canvas.render_canvas_border = false
workspaceStore.spinner = false

View File

@@ -87,6 +87,7 @@ import type { PickNevers } from './types/utility'
import type { IBaseWidget } from './types/widgets'
import { alignNodes, distributeNodes, getBoundaryNodes } from './utils/arrange'
import { findFirstNode, getAllNestedItems } from './utils/collections'
import { resolveConnectingLinkColor } from './utils/linkColors'
import type { UUID } from './utils/uuid'
import { BaseWidget } from './widgets/BaseWidget'
import { toConcreteWidget } from './widgets/widgetMap'
@@ -4717,10 +4718,7 @@ export class LGraphCanvas
const connShape = fromSlot.shape
const connType = fromSlot.type
const colour =
connType === LiteGraph.EVENT
? LiteGraph.EVENT_LINK_COLOR
: LiteGraph.CONNECTING_LINK_COLOR
const colour = resolveConnectingLinkColor(connType)
// the connection being dragged by the mouse
if (this.linkRenderer) {

View File

@@ -0,0 +1,13 @@
import type { CanvasColour, ISlotType } from '../interfaces'
import { LiteGraph } from '../litegraph'
/**
* Resolve the colour used while rendering or previewing a connection of a given slot type.
*/
export function resolveConnectingLinkColor(
type: ISlotType | undefined
): CanvasColour {
return type === LiteGraph.EVENT
? LiteGraph.EVENT_LINK_COLOR
: LiteGraph.CONNECTING_LINK_COLOR
}

View File

@@ -7,13 +7,10 @@
* Maintains backward compatibility with existing litegraph integration.
*/
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import type { Reroute } from '@/lib/litegraph/src/Reroute'
import type {
CanvasColour,
INodeInputSlot,
INodeOutputSlot,
ReadOnlyPoint
} from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
@@ -27,7 +24,6 @@ import {
type ArrowShape,
CanvasPathRenderer,
type Direction,
type DragLinkData,
type LinkRenderData,
type RenderContext as PathRenderContext,
type Point,
@@ -356,8 +352,8 @@ export class LitegraphLinkAdapter {
} = {}
): void {
// Apply same defaults as original renderLink
const startDir = start_dir || LinkDirection.RIGHT
const endDir = end_dir || LinkDirection.LEFT
const startDir = start_dir ?? LinkDirection.RIGHT
const endDir = end_dir ?? LinkDirection.LEFT
// Convert flow to boolean
const flowBool = flow === true || (typeof flow === 'number' && flow > 0)
@@ -502,57 +498,33 @@ export class LitegraphLinkAdapter {
}
}
/**
* Render a link being dragged from a slot to mouse position
* Used during link creation/reconnection
*/
renderDraggingLink(
renderDragPreview(
ctx: CanvasRenderingContext2D,
fromNode: LGraphNode | null,
fromSlot: INodeOutputSlot | INodeInputSlot,
fromSlotIndex: number,
toPosition: ReadOnlyPoint,
context: LinkRenderContext,
options: {
fromInput?: boolean
color?: CanvasColour
disabled?: boolean
} = {}
from: ReadOnlyPoint,
to: ReadOnlyPoint,
colour: CanvasColour,
startDir: LinkDirection,
endDir: LinkDirection,
context: LinkRenderContext
): void {
if (!fromNode) return
// Get slot position using layout tree if available
const slotPos = getSlotPosition(
fromNode,
fromSlotIndex,
options.fromInput || false
this.renderLinkDirect(
ctx,
from,
to,
null,
false,
null,
colour,
startDir,
endDir,
{
...context,
linkMarkerShape: LinkMarkerShape.None
},
{
disabled: false
}
)
if (!slotPos) return
// Get slot direction
const slotDir =
fromSlot.dir ||
(options.fromInput ? LinkDirection.LEFT : LinkDirection.RIGHT)
// Create drag data
const dragData: DragLinkData = {
fixedPoint: { x: slotPos[0], y: slotPos[1] },
fixedDirection: this.convertDirection(slotDir),
dragPoint: { x: toPosition[0], y: toPosition[1] },
color: options.color ? String(options.color) : undefined,
type: fromSlot.type !== undefined ? String(fromSlot.type) : undefined,
disabled: options.disabled || false,
fromInput: options.fromInput || false
}
// Convert context
const pathContext = this.convertToPathRenderContext(context)
// Hide center marker when dragging links
pathContext.style.showCenterMarker = false
// Render using pure renderer
this.pathRenderer.drawDraggingLink(ctx, dragData, pathContext)
}
/**

View File

@@ -0,0 +1,83 @@
import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
SlotDragSource,
SlotDropCandidate
} from '@/renderer/core/linkInteractions/slotLinkDragState'
import { app } from '@/scripts/app'
export interface CompatibilityResult {
allowable: boolean
targetNode?: LGraphNode
targetSlot?: INodeInputSlot | INodeOutputSlot
}
function resolveNode(nodeId: string | number) {
const canvas = app.canvas
const graph = canvas?.graph
if (!graph) return null
const id = typeof nodeId === 'string' ? Number(nodeId) : nodeId
if (Number.isNaN(id)) return null
return graph.getNodeById(id)
}
export function evaluateCompatibility(
source: SlotDragSource,
candidate: SlotDropCandidate
): CompatibilityResult {
if (
candidate.layout.nodeId === source.nodeId &&
candidate.layout.index === source.slotIndex
) {
return { allowable: false }
}
const isOutputToInput =
source.type === 'output' && candidate.layout.type === 'input'
const isInputToOutput =
source.type === 'input' && candidate.layout.type === 'output'
if (!isOutputToInput && !isInputToOutput) {
return { allowable: false }
}
const sourceNode = resolveNode(source.nodeId)
const targetNode = resolveNode(candidate.layout.nodeId)
if (!sourceNode || !targetNode) {
return { allowable: false }
}
const sourceSlot = isOutputToInput
? sourceNode.outputs?.[source.slotIndex]
: sourceNode.inputs?.[source.slotIndex]
const targetSlot = isOutputToInput
? targetNode.inputs?.[candidate.layout.index]
: targetNode.outputs?.[candidate.layout.index]
if (!sourceSlot || !targetSlot) {
return { allowable: false }
}
if (isOutputToInput) {
const outputSlot = sourceSlot as INodeOutputSlot | undefined
const inputSlot = targetSlot as INodeInputSlot | undefined
if (!outputSlot || !inputSlot) {
return { allowable: false }
}
const allowable = sourceNode.canConnectTo(targetNode, inputSlot, outputSlot)
return { allowable, targetNode, targetSlot: inputSlot }
}
const inputSlot = sourceSlot as INodeInputSlot | undefined
const outputSlot = targetSlot as INodeOutputSlot | undefined
if (!inputSlot || !outputSlot) {
return { allowable: false }
}
const allowable = targetNode.canConnectTo(sourceNode, inputSlot, outputSlot)
return { allowable, targetNode, targetSlot: outputSlot }
}

View File

@@ -0,0 +1,89 @@
import { reactive, readonly, shallowReactive } from 'vue'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { SlotLayout } from '@/renderer/core/layout/types'
export type SlotDragType = 'input' | 'output'
export interface SlotDragSource {
nodeId: string
slotIndex: number
type: SlotDragType
direction: LinkDirection
position: Readonly<{ x: number; y: number }>
}
export interface SlotDropCandidate {
layout: SlotLayout
compatible: boolean
}
export interface PointerPosition {
client: Readonly<{ x: number; y: number }>
canvas: Readonly<{ x: number; y: number }>
}
export interface SlotDragState {
active: boolean
pointerId: number | null
source: SlotDragSource | null
pointer: PointerPosition
candidate: SlotDropCandidate | null
}
const defaultPointer: PointerPosition = Object.freeze({
client: { x: 0, y: 0 },
canvas: { x: 0, y: 0 }
})
const state = reactive<SlotDragState>({
active: false,
pointerId: null,
source: null,
pointer: defaultPointer,
candidate: null
})
function updatePointerPosition(position: PointerPosition) {
state.pointer = shallowReactive({
client: position.client,
canvas: position.canvas
})
}
function setCandidate(candidate: SlotDropCandidate | null) {
state.candidate = candidate
}
function beginDrag(source: SlotDragSource, pointerId: number) {
state.active = true
state.source = source
state.pointerId = pointerId
state.candidate = null
}
function endDrag() {
state.active = false
state.pointerId = null
state.source = null
state.pointer = defaultPointer
state.candidate = null
}
function getSlotLayout(nodeId: string, slotIndex: number, isInput: boolean) {
const slotKey = getSlotKey(nodeId, slotIndex, isInput)
return layoutStore.getSlotLayout(slotKey)
}
export function useSlotLinkDragState() {
return {
state: readonly(state),
beginDrag,
endDrag,
updatePointerPosition,
setCandidate,
getSlotLayout
}
}

View File

@@ -0,0 +1,96 @@
import { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type {
INodeInputSlot,
INodeOutputSlot,
ReadOnlyPoint
} from '@/lib/litegraph/src/interfaces'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import { resolveConnectingLinkColor } from '@/lib/litegraph/src/utils/linkColors'
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import {
type SlotDragSource,
useSlotLinkDragState
} from '@/renderer/core/linkInteractions/slotLinkDragState'
function buildContext(canvas: LGraphCanvas): LinkRenderContext {
return {
renderMode: canvas.links_render_mode,
connectionWidth: canvas.connections_width,
renderBorder: canvas.render_connections_border,
lowQuality: canvas.low_quality,
highQualityRender: canvas.highquality_render,
scale: canvas.ds.scale,
linkMarkerShape: canvas.linkMarkerShape,
renderConnectionArrows: canvas.render_connection_arrows,
highlightedLinks: new Set(Object.keys(canvas.highlighted_links)),
defaultLinkColor: canvas.default_link_color,
linkTypeColors: (canvas.constructor as typeof LGraphCanvas)
.link_type_colors,
disabledPattern: canvas._pattern
}
}
export function attachSlotLinkPreviewRenderer(canvas: LGraphCanvas) {
const originalOnRender = canvas.onRender?.bind(canvas)
const patched = (
canvasElement: HTMLCanvasElement,
ctx: CanvasRenderingContext2D
) => {
originalOnRender?.(canvasElement, ctx)
const { state } = useSlotLinkDragState()
if (!state.active || !state.source) return
const { pointer, source } = state
const start = source.position
const sourceSlot = resolveSourceSlot(canvas, source)
const linkRenderer = canvas.linkRenderer
if (!linkRenderer) return
const context = buildContext(canvas)
const from: ReadOnlyPoint = [start.x, start.y]
const to: ReadOnlyPoint = [pointer.canvas.x, pointer.canvas.y]
const startDir = source.direction ?? LinkDirection.RIGHT
const endDir = LinkDirection.NONE
const colour = resolveConnectingLinkColor(sourceSlot?.type)
ctx.save()
canvas.ds.toCanvasContext(ctx)
linkRenderer.renderDragPreview(
ctx,
from,
to,
colour,
startDir,
endDir,
context
)
ctx.restore()
}
canvas.onRender = patched
}
function resolveSourceSlot(
canvas: LGraphCanvas,
source: SlotDragSource
): INodeInputSlot | INodeOutputSlot | undefined {
const graph = canvas.graph
if (!graph) return undefined
const nodeId = Number(source.nodeId)
if (!Number.isFinite(nodeId)) return undefined
const node = graph.getNodeById(nodeId)
if (!node) return undefined
return source.type === 'output'
? node.outputs?.[source.slotIndex]
: node.inputs?.[source.slotIndex]
}

View File

@@ -1,89 +0,0 @@
import { type ComponentMountingOptions, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import InputSlot from './InputSlot.vue'
import OutputSlot from './OutputSlot.vue'
// Mock composable used by InputSlot/OutputSlot so we can assert call params
vi.mock(
'@/renderer/extensions/vueNodes/composables/useSlotElementTracking',
() => ({
useSlotElementTracking: vi.fn(() => ({ stop: vi.fn() }))
})
)
type InputSlotProps = ComponentMountingOptions<typeof InputSlot>['props']
type OutputSlotProps = ComponentMountingOptions<typeof OutputSlot>['props']
const mountInputSlot = (props: InputSlotProps) =>
mount(InputSlot, {
global: {
plugins: [
createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
}),
createPinia()
]
},
props
})
const mountOutputSlot = (props: OutputSlotProps) =>
mount(OutputSlot, {
global: {
plugins: [
createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
}),
createPinia()
]
},
props
})
describe('InputSlot/OutputSlot', () => {
beforeEach(() => {
vi.mocked(useSlotElementTracking).mockClear()
})
it('InputSlot registers with correct options', () => {
mountInputSlot({
nodeId: 'node-1',
index: 3,
slotData: { name: 'A', type: 'any', boundingRect: [0, 0, 0, 0] }
})
expect(useSlotElementTracking).toHaveBeenLastCalledWith(
expect.objectContaining({
nodeId: 'node-1',
index: 3,
type: 'input'
})
)
})
it('OutputSlot registers with correct options', () => {
mountOutputSlot({
nodeId: 'node-2',
index: 1,
slotData: { name: 'B', type: 'any', boundingRect: [0, 0, 0, 0] }
})
expect(useSlotElementTracking).toHaveBeenLastCalledWith(
expect.objectContaining({
nodeId: 'node-2',
index: 1,
type: 'output'
})
)
})
})

View File

@@ -2,8 +2,10 @@
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs"></div>
<div
v-else
class="lg-slot lg-slot--input flex items-center cursor-crosshair group rounded-r-lg h-6"
class="lg-slot lg-slot--input flex items-center group rounded-r-lg h-6"
:class="{
'cursor-crosshair': !readonly,
'cursor-default': readonly,
'opacity-70': readonly,
'lg-slot--connected': connected,
'lg-slot--compatible': compatible,
@@ -16,6 +18,7 @@
ref="connectionDotRef"
:color="slotColor"
class="-translate-x-1/2"
v-on="readonly ? {} : { pointerdown: onPointerDown }"
/>
<!-- Slot Name -->
@@ -41,6 +44,7 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors'
import { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import SlotConnectionDot from './SlotConnectionDot.vue'
@@ -88,4 +92,10 @@ useSlotElementTracking({
type: 'input',
element: slotElRef
})
const { onPointerDown } = useSlotLinkInteraction({
nodeId: props.nodeId ?? '',
index: props.index,
type: 'input'
})
</script>

View File

@@ -2,8 +2,10 @@
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs"></div>
<div
v-else
class="lg-slot lg-slot--output flex items-center cursor-crosshair justify-end group rounded-l-lg h-6"
class="lg-slot lg-slot--output flex items-center justify-end group rounded-l-lg h-6"
:class="{
'cursor-crosshair': !readonly,
'cursor-default': readonly,
'opacity-70': readonly,
'lg-slot--connected': connected,
'lg-slot--compatible': compatible,
@@ -25,6 +27,7 @@
ref="connectionDotRef"
:color="slotColor"
class="translate-x-1/2"
v-on="readonly ? {} : { pointerdown: onPointerDown }"
/>
</div>
</template>
@@ -42,6 +45,7 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors'
import type { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import SlotConnectionDot from './SlotConnectionDot.vue'
@@ -90,4 +94,10 @@ useSlotElementTracking({
type: 'output',
element: slotElRef
})
const { onPointerDown } = useSlotLinkInteraction({
nodeId: props.nodeId ?? '',
index: props.index,
type: 'output'
})
</script>

View File

@@ -184,6 +184,8 @@ export function useSlotElementTracking(options: {
// Register slot
const slotKey = getSlotKey(nodeId, index, type === 'input')
el.dataset.slotKey = slotKey
node.slots.set(slotKey, { el, index, type })
// Seed initial sync from DOM
@@ -203,7 +205,11 @@ export function useSlotElementTracking(options: {
// Remove this slot from registry and layout
const slotKey = getSlotKey(nodeId, index, type === 'input')
node.slots.delete(slotKey)
const entry = node.slots.get(slotKey)
if (entry) {
delete entry.el.dataset.slotKey
node.slots.delete(slotKey)
}
layoutStore.deleteSlotLayout(slotKey)
// If node has no more slots, clean up

View File

@@ -0,0 +1,203 @@
import { onBeforeUnmount } from 'vue'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { SlotLayout } from '@/renderer/core/layout/types'
import { evaluateCompatibility } from '@/renderer/core/linkInteractions/slotLinkCompatibility'
import {
type SlotDropCandidate,
useSlotLinkDragState
} from '@/renderer/core/linkInteractions/slotLinkDragState'
import { app } from '@/scripts/app'
interface SlotInteractionOptions {
nodeId: string
index: number
type: 'input' | 'output'
readonly?: boolean
}
export function useSlotLinkInteraction({
nodeId,
index,
type,
readonly
}: SlotInteractionOptions) {
const { state, beginDrag, endDrag, updatePointerPosition } =
useSlotLinkDragState()
function candidateFromTarget(
target: EventTarget | null
): SlotDropCandidate | null {
if (!(target instanceof HTMLElement)) return null
const key = target.dataset['slotKey']
if (!key) return null
const layout = layoutStore.getSlotLayout(key)
if (!layout) return null
return { layout, compatible: true }
}
const conversion = useSharedCanvasPositionConversion()
let activePointerId: number | null = null
const cleanupListeners = () => {
window.removeEventListener('pointermove', handlePointerMove, true)
window.removeEventListener('pointerup', handlePointerUp, true)
window.removeEventListener('pointercancel', handlePointerCancel, true)
activePointerId = null
endDrag()
}
const updatePointerState = (event: PointerEvent) => {
const client = { x: event.clientX, y: event.clientY }
const [canvasX, canvasY] = conversion.clientPosToCanvasPos([
client.x,
client.y
])
updatePointerPosition({
client,
canvas: { x: canvasX, y: canvasY }
})
}
const handlePointerMove = (event: PointerEvent) => {
if (event.pointerId !== activePointerId) return
updatePointerState(event)
app.canvas?.setDirty(true)
}
const connectSlots = (slotLayout: SlotLayout) => {
const canvas = app.canvas
const graph = canvas?.graph
const source = state.source
if (!canvas || !graph || !source) return
const sourceNode = graph.getNodeById(Number(source.nodeId))
const targetNode = graph.getNodeById(Number(slotLayout.nodeId))
if (!sourceNode || !targetNode) return
const sourceSlot =
source.type === 'output'
? sourceNode.outputs?.[source.slotIndex]
: sourceNode.inputs?.[source.slotIndex]
const targetSlot =
slotLayout.type === 'input'
? targetNode.inputs?.[slotLayout.index]
: targetNode.outputs?.[slotLayout.index]
if (!sourceSlot || !targetSlot) return
if (source.type === 'output' && slotLayout.type === 'input') {
const outputSlot = sourceSlot as INodeOutputSlot | undefined
const inputSlot = targetSlot as INodeInputSlot | undefined
if (!outputSlot || !inputSlot) return
graph.beforeChange()
sourceNode.connectSlots(outputSlot, targetNode, inputSlot, undefined)
return
}
if (source.type === 'input' && slotLayout.type === 'output') {
const inputSlot = sourceSlot as INodeInputSlot | undefined
const outputSlot = targetSlot as INodeOutputSlot | undefined
if (!inputSlot || !outputSlot) return
graph.beforeChange()
sourceNode.disconnectInput(source.slotIndex, true)
targetNode.connectSlots(outputSlot, sourceNode, inputSlot, undefined)
}
}
const finishInteraction = (event: PointerEvent) => {
if (event.pointerId !== activePointerId) return
event.preventDefault()
if (state.source) {
const candidate = candidateFromTarget(event.target)
if (candidate) {
const result = evaluateCompatibility(state.source, candidate)
if (result.allowable) {
connectSlots(candidate.layout)
}
}
}
cleanupListeners()
app.canvas?.setDirty(true)
}
const handlePointerUp = (event: PointerEvent) => {
finishInteraction(event)
}
const handlePointerCancel = (event: PointerEvent) => {
if (event.pointerId !== activePointerId) return
cleanupListeners()
app.canvas?.setDirty(true, true)
}
const onPointerDown = (event: PointerEvent) => {
if (readonly) return
if (event.button !== 0) return
if (!nodeId) return
if (activePointerId !== null) return
const canvas = app.canvas
const graph = canvas?.graph
if (!canvas || !graph) return
const layout = layoutStore.getSlotLayout(
getSlotKey(nodeId, index, type === 'input')
)
if (!layout) return
const resolvedNode = graph.getNodeById(Number(nodeId))
const slot =
type === 'input'
? resolvedNode?.inputs?.[index]
: resolvedNode?.outputs?.[index]
const direction =
slot?.dir ?? (type === 'input' ? LinkDirection.LEFT : LinkDirection.RIGHT)
beginDrag(
{
nodeId,
slotIndex: index,
type,
direction,
position: layout.position
},
event.pointerId
)
activePointerId = event.pointerId
updatePointerState(event)
window.addEventListener('pointermove', handlePointerMove, true)
window.addEventListener('pointerup', handlePointerUp, true)
window.addEventListener('pointercancel', handlePointerCancel, true)
app.canvas?.setDirty(true, true)
event.preventDefault()
event.stopPropagation()
}
onBeforeUnmount(() => {
if (activePointerId !== null) {
cleanupListeners()
}
})
return {
onPointerDown
}
}