Files
xmarre-ComfyUI-Image-Conveyor/web/image_conveyor_pointer_drag.js
T

400 lines
14 KiB
JavaScript

import { app } from '../../scripts/app.js'
import { referenceShelfHit } from './image_conveyor_math.mjs?v=20260813-pointer-drag'
const EXTENSION_NAME = 'Comfy.ImageConveyor.PointerCardDrag'
const NODE_CLASSES = new Set(['ImageConveyor', 'SequentialBatchImageLoader'])
const DRAG_THRESHOLD = 5
const DRIVE_INTERVAL_MS = 32
const CLICK_SUPPRESS_MS = 240
const patchedNodes = new Set()
function nowMs() {
return globalThis.performance?.now?.() ?? Date.now()
}
function cardSlotAtTarget(ctx, target) {
if (!(target instanceof Node)) return null
return ctx.cardPool?.find((slot) => slot?.itemId && slot.card?.contains(target)) ?? null
}
function isInteractiveTarget(target) {
return target instanceof Element && Boolean(
target.closest('button, input, textarea, select, a[href], [contenteditable="true"]')
)
}
function markPointerSynthetic(event) {
try {
Object.defineProperty(event, '__icxPointerSyntheticDrag', { value: true })
} catch {
try { event.__icxPointerSyntheticDrag = true } catch {}
}
return event
}
function syntheticDragEvent(type, clientX, clientY) {
const options = {
bubbles: true,
cancelable: true,
clientX: Number(clientX) || 0,
clientY: Number(clientY) || 0
}
try {
return markPointerSynthetic(new DragEvent(type, options))
} catch {
const event = new Event(type, { bubbles: true, cancelable: true })
for (const [key, value] of Object.entries({ clientX: options.clientX, clientY: options.clientY })) {
try { Object.defineProperty(event, key, { value }) } catch {}
}
return markPointerSynthetic(event)
}
}
function referenceHitAt(node, clientX, clientY) {
const ctx = node.__bil
const layout = ctx?.referenceShelfLayout
if (!layout?.usable) return null
const event = syntheticDragEvent('dragover', clientX, clientY)
try { app.canvas?.adjustMouseEvent?.(event) } catch {}
const canvasX = Number(event.canvasX)
const canvasY = Number(event.canvasY)
if (!Number.isFinite(canvasX) || !Number.isFinite(canvasY)) return null
return referenceShelfHit(
layout,
canvasX - Number(node.pos?.[0] || 0),
canvasY - Number(node.pos?.[1] || 0)
)
}
function makeGhost(ctx, candidate) {
const ghost = document.createElement('div')
ghost.className = 'icx-pointer-drag-ghost'
const selection = ctx.browser?.[candidate.sourceView]?.selected
?? ctx.browser?.folderViews?.get(candidate.sourceView)?.selected
const selectedCount = selection instanceof Set && selection.has(candidate.itemId)
? selection.size
: 1
const label = String(candidate.label || 'Image')
ghost.textContent = selectedCount > 1 ? `${selectedCount} images` : label
document.body.appendChild(ghost)
return ghost
}
function positionGhost(session) {
if (!session?.ghost) return
session.ghost.style.transform = `translate3d(${session.clientX + 14}px, ${session.clientY + 16}px, 0)`
const drag = session.ctx.icx?.batchDrag ?? session.ctx.icx?.cardDrag
const count = drag?.items?.length ?? 0
if (count > 1 && session.ghost.textContent !== `${count} images`) {
session.ghost.textContent = `${count} images`
}
}
function clearReferenceHover(node) {
const ctx = node.__bil
if (!ctx || ctx.referenceDragHoverIndex == null) return
ctx.referenceDragHoverIndex = null
node.setDirtyCanvas?.(true, false)
}
function driveDragOver(node, session) {
const ctx = node.__bil
if (!session?.active || !ctx || ctx.removed || node.__bil !== session.ctx) return
positionGhost(session)
const hit = referenceHitAt(node, session.clientX, session.clientY)
if (hit?.type === 'slot') {
const event = syntheticDragEvent('dragover', session.clientX, session.clientY)
node.onDragOver?.call(node, event)
return
}
clearReferenceHover(node)
const target = document.elementFromPoint(session.clientX, session.clientY)
if (!(target instanceof Element)) return
target.dispatchEvent(syntheticDragEvent('dragover', session.clientX, session.clientY))
}
function stopDriveLoop(session) {
if (!session?.frame) return
cancelAnimationFrame(session.frame)
session.frame = 0
}
function startDriveLoop(node, session) {
const tick = (timestamp) => {
session.frame = 0
if (!session.active || node.__bil !== session.ctx || session.ctx.removed) return
if (!session.lastDriveAt || timestamp - session.lastDriveAt >= DRIVE_INTERVAL_MS) {
session.lastDriveAt = timestamp
driveDragOver(node, session)
} else {
positionGhost(session)
}
session.frame = requestAnimationFrame(tick)
}
session.frame = requestAnimationFrame(tick)
}
function restoreSourceDraggable(session) {
const slot = session?.sourceSlot
if (!slot?.card) return
slot.card.draggable = Boolean(slot.draggable)
}
function dispatchDragEnd(session) {
const sourceCard = session?.sourceCard
if (!(sourceCard instanceof Element)) return
sourceCard.dispatchEvent(syntheticDragEvent('dragend', session.clientX, session.clientY))
}
function cleanupSession(node, session, { suppressClick = false } = {}) {
const ctx = node.__bil
const ext = session?.ctx?.icx
stopDriveLoop(session)
restoreSourceDraggable(session)
session?.ghost?.remove?.()
document.body.classList.remove('icx-pointer-card-grabbing')
clearReferenceHover(node)
if (ext) {
if (suppressClick) {
ext.pointerSuppressClick = {
until: nowMs() + CLICK_SUPPRESS_MS,
clientX: session.clientX,
clientY: session.clientY
}
}
if (ext.pointerCardDrag === session) ext.pointerCardDrag = null
if (ext.pointerCandidate === session) ext.pointerCandidate = null
}
if (ctx && ctx.dragIntent && !ctx.draggedId) ctx.dragIntent = null
}
function startPointerDrag(node, session) {
const ctx = node.__bil
const ext = ctx?.icx
if (!ctx || !ext || session.active || ctx.removed) return false
session.active = true
ext.pointerCardDrag = session
document.body.classList.add('icx-pointer-card-grabbing')
session.ghost = makeGhost(ctx, session)
positionGhost(session)
session.sourceCard.dispatchEvent(syntheticDragEvent('dragstart', session.clientX, session.clientY))
queueMicrotask(() => {
if (!session.active || node.__bil !== ctx || ctx.removed) return
driveDragOver(node, session)
})
startDriveLoop(node, session)
return true
}
function finishPointerDrag(node, session, pointerEvent) {
if (!session?.active) {
cleanupSession(node, session)
return
}
session.clientX = Number(pointerEvent?.clientX ?? session.clientX) || 0
session.clientY = Number(pointerEvent?.clientY ?? session.clientY) || 0
driveDragOver(node, session)
const hit = referenceHitAt(node, session.clientX, session.clientY)
if (hit?.type === 'slot') {
const dropEvent = syntheticDragEvent('drop', session.clientX, session.clientY)
try {
const result = node.onDragDrop?.call(node, dropEvent)
Promise.resolve(result).catch((error) => {
console.error('Image Conveyor: pointer reference drop failed.', error)
window.alert(error?.message || 'Unable to apply the selected reference images.')
})
} catch (error) {
console.error('Image Conveyor: pointer reference drop failed.', error)
window.alert(error?.message || 'Unable to apply the selected reference images.')
}
} else {
const target = document.elementFromPoint(session.clientX, session.clientY)
if (target instanceof Element) {
target.dispatchEvent(syntheticDragEvent('drop', session.clientX, session.clientY))
}
}
dispatchDragEnd(session)
cleanupSession(node, session, { suppressClick: true })
}
function cancelPointerDrag(node, session) {
if (!session) return
if (session.active) dispatchDragEnd(session)
cleanupSession(node, session)
}
function installStyles() {
if (document.getElementById('image-conveyor-pointer-drag-style')) return
const style = document.createElement('style')
style.id = 'image-conveyor-pointer-drag-style'
style.textContent = `
body.icx-pointer-card-grabbing, body.icx-pointer-card-grabbing * { cursor: grabbing !important; }
.icx-pointer-drag-ghost {
position: fixed; left: 0; top: 0; z-index: 100004; pointer-events: none;
max-width: min(320px, 45vw); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
padding: 6px 9px; border: 1px solid rgba(130,190,255,.72); border-radius: 7px;
background: rgba(28,32,38,.94); color: #eef6ff; box-shadow: 0 8px 24px rgba(0,0,0,.38);
font: 12px/1.25 system-ui, sans-serif;
}
`
document.head.appendChild(style)
}
function installNode(node) {
const ctx = node.__bil
if (!ctx?.icx?.batchDragV2 || ctx.icx.pointerCardDragV1 || ctx.removed) return false
installStyles()
const ext = ctx.icx
ext.pointerCardDragV1 = true
ext.pointerCandidate = null
ext.pointerCardDrag = null
ext.pointerSuppressClick = null
patchedNodes.add(node)
ext.pointerRootDown = (event) => {
if (event.button !== 0 || (event.pointerType && event.pointerType !== 'mouse')) return
if (event.defaultPrevented || isInteractiveTarget(event.target)) return
const slot = cardSlotAtTarget(ctx, event.target)
if (!slot || slot.item?.kind === 'folder' || !slot.draggable || !slot.itemId) return
if (ext.pointerCandidate) cancelPointerDrag(node, ext.pointerCandidate)
slot.card.draggable = false
ext.pointerCandidate = {
node,
ctx,
pointerId: event.pointerId,
sourceView: ctx.browser.activeView,
sourceSlot: slot,
sourceCard: slot.card,
itemId: String(slot.itemId),
label: slot.item?.filename || slot.item?.relative_path || slot.itemId,
startX: Number(event.clientX) || 0,
startY: Number(event.clientY) || 0,
clientX: Number(event.clientX) || 0,
clientY: Number(event.clientY) || 0,
active: false,
ghost: null,
frame: 0,
lastDriveAt: 0
}
}
ext.pointerWindowMove = (event) => {
const session = ext.pointerCandidate
if (!session || event.pointerId !== session.pointerId) return
session.clientX = Number(event.clientX) || 0
session.clientY = Number(event.clientY) || 0
if (!session.active) {
const distance = Math.hypot(session.clientX - session.startX, session.clientY - session.startY)
if (distance < DRAG_THRESHOLD) return
startPointerDrag(node, session)
}
if (!session.active) return
event.preventDefault()
event.stopPropagation()
driveDragOver(node, session)
}
ext.pointerWindowUp = (event) => {
const session = ext.pointerCandidate
if (!session || event.pointerId !== session.pointerId) return
if (session.active) {
event.preventDefault()
event.stopPropagation()
finishPointerDrag(node, session, event)
} else {
cleanupSession(node, session)
}
}
ext.pointerWindowCancel = (event) => {
const session = ext.pointerCandidate
if (!session || event.pointerId !== session.pointerId) return
cancelPointerDrag(node, session)
}
ext.pointerWindowWheel = () => {
const session = ext.pointerCardDrag
if (!session?.active) return
queueMicrotask(() => {
if (session.active && node.__bil === ctx && !ctx.removed) driveDragOver(node, session)
})
}
ext.pointerWindowClick = (event) => {
const suppression = ext.pointerSuppressClick
if (!suppression) return
if (nowMs() > suppression.until) {
ext.pointerSuppressClick = null
return
}
const distance = Math.hypot(
Number(event.clientX || 0) - suppression.clientX,
Number(event.clientY || 0) - suppression.clientY
)
if (distance > 18) return
ext.pointerSuppressClick = null
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation?.()
}
ext.pointerWindowBlur = () => {
if (ext.pointerCandidate) cancelPointerDrag(node, ext.pointerCandidate)
}
ext.pointerBlockNativeDragStart = (event) => {
if (event.__icxPointerSyntheticDrag) return
if (!ext.pointerCandidate && !ext.pointerCardDrag) return
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation?.()
}
ctx.root.addEventListener('pointerdown', ext.pointerRootDown, true)
window.addEventListener('pointermove', ext.pointerWindowMove, true)
window.addEventListener('pointerup', ext.pointerWindowUp, true)
window.addEventListener('pointercancel', ext.pointerWindowCancel, true)
window.addEventListener('wheel', ext.pointerWindowWheel, true)
window.addEventListener('click', ext.pointerWindowClick, true)
window.addEventListener('blur', ext.pointerWindowBlur)
window.addEventListener('dragstart', ext.pointerBlockNativeDragStart, true)
const previousRemoved = node.onRemoved
node.onRemoved = function (...args) {
if (ext.pointerCandidate) cancelPointerDrag(node, ext.pointerCandidate)
patchedNodes.delete(node)
ctx.root?.removeEventListener('pointerdown', ext.pointerRootDown, true)
window.removeEventListener('pointermove', ext.pointerWindowMove, true)
window.removeEventListener('pointerup', ext.pointerWindowUp, true)
window.removeEventListener('pointercancel', ext.pointerWindowCancel, true)
window.removeEventListener('wheel', ext.pointerWindowWheel, true)
window.removeEventListener('click', ext.pointerWindowClick, true)
window.removeEventListener('blur', ext.pointerWindowBlur)
window.removeEventListener('dragstart', ext.pointerBlockNativeDragStart, true)
return previousRemoved?.apply(this, args)
}
return true
}
function scheduleInstall(node, attempts = 0) {
if (!node || node.__bil?.removed || attempts > 120) return
if (installNode(node)) return
requestAnimationFrame(() => scheduleInstall(node, attempts + 1))
}
app.registerExtension({
name: EXTENSION_NAME,
nodeCreated(node) {
const type = String(node?.comfyClass || node?.type || '')
if (!NODE_CLASSES.has(type)) return
queueMicrotask(() => scheduleInstall(node))
}
})