Files
xmarre-ComfyUI-Image-Conveyor/web/image_conveyor_math.mjs
T
2026-08-10 11:43:31 +02:00

602 lines
22 KiB
JavaScript

export const CARD_FOOTER_HEIGHT = 58
export function isGalleryViewportMeasurable(width, height) {
const numericWidth = Number(width)
const numericHeight = Number(height)
return Number.isFinite(numericWidth) && numericWidth > 0 &&
Number.isFinite(numericHeight) && numericHeight > 0
}
export function calculateGalleryMetrics(width, minimumCardWidth, gap = 10) {
const safeWidth = Math.max(1, Math.floor(Number(width) || 1))
const safeMinimum = Math.max(1, Math.floor(Number(minimumCardWidth) || 1))
const safeGap = Math.max(0, Math.floor(Number(gap) || 0))
const columns = Math.max(1, Math.floor((safeWidth + safeGap) / (safeMinimum + safeGap)))
const cardWidth = Math.max(96, Math.floor((safeWidth - safeGap * (columns - 1)) / columns))
const mediaHeight = Math.max(84, Math.round(cardWidth * 0.78))
const cardHeight = mediaHeight + CARD_FOOTER_HEIGHT
return {
width: safeWidth,
columns,
cardWidth,
columnStride: cardWidth + safeGap,
mediaHeight,
cardHeight,
rowStride: cardHeight + safeGap
}
}
export function calculateVisibleCardRange(
totalItems,
columns,
rowStride,
cardGap,
scrollTop,
viewportHeight,
overscanRows = 2
) {
const count = Math.max(0, Math.floor(Number(totalItems) || 0))
if (!count) return { start: 0, end: 0, totalHeight: 0, startRow: 0, scrollTop: 0 }
const safeColumns = Math.max(1, Math.floor(Number(columns) || 1))
const safeStride = Math.max(1, Number(rowStride) || 1)
const safeViewport = Math.max(Number(viewportHeight) || safeStride, safeStride)
const rows = Math.ceil(count / safeColumns)
const maxScroll = Math.max(0, rows * safeStride - Math.max(0, Number(cardGap) || 0) - safeViewport)
const clampedScroll = Math.min(Math.max(Number(scrollTop) || 0, 0), maxScroll)
const firstRow = Math.floor(clampedScroll / safeStride)
const visibleRows = Math.max(1, Math.ceil(safeViewport / safeStride))
const overscan = Math.max(0, Math.floor(Number(overscanRows) || 0))
const startRow = Math.max(0, firstRow - overscan)
const endRow = Math.min(rows, firstRow + visibleRows + overscan + 1)
return {
start: startRow * safeColumns,
end: Math.min(count, endRow * safeColumns),
totalHeight: Math.max(0, rows * safeStride - Math.max(0, Number(cardGap) || 0)),
startRow,
scrollTop: clampedScroll
}
}
export function planCardSlotReuse(previousItemIds, nextItemIds) {
const previous = Array.isArray(previousItemIds) ? previousItemIds : []
const next = Array.isArray(nextItemIds) ? nextItemIds : []
const availableById = new Map()
for (let index = 0; index < previous.length; index += 1) {
const itemId = previous[index]
if (itemId != null && !availableById.has(itemId)) availableById.set(itemId, index)
}
const assignments = new Array(next.length).fill(-1)
const used = new Set()
for (let index = 0; index < next.length; index += 1) {
const previousIndex = availableById.get(next[index])
if (previousIndex == null || used.has(previousIndex)) continue
assignments[index] = previousIndex
used.add(previousIndex)
}
const free = []
for (let index = 0; index < previous.length; index += 1) {
if (!used.has(index)) free.push(index)
}
let freeIndex = 0
let nextNewIndex = previous.length
for (let index = 0; index < assignments.length; index += 1) {
if (assignments[index] >= 0) continue
assignments[index] = freeIndex < free.length ? free[freeIndex++] : nextNewIndex++
}
return assignments
}
export function isHighVelocityScroll(deltaPixels, elapsedMs, rowStride) {
const distance = Math.abs(Number(deltaPixels) || 0)
const elapsed = Math.max(8, Number(elapsedMs) || 8)
const stride = Math.max(1, Number(rowStride) || 1)
return distance >= stride * 1.5 || distance / elapsed >= stride / 32
}
export function restoreGraphCanvasFocus(previousOwner, canvas) {
if (!canvas || typeof canvas.focus !== 'function') return false
previousOwner?.blur?.()
if (typeof canvas.tabIndex === 'number' && canvas.tabIndex < 0) canvas.tabIndex = -1
try {
canvas.focus({ preventScroll: true })
} catch {
canvas.focus()
}
return !canvas.ownerDocument || canvas.ownerDocument.activeElement === canvas
}
const TEXT_INPUT_RESERVED_SHORTCUTS = new Set([
'Ctrl+a', 'Ctrl+c', 'Ctrl+v', 'Ctrl+x', 'Ctrl+z', 'Ctrl+y', 'Ctrl+p',
'Enter', 'Shift+Enter', 'Ctrl+Backspace', 'Ctrl+Delete',
'Home', 'Ctrl+Home', 'Ctrl+Shift+Home',
'End', 'Ctrl+End', 'Ctrl+Shift+End',
'PageUp', 'Shift+PageUp', 'PageDown', 'Shift+PageDown',
'ArrowLeft', 'Ctrl+ArrowLeft', 'Shift+ArrowLeft', 'Ctrl+Shift+ArrowLeft',
'ArrowRight', 'Ctrl+ArrowRight', 'Shift+ArrowRight', 'Ctrl+Shift+ArrowRight',
'ArrowUp', 'Shift+ArrowUp', 'ArrowDown', 'Shift+ArrowDown'
])
export function isReservedTextInputShortcut(event) {
if (!event) return true
const ctrl = Boolean(event.ctrlKey || event.metaKey)
const alt = Boolean(event.altKey)
const shift = Boolean(event.shiftKey)
if (!ctrl && !alt) return true
const modifiers = []
if (ctrl) modifiers.push('Ctrl')
if (alt) modifiers.push('Alt')
if (shift) modifiers.push('Shift')
const rawKey = String(event.key || '')
modifiers.push(rawKey.length === 1 ? rawKey.toLowerCase() : rawKey)
return TEXT_INPUT_RESERVED_SHORTCUTS.has(modifiers.join('+'))
}
export function keyboardComboSignature(eventOrCombo) {
if (!eventOrCombo) return ''
const key = String(eventOrCombo.key ?? '').toUpperCase()
const isCombo = Object.hasOwn(eventOrCombo, 'ctrl')
const ctrl = isCombo
? Boolean(eventOrCombo.ctrl)
: Boolean(eventOrCombo.ctrlKey || eventOrCombo.metaKey)
const alt = Boolean(isCombo ? eventOrCombo.alt : eventOrCombo.altKey)
const shift = Boolean(isCombo ? eventOrCombo.shift : eventOrCombo.shiftKey)
return `${key}:${ctrl}:${alt}:${shift}`
}
function isTextEditingTarget(target) {
const tagName = String(target?.tagName ?? '').toUpperCase()
return tagName === 'INPUT' ||
tagName === 'TEXTAREA' ||
target?.contentEditable === 'true' ||
(tagName === 'SPAN' && target?.classList?.contains?.('property_value'))
}
export function findKeyboundCommand(commands, event, target, documentRef = globalThis.document) {
if (!event || event.defaultPrevented || event.isComposing) return null
const eventSignature = keyboardComboSignature(event)
if (!eventSignature || eventSignature.startsWith('CONTROL:') ||
eventSignature.startsWith('META:') || eventSignature.startsWith('ALT:') ||
eventSignature.startsWith('SHIFT:')) return null
if (event.key === 'Escape' && target?.closest?.('[role="menu"]')) return null
for (const command of Array.from(commands ?? [])) {
let binding
try {
binding = command?.keybinding
} catch {
continue
}
if (!binding?.combo || keyboardComboSignature(binding.combo) !== eventSignature) continue
if (isTextEditingTarget(target)) {
const reserved = typeof binding.combo.isReservedByTextInput === 'boolean'
? binding.combo.isReservedByTextInput
: isReservedTextInputShortcut(event)
if (reserved) return null
}
const targetElementId = binding.targetElementId === 'graph-canvas'
? 'graph-canvas-container'
: binding.targetElementId
if (targetElementId) {
const container = documentRef?.getElementById?.(targetElementId)
if (!container?.contains?.(target)) continue
}
return command
}
return null
}
const RUN_COMMAND_IDS = new Set([
'Comfy.QueuePrompt',
'Comfy.QueuePromptFront',
'Comfy.QueueSelectedOutputNodes'
])
export function dispatchKeyboundCommandFallback(
event,
commandManager,
{
target = event?.composedPath?.()[0] ?? event?.target,
documentRef = globalThis.document,
modalOpen = false,
onError = () => {}
} = {}
) {
if (modalOpen || typeof commandManager?.execute !== 'function') return false
const command = findKeyboundCommand(commandManager.commands, event, target, documentRef)
if (!command?.id) return false
event.preventDefault?.()
event.stopImmediatePropagation?.()
const options = {
errorHandler: onError,
...(RUN_COMMAND_IDS.has(command.id)
? { metadata: { trigger_source: 'keybinding' } }
: {})
}
try {
const result = commandManager.execute(command.id, options)
result?.catch?.(onError)
} catch (error) {
onError(error)
}
return true
}
export function isConveyorDeleteShortcut(event) {
if (!event || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return false
return event.key === 'Delete' || event.code === 'Delete'
}
const CONVEYOR_GALLERY_KEYS = new Set([
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'ArrowDown',
'Home',
'End',
'PageUp',
'PageDown',
'Enter',
' ',
'Escape'
])
export function isConveyorGalleryShortcut(event) {
if (!event || event.defaultPrevented || event.isComposing) return false
if (isConveyorDeleteShortcut(event)) return true
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return false
return CONVEYOR_GALLERY_KEYS.has(event.key)
}
export function calculateMarqueeGridIndexes(totalItems, metrics, bounds) {
const count = Math.max(0, Math.floor(Number(totalItems) || 0))
if (!count || !metrics || !bounds) return []
const columns = Math.max(1, Math.floor(Number(metrics.columns) || 1))
const cardWidth = Math.max(1, Number(metrics.cardWidth) || 1)
const cardHeight = Math.max(1, Number(metrics.cardHeight) || 1)
const rowStride = Math.max(cardHeight, Number(metrics.rowStride) || cardHeight)
const columnStride = Math.max(cardWidth, Number(metrics.columnStride) || cardWidth)
const left = Math.min(Number(bounds.left) || 0, Number(bounds.right) || 0)
const right = Math.max(Number(bounds.left) || 0, Number(bounds.right) || 0)
const top = Math.min(Number(bounds.top) || 0, Number(bounds.bottom) || 0)
const bottom = Math.max(Number(bounds.top) || 0, Number(bounds.bottom) || 0)
if (right <= left || bottom <= top) return []
const rows = Math.ceil(count / columns)
const firstRow = Math.max(0, Math.floor(top / rowStride))
const lastRow = Math.min(rows - 1, Math.floor(bottom / rowStride))
const firstColumn = Math.max(0, Math.floor(left / columnStride))
const lastColumn = Math.min(columns - 1, Math.floor(right / columnStride))
const indexes = []
for (let row = firstRow; row <= lastRow; row += 1) {
const cardTop = row * rowStride
const cardBottom = cardTop + cardHeight
if (cardBottom <= top || cardTop >= bottom) continue
for (let column = firstColumn; column <= lastColumn; column += 1) {
const index = row * columns + column
if (index >= count) break
const cardLeft = column * columnStride
const cardRight = cardLeft + cardWidth
if (cardRight > left && cardLeft < right) indexes.push(index)
}
}
return indexes
}
export function clientPointToScrollContent(
clientX,
clientY,
rect,
clientWidth,
clientHeight,
scrollLeft = 0,
scrollTop = 0,
layoutWidth = clientWidth,
layoutHeight = clientHeight
) {
const width = Math.max(0, Number(clientWidth) || 0)
const height = Math.max(0, Number(clientHeight) || 0)
const boxWidth = Math.max(0, Number(layoutWidth) || width)
const boxHeight = Math.max(0, Number(layoutHeight) || height)
const renderedWidth = Math.max(0, Number(rect?.width) || 0)
const renderedHeight = Math.max(0, Number(rect?.height) || 0)
const scaleX = boxWidth > 0 && renderedWidth > 0 ? renderedWidth / boxWidth : 1
const scaleY = boxHeight > 0 && renderedHeight > 0 ? renderedHeight / boxHeight : 1
const localX = (Number(clientX) - (Number(rect?.left) || 0)) / scaleX
const localY = (Number(clientY) - (Number(rect?.top) || 0)) / scaleY
return {
x: Math.min(Math.max(localX, 0), width) + Math.max(0, Number(scrollLeft) || 0),
y: Math.min(Math.max(localY, 0), height) + Math.max(0, Number(scrollTop) || 0)
}
}
export function calculateReorderDestinationIndex(length, fromIndex, insertionIndex) {
const count = Math.max(0, Math.floor(Number(length) || 0))
const from = Math.floor(Number(fromIndex))
if (!count || !Number.isFinite(from) || from < 0 || from >= count) return -1
const boundary = Math.min(count, Math.max(0, Math.floor(Number(insertionIndex) || 0)))
const destination = from < boundary ? boundary - 1 : boundary
return destination === from ? -1 : destination
}
export function calculateGalleryDropIntent(totalItems, metrics, contentX, contentY) {
const count = Math.max(0, Math.floor(Number(totalItems) || 0))
if (!count || !metrics) return null
const columns = Math.max(1, Math.floor(Number(metrics.columns) || 1))
const cardWidth = Math.max(1, Number(metrics.cardWidth) || 1)
const cardHeight = Math.max(1, Number(metrics.cardHeight) || 1)
const columnStride = Math.max(cardWidth, Number(metrics.columnStride) || cardWidth)
const rowStride = Math.max(cardHeight, Number(metrics.rowStride) || cardHeight)
const gap = Math.max(0, rowStride - cardHeight)
const rows = Math.ceil(count / columns)
const x = Math.max(0, Number(contentX) || 0)
const y = Math.max(0, Number(contentY) || 0)
const row = Math.min(rows - 1, Math.floor(y / rowStride))
const rowStart = row * columns
const rowCount = Math.min(columns, count - rowStart)
const rowTop = row * rowStride
const rowOffset = y - rowTop
if (rowOffset >= cardHeight && row < rows - 1) {
return {
type: 'insertion',
insertionIndex: Math.min(count, rowStart + rowCount),
orientation: 'horizontal',
left: 0,
top: rowTop + cardHeight + gap / 2
}
}
const column = Math.floor(x / columnStride)
if (column >= rowCount) {
const lastColumn = rowCount - 1
return {
type: 'insertion',
insertionIndex: rowStart + rowCount,
orientation: 'vertical',
left: lastColumn * columnStride + cardWidth,
top: rowTop,
height: cardHeight
}
}
const cardIndex = rowStart + Math.max(0, column)
const cardLeft = Math.max(0, column) * columnStride
const columnOffset = x - cardLeft
const edgeWidth = Math.min(cardWidth / 3, Math.max(18, cardWidth * 0.2))
if (columnOffset <= edgeWidth) {
return {
type: 'insertion',
insertionIndex: cardIndex,
orientation: 'vertical',
left: cardLeft,
top: rowTop,
height: cardHeight
}
}
if (columnOffset >= cardWidth - edgeWidth) {
return {
type: 'insertion',
insertionIndex: cardIndex + 1,
orientation: 'vertical',
left: cardLeft + cardWidth,
top: rowTop,
height: cardHeight
}
}
return { type: 'card', targetIndex: cardIndex }
}
export function planViewScrollSwitch(
activeView,
nextView,
liveScrollTop,
savedScrollTops,
pendingView = null
) {
const positions = { ...savedScrollTops }
if (pendingView !== activeView) {
positions[activeView] = Math.max(0, Number(liveScrollTop) || 0)
}
positions[nextView] = Math.max(0, Number(positions[nextView]) || 0)
return {
positions,
restore: { view: nextView, scrollTop: positions[nextView] }
}
}
export function chooseViewAfterClose(tabOrder, activeView, closingView, fallbackView = 'input') {
const order = Array.isArray(tabOrder) ? tabOrder : []
if (activeView !== closingView) return activeView
const index = order.indexOf(closingView)
if (index < 0) return fallbackView
return order[index + 1] ?? order[index - 1] ?? fallbackView
}
function normalizePickerRelativePath(value) {
const parts = String(value ?? '')
.replace(/\\/g, '/')
.split('/')
.map((part) => part.trim())
.filter((part) => part && part !== '.')
if (parts.length < 2 || parts.some((part) => part === '..')) return null
return parts
}
export function groupDirectoryPickerFiles(files, isSupportedFile = () => true) {
const groups = new Map()
for (const file of Array.from(files ?? [])) {
const parts = normalizePickerRelativePath(file?.webkitRelativePath)
if (!parts) continue
const rootName = parts[0]
let group = groups.get(rootName)
if (!group) {
group = { name: rootName, files: [], directories: new Set(['']) }
groups.set(rootName, group)
}
for (let index = 1; index < parts.length - 1; index += 1) {
group.directories.add(parts.slice(1, index + 1).join('/'))
}
if (!isSupportedFile(file)) continue
group.files.push({
file,
relativePath: parts.slice(1).join('/')
})
}
return Array.from(groups.values()).map((group) => ({
name: group.name,
files: group.files,
directories: Array.from(group.directories)
}))
}
export function isDragLeavingDocument(event, documentElement) {
if (!documentElement) return false
const target = event?.target ?? null
if (target === documentElement || target === documentElement.ownerDocument) return true
const relatedTarget = event?.relatedTarget ?? null
if (relatedTarget == null) return true
try {
return !documentElement.contains(relatedTarget)
} catch {
return true
}
}
export function prepareManagedDuplicateCleanup(groups, protectedPaths = new Set()) {
const protectedSet = protectedPaths instanceof Set ? protectedPaths : new Set(protectedPaths ?? [])
let protectedCount = 0
const cleanupGroups = (Array.isArray(groups) ? groups : []).map((group) => ({
...group,
duplicates: (Array.isArray(group?.duplicates) ? group.duplicates : []).filter((duplicate) => {
if (!protectedSet.has(String(duplicate?.relative_path ?? ''))) return true
protectedCount += 1
return false
})
})).filter((group) => group.duplicates.length)
const duplicateCount = cleanupGroups.reduce((count, group) => count + group.duplicates.length, 0)
const reclaimableBytes = cleanupGroups.reduce((total, group) => (
total + group.duplicates.reduce((subtotal, duplicate) => subtotal + (Number(duplicate?.size) || 0), 0)
), 0)
return { groups: cleanupGroups, duplicateCount, reclaimableBytes, protectedCount }
}
export function normalizeImagesPerExecution(value) {
const number = Number(value)
if (!Number.isFinite(number)) return 1
return Math.max(1, Math.min(9, Math.trunc(number)))
}
export function selectExecutionGroup(items, imagesPerExecution, dontConsume = false) {
const source = Array.isArray(items) ? items : []
const count = normalizeImagesPerExecution(imagesPerExecution)
const eligible = source.filter((item) => (
dontConsume
? item?.status === 'pending' || item?.status === 'queued'
: item?.status === 'pending'
))
const candidates = dontConsume && eligible.length === 0 ? source : eligible
return candidates.slice(0, count)
}
function normalizeReservationMember(value) {
if (!value || typeof value !== 'object') return null
const id = String(value.id ?? '').trim()
const annotated = String(value.annotated ?? '').trim()
return id && annotated ? { id, annotated } : null
}
export function makeQueueReservationPayload(items) {
const members = (Array.isArray(items) ? items : [])
.map(normalizeReservationMember)
.filter(Boolean)
if (!members.length) return null
const first = members[0]
if (members.length === 1) return first
return { ...first, items: members }
}
export function queueReservationMembers(payload) {
if (!payload || typeof payload !== 'object') return []
if (Object.hasOwn(payload, 'items')) {
if (!Array.isArray(payload.items) || !payload.items.length) return []
const members = payload.items.map(normalizeReservationMember)
if (members.some((member) => member == null)) return []
const ids = new Set(members.map((member) => member.id))
if (ids.size !== members.length) return []
const first = normalizeReservationMember(payload)
if (first && (first.id !== members[0].id || first.annotated !== members[0].annotated)) return []
return members
}
const member = normalizeReservationMember(payload)
return member ? [member] : []
}
export function markReservedGroupQueued(items, payload, dontConsume = false, now = Date.now()) {
if (dontConsume) return 0
const members = queueReservationMembers(payload)
if (!members.length) return 0
const ids = new Set(members.map((member) => member.id))
let changed = 0
for (const item of Array.isArray(items) ? items : []) {
if (!ids.has(item?.id)) continue
item.status = 'queued'
item.last_queued_at = now
changed += 1
}
return changed
}
export function processedItemIdsFromDelta(delta) {
if (!delta || typeof delta !== 'object' || delta.consumed === false) return []
if (Array.isArray(delta.processed_items) && delta.processed_items.length) {
const ids = delta.processed_items
.map((item) => String(item?.id ?? '').trim())
.filter(Boolean)
return Array.from(new Set(ids))
}
const id = String(delta.processed_item_id ?? '').trim()
return id ? [id] : []
}
export function completeExecutionGroupCount(pendingCount, imagesPerExecution) {
const pending = Math.max(0, Math.floor(Number(pendingCount) || 0))
const count = normalizeImagesPerExecution(imagesPerExecution)
return Math.floor(pending / count)
}
export function calculateAutoQueueExtraExecutions(
pendingCount,
imagesPerExecution,
requestedBatchCount = 1
) {
const completeGroups = completeExecutionGroupCount(pendingCount, imagesPerExecution)
const requested = Math.max(1, Math.floor(Number(requestedBatchCount) || 1))
return Math.max(0, completeGroups - requested)
}
export function shouldReanchorGalleryResize(
previousWidgetWidth,
currentWidgetWidth,
previousViewportWidth,
currentViewportWidth
) {
const previousWidget = Number(previousWidgetWidth)
const currentWidget = Number(currentWidgetWidth)
const previousViewport = Number(previousViewportWidth)
const currentViewport = Number(currentViewportWidth)
return Number.isFinite(previousWidget) && Number.isFinite(currentWidget) &&
Number.isFinite(previousViewport) && Number.isFinite(currentViewport) &&
previousWidget > 0 && currentWidget > 0 &&
previousWidget !== currentWidget && previousViewport !== currentViewport
}