3928 lines
155 KiB
JavaScript
3928 lines
155 KiB
JavaScript
import { app } from '../../scripts/app.js'
|
||
import { api } from '../../scripts/api.js'
|
||
import '../../scripts/domWidget.js'
|
||
import {
|
||
CARD_FOOTER_HEIGHT,
|
||
calculateAutoQueueExtraExecutions,
|
||
calculateGalleryDropIntent,
|
||
calculateMarqueeGridIndexes,
|
||
calculateGalleryMetrics,
|
||
calculateReorderDestinationIndex,
|
||
calculateVisibleCardRange,
|
||
chooseViewAfterClose,
|
||
clientPointToScrollContent,
|
||
completeExecutionGroupCount,
|
||
dispatchKeyboundCommandFallback,
|
||
groupDirectoryPickerFiles,
|
||
isDragLeavingDocument,
|
||
isGalleryViewportMeasurable,
|
||
isHighVelocityScroll,
|
||
isConveyorGalleryShortcut,
|
||
makeQueueReservationPayload,
|
||
markReservedGroupQueued,
|
||
normalizeImagesPerExecution,
|
||
planCardSlotReuse,
|
||
planViewScrollSwitch,
|
||
prepareManagedDuplicateCleanup,
|
||
processedItemIdsFromDelta,
|
||
queueReservationMembers,
|
||
restoreGraphCanvasFocus,
|
||
selectExecutionGroup,
|
||
shouldReanchorGalleryResize,
|
||
isConveyorDeleteShortcut
|
||
} from './image_conveyor_math.mjs'
|
||
|
||
const EXTENSION_NAME = 'Comfy.ImageConveyor.VueNodes'
|
||
const NODE_CLASSES = new Set(['ImageConveyor', 'SequentialBatchImageLoader'])
|
||
const STATE_WIDGET = 'state_json'
|
||
const UI_STATE_WIDGET = 'ui_state_json'
|
||
const QUEUE_WIDGET = 'queue_item_json'
|
||
const CUSTOM_WIDGET_INPUT = 'batch_loader_ui'
|
||
const CUSTOM_WIDGET_TYPE = 'BATCH_IMAGE_LOADER_UI'
|
||
const DOM_WIDGET_NAME = 'batch_loader_ui'
|
||
const STYLE_ID = 'comfy-batch-image-loader-style'
|
||
const STATE_VERSION = 1
|
||
const IMAGE_EXTENSIONS = new Set([
|
||
'png',
|
||
'jpg',
|
||
'jpeg',
|
||
'webp',
|
||
'bmp',
|
||
'gif',
|
||
'tif',
|
||
'tiff',
|
||
'avif'
|
||
])
|
||
const MIN_WIDGET_HEIGHT = 540
|
||
const MIN_NODE_WIDTH = 520
|
||
const MIN_NODE_HEIGHT = 760
|
||
const CARD_GAP = 10
|
||
const GALLERY_OVERSCAN_ROWS = 2
|
||
const FAST_SCROLL_SETTLE_MS = 80
|
||
const MARQUEE_DRAG_THRESHOLD = 4
|
||
const MARQUEE_AUTOSCROLL_EDGE = 36
|
||
const MARQUEE_AUTOSCROLL_MAX = 18
|
||
const LOCAL_OBJECT_URL_LIMIT = 512
|
||
const CARD_SIZES = {
|
||
small: { minWidth: 124, thumbnail: 160 },
|
||
medium: { minWidth: 172, thumbnail: 256 },
|
||
large: { minWidth: 224, thumbnail: 384 }
|
||
}
|
||
let keyboardOwnerNode = null
|
||
const keyboardNodes = new Set()
|
||
|
||
function claimGalleryKeyboardOwnership(node) {
|
||
if (keyboardOwnerNode && keyboardOwnerNode !== node) {
|
||
const previous = keyboardOwnerNode.__bil
|
||
if (previous) previous.keyboardActive = false
|
||
}
|
||
keyboardOwnerNode = node
|
||
if (node.__bil) node.__bil.keyboardActive = true
|
||
}
|
||
|
||
function releaseGalleryKeyboardOwnership(node) {
|
||
if (node.__bil) node.__bil.keyboardActive = false
|
||
if (keyboardOwnerNode === node) keyboardOwnerNode = null
|
||
}
|
||
|
||
function eventOrigin(event) {
|
||
return event.composedPath?.()[0] ?? event.target
|
||
}
|
||
|
||
function isNeutralKeyboardTarget(target) {
|
||
return target === document || target === document.body || target === document.documentElement
|
||
}
|
||
|
||
function conveyorForKeyboardTarget(target) {
|
||
if (!(target instanceof Node)) return null
|
||
for (const node of keyboardNodes) {
|
||
if (node.__bil?.root?.contains(target)) return node
|
||
}
|
||
return null
|
||
}
|
||
|
||
const keyboardCoordinator = {
|
||
attached: false,
|
||
|
||
registerNode(node) {
|
||
keyboardNodes.add(node)
|
||
if (this.attached) return
|
||
document.addEventListener('keydown', this.handleGalleryKeyDown, true)
|
||
window.addEventListener('keydown', this.handleComfyFallbackKeyDown)
|
||
this.attached = true
|
||
},
|
||
|
||
unregisterNode(node) {
|
||
keyboardNodes.delete(node)
|
||
releaseGalleryKeyboardOwnership(node)
|
||
if (keyboardNodes.size || !this.attached) return
|
||
document.removeEventListener('keydown', this.handleGalleryKeyDown, true)
|
||
window.removeEventListener('keydown', this.handleComfyFallbackKeyDown)
|
||
this.attached = false
|
||
},
|
||
|
||
handleGalleryKeyDown(event) {
|
||
if (event.defaultPrevented || event.isComposing || !keyboardNodes.size) return
|
||
const target = eventOrigin(event)
|
||
const canvas = app.canvas?.canvas
|
||
const neutralTarget = isNeutralKeyboardTarget(target)
|
||
const targetNode = target === canvas || neutralTarget
|
||
? null
|
||
: conveyorForKeyboardTarget(target)
|
||
const owner = keyboardOwnerNode?.__bil ? keyboardOwnerNode : null
|
||
const ownerOwnsTarget = owner && (
|
||
target === canvas ||
|
||
targetNode === owner ||
|
||
neutralTarget
|
||
)
|
||
if (ownerOwnsTarget && isConveyorGalleryShortcut(event)) handleGalleryKeyDown(owner, event)
|
||
},
|
||
|
||
handleComfyFallbackKeyDown(event) {
|
||
if (event.defaultPrevented || event.isComposing || !keyboardNodes.size) return
|
||
dispatchKeyboundCommandFallback(event, app.extensionManager?.command, {
|
||
modalOpen: hasVisibleModal(),
|
||
onError: (error) => console.error('Image Conveyor: ComfyUI shortcut command failed.', error)
|
||
})
|
||
}
|
||
}
|
||
|
||
function hasVisibleModal() {
|
||
const candidates = document.querySelectorAll(
|
||
'dialog[open], [aria-modal="true"], .p-dialog-mask, .comfy-modal'
|
||
)
|
||
for (const element of candidates) {
|
||
if (element.hidden || element.getAttribute?.('aria-hidden') === 'true') continue
|
||
const style = globalThis.getComputedStyle?.(element)
|
||
if (style?.display === 'none' || style?.visibility === 'hidden') continue
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
function structuredCloneCompat(value) {
|
||
if (typeof structuredClone === 'function') return structuredClone(value)
|
||
return JSON.parse(JSON.stringify(value))
|
||
}
|
||
|
||
function safeJsonParse(raw, fallback) {
|
||
if (typeof raw !== 'string' || !raw.trim()) return structuredCloneCompat(fallback)
|
||
try {
|
||
const parsed = JSON.parse(raw)
|
||
return parsed && typeof parsed === 'object'
|
||
? parsed
|
||
: structuredCloneCompat(fallback)
|
||
} catch {
|
||
return structuredCloneCompat(fallback)
|
||
}
|
||
}
|
||
|
||
function defaultState() {
|
||
return {
|
||
version: STATE_VERSION,
|
||
items: [],
|
||
auto_queue: false,
|
||
dont_consume: false,
|
||
catch_canvas_drops: false,
|
||
images_per_execution: 1
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Create the default UI state for the image conveyor widget.
|
||
*
|
||
* @returns {{version:number, selected_ids:string[], source_paths:Object}} An object with:
|
||
* - `version`: the UI state schema version,
|
||
* - `selected_ids`: an array of item IDs that are currently selected,
|
||
* - `source_paths`: a mapping from item ID to the runtime source path string.
|
||
*/
|
||
function defaultUiState() {
|
||
return {
|
||
version: STATE_VERSION,
|
||
selected_ids: [],
|
||
source_paths: {}
|
||
}
|
||
}
|
||
|
||
function normalizeItem(item) {
|
||
if (!item || typeof item !== 'object') return null
|
||
const id = String(item.id ?? '').trim()
|
||
const annotated = String(item.annotated ?? '').trim()
|
||
if (!id || !annotated) return null
|
||
|
||
const rawStatus = String(item.status ?? 'pending').toLowerCase()
|
||
const status = ['pending', 'queued', 'processed'].includes(rawStatus)
|
||
? rawStatus
|
||
: 'pending'
|
||
|
||
return {
|
||
id,
|
||
annotated,
|
||
filename: String(item.filename ?? '').trim(),
|
||
subfolder: String(item.subfolder ?? '').trim(),
|
||
source_path: sanitizePersistedSourcePath(item.source_path),
|
||
type: String(item.type ?? 'input').trim() || 'input',
|
||
status,
|
||
added_at: Number(item.added_at ?? 0) || 0,
|
||
last_queued_at: Number(item.last_queued_at ?? 0) || 0,
|
||
last_processed_at: Number(item.last_processed_at ?? 0) || 0
|
||
}
|
||
}
|
||
|
||
function parseState(raw) {
|
||
const state = safeJsonParse(raw, defaultState())
|
||
const items = Array.isArray(state.items)
|
||
? state.items.map(normalizeItem).filter(Boolean)
|
||
: []
|
||
return {
|
||
version: STATE_VERSION,
|
||
items,
|
||
auto_queue: Boolean(state.auto_queue),
|
||
dont_consume: Boolean(state.dont_consume),
|
||
catch_canvas_drops: Boolean(state.catch_canvas_drops),
|
||
images_per_execution: normalizeImagesPerExecution(state.images_per_execution ?? 1)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Parse a stored UI-state JSON value into a normalized runtime UI state.
|
||
*
|
||
* Parses `raw` using safe JSON parsing and normalizes `selected_ids` into an array of non-empty strings
|
||
* and `source_paths` into an object mapping item ids to normalized source path strings. Always returns
|
||
* the current `STATE_VERSION` along with the normalized fields.
|
||
*
|
||
* @param {*} raw - The raw stored widget value (JSON string or object) to parse.
|
||
* @returns {{version:number, selected_ids:string[], source_paths:Record<string,string>}} The normalized UI state containing `version`, `selected_ids`, and `source_paths`.
|
||
*/
|
||
function parseUiState(raw) {
|
||
const uiState = safeJsonParse(raw, defaultUiState())
|
||
const selectedIds = Array.isArray(uiState.selected_ids)
|
||
? uiState.selected_ids.map((value) => String(value)).filter(Boolean)
|
||
: []
|
||
const sourcePaths = {}
|
||
if (uiState.source_paths && typeof uiState.source_paths === 'object') {
|
||
for (const [key, value] of Object.entries(uiState.source_paths)) {
|
||
const itemId = String(key ?? '').trim()
|
||
const sourcePath = normalizeSourcePath(value)
|
||
if (itemId && sourcePath) sourcePaths[itemId] = sourcePath
|
||
}
|
||
}
|
||
return {
|
||
version: STATE_VERSION,
|
||
selected_ids: selectedIds,
|
||
source_paths: sourcePaths
|
||
}
|
||
}
|
||
|
||
function serializeState(state) {
|
||
return JSON.stringify(
|
||
{
|
||
version: STATE_VERSION,
|
||
items: state.items,
|
||
auto_queue: Boolean(state.auto_queue),
|
||
dont_consume: Boolean(state.dont_consume),
|
||
catch_canvas_drops: Boolean(state.catch_canvas_drops),
|
||
images_per_execution: normalizeImagesPerExecution(state.images_per_execution)
|
||
},
|
||
null,
|
||
0
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Serialize the UI state into a compact JSON string suitable for widget storage.
|
||
* @param {{selected_ids: string[], source_paths: Record<string, string>}} uiState - UI state containing selected item IDs and optional per-item runtime source paths.
|
||
* @returns {string} JSON text containing `version` (STATE_VERSION), `selected_ids`, and `source_paths`.
|
||
*/
|
||
function serializeUiState(uiState) {
|
||
return JSON.stringify(
|
||
{
|
||
version: STATE_VERSION,
|
||
selected_ids: uiState.selected_ids,
|
||
source_paths: uiState.source_paths
|
||
},
|
||
null,
|
||
0
|
||
)
|
||
}
|
||
|
||
function makeId() {
|
||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
|
||
return `bil_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
|
||
}
|
||
|
||
function itemStatusRank(status) {
|
||
switch (status) {
|
||
case 'pending':
|
||
return 0
|
||
case 'queued':
|
||
return 1
|
||
case 'processed':
|
||
return 2
|
||
default:
|
||
return 3
|
||
}
|
||
}
|
||
|
||
function setWidgetValue(widget, value) {
|
||
widget.value = value
|
||
widget.callback?.(value)
|
||
}
|
||
|
||
function markNodeDirty(node) {
|
||
node.setDirtyCanvas?.(true, true)
|
||
node.graph?.change?.()
|
||
}
|
||
|
||
function getWidgets(node) {
|
||
const widgetsByName = new Map()
|
||
for (const widget of node.widgets ?? []) {
|
||
widgetsByName.set(widget.name, widget)
|
||
}
|
||
return {
|
||
stateWidget: widgetsByName.get(STATE_WIDGET),
|
||
uiStateWidget: widgetsByName.get(UI_STATE_WIDGET),
|
||
queueWidget: widgetsByName.get(QUEUE_WIDGET)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Read and normalize the node's stored state and UI state, pruning UI selections and source paths to items that exist in the state.
|
||
* @param {object} node - The ComfyUI node containing the hidden `state` and `ui_state` widgets.
|
||
* @returns {{state: object, uiState: object}} An object with `state` (normalized state shape) and `uiState` (normalized UI shape). `uiState.selected_ids` will contain only IDs present in `state.items`, and `uiState.source_paths` will only include entries whose keys match `state.items` IDs.
|
||
*/
|
||
function getCurrentState(node, { fromWidgets = false } = {}) {
|
||
const cached = node.__bil
|
||
if (!fromWidgets && cached?.state && cached?.uiState) {
|
||
return { state: cached.state, uiState: cached.uiState }
|
||
}
|
||
const { stateWidget, uiStateWidget } = getWidgets(node)
|
||
const state = parseState(stateWidget?.value ?? '')
|
||
const uiState = parseUiState(uiStateWidget?.value ?? '')
|
||
const validIds = new Set(state.items.map((item) => item.id))
|
||
uiState.selected_ids = uiState.selected_ids.filter((id) => validIds.has(id))
|
||
uiState.source_paths = Object.fromEntries(
|
||
Object.entries(uiState.source_paths).filter(([itemId]) => validIds.has(itemId))
|
||
)
|
||
return { state, uiState }
|
||
}
|
||
|
||
function cacheRenderableState(node, state, uiState) {
|
||
const ctx = node.__bil
|
||
if (!ctx) return
|
||
ctx.state = state
|
||
ctx.uiState = uiState
|
||
ctx.renderVersion = (ctx.renderVersion || 0) + 1
|
||
}
|
||
|
||
function getRenderableState(node) {
|
||
const ctx = node.__bil
|
||
if (ctx?.state && ctx?.uiState) {
|
||
return { state: ctx.state, uiState: ctx.uiState }
|
||
}
|
||
const snapshot = getCurrentState(node)
|
||
cacheRenderableState(node, snapshot.state, snapshot.uiState)
|
||
return snapshot
|
||
}
|
||
|
||
function updateState(
|
||
node,
|
||
state,
|
||
uiState,
|
||
{ rerender = true, commitState = true, commitUi = true } = {}
|
||
) {
|
||
const { stateWidget, uiStateWidget } = getWidgets(node)
|
||
if (!stateWidget || !uiStateWidget) return
|
||
if (commitState) setWidgetValue(stateWidget, serializeState(state))
|
||
if (commitUi) setWidgetValue(uiStateWidget, serializeUiState(uiState))
|
||
if (commitState && node.__bil) {
|
||
node.__bil.queueRevision = (node.__bil.queueRevision || 0) + 1
|
||
node.__bil.annotatedCountsRevision = -1
|
||
}
|
||
cacheRenderableState(node, state, uiState)
|
||
if (commitState) markNodeDirty(node)
|
||
if (rerender) scheduleRenderNode(node)
|
||
}
|
||
|
||
function scheduleRenderNode(node, { viewportOnly = false, forceVisibleRows = false } = {}) {
|
||
const ctx = node.__bil
|
||
if (!ctx || ctx.removed) return
|
||
if (forceVisibleRows) ctx.renderedRangeKey = ''
|
||
ctx.renderViewportOnly = ctx.renderFrame
|
||
? Boolean(ctx.renderViewportOnly && viewportOnly)
|
||
: Boolean(viewportOnly)
|
||
if (ctx.renderFrame) return
|
||
ctx.renderFrame = requestAnimationFrame(() => {
|
||
const renderViewportOnly = ctx.renderViewportOnly
|
||
ctx.renderFrame = 0
|
||
ctx.renderViewportOnly = false
|
||
if (renderViewportOnly) {
|
||
renderVisibleCards(node)
|
||
} else {
|
||
renderGalleryNode(node)
|
||
}
|
||
})
|
||
}
|
||
|
||
function getFiniteNumber(value, fallback = 0) {
|
||
const number = Number(value)
|
||
return Number.isFinite(number) ? number : fallback
|
||
}
|
||
|
||
function getWidgetOuterHeight(node, widget) {
|
||
const nodeHeight = Math.max(
|
||
MIN_NODE_HEIGHT,
|
||
getFiniteNumber(node?.size?.[1], MIN_NODE_HEIGHT)
|
||
)
|
||
const widgetY = Math.max(
|
||
0,
|
||
getFiniteNumber(widget?.y, getFiniteNumber(widget?.last_y, 0))
|
||
)
|
||
return Math.max(MIN_WIDGET_HEIGHT, Math.floor(nodeHeight - widgetY))
|
||
}
|
||
|
||
function syncDomWidgetSize(node, widget) {
|
||
const ctx = node.__bil
|
||
if (!ctx || !widget) return
|
||
|
||
const margin = Math.max(0, getFiniteNumber(widget.margin, 10))
|
||
const outerHeight = getWidgetOuterHeight(node, widget)
|
||
const innerHeight = Math.max(0, outerHeight - margin * 2)
|
||
const width = Math.max(
|
||
MIN_NODE_WIDTH,
|
||
getFiniteNumber(node?.size?.[0], MIN_NODE_WIDTH)
|
||
)
|
||
|
||
const changed =
|
||
ctx.widgetOuterHeight !== outerHeight ||
|
||
ctx.widgetInnerHeight !== innerHeight ||
|
||
ctx.widgetWidth !== width
|
||
|
||
widget.computedHeight = outerHeight
|
||
widget.width = width
|
||
|
||
if (ctx.widgetInnerHeight !== innerHeight) {
|
||
ctx.root.style.height = `${innerHeight}px`
|
||
ctx.root.style.minHeight = `${Math.max(0, MIN_WIDGET_HEIGHT - margin * 2)}px`
|
||
}
|
||
ctx.widgetOuterHeight = outerHeight
|
||
ctx.widgetInnerHeight = innerHeight
|
||
ctx.widgetWidth = width
|
||
|
||
if (changed) {
|
||
scheduleRenderNode(node, { viewportOnly: true, forceVisibleRows: true })
|
||
}
|
||
}
|
||
|
||
function updateQueueWidget(node, payload) {
|
||
const { queueWidget } = getWidgets(node)
|
||
if (!queueWidget) return
|
||
setWidgetValue(queueWidget, payload ? JSON.stringify(payload) : '')
|
||
}
|
||
|
||
function findFirstByStatus(state, statuses) {
|
||
return state.items.find((item) => statuses.includes(item.status)) ?? null
|
||
}
|
||
|
||
function findNextLoadGroup(state) {
|
||
return selectExecutionGroup(
|
||
state.items,
|
||
state.images_per_execution,
|
||
state.dont_consume
|
||
)
|
||
}
|
||
|
||
function findNextLoadItem(state) {
|
||
return findNextLoadGroup(state)[0] ?? null
|
||
}
|
||
|
||
function countItemsByStatus(state, status) {
|
||
let count = 0
|
||
for (const item of state.items) {
|
||
if (item.status === status) count += 1
|
||
}
|
||
return count
|
||
}
|
||
|
||
const autoQueueCoordinator = {
|
||
nodes: new Set(),
|
||
listenerAttached: false,
|
||
pendingInternalQueueRequests: 0,
|
||
warnedAboutMultipleNodes: false,
|
||
|
||
registerNode(node) {
|
||
this.nodes.add(node)
|
||
this.attach()
|
||
},
|
||
|
||
unregisterNode(node) {
|
||
this.nodes.delete(node)
|
||
if (!this.nodes.size) {
|
||
this.warnedAboutMultipleNodes = false
|
||
}
|
||
},
|
||
|
||
attach() {
|
||
if (this.listenerAttached) return
|
||
this.listenerAttached = true
|
||
api.addEventListener('promptQueueing', (event) => {
|
||
this.handlePromptQueueing(event)
|
||
})
|
||
},
|
||
|
||
getEligibleNodes() {
|
||
const eligible = []
|
||
for (const node of this.nodes) {
|
||
if (!node?.graph || !node.__bilInitialized) continue
|
||
const { state } = getCurrentState(node)
|
||
if (!state.auto_queue || state.dont_consume) continue
|
||
const pendingCount = countItemsByStatus(state, 'pending')
|
||
const imagesPerExecution = normalizeImagesPerExecution(state.images_per_execution)
|
||
const completeGroups = completeExecutionGroupCount(pendingCount, imagesPerExecution)
|
||
if (completeGroups <= 0) continue
|
||
eligible.push({ node, pendingCount, imagesPerExecution, completeGroups })
|
||
}
|
||
return eligible
|
||
},
|
||
|
||
handlePromptQueueing(event) {
|
||
if (this.pendingInternalQueueRequests > 0) {
|
||
this.pendingInternalQueueRequests -= 1
|
||
return
|
||
}
|
||
|
||
const eligibleNodes = this.getEligibleNodes()
|
||
if (eligibleNodes.length !== 1) {
|
||
if (eligibleNodes.length > 1 && !this.warnedAboutMultipleNodes) {
|
||
this.warnedAboutMultipleNodes = true
|
||
console.warn(
|
||
'Image Conveyor: auto-queue is only applied when exactly one conveyor node with complete pending groups has auto-queue enabled.'
|
||
)
|
||
}
|
||
if (eligibleNodes.length <= 1) {
|
||
this.warnedAboutMultipleNodes = false
|
||
}
|
||
return
|
||
}
|
||
|
||
this.warnedAboutMultipleNodes = false
|
||
|
||
const requestedBatchCount = Math.max(
|
||
1,
|
||
Math.floor(Number(event?.detail?.batchCount) || 1)
|
||
)
|
||
const { pendingCount, imagesPerExecution } = eligibleNodes[0]
|
||
const extraCount = calculateAutoQueueExtraExecutions(
|
||
pendingCount,
|
||
imagesPerExecution,
|
||
requestedBatchCount
|
||
)
|
||
if (extraCount <= 0) return
|
||
|
||
this.pendingInternalQueueRequests += 1
|
||
queueMicrotask(() => {
|
||
void app.queuePrompt(0, extraCount).catch((error) => {
|
||
console.error(
|
||
'Image Conveyor: failed to auto-queue remaining complete image groups.',
|
||
error
|
||
)
|
||
})
|
||
})
|
||
}
|
||
|
||
}
|
||
|
||
function isTargetInsideConveyorWidget(target) {
|
||
return target instanceof Element && Boolean(target.closest('.bil-root'))
|
||
}
|
||
|
||
function isGraphCanvasDropTarget(target) {
|
||
if (!(target instanceof Node)) return false
|
||
|
||
const container =
|
||
app.canvasContainer ??
|
||
document.getElementById('graph-canvas-container') ??
|
||
app.canvas?.canvas?.parentElement ??
|
||
null
|
||
if (container?.contains?.(target)) return true
|
||
|
||
const canvasElement = app.canvas?.canvas ?? app.canvasEl ?? null
|
||
return canvasElement === target
|
||
}
|
||
|
||
function getCanvasNodeAtEvent(event) {
|
||
if (!app.canvas?.graph) return null
|
||
try {
|
||
app.canvas.adjustMouseEvent?.(event)
|
||
} catch {
|
||
// Some frontend versions may not accept DragEvent in adjustMouseEvent.
|
||
}
|
||
const canvasX = Number(event?.canvasX)
|
||
const canvasY = Number(event?.canvasY)
|
||
if (!Number.isFinite(canvasX) || !Number.isFinite(canvasY)) return null
|
||
return app.canvas.graph.getNodeOnPos?.(canvasX, canvasY) ?? null
|
||
}
|
||
|
||
function isCanvasNodeSelected(node) {
|
||
if (!node || !app.canvas) return false
|
||
const selected = app.canvas.selected_nodes
|
||
if (!selected) return Boolean(node.selected || node.flags?.selected)
|
||
if (selected instanceof Set) return selected.has(node)
|
||
if (Array.isArray(selected)) return selected.includes(node)
|
||
if (typeof selected === 'object') {
|
||
if (selected[node.id] === node || selected[String(node.id)] === node) return true
|
||
return Object.values(selected).includes(node)
|
||
}
|
||
return Boolean(node.selected || node.flags?.selected)
|
||
}
|
||
|
||
function setCanvasDropTargetActive(node, active) {
|
||
const ctx = node?.__bil
|
||
if (!ctx) return
|
||
ctx.root.classList.toggle('bil-dragover', active)
|
||
ctx.dropzone.classList.toggle('bil-dragover', active)
|
||
if (!active) ctx.tabs?.classList.remove('bil-folder-drop-ready', 'bil-folder-drop-hover')
|
||
if (!active && ctx.cardPool) clearCardDragTargets(ctx)
|
||
}
|
||
|
||
const canvasDropCoordinator = {
|
||
nodes: new Set(),
|
||
listenerAttached: false,
|
||
dragOverHandler: null,
|
||
dropHandler: null,
|
||
dragLeaveHandler: null,
|
||
dragEndHandler: null,
|
||
windowBlurHandler: null,
|
||
dragExitTimer: null,
|
||
activeNode: null,
|
||
warnedAboutMultipleNodes: false,
|
||
|
||
registerNode(node) {
|
||
this.nodes.add(node)
|
||
this.attach()
|
||
},
|
||
|
||
unregisterNode(node) {
|
||
this.nodes.delete(node)
|
||
if (this.activeNode === node) this.setActiveNode(null)
|
||
if (!this.nodes.size) {
|
||
this.warnedAboutMultipleNodes = false
|
||
this.detach()
|
||
}
|
||
},
|
||
|
||
attach() {
|
||
if (this.listenerAttached) return
|
||
this.listenerAttached = true
|
||
this.dragOverHandler = (event) => this.handleDragOver(event)
|
||
this.dropHandler = (event) => {
|
||
void this.handleDrop(event)
|
||
}
|
||
this.dragLeaveHandler = (event) => this.handleDragLeave(event)
|
||
this.dragEndHandler = () => {
|
||
this.cancelPendingDragExit()
|
||
this.clearAllDragTargets()
|
||
}
|
||
this.windowBlurHandler = this.dragEndHandler
|
||
document.addEventListener('dragover', this.dragOverHandler, true)
|
||
document.addEventListener('drop', this.dropHandler, true)
|
||
document.addEventListener('dragleave', this.dragLeaveHandler, true)
|
||
document.addEventListener('dragend', this.dragEndHandler, true)
|
||
window.addEventListener('blur', this.windowBlurHandler)
|
||
},
|
||
|
||
detach() {
|
||
if (!this.listenerAttached) return
|
||
document.removeEventListener('dragover', this.dragOverHandler, true)
|
||
document.removeEventListener('drop', this.dropHandler, true)
|
||
document.removeEventListener('dragleave', this.dragLeaveHandler, true)
|
||
document.removeEventListener('dragend', this.dragEndHandler, true)
|
||
window.removeEventListener('blur', this.windowBlurHandler)
|
||
this.listenerAttached = false
|
||
this.dragOverHandler = null
|
||
this.dropHandler = null
|
||
this.dragLeaveHandler = null
|
||
this.dragEndHandler = null
|
||
this.windowBlurHandler = null
|
||
this.cancelPendingDragExit()
|
||
this.clearAllDragTargets()
|
||
},
|
||
|
||
cancelPendingDragExit() {
|
||
if (this.dragExitTimer == null) return
|
||
clearTimeout(this.dragExitTimer)
|
||
this.dragExitTimer = null
|
||
},
|
||
|
||
clearAllDragTargets() {
|
||
this.setActiveNode(null)
|
||
for (const node of this.nodes) {
|
||
node?.__bil?.clearExternalDragState?.()
|
||
}
|
||
},
|
||
|
||
scheduleDragExitClear() {
|
||
this.cancelPendingDragExit()
|
||
this.dragExitTimer = setTimeout(() => {
|
||
this.dragExitTimer = null
|
||
this.clearAllDragTargets()
|
||
}, 0)
|
||
},
|
||
|
||
getEligibleNodes() {
|
||
const eligible = []
|
||
for (const node of this.nodes) {
|
||
if (!node?.graph || !node.__bilInitialized) continue
|
||
const { state } = getCurrentState(node)
|
||
if (!state.catch_canvas_drops) continue
|
||
eligible.push(node)
|
||
}
|
||
return eligible
|
||
},
|
||
|
||
resolveDropNode(event) {
|
||
if (!isGraphCanvasDropTarget(event?.target)) return null
|
||
if (isTargetInsideConveyorWidget(event?.target)) return null
|
||
|
||
const eligibleNodes = this.getEligibleNodes()
|
||
if (!eligibleNodes.length) {
|
||
this.warnedAboutMultipleNodes = false
|
||
return null
|
||
}
|
||
|
||
const selectedNodes = eligibleNodes.filter((node) => isCanvasNodeSelected(node))
|
||
if (selectedNodes.length === 1) {
|
||
this.warnedAboutMultipleNodes = false
|
||
return selectedNodes[0]
|
||
}
|
||
|
||
const nodeAtDropPosition = getCanvasNodeAtEvent(event)
|
||
if (eligibleNodes.includes(nodeAtDropPosition)) {
|
||
this.warnedAboutMultipleNodes = false
|
||
return nodeAtDropPosition
|
||
}
|
||
|
||
if (eligibleNodes.length === 1) {
|
||
this.warnedAboutMultipleNodes = false
|
||
return eligibleNodes[0]
|
||
}
|
||
|
||
if (!this.warnedAboutMultipleNodes) {
|
||
this.warnedAboutMultipleNodes = true
|
||
console.warn(
|
||
'Image Conveyor: multiple conveyors have canvas-drop capture enabled. Select one conveyor before dropping images on empty canvas.'
|
||
)
|
||
}
|
||
return null
|
||
},
|
||
|
||
setActiveNode(node) {
|
||
if (this.activeNode === node) return
|
||
if (this.activeNode) setCanvasDropTargetActive(this.activeNode, false)
|
||
this.activeNode = node
|
||
if (this.activeNode) setCanvasDropTargetActive(this.activeNode, true)
|
||
},
|
||
|
||
handleDragOver(event) {
|
||
this.cancelPendingDragExit()
|
||
if (event.defaultPrevented && !isGraphCanvasDropTarget(event.target)) {
|
||
this.setActiveNode(null)
|
||
return
|
||
}
|
||
const node = this.resolveDropNode(event)
|
||
if (!node) {
|
||
this.setActiveNode(null)
|
||
return
|
||
}
|
||
if (!hasExternalFileDrag(event) && !hasPotentialExternalFileDrag(event)) {
|
||
this.setActiveNode(null)
|
||
return
|
||
}
|
||
activatePotentialExternalFileDrag(event)
|
||
this.setActiveNode(node)
|
||
},
|
||
|
||
async handleDrop(event) {
|
||
this.cancelPendingDragExit()
|
||
if (event.defaultPrevented && !isGraphCanvasDropTarget(event.target)) {
|
||
this.clearAllDragTargets()
|
||
return
|
||
}
|
||
const node = this.resolveDropNode(event)
|
||
if (!node) {
|
||
this.clearAllDragTargets()
|
||
return
|
||
}
|
||
if (!hasExternalFileDrag(event)) {
|
||
this.clearAllDragTargets()
|
||
return
|
||
}
|
||
|
||
finalizeExternalFileDrag(event)
|
||
this.clearAllDragTargets()
|
||
node.__bil?.restoreCanvasShortcutFocus?.()
|
||
|
||
const files = await getDroppedImageFiles(event)
|
||
if (!files.length) return
|
||
try {
|
||
await uploadViaNode(node, files)
|
||
} catch (error) {
|
||
console.error('Image Conveyor: drop import failed.', error)
|
||
const ctx = node.__bil
|
||
if (ctx && !ctx.removed) {
|
||
ctx.browser.input.error = error?.message || 'Import failed'
|
||
scheduleRenderNode(node)
|
||
}
|
||
}
|
||
},
|
||
|
||
handleDragLeave(event) {
|
||
if (isDragLeavingDocument(event, document.documentElement)) this.scheduleDragExitClear()
|
||
}
|
||
}
|
||
|
||
function moveItems(state, draggedId, targetId) {
|
||
if (!draggedId || !targetId || draggedId === targetId) return false
|
||
const fromIndex = state.items.findIndex((item) => item.id === draggedId)
|
||
const toIndex = state.items.findIndex((item) => item.id === targetId)
|
||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return false
|
||
const [moved] = state.items.splice(fromIndex, 1)
|
||
state.items.splice(toIndex, 0, moved)
|
||
return true
|
||
}
|
||
|
||
function moveItemToInsertionIndex(state, draggedId, insertionIndex) {
|
||
if (!draggedId || !Array.isArray(state?.items)) return false
|
||
const fromIndex = state.items.findIndex((item) => item.id === draggedId)
|
||
if (fromIndex < 0) return false
|
||
const destination = calculateReorderDestinationIndex(state.items.length, fromIndex, insertionIndex)
|
||
if (destination < 0) return false
|
||
const [moved] = state.items.splice(fromIndex, 1)
|
||
state.items.splice(destination, 0, moved)
|
||
return true
|
||
}
|
||
|
||
function getFileExtension(name) {
|
||
const fileName = String(name ?? '')
|
||
const match = fileName.match(/\.([^.]+)$/)
|
||
return match ? match[1].toLowerCase() : ''
|
||
}
|
||
|
||
function isProbablyImageFile(file) {
|
||
if (!(file instanceof File)) return false
|
||
if (typeof file.type === 'string' && file.type.startsWith('image/')) return true
|
||
return IMAGE_EXTENSIONS.has(getFileExtension(file.name))
|
||
}
|
||
|
||
function normalizeRelativeSubfolder(path) {
|
||
return String(path ?? '')
|
||
.replace(/\\/g, '/')
|
||
.split('/')
|
||
.map((segment) => segment.trim())
|
||
.filter((segment) => segment && segment !== '.' && segment !== '..')
|
||
.join('/')
|
||
}
|
||
|
||
function normalizeSourcePath(path) {
|
||
const trimmed = String(path ?? '').trim()
|
||
if (!trimmed) return ''
|
||
|
||
const windowsAbsolute = /^[a-zA-Z]:[\\/]/.test(trimmed)
|
||
const uncAbsolute = /^[\\/]{2}[^\\/]+[\\/]+[^\\/]+/.test(trimmed)
|
||
if (windowsAbsolute || uncAbsolute) {
|
||
return trimmed.replace(/\\/g, '/')
|
||
}
|
||
|
||
const hasLeadingSlash = /^\/+/.test(trimmed)
|
||
const normalized = trimmed
|
||
.replace(/\\/g, '/')
|
||
.replace(/^\/+/, hasLeadingSlash ? '/' : '')
|
||
.split('/')
|
||
.map((segment) => segment.trim())
|
||
.filter((segment) => segment && segment !== '.' && segment !== '..')
|
||
.join('/')
|
||
if (!normalized) return ''
|
||
if (hasLeadingSlash && !normalized.startsWith('/')) return `/${normalized}`
|
||
return normalized
|
||
}
|
||
|
||
function isAbsoluteSourcePath(path) {
|
||
return /^[a-zA-Z]:\//.test(path) || path.startsWith('//') || path.startsWith('/')
|
||
}
|
||
|
||
function sanitizePersistedSourcePath(path) {
|
||
const normalized = normalizeSourcePath(path)
|
||
if (!normalized) return ''
|
||
if (!isAbsoluteSourcePath(normalized)) return normalized
|
||
const segments = normalized.split('/').filter(Boolean)
|
||
return segments.length ? segments[segments.length - 1] : ''
|
||
}
|
||
|
||
function isMeaningfulSourcePath(path) {
|
||
return /^[a-zA-Z]:\//.test(path) || path.startsWith('//') || path.includes('/')
|
||
}
|
||
|
||
/**
|
||
* Derives a meaningful persisted source path hint from a dropped file entry.
|
||
*
|
||
* Examines available metadata on `entry` in priority order and returns the first plausible path:
|
||
* 1. `file.path` (normalized) unless it appears to be a browser "fakepath",
|
||
* 2. `entry.entry.fullPath` (normalized and trimmed of leading slashes),
|
||
* 3. `file.webkitRelativePath` (normalized),
|
||
* 4. `<relativeSubfolder>/<file.name>` when `relativeSubfolder` is present and normalized;
|
||
* otherwise returns an empty string.
|
||
*
|
||
* @param {object} entry - Drop entry wrapper from drag/drop or file input handling.
|
||
* @param {File} [entry.file] - The File object for the dropped item.
|
||
* @param {object} [entry.entry] - Optional FileSystem entry (may contain `fullPath`).
|
||
* @param {string} [entry.relativeSubfolder] - Optional relative subfolder inferred during directory traversal.
|
||
* @returns {string} A normalized, meaningful source path hint when available, or an empty string.
|
||
*/
|
||
function getSourcePathHint(entry) {
|
||
const file = entry?.file
|
||
if (!(file instanceof File)) return ''
|
||
|
||
const nativePath = typeof file.path === 'string' ? normalizeSourcePath(file.path) : ''
|
||
if (nativePath && !/^[a-zA-Z]:\/fakepath\//i.test(nativePath)) return nativePath
|
||
|
||
const entryFullPath = normalizeSourcePath(entry?.entry?.fullPath).replace(/^\/+/, '')
|
||
if (isMeaningfulSourcePath(entryFullPath)) return entryFullPath
|
||
|
||
const relativePath = normalizeSourcePath(file.webkitRelativePath)
|
||
if (isMeaningfulSourcePath(relativePath)) return relativePath
|
||
|
||
const relativeSubfolder = normalizeRelativeSubfolder(entry?.relativeSubfolder)
|
||
if (!relativeSubfolder) return ''
|
||
return normalizeSourcePath(`${relativeSubfolder}/${file.name}`)
|
||
}
|
||
|
||
/**
|
||
* Selects the runtime source path to display for an item, preferring a UI-provided override.
|
||
* @param {Object} item - Item object with at least `id` and `source_path`.
|
||
* @param {Object|null} uiState - UI state that may contain a `source_paths` map of item id → override path.
|
||
* @returns {string} The normalized source path to use for display, or an empty string if none is available.
|
||
*/
|
||
function getRuntimeSourcePath(item, uiState = null) {
|
||
const sourcePath = normalizeSourcePath(uiState?.source_paths?.[item.id] ?? item.source_path)
|
||
return sourcePath || ''
|
||
}
|
||
|
||
function stripAnnotatedStorageTypeSuffix(value) {
|
||
return String(value ?? '').replace(/ \[(input|output|temp)\]$/, '')
|
||
}
|
||
|
||
/**
|
||
* Choose the display path for an item, preferring a runtime source path when available.
|
||
* @param {Object} item - Item object containing at least `annotated` and persisted `source_path`.
|
||
* @param {Object|null} [uiState=null] - Optional UI state that may contain `source_paths[item.id]` to override the item's persisted path.
|
||
* @returns {string} The runtime `source_path` if it appears meaningful, otherwise the item's `annotated` text without the storage-type suffix.
|
||
*/
|
||
function getItemDisplayPath(item, uiState = null) {
|
||
const sourcePath = getRuntimeSourcePath(item, uiState)
|
||
return isMeaningfulSourcePath(sourcePath)
|
||
? sourcePath
|
||
: stripAnnotatedStorageTypeSuffix(item.annotated)
|
||
}
|
||
|
||
/**
|
||
* Build the input-relative upload folder while preserving a dropped directory's structure.
|
||
* @param {string} relativeSubfolder - A relative subfolder path (may be empty or contain redundant segments).
|
||
* @returns {string} The normalized input-relative subfolder, or an empty string for the input root.
|
||
*/
|
||
function buildUploadSubfolder(relativeSubfolder = '') {
|
||
return normalizeRelativeSubfolder(relativeSubfolder)
|
||
}
|
||
|
||
function normalizeUploadFiles(files) {
|
||
return Array.from(files ?? [])
|
||
.map((entry) => {
|
||
if (entry instanceof File) {
|
||
return { file: entry, relativeSubfolder: '' }
|
||
}
|
||
if (entry?.file instanceof File) {
|
||
return {
|
||
file: entry.file,
|
||
relativeSubfolder: normalizeRelativeSubfolder(entry.relativeSubfolder)
|
||
}
|
||
}
|
||
return null
|
||
})
|
||
.filter((entry) => entry && isProbablyImageFile(entry.file))
|
||
}
|
||
|
||
function getTransferItemEntry(item) {
|
||
if (!item || typeof item.webkitGetAsEntry !== 'function') return null
|
||
try {
|
||
return item.webkitGetAsEntry()
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function getTransferItemFile(item) {
|
||
if (!item || typeof item.getAsFile !== 'function') return null
|
||
try {
|
||
return item.getAsFile()
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function hasExternalFileDrag(event) {
|
||
const transfer = event?.dataTransfer
|
||
if (!transfer) return false
|
||
|
||
const items = Array.from(transfer.items ?? []).filter((item) => item?.kind === 'file')
|
||
if (
|
||
items.some((item) => {
|
||
const entry = getTransferItemEntry(item)
|
||
if (entry?.isDirectory) return true
|
||
return isProbablyImageFile(getTransferItemFile(item))
|
||
})
|
||
) {
|
||
return true
|
||
}
|
||
|
||
const files = Array.from(transfer.files ?? [])
|
||
return files.some((file) => isProbablyImageFile(file))
|
||
}
|
||
|
||
function hasExternalDirectoryDrag(event) {
|
||
return Array.from(event?.dataTransfer?.items ?? [])
|
||
.filter((item) => item?.kind === 'file')
|
||
.some((item) => getTransferItemEntry(item)?.isDirectory)
|
||
}
|
||
|
||
function hasPotentialExternalFileDrag(event) {
|
||
const transfer = event?.dataTransfer
|
||
if (!transfer) return false
|
||
if (hasExternalFileDrag(event)) return true
|
||
|
||
const types = Array.from(transfer.types ?? [])
|
||
return types.includes('Files')
|
||
}
|
||
|
||
function finalizeExternalFileDrag(event) {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
event.stopImmediatePropagation?.()
|
||
if (event.dataTransfer) {
|
||
try {
|
||
event.dataTransfer.dropEffect = 'copy'
|
||
} catch {
|
||
// ignore browser-specific dropEffect failures
|
||
}
|
||
}
|
||
}
|
||
|
||
function readDirectoryEntries(reader) {
|
||
return new Promise((resolve, reject) => {
|
||
const entries = []
|
||
|
||
const pump = () => {
|
||
reader.readEntries(
|
||
(batch) => {
|
||
if (!batch.length) {
|
||
resolve(entries)
|
||
return
|
||
}
|
||
entries.push(...batch)
|
||
pump()
|
||
},
|
||
(error) => reject(error)
|
||
)
|
||
}
|
||
|
||
pump()
|
||
})
|
||
}
|
||
|
||
function compareFileSystemEntryNames(left, right) {
|
||
return String(left?.name ?? '').localeCompare(String(right?.name ?? ''), undefined, {
|
||
numeric: true,
|
||
sensitivity: 'base'
|
||
})
|
||
}
|
||
|
||
async function collectImageFilesFromEntry(entry, parentPath = '') {
|
||
if (!entry) return []
|
||
|
||
if (entry.isFile) {
|
||
const file = await new Promise((resolve, reject) => {
|
||
entry.file(resolve, reject)
|
||
})
|
||
if (!isProbablyImageFile(file)) return []
|
||
return [
|
||
{
|
||
file,
|
||
relativeSubfolder: normalizeRelativeSubfolder(parentPath)
|
||
}
|
||
]
|
||
}
|
||
|
||
if (!entry.isDirectory || typeof entry.createReader !== 'function') return []
|
||
|
||
const directoryPath = normalizeRelativeSubfolder(
|
||
parentPath ? `${parentPath}/${entry.name}` : entry.name
|
||
)
|
||
const reader = entry.createReader()
|
||
const children = await readDirectoryEntries(reader)
|
||
children.sort(compareFileSystemEntryNames)
|
||
|
||
const files = []
|
||
for (const child of children) {
|
||
files.push(...(await collectImageFilesFromEntry(child, directoryPath)))
|
||
}
|
||
return files
|
||
}
|
||
|
||
async function getDroppedImageFiles(event) {
|
||
const transfer = event?.dataTransfer
|
||
const fallbackFiles = Array.from(transfer?.files ?? [])
|
||
const items = Array.from(transfer?.items ?? []).filter((item) => item?.kind === 'file')
|
||
|
||
if (items.length) {
|
||
const snapshots = items
|
||
.map((item) => {
|
||
let entry = null
|
||
try {
|
||
entry = getTransferItemEntry(item)
|
||
} catch {
|
||
// fall back to plain file extraction when entry lookup fails
|
||
}
|
||
|
||
const file = getTransferItemFile(item)
|
||
if (!entry && !file) return null
|
||
return { entry, file }
|
||
})
|
||
.filter(Boolean)
|
||
|
||
const expanded = []
|
||
for (const snapshot of snapshots) {
|
||
if (snapshot.entry) {
|
||
try {
|
||
expanded.push(...(await collectImageFilesFromEntry(snapshot.entry)))
|
||
continue
|
||
} catch {
|
||
// fall back to plain file extraction when directory traversal fails
|
||
}
|
||
}
|
||
if (isProbablyImageFile(snapshot.file)) {
|
||
expanded.push({ file: snapshot.file, relativeSubfolder: '' })
|
||
}
|
||
}
|
||
if (expanded.length) return expanded
|
||
}
|
||
|
||
return normalizeUploadFiles(fallbackFiles)
|
||
}
|
||
|
||
async function collectFolderSourceFromEntry(entry) {
|
||
if (!entry?.isDirectory || typeof entry.createReader !== 'function') return null
|
||
const source = {
|
||
id: makeId(),
|
||
name: String(entry.name || 'Folder').trim() || 'Folder',
|
||
files: [],
|
||
directories: new Set([''])
|
||
}
|
||
|
||
const walk = async (directory, parentPath) => {
|
||
const reader = directory.createReader()
|
||
const children = await readDirectoryEntries(reader)
|
||
children.sort(compareFileSystemEntryNames)
|
||
for (const child of children) {
|
||
const childPath = normalizeRelativeSubfolder(
|
||
parentPath ? `${parentPath}/${child.name}` : child.name
|
||
)
|
||
if (child.isDirectory) {
|
||
source.directories.add(childPath)
|
||
await walk(child, childPath)
|
||
continue
|
||
}
|
||
if (!child.isFile) continue
|
||
const file = await new Promise((resolve, reject) => child.file(resolve, reject))
|
||
if (isProbablyImageFile(file)) source.files.push({ file, relativePath: childPath })
|
||
}
|
||
}
|
||
|
||
await walk(entry, '')
|
||
return source
|
||
}
|
||
|
||
async function getDroppedFolderSources(event) {
|
||
const items = Array.from(event?.dataTransfer?.items ?? []).filter((item) => item?.kind === 'file')
|
||
const directoryEntries = items
|
||
.map((item) => getTransferItemEntry(item))
|
||
.filter((entry) => entry?.isDirectory)
|
||
const sources = []
|
||
for (const entry of directoryEntries) {
|
||
try {
|
||
const source = await collectFolderSourceFromEntry(entry)
|
||
if (source) sources.push(source)
|
||
} catch (error) {
|
||
console.error(`Image Conveyor: failed to read folder '${entry?.name || 'unknown'}'.`, error)
|
||
}
|
||
}
|
||
return sources
|
||
}
|
||
|
||
function makePickerFolderSources(files) {
|
||
return groupDirectoryPickerFiles(files, isProbablyImageFile).map((group) => ({
|
||
id: makeId(),
|
||
name: group.name,
|
||
files: group.files,
|
||
directories: new Set(group.directories)
|
||
}))
|
||
}
|
||
|
||
function consumeExternalFileDrag(event) {
|
||
if (!hasExternalFileDrag(event)) return false
|
||
finalizeExternalFileDrag(event)
|
||
return true
|
||
}
|
||
|
||
function activatePotentialExternalFileDrag(event) {
|
||
if (!hasPotentialExternalFileDrag(event)) return false
|
||
event.preventDefault()
|
||
if (event.dataTransfer) {
|
||
try {
|
||
event.dataTransfer.dropEffect = 'copy'
|
||
} catch {
|
||
// ignore browser-specific dropEffect failures
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
function getClipboardImageFiles(event) {
|
||
const transfer = event?.clipboardData
|
||
if (!transfer) return []
|
||
|
||
const items = Array.from(transfer.items ?? [])
|
||
const filesFromItems = items
|
||
.filter((item) => item?.kind === 'file' && String(item.type ?? '').startsWith('image/'))
|
||
.map((item) => {
|
||
try {
|
||
return item.getAsFile()
|
||
} catch {
|
||
return null
|
||
}
|
||
})
|
||
.filter((file) => file instanceof File && isProbablyImageFile(file))
|
||
|
||
if (filesFromItems.length) return filesFromItems
|
||
|
||
return Array.from(transfer.files ?? []).filter((file) => isProbablyImageFile(file))
|
||
}
|
||
|
||
function shouldIgnoreClipboardPasteTarget(target) {
|
||
if (target instanceof HTMLTextAreaElement) return true
|
||
if (target instanceof HTMLInputElement) {
|
||
return !['button', 'checkbox', 'file', 'hidden', 'image', 'radio', 'range', 'reset', 'submit'].includes(target.type)
|
||
}
|
||
for (let element = target instanceof HTMLElement ? target : null; element; element = element.parentElement) {
|
||
if (element.isContentEditable) return true
|
||
}
|
||
return Boolean(window.getSelection?.()?.toString().trim())
|
||
}
|
||
|
||
function isModifiedPlainTextPaste(event) {
|
||
return event.shiftKey && (event.ctrlKey || event.metaKey)
|
||
}
|
||
|
||
function pruneLocalObjectUrls(ctx) {
|
||
if (ctx.localObjectUrls.size <= LOCAL_OBJECT_URL_LIMIT) return
|
||
const inUse = new Set(ctx.cardPool.map((slot) => slot.previewUrl).filter(Boolean))
|
||
const lightboxUrl = ctx.lightbox?.image?.getAttribute?.('src')
|
||
if (lightboxUrl) inUse.add(lightboxUrl)
|
||
for (const [file, entry] of ctx.localObjectUrls) {
|
||
if (ctx.localObjectUrls.size <= LOCAL_OBJECT_URL_LIMIT) break
|
||
if (inUse.has(entry.url)) continue
|
||
URL.revokeObjectURL(entry.url)
|
||
ctx.localObjectUrls.delete(file)
|
||
}
|
||
}
|
||
|
||
function localObjectUrl(ctx, item) {
|
||
const file = item?.localFile
|
||
if (!(file instanceof File)) return ''
|
||
const existing = ctx.localObjectUrls.get(file)
|
||
if (existing) {
|
||
ctx.localObjectUrls.delete(file)
|
||
ctx.localObjectUrls.set(file, existing)
|
||
return existing.url
|
||
}
|
||
const url = URL.createObjectURL(file)
|
||
ctx.localObjectUrls.set(file, { url, sourceId: item.sourceId })
|
||
pruneLocalObjectUrls(ctx)
|
||
return url
|
||
}
|
||
|
||
function releaseLocalSourceUrls(ctx, sourceId) {
|
||
for (const [file, entry] of ctx.localObjectUrls) {
|
||
if (entry.sourceId !== sourceId) continue
|
||
URL.revokeObjectURL(entry.url)
|
||
ctx.localObjectUrls.delete(file)
|
||
}
|
||
}
|
||
|
||
function filePreviewUrl(item, ctx = null) {
|
||
if (item?.localFile && ctx) return localObjectUrl(ctx, item)
|
||
const params = new URLSearchParams()
|
||
params.set(
|
||
'filename',
|
||
item.filename || item.annotated.replace(/ \[(input|output|temp)\]$/, '')
|
||
)
|
||
if (item.subfolder) params.set('subfolder', item.subfolder)
|
||
params.set('type', item.type || 'input')
|
||
params.set(
|
||
'rand',
|
||
String(item.last_processed_at || item.last_queued_at || item.added_at || 0)
|
||
)
|
||
return api.apiURL(`/view?${params.toString()}`)
|
||
}
|
||
|
||
function getInputRelativePath(item) {
|
||
if (String(item?.type ?? 'input') !== 'input') return ''
|
||
const annotated = stripAnnotatedStorageTypeSuffix(item?.annotated)
|
||
const explicit = String(item?.relative_path ?? '').trim()
|
||
return normalizeSourcePath(explicit || annotated).replace(/^\/+/, '')
|
||
}
|
||
|
||
function thumbnailUrl(item, density = 'medium') {
|
||
const relativePath = getInputRelativePath(item)
|
||
if (!relativePath) return filePreviewUrl(item)
|
||
const params = new URLSearchParams()
|
||
params.set('relative_path', relativePath)
|
||
params.set('size', String(CARD_SIZES[density]?.thumbnail ?? CARD_SIZES.medium.thumbnail))
|
||
if (item.source_version || item.mtime_ns) params.set('v', String(item.source_version || item.mtime_ns))
|
||
return api.apiURL(`/image-conveyor/thumbnail?${params.toString()}`)
|
||
}
|
||
|
||
function cachedThumbnailUrl(ctx, item, density) {
|
||
if (item?.localFile) return localObjectUrl(ctx, item)
|
||
if (!item?.source_version) return thumbnailUrl(item, density)
|
||
let urls = ctx.thumbnailUrlCache.get(item)
|
||
if (!urls) {
|
||
urls = new Map()
|
||
ctx.thumbnailUrlCache.set(item, urls)
|
||
}
|
||
const cached = urls.get(density)
|
||
if (cached?.sourceVersion === item.source_version) return cached.url
|
||
const url = thumbnailUrl(item, density)
|
||
urls.set(density, { sourceVersion: item.source_version, url })
|
||
return url
|
||
}
|
||
|
||
function makeItemFromInputFile(entry) {
|
||
const relativePath = normalizeSourcePath(entry?.relative_path).replace(/^\/+/, '')
|
||
const filename = String(entry?.filename ?? '').trim()
|
||
if (!relativePath || !filename) return null
|
||
const subfolder = String(entry?.subfolder ?? '').trim()
|
||
return {
|
||
id: makeId(),
|
||
annotated: `${relativePath} [input]`,
|
||
filename,
|
||
subfolder,
|
||
source_path: relativePath,
|
||
type: 'input',
|
||
status: 'pending',
|
||
added_at: Date.now(),
|
||
last_queued_at: 0,
|
||
last_processed_at: 0
|
||
}
|
||
}
|
||
|
||
function makeItemFromUploadResponse(data) {
|
||
const filename = String(data?.name ?? '').trim()
|
||
const subfolder = String(data?.subfolder ?? '').trim()
|
||
const sourcePath = sanitizePersistedSourcePath(data?.source_path)
|
||
const type = String(data?.type ?? 'input').trim() || 'input'
|
||
if (!filename) return null
|
||
|
||
const path = subfolder ? `${subfolder}/${filename}` : filename
|
||
return {
|
||
id: makeId(),
|
||
annotated: `${path} [${type}]`,
|
||
filename,
|
||
subfolder,
|
||
source_path: sourcePath,
|
||
type,
|
||
status: 'pending',
|
||
added_at: Date.now(),
|
||
last_queued_at: 0,
|
||
last_processed_at: 0
|
||
}
|
||
}
|
||
|
||
async function uploadFiles(files) {
|
||
const uploaded = []
|
||
const errors = []
|
||
const entries = normalizeUploadFiles(files)
|
||
let snapshotRefreshed = false
|
||
for (let index = 0; index < entries.length; index += 1) {
|
||
const entry = entries[index]
|
||
const { file, relativeSubfolder } = entry
|
||
try {
|
||
const body = new FormData()
|
||
body.append('image', file)
|
||
body.append('type', 'input')
|
||
body.append('subfolder', buildUploadSubfolder(relativeSubfolder))
|
||
if (!snapshotRefreshed) body.append('refresh_snapshot', 'true')
|
||
const response = await api.fetchApi('/image-conveyor/resolve-upload', {
|
||
method: 'POST',
|
||
body
|
||
})
|
||
if (!response.ok) {
|
||
let detail = response.statusText
|
||
try {
|
||
const errorPayload = await response.json()
|
||
detail = errorPayload?.error || detail
|
||
} catch {
|
||
// Keep the HTTP status text when the server did not return JSON.
|
||
}
|
||
throw new Error(`Failed to import '${file.name}': ${response.status} ${detail}`)
|
||
}
|
||
const payload = await response.json()
|
||
if (
|
||
!payload ||
|
||
typeof payload !== 'object' ||
|
||
Array.isArray(payload) ||
|
||
typeof payload.name !== 'string' ||
|
||
!payload.name.trim()
|
||
) {
|
||
throw new Error(`Invalid upload response for '${file.name}'.`)
|
||
}
|
||
payload.source_path = getSourcePathHint(entry)
|
||
uploaded.push(payload)
|
||
snapshotRefreshed = true
|
||
} catch (error) {
|
||
errors.push({ file, error: error instanceof Error ? error : new Error(String(error)) })
|
||
}
|
||
}
|
||
return { uploaded, errors }
|
||
}
|
||
|
||
function formatByteCount(value) {
|
||
const bytes = Math.max(0, Number(value) || 0)
|
||
if (bytes < 1024) return `${bytes} B`
|
||
const units = ['KiB', 'MiB', 'GiB', 'TiB']
|
||
let amount = bytes
|
||
let unit = -1
|
||
do {
|
||
amount /= 1024
|
||
unit += 1
|
||
} while (amount >= 1024 && unit < units.length - 1)
|
||
return `${amount.toFixed(amount >= 10 ? 1 : 2)} ${units[unit]}`
|
||
}
|
||
|
||
async function readJsonResponse(response, fallbackMessage) {
|
||
let payload = null
|
||
try {
|
||
payload = await response.json()
|
||
} catch {
|
||
// The fallback below includes the HTTP status when the response is not JSON.
|
||
}
|
||
if (!response.ok) {
|
||
throw new Error(payload?.error || `${fallbackMessage} (${response.status} ${response.statusText})`)
|
||
}
|
||
return payload
|
||
}
|
||
|
||
function rewriteLiveInputReferences(replacements) {
|
||
const byDeletedPath = new Map(
|
||
replacements
|
||
.filter((entry) => entry?.relative_path && entry?.keep_path)
|
||
.map((entry) => [normalizeSourcePath(entry.relative_path), normalizeSourcePath(entry.keep_path)])
|
||
)
|
||
if (!byDeletedPath.size) return 0
|
||
|
||
let rewritten = 0
|
||
for (const node of autoQueueCoordinator.nodes) {
|
||
const ctx = node?.__bil
|
||
if (!ctx || ctx.removed) continue
|
||
const { state, uiState } = getRenderableState(node)
|
||
let changed = false
|
||
for (const item of state.items) {
|
||
if (item.type !== 'input') continue
|
||
const oldPath = normalizeSourcePath(stripAnnotatedStorageTypeSuffix(item.annotated))
|
||
const keepPath = byDeletedPath.get(oldPath)
|
||
if (!keepPath) continue
|
||
const separator = keepPath.lastIndexOf('/')
|
||
item.annotated = `${keepPath} [input]`
|
||
item.filename = separator >= 0 ? keepPath.slice(separator + 1) : keepPath
|
||
item.subfolder = separator >= 0 ? keepPath.slice(0, separator) : ''
|
||
if (normalizeSourcePath(item.source_path) === oldPath) item.source_path = keepPath
|
||
if (normalizeSourcePath(uiState.source_paths[item.id]) === oldPath) uiState.source_paths[item.id] = keepPath
|
||
rewritten += 1
|
||
changed = true
|
||
}
|
||
if (changed) updateState(node, state, uiState)
|
||
}
|
||
return rewritten
|
||
}
|
||
|
||
function getQueuedLegacyInputPaths() {
|
||
const paths = new Set()
|
||
for (const node of autoQueueCoordinator.nodes) {
|
||
const ctx = node?.__bil
|
||
if (!ctx || ctx.removed) continue
|
||
const { state } = getRenderableState(node)
|
||
for (const item of state.items) {
|
||
if (item.type !== 'input' || item.status !== 'queued') continue
|
||
const path = normalizeSourcePath(stripAnnotatedStorageTypeSuffix(item.annotated))
|
||
if (path === 'image_conveyor' || path.startsWith('image_conveyor/')) paths.add(path)
|
||
}
|
||
}
|
||
return paths
|
||
}
|
||
|
||
async function cleanManagedDuplicates(node) {
|
||
const ctx = node.__bil
|
||
if (!ctx || ctx.duplicateCleanupBusy) return
|
||
ctx.duplicateCleanupBusy = true
|
||
const button = ctx.cleanDuplicatesBtn
|
||
const previousLabel = button.textContent
|
||
button.disabled = true
|
||
button.textContent = 'Scanning duplicates…'
|
||
try {
|
||
const scanResponse = await api.fetchApi('/image-conveyor/managed-duplicates/scan', { method: 'POST' })
|
||
const report = await readJsonResponse(scanResponse, 'Duplicate scan failed')
|
||
if (node.__bil !== ctx || ctx.removed) return
|
||
const cleanup = prepareManagedDuplicateCleanup(report?.groups, getQueuedLegacyInputPaths())
|
||
const { groups, duplicateCount, reclaimableBytes, protectedCount: queuedCount } = cleanup
|
||
if (!duplicateCount || !groups.length) {
|
||
window.alert(queuedCount
|
||
? 'The managed duplicates are currently reserved by queued Conveyor items. Run cleanup after those prompts finish or are released.'
|
||
: 'No byte-identical redundant files were found under input/image_conveyor.')
|
||
return
|
||
}
|
||
|
||
const mappings = groups.flatMap((group) => (
|
||
(Array.isArray(group.duplicates) ? group.duplicates : []).map((duplicate) => (
|
||
`${duplicate.relative_path} → ${group.keep_path}`
|
||
))
|
||
))
|
||
const previewLimit = 8
|
||
const preview = mappings.slice(0, previewLimit).join('\n')
|
||
const omitted = mappings.length > previewLimit ? `\n…and ${mappings.length - previewLimit} more` : ''
|
||
const confirmed = window.confirm(
|
||
`Found ${duplicateCount} byte-identical managed duplicate${duplicateCount === 1 ? '' : 's'} ` +
|
||
`(${formatByteCount(reclaimableBytes)}).` +
|
||
(queuedCount ? ` ${queuedCount} queued file${queuedCount === 1 ? ' is' : 's are'} protected and will be left in place.` : '') +
|
||
`\n\n${preview}${omitted}\n\n` +
|
||
'Delete the image_conveyor copies and keep the listed input files? ' +
|
||
'Open Conveyor nodes will be relinked before deletion. Start cleanup when no generation is running. ' +
|
||
'Saved workflows that are not currently open can still reference a deleted legacy path.'
|
||
)
|
||
if (!confirmed) return
|
||
|
||
const protectedPaths = getQueuedLegacyInputPaths()
|
||
const plannedReplacements = groups.flatMap((group) => group.duplicates
|
||
.filter((duplicate) => !protectedPaths.has(normalizeSourcePath(duplicate.relative_path)))
|
||
.map((duplicate) => ({
|
||
relative_path: duplicate.relative_path,
|
||
keep_path: group.keep_path
|
||
})))
|
||
const preRewritten = rewriteLiveInputReferences(plannedReplacements)
|
||
button.textContent = 'Deleting duplicates…'
|
||
const deleteResponse = await api.fetchApi('/image-conveyor/managed-duplicates/delete', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ groups, protected_paths: Array.from(protectedPaths) })
|
||
})
|
||
const result = await readJsonResponse(deleteResponse, 'Duplicate cleanup failed')
|
||
const deleted = Array.isArray(result?.deleted) ? result.deleted : []
|
||
const skipped = Array.isArray(result?.skipped) ? result.skipped : []
|
||
const reservationSkipReason = 'The duplicate is reserved by a queued Conveyor item.'
|
||
const reservationSkips = skipped.filter((entry) => entry?.reason === reservationSkipReason).length
|
||
const changedSkips = skipped.length - reservationSkips
|
||
const rewrittenAfterDelete = rewriteLiveInputReferences(deleted)
|
||
const rewritten = preRewritten + rewrittenAfterDelete
|
||
await Promise.all(
|
||
Array.from(autoQueueCoordinator.nodes)
|
||
.filter((candidate) => candidate?.__bil && !candidate.__bil.removed)
|
||
.map((candidate) => refreshInputFiles(candidate, { force: true }))
|
||
)
|
||
const summary = [
|
||
`Deleted ${deleted.length} exact duplicate${deleted.length === 1 ? '' : 's'} ` +
|
||
`and reclaimed ${formatByteCount(result?.reclaimed_bytes)}.`,
|
||
rewritten ? `Updated ${rewritten} open Conveyor reference${rewritten === 1 ? '' : 's'}.` : '',
|
||
reservationSkips ? `${reservationSkips} newly queued file${reservationSkips === 1 ? ' was' : 's were'} protected.` : '',
|
||
changedSkips ? `${changedSkips} file${changedSkips === 1 ? ' was' : 's were'} skipped because the filesystem changed after the preview.` : ''
|
||
].filter(Boolean).join('\n')
|
||
if (node.__bil === ctx && !ctx.removed) window.alert(summary)
|
||
} catch (error) {
|
||
if (node.__bil === ctx && !ctx.removed) {
|
||
ctx.browser.input.error = error?.message || 'Duplicate cleanup failed'
|
||
scheduleRenderNode(node)
|
||
window.alert(ctx.browser.input.error)
|
||
}
|
||
} finally {
|
||
if (node.__bil === ctx && !ctx.removed) {
|
||
ctx.duplicateCleanupBusy = false
|
||
button.disabled = false
|
||
button.textContent = previousLabel
|
||
}
|
||
}
|
||
}
|
||
|
||
function ensureStyles() {
|
||
if (document.getElementById(STYLE_ID)) return
|
||
|
||
const style = document.createElement('style')
|
||
style.id = STYLE_ID
|
||
style.textContent = `
|
||
.bil-root {
|
||
--comfy-widget-min-height: ${MIN_WIDGET_HEIGHT}px;
|
||
--comfy-widget-height: 100%;
|
||
display: flex; flex-direction: column; gap: 8px; height: 100%;
|
||
min-height: ${MIN_WIDGET_HEIGHT}px; overflow: hidden; box-sizing: border-box;
|
||
padding: 2px 0; color: var(--input-text, #ddd); font: 12px/1.35 system-ui, sans-serif;
|
||
}
|
||
.bil-root.bil-dragover { outline: 2px dashed #73aef5; outline-offset: -3px; border-radius: 10px; background: rgba(90,155,235,.08); }
|
||
.bil-header, .bil-browserbar, .bil-summary, .bil-contextbar, .bil-settings-row { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||
.bil-header { justify-content: space-between; }
|
||
.bil-tabs { display: flex; flex: 1 1 0; gap: 3px; min-width: 0; overflow-x: auto; overflow-y: hidden; padding: 2px; border-radius: 8px; scrollbar-width: none; }
|
||
.bil-tabs::-webkit-scrollbar { display: none; }
|
||
.bil-tabs.bil-folder-drop-ready { outline: 1px dashed rgba(115,175,250,.75); outline-offset: -2px; }
|
||
.bil-tabs.bil-folder-drop-hover { outline: 2px solid #73aef5; background: rgba(90,155,235,.16); }
|
||
.bil-tabs > .bil-tab, .bil-tab-shell { flex: 1 1 150px; min-width: 24px; max-width: 210px; }
|
||
.bil-tab-shell { position: relative; display: flex; overflow: hidden; container: bil-tab / inline-size; }
|
||
.bil-tab-shell.bil-tab-active { min-width: 54px; }
|
||
.bil-tab-shell > .bil-tab { width: 100%; min-width: 0; padding-right: 25px; }
|
||
.bil-tab-label { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.bil-tab-close { position: absolute; top: 50%; right: 5px; width: 18px; height: 18px; transform: translateY(-50%); padding: 0; border: 0; border-radius: 50%; background: transparent; color: inherit; font: 16px/17px system-ui, sans-serif; cursor: pointer; }
|
||
.bil-tab-close:hover { background: rgba(255,255,255,.16); }
|
||
.bil-tab-close[hidden] { display: none; }
|
||
@container bil-tab (max-width: 74px) {
|
||
.bil-tab-shell:not(.bil-tab-active) > .bil-tab { padding-right: 8px; }
|
||
.bil-tab-shell:not(.bil-tab-active) > .bil-tab-close { display: none; }
|
||
}
|
||
.bil-tab, .bil-btn, .bil-select, .bil-input, .bil-icon-btn {
|
||
border: 1px solid rgba(255,255,255,.16); background: rgba(255,255,255,.055);
|
||
color: inherit; border-radius: 7px; padding: 5px 8px; font: inherit; box-sizing: border-box;
|
||
}
|
||
.bil-tab { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.bil-tab, .bil-btn, .bil-icon-btn { cursor: pointer; }
|
||
.bil-tabs > .bil-tab[aria-selected="true"] { min-width: 54px; }
|
||
.bil-tab[aria-selected="true"] { background: rgba(105,165,240,.20); border-color: rgba(115,175,250,.65); }
|
||
.bil-btn:disabled, .bil-icon-btn:disabled { opacity: .4; cursor: not-allowed; }
|
||
.bil-add-btn { font-weight: 650; white-space: nowrap; }
|
||
.bil-browserbar { display: grid; grid-template-columns: minmax(110px,1fr) auto auto auto; }
|
||
.bil-input { min-width: 0; width: 100%; }
|
||
.bil-select { min-width: 0; max-width: 132px; }
|
||
.bil-summary { justify-content: space-between; color: color-mix(in srgb, currentColor 78%, transparent); white-space: nowrap; overflow: hidden; }
|
||
.bil-summary > * { overflow: hidden; text-overflow: ellipsis; }
|
||
.bil-contextbar { min-height: 29px; padding: 4px 6px; border-radius: 7px; background: rgba(105,165,240,.11); }
|
||
.bil-contextbar[hidden] { display: none; }
|
||
.bil-context-label { margin-right: auto; font-weight: 650; }
|
||
.bil-settings { border: 1px solid rgba(255,255,255,.10); border-radius: 7px; }
|
||
.bil-settings > summary { cursor: pointer; padding: 5px 7px; user-select: none; opacity: .82; }
|
||
.bil-settings-row { flex-wrap: wrap; padding: 0 7px 7px; }
|
||
.bil-toggle { display: inline-flex; align-items: center; gap: 5px; user-select: none; white-space: nowrap; }
|
||
.bil-toggle input { margin: 0; }
|
||
.bil-images-per-execution { max-width: 52px; padding-left: 5px; padding-right: 5px; }
|
||
.bil-list { position: relative; min-height: 0; overflow: auto; flex: 1 1 0; overscroll-behavior: contain; outline: none; }
|
||
.bil-list-inner, .bil-list-window { position: relative; min-height: 100%; }
|
||
.bil-selection-marquee { position: absolute; z-index: 4; pointer-events: none; box-sizing: border-box; border: 1px solid rgba(125,185,255,.95); background: rgba(80,145,225,.18); box-shadow: inset 0 0 0 1px rgba(255,255,255,.08); }
|
||
.bil-selection-marquee[hidden] { display: none; }
|
||
.bil-drop-indicator { position: absolute; z-index: 5; pointer-events: none; border-radius: 999px; background: #7db9ff; box-shadow: 0 0 0 1px rgba(15,35,58,.75), 0 0 10px rgba(100,175,255,.85); }
|
||
.bil-drop-indicator[hidden] { display: none; }
|
||
.bil-empty { min-height: 100%; display: flex; align-items: center; justify-content: center; box-sizing: border-box; padding: 20px; text-align: center; border: 1px dashed rgba(255,255,255,.14); border-radius: 10px; opacity: .7; }
|
||
.bil-card {
|
||
position: absolute; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;
|
||
border: 1px solid rgba(255,255,255,.11); border-radius: 10px; background: rgba(0,0,0,.18);
|
||
contain: layout paint style; transition: border-color 80ms ease, background 80ms ease;
|
||
}
|
||
.bil-card.bil-selected { border-color: rgba(110,175,255,.95); box-shadow: inset 0 0 0 1px rgba(110,175,255,.34); background: rgba(80,145,225,.11); }
|
||
.bil-card.bil-focused { outline: 2px solid rgba(150,200,255,.95); outline-offset: -3px; }
|
||
.bil-card.bil-drag-target { outline: 2px dashed rgba(120,185,255,.95); outline-offset: -4px; }
|
||
.bil-media { position: relative; flex: 1 1 auto; min-height: 0; background: transparent; cursor: pointer; }
|
||
.bil-thumb { width: 100%; height: 100%; display: block; object-fit: contain; background: transparent; opacity: 0; }
|
||
.bil-thumb.bil-thumb-ready { opacity: 1; }
|
||
.bil-thumb-error { opacity: .32; filter: grayscale(1); outline: 1px dashed rgba(255,110,110,.78); outline-offset: -2px; }
|
||
.bil-card-overlay { position: absolute; inset: 6px 6px auto 6px; display: flex; align-items: flex-start; justify-content: flex-end; gap: 4px; pointer-events: none; }
|
||
.bil-badge { padding: 2px 6px; border-radius: 999px; font-size: 10px; text-transform: uppercase; letter-spacing: .025em; background: rgba(20,20,20,.82); }
|
||
.bil-badge-pending { color: #e2e2e2; } .bil-badge-queued { color: #ffd276; } .bil-badge-processed { color: #8bea9e; }
|
||
.bil-count-badge { color: #cce4ff; text-transform: none; }
|
||
.bil-folder-card .bil-media { display: flex; align-items: center; justify-content: center; }
|
||
.bil-folder-icon { position: relative; width: 48%; height: 34%; border-radius: 7px; background: linear-gradient(145deg, #78b8f8, #4d86c7); box-shadow: 0 10px 24px rgba(0,0,0,.22); }
|
||
.bil-folder-icon::before { content: ''; position: absolute; left: 8%; top: -28%; width: 42%; height: 36%; border-radius: 6px 6px 0 0; background: #78b8f8; }
|
||
.bil-card-footer { flex: 0 0 ${CARD_FOOTER_HEIGHT}px; display: flex; flex-direction: column; justify-content: center; gap: 4px; padding: 5px 7px 6px; min-width: 0; }
|
||
.bil-card-title-row, .bil-card-actions { display: flex; align-items: center; gap: 4px; min-width: 0; }
|
||
.bil-name { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 620; }
|
||
.bil-index { flex: 0 0 auto; opacity: .65; font-variant-numeric: tabular-nums; }
|
||
.bil-path { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; opacity: .62; font-size: 10px; }
|
||
.bil-card-actions { justify-content: flex-end; }
|
||
.bil-mini-btn { border: 0; background: transparent; color: inherit; border-radius: 5px; padding: 2px 5px; font: inherit; cursor: pointer; opacity: .78; }
|
||
.bil-mini-btn:hover { background: rgba(255,255,255,.10); opacity: 1; }
|
||
.bil-position { margin-left: auto; opacity: .65; font-variant-numeric: tabular-nums; }
|
||
.bil-lightbox { position: fixed; inset: 0; z-index: 100000; display: flex; align-items: center; justify-content: center; padding: 36px; background: rgba(0,0,0,.86); }
|
||
.bil-lightbox[hidden] { display: none; }
|
||
.bil-lightbox img { max-width: 94vw; max-height: 90vh; object-fit: contain; box-shadow: 0 18px 60px rgba(0,0,0,.45); }
|
||
.bil-lightbox-label { position: absolute; left: 24px; bottom: 18px; right: 70px; color: white; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.bil-lightbox-close { position: absolute; top: 18px; right: 20px; font-size: 24px; color: white; background: rgba(255,255,255,.12); border: 0; border-radius: 8px; width: 38px; height: 38px; cursor: pointer; }
|
||
@media (max-width: 600px) { .bil-browserbar { grid-template-columns: minmax(100px,1fr) auto auto; } .bil-browserbar .bil-size-select { display: none; } }
|
||
`
|
||
document.head.appendChild(style)
|
||
}
|
||
|
||
function applyBackendDelta(node, delta) {
|
||
if (!delta || typeof delta !== 'object') return
|
||
const processedIds = processedItemIdsFromDelta(delta)
|
||
if (!processedIds.length) return
|
||
const { state, uiState } = getCurrentState(node)
|
||
const processedSet = new Set(processedIds)
|
||
const now = Date.now()
|
||
let changed = false
|
||
for (const item of state.items) {
|
||
if (!processedSet.has(item.id)) continue
|
||
item.status = delta.new_status === 'processed' ? 'processed' : item.status
|
||
item.last_processed_at = now
|
||
changed = true
|
||
}
|
||
if (changed) updateState(node, state, uiState, { rerender: true })
|
||
}
|
||
|
||
function attachQueueLifecycle(node) {
|
||
if (node.__bilQueueLifecycleAttached) return
|
||
node.__bilQueueLifecycleAttached = true
|
||
|
||
const { queueWidget } = getWidgets(node)
|
||
if (!queueWidget) return
|
||
|
||
queueWidget.beforeQueued = () => {
|
||
const { state } = getCurrentState(node)
|
||
const count = normalizeImagesPerExecution(state.images_per_execution)
|
||
const group = findNextLoadGroup(state)
|
||
updateQueueWidget(
|
||
node,
|
||
group.length === count ? makeQueueReservationPayload(group) : null
|
||
)
|
||
}
|
||
|
||
queueWidget.afterQueued = () => {
|
||
const queuePayload = safeJsonParse(queueWidget.value, {})
|
||
const members = queueReservationMembers(queuePayload)
|
||
if (!members.length) return
|
||
const { state, uiState } = getCurrentState(node)
|
||
if (state.dont_consume) return
|
||
if (Array.isArray(queuePayload.items)) {
|
||
const byId = new Map(state.items.map((item) => [item.id, item]))
|
||
if (!members.every((member) => byId.get(member.id)?.annotated === member.annotated)) return
|
||
}
|
||
const changed = markReservedGroupQueued(
|
||
state.items,
|
||
queuePayload,
|
||
false,
|
||
Date.now()
|
||
)
|
||
if (changed) updateState(node, state, uiState, { rerender: true })
|
||
}
|
||
}
|
||
|
||
function chainNodeCallback(node, key, handler) {
|
||
const previous = node[key]
|
||
node[key] = function (...args) {
|
||
previous?.apply(this, args)
|
||
return handler.apply(this, args)
|
||
}
|
||
}
|
||
|
||
function createBrowserState() {
|
||
return {
|
||
activeView: 'conveyor',
|
||
tabOrder: ['conveyor', 'input'],
|
||
folderSources: new Map(),
|
||
folderViews: new Map(),
|
||
conveyor: {
|
||
query: '', filter: 'all', sort: 'manual', size: 'medium', scrollTop: 0,
|
||
focusedId: null, lastSelectedId: null, selected: new Set()
|
||
},
|
||
input: {
|
||
query: '', folder: 'all', sort: 'name_asc', size: 'medium', scrollTop: 0,
|
||
focusedId: null, lastSelectedId: null, files: [], selected: new Set(),
|
||
loaded: false, loading: false, error: '', snapshotVersion: 0
|
||
}
|
||
}
|
||
}
|
||
|
||
function browserForView(ctx, view) {
|
||
return ctx.browser[view] ?? ctx.browser.folderViews.get(view)
|
||
}
|
||
|
||
function activeBrowser(ctx) {
|
||
return browserForView(ctx, ctx.browser.activeView)
|
||
}
|
||
|
||
function suspendGalleryViewport(ctx) {
|
||
if (!ctx) return
|
||
if (
|
||
!ctx.galleryViewportSuspended &&
|
||
ctx.pendingScrollRestore?.view !== ctx.browser.activeView &&
|
||
isGalleryViewportMeasurable(ctx.list.clientWidth, ctx.list.clientHeight)
|
||
) {
|
||
activeBrowser(ctx).scrollTop = ctx.list.scrollTop
|
||
}
|
||
if (!ctx.galleryViewportSuspended) ctx.galleryViewportEpoch += 1
|
||
ctx.galleryViewportSuspended = true
|
||
ctx.renderedRangeKey = ''
|
||
}
|
||
|
||
function isFolderView(ctx, view = ctx.browser.activeView) {
|
||
return ctx.browser.folderViews.has(view)
|
||
}
|
||
|
||
function isLibraryView(ctx, view = ctx.browser.activeView) {
|
||
return view === 'input' || isFolderView(ctx, view)
|
||
}
|
||
|
||
function getViewItemId(ctx, item, view = ctx.browser.activeView) {
|
||
return isLibraryView(ctx, view) ? item.key ?? item.relative_path : item.id
|
||
}
|
||
|
||
function relativeParent(path) {
|
||
const normalized = normalizeRelativeSubfolder(path)
|
||
const separator = normalized.lastIndexOf('/')
|
||
return separator < 0 ? '' : normalized.slice(0, separator)
|
||
}
|
||
|
||
function relativeName(path) {
|
||
const normalized = normalizeRelativeSubfolder(path)
|
||
const separator = normalized.lastIndexOf('/')
|
||
return separator < 0 ? normalized : normalized.slice(separator + 1)
|
||
}
|
||
|
||
function createFolderBrowserState(source, folderPath = '') {
|
||
const currentPath = normalizeRelativeSubfolder(folderPath)
|
||
const entries = []
|
||
for (const directory of source.directories) {
|
||
if (!directory || relativeParent(directory) !== currentPath) continue
|
||
const fullPath = `${source.name}/${directory}`
|
||
entries.push({
|
||
kind: 'folder',
|
||
key: `folder:${source.id}:${directory}`,
|
||
sourceId: source.id,
|
||
folderPath: directory,
|
||
filename: relativeName(directory),
|
||
relative_path: fullPath,
|
||
subfolder: source.name
|
||
})
|
||
}
|
||
for (const entry of source.files) {
|
||
const relativePath = normalizeRelativeSubfolder(entry.relativePath)
|
||
if (!relativePath || relativeParent(relativePath) !== currentPath) continue
|
||
const fullPath = `${source.name}/${relativePath}`
|
||
entries.push({
|
||
kind: 'local-image',
|
||
key: `local:${source.id}:${relativePath}`,
|
||
sourceId: source.id,
|
||
filename: relativeName(relativePath),
|
||
relative_path: fullPath,
|
||
subfolder: relativeParent(fullPath),
|
||
relativeSubfolder: relativeParent(fullPath),
|
||
type: 'local',
|
||
size: Number(entry.file?.size || 0),
|
||
mtime_ns: Number(entry.file?.lastModified || 0),
|
||
source_version: `${Number(entry.file?.size || 0)}-${Number(entry.file?.lastModified || 0)}`,
|
||
localFile: entry.file
|
||
})
|
||
}
|
||
return {
|
||
sourceId: source.id,
|
||
folderPath: currentPath,
|
||
query: '', sort: 'name_asc', size: 'medium', scrollTop: 0,
|
||
focusedId: null, lastSelectedId: null, entries, selected: new Set(),
|
||
loading: false, error: ''
|
||
}
|
||
}
|
||
|
||
function compareNatural(left, right) {
|
||
return String(left ?? '').localeCompare(String(right ?? ''), undefined, {
|
||
numeric: true,
|
||
sensitivity: 'base'
|
||
})
|
||
}
|
||
|
||
function getViewItems(node) {
|
||
const ctx = node.__bil
|
||
const browser = activeBrowser(ctx)
|
||
if (isLibraryView(ctx)) {
|
||
const query = browser.query.trim().toLocaleLowerCase()
|
||
const sourceItems = ctx.browser.activeView === 'input' ? browser.files : browser.entries
|
||
const items = sourceItems.filter((entry) => {
|
||
if (ctx.browser.activeView === 'input' && browser.folder !== 'all') {
|
||
const folder = String(entry.subfolder || '')
|
||
if (folder !== browser.folder && !folder.startsWith(`${browser.folder}/`)) return false
|
||
}
|
||
return !query || `${entry.filename} ${entry.relative_path}`.toLocaleLowerCase().includes(query)
|
||
})
|
||
const folderFirst = (left, right) => Number(right.kind === 'folder') - Number(left.kind === 'folder')
|
||
switch (browser.sort) {
|
||
case 'name_desc': items.sort((a, b) => folderFirst(a, b) || compareNatural(b.relative_path, a.relative_path)); break
|
||
case 'newest': items.sort((a, b) => folderFirst(a, b) || (b.mtime_ns || 0) - (a.mtime_ns || 0) || compareNatural(a.relative_path, b.relative_path)); break
|
||
case 'oldest': items.sort((a, b) => folderFirst(a, b) || (a.mtime_ns || 0) - (b.mtime_ns || 0) || compareNatural(a.relative_path, b.relative_path)); break
|
||
default: items.sort((a, b) => folderFirst(a, b) || compareNatural(a.relative_path, b.relative_path)); break
|
||
}
|
||
return items
|
||
}
|
||
|
||
const { state, uiState } = getRenderableState(node)
|
||
const query = browser.query.trim().toLocaleLowerCase()
|
||
return state.items.filter((item) => {
|
||
if (browser.filter !== 'all' && item.status !== browser.filter) return false
|
||
if (!query) return true
|
||
return `${item.filename} ${getItemDisplayPath(item, uiState)}`.toLocaleLowerCase().includes(query)
|
||
})
|
||
}
|
||
|
||
function getGalleryMetrics(ctx) {
|
||
const browser = activeBrowser(ctx)
|
||
const definition = CARD_SIZES[browser.size] ?? CARD_SIZES.medium
|
||
const width = Math.max(1, Math.floor(ctx.list.clientWidth || ctx.widgetWidth || MIN_NODE_WIDTH))
|
||
return calculateGalleryMetrics(width, definition.minWidth, CARD_GAP)
|
||
}
|
||
|
||
function getVisibleCardRange(ctx, totalItems, metrics, scrollTop = ctx.list.scrollTop) {
|
||
return calculateVisibleCardRange(
|
||
totalItems,
|
||
metrics.columns,
|
||
metrics.rowStride,
|
||
CARD_GAP,
|
||
scrollTop,
|
||
ctx.list.clientHeight,
|
||
GALLERY_OVERSCAN_ROWS
|
||
)
|
||
}
|
||
|
||
function canReorderConveyor(ctx) {
|
||
const browser = ctx.browser.conveyor
|
||
return ctx.browser.activeView === 'conveyor' && browser.filter === 'all' && !browser.query.trim()
|
||
}
|
||
|
||
function getViewSelectedIds(node) {
|
||
const ctx = node.__bil
|
||
return activeBrowser(ctx).selected
|
||
}
|
||
|
||
function renderSelectionContext(node) {
|
||
const ctx = node.__bil
|
||
if (!ctx) return
|
||
const inputView = isLibraryView(ctx)
|
||
const selected = getViewSelectedIds(node)
|
||
ctx.contextBar.hidden = selected.size === 0
|
||
ctx.contextLabel.textContent = `${selected.size} selected`
|
||
ctx.setPendingBtn.hidden = inputView
|
||
ctx.setProcessedBtn.hidden = inputView
|
||
ctx.deleteSelectedBtn.hidden = inputView
|
||
ctx.contextAddBtn.hidden = !inputView
|
||
}
|
||
|
||
function deleteSelectedConveyorItems(node) {
|
||
const ctx = node.__bil
|
||
if (!ctx || ctx.browser.activeView !== 'conveyor') return false
|
||
const selected = ctx.browser.conveyor.selected
|
||
if (!selected.size) return false
|
||
const { state, uiState } = getRenderableState(node)
|
||
const kept = state.items.filter((item) => !selected.has(item.id))
|
||
if (kept.length === state.items.length) return false
|
||
state.items = kept
|
||
uiState.selected_ids = []
|
||
uiState.source_paths = Object.fromEntries(
|
||
Object.entries(uiState.source_paths).filter(([id]) => !selected.has(id))
|
||
)
|
||
selected.clear()
|
||
updateState(node, state, uiState)
|
||
return true
|
||
}
|
||
|
||
function setItemSelected(node, itemId, checked, event = null) {
|
||
const ctx = node.__bil
|
||
const browser = activeBrowser(ctx)
|
||
const items = ctx.visibleItems || []
|
||
const itemIdentifier = (item) => getViewItemId(ctx, item)
|
||
const currentItem = items.find((item) => itemIdentifier(item) === itemId)
|
||
if (currentItem?.kind === 'folder') return
|
||
const selected = browser.selected
|
||
if (event?.shiftKey && browser.lastSelectedId) {
|
||
const anchor = items.findIndex((item) => itemIdentifier(item) === browser.lastSelectedId)
|
||
const current = items.findIndex((item) => itemIdentifier(item) === itemId)
|
||
if (anchor >= 0 && current >= 0) {
|
||
for (let index = Math.min(anchor, current); index <= Math.max(anchor, current); index += 1) {
|
||
if (items[index].kind !== 'folder') selected.add(itemIdentifier(items[index]))
|
||
}
|
||
}
|
||
} else if (checked) selected.add(itemId)
|
||
else selected.delete(itemId)
|
||
browser.lastSelectedId = itemId
|
||
renderSelectionContext(node)
|
||
scheduleRenderNode(node, { viewportOnly: true, forceVisibleRows: true })
|
||
}
|
||
|
||
function selectItemFromClick(node, itemId, event) {
|
||
const ctx = node.__bil
|
||
if (!ctx) return
|
||
const browser = activeBrowser(ctx)
|
||
browser.focusedId = itemId
|
||
if (event.shiftKey) {
|
||
setItemSelected(node, itemId, true, event)
|
||
return
|
||
}
|
||
if (event.ctrlKey || event.metaKey) {
|
||
setItemSelected(node, itemId, !browser.selected.has(itemId), event)
|
||
return
|
||
}
|
||
browser.selected = new Set([itemId])
|
||
browser.lastSelectedId = itemId
|
||
renderSelectionContext(node)
|
||
scheduleRenderNode(node, { viewportOnly: true, forceVisibleRows: true })
|
||
}
|
||
|
||
function setsEqual(left, right) {
|
||
if (left.size !== right.size) return false
|
||
for (const value of left) if (!right.has(value)) return false
|
||
return true
|
||
}
|
||
|
||
function marqueeContentPoint(ctx, clientX, clientY) {
|
||
const rect = ctx.list.getBoundingClientRect()
|
||
return clientPointToScrollContent(
|
||
clientX,
|
||
clientY,
|
||
rect,
|
||
ctx.list.clientWidth,
|
||
ctx.list.clientHeight,
|
||
ctx.list.scrollLeft,
|
||
ctx.list.scrollTop,
|
||
ctx.list.offsetWidth,
|
||
ctx.list.offsetHeight
|
||
)
|
||
}
|
||
|
||
function marqueeAutoscrollVelocity(list, clientY) {
|
||
const rect = list.getBoundingClientRect()
|
||
const topDistance = clientY - rect.top
|
||
if (topDistance < MARQUEE_AUTOSCROLL_EDGE) {
|
||
return -MARQUEE_AUTOSCROLL_MAX * Math.min(1, (MARQUEE_AUTOSCROLL_EDGE - topDistance) / MARQUEE_AUTOSCROLL_EDGE)
|
||
}
|
||
const bottomDistance = rect.bottom - clientY
|
||
if (bottomDistance < MARQUEE_AUTOSCROLL_EDGE) {
|
||
return MARQUEE_AUTOSCROLL_MAX * Math.min(1, (MARQUEE_AUTOSCROLL_EDGE - bottomDistance) / MARQUEE_AUTOSCROLL_EDGE)
|
||
}
|
||
return 0
|
||
}
|
||
|
||
function renderMarqueeSelection(node, selection) {
|
||
const ctx = node.__bil
|
||
if (!ctx || ctx.marqueeSelection !== selection || ctx.browser.activeView !== selection.view) return
|
||
const current = marqueeContentPoint(ctx, selection.clientX, selection.clientY)
|
||
const distance = Math.hypot(selection.clientX - selection.anchorClientX, selection.clientY - selection.anchorClientY)
|
||
if (!selection.active && distance < MARQUEE_DRAG_THRESHOLD) return
|
||
selection.active = true
|
||
|
||
const bounds = {
|
||
left: Math.min(selection.anchor.x, current.x),
|
||
right: Math.max(selection.anchor.x, current.x),
|
||
top: Math.min(selection.anchor.y, current.y),
|
||
bottom: Math.max(selection.anchor.y, current.y)
|
||
}
|
||
const items = ctx.visibleItems || []
|
||
const metrics = getGalleryMetrics(ctx)
|
||
const hitIds = []
|
||
for (const index of calculateMarqueeGridIndexes(items.length, metrics, bounds)) {
|
||
const item = items[index]
|
||
if (!item || item.kind === 'folder') continue
|
||
hitIds.push(getViewItemId(ctx, item))
|
||
}
|
||
|
||
const next = selection.toggle || selection.additive
|
||
? new Set(selection.baseline)
|
||
: new Set()
|
||
for (const id of hitIds) {
|
||
if (selection.toggle && selection.baseline.has(id)) next.delete(id)
|
||
else next.add(id)
|
||
}
|
||
const browser = activeBrowser(ctx)
|
||
if (!setsEqual(browser.selected, next)) {
|
||
browser.selected = next
|
||
renderSelectionContext(node)
|
||
scheduleRenderNode(node, { viewportOnly: true, forceVisibleRows: true })
|
||
}
|
||
selection.lastHitId = hitIds.at(-1) ?? null
|
||
Object.assign(ctx.selectionMarquee.style, {
|
||
left: `${bounds.left}px`,
|
||
top: `${bounds.top}px`,
|
||
width: `${Math.max(1, bounds.right - bounds.left)}px`,
|
||
height: `${Math.max(1, bounds.bottom - bounds.top)}px`
|
||
})
|
||
ctx.selectionMarquee.hidden = false
|
||
}
|
||
|
||
function scheduleMarqueeSelectionFrame(node) {
|
||
const ctx = node.__bil
|
||
const selection = ctx?.marqueeSelection
|
||
if (!selection || selection.frame) return
|
||
selection.frame = requestAnimationFrame(() => {
|
||
selection.frame = 0
|
||
if (ctx.removed || ctx.marqueeSelection !== selection) return
|
||
const velocity = marqueeAutoscrollVelocity(ctx.list, selection.clientY)
|
||
const previousTop = ctx.list.scrollTop
|
||
if (velocity) ctx.list.scrollTop += velocity
|
||
renderMarqueeSelection(node, selection)
|
||
if (velocity && ctx.list.scrollTop !== previousTop) scheduleMarqueeSelectionFrame(node)
|
||
})
|
||
}
|
||
|
||
function finishMarqueeSelection(node, event, cancelled = false) {
|
||
const ctx = node.__bil
|
||
const selection = ctx?.marqueeSelection
|
||
if (!selection || (event?.pointerId != null && event.pointerId !== selection.pointerId)) return
|
||
if (selection.frame) cancelAnimationFrame(selection.frame)
|
||
selection.frame = 0
|
||
if (!cancelled && event) {
|
||
selection.clientX = event.clientX
|
||
selection.clientY = event.clientY
|
||
renderMarqueeSelection(node, selection)
|
||
}
|
||
const browser = browserForView(ctx, selection.view)
|
||
if (cancelled && browser) {
|
||
browser.selected = new Set(selection.baseline)
|
||
renderSelectionContext(node)
|
||
scheduleRenderNode(node, { viewportOnly: true, forceVisibleRows: true })
|
||
} else if (!selection.active && !selection.toggle && !selection.additive && browser) {
|
||
browser.selected.clear()
|
||
renderSelectionContext(node)
|
||
scheduleRenderNode(node, { viewportOnly: true, forceVisibleRows: true })
|
||
} else if (selection.lastHitId && browser) {
|
||
browser.focusedId = selection.lastHitId
|
||
browser.lastSelectedId = selection.lastHitId
|
||
}
|
||
ctx.selectionMarquee.hidden = true
|
||
ctx.marqueeSelection = null
|
||
try {
|
||
ctx.list.releasePointerCapture?.(selection.pointerId)
|
||
} catch {
|
||
// The pointer may already have been released by the browser.
|
||
}
|
||
}
|
||
|
||
function cancelMarqueeSelection(node, restoreBaseline = true) {
|
||
finishMarqueeSelection(node, null, restoreBaseline)
|
||
}
|
||
|
||
function beginMarqueeSelection(node, event) {
|
||
const ctx = node.__bil
|
||
if (!ctx || event.button !== 0 || event.isPrimary === false || ctx.marqueeSelection) return false
|
||
if (event.target instanceof Element && event.target.closest('.bil-card')) return false
|
||
const rect = ctx.list.getBoundingClientRect()
|
||
if (event.clientX < rect.left || event.clientY < rect.top || event.clientX > rect.right || event.clientY > rect.bottom) return false
|
||
const browser = activeBrowser(ctx)
|
||
ctx.marqueeSelection = {
|
||
pointerId: event.pointerId,
|
||
view: ctx.browser.activeView,
|
||
anchor: marqueeContentPoint(ctx, event.clientX, event.clientY),
|
||
anchorClientX: event.clientX,
|
||
anchorClientY: event.clientY,
|
||
clientX: event.clientX,
|
||
clientY: event.clientY,
|
||
baseline: new Set(browser.selected),
|
||
toggle: Boolean(event.ctrlKey || event.metaKey),
|
||
additive: Boolean(event.shiftKey),
|
||
active: false,
|
||
lastHitId: null,
|
||
frame: 0
|
||
}
|
||
ctx.list.setPointerCapture?.(event.pointerId)
|
||
event.preventDefault()
|
||
return true
|
||
}
|
||
|
||
function createLightbox(node) {
|
||
const lightbox = document.createElement('div')
|
||
lightbox.className = 'bil-lightbox'
|
||
lightbox.hidden = true
|
||
lightbox.setAttribute('role', 'dialog')
|
||
lightbox.setAttribute('aria-modal', 'true')
|
||
lightbox.tabIndex = -1
|
||
const image = document.createElement('img')
|
||
const label = document.createElement('div')
|
||
label.className = 'bil-lightbox-label'
|
||
const close = document.createElement('button')
|
||
close.className = 'bil-lightbox-close'
|
||
close.type = 'button'
|
||
close.textContent = '×'
|
||
close.setAttribute('aria-label', 'Close preview')
|
||
lightbox.append(image, label, close)
|
||
const hide = () => {
|
||
lightbox.hidden = true
|
||
image.removeAttribute('src')
|
||
delete lightbox.dataset.sourceId
|
||
restoreGraphCanvasFocus(lightbox, app.canvas?.canvas)
|
||
}
|
||
close.addEventListener('click', hide)
|
||
lightbox.addEventListener('click', (event) => { if (event.target === lightbox) hide() })
|
||
lightbox.addEventListener('keydown', (event) => {
|
||
if (event.key !== 'Escape' || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||
consumeGalleryKeyboardEvent(event)
|
||
hide()
|
||
})
|
||
document.body.appendChild(lightbox)
|
||
return { root: lightbox, image, label, close, hide }
|
||
}
|
||
|
||
function openPreview(node, item) {
|
||
const ctx = node.__bil
|
||
if (!ctx?.lightbox || !item || item.kind === 'folder') return
|
||
const label = item.filename || item.relative_path || getItemDisplayPath(item)
|
||
ctx.lightbox.image.src = filePreviewUrl(item, ctx)
|
||
ctx.lightbox.image.alt = label
|
||
ctx.lightbox.label.textContent = label
|
||
if (item.sourceId) ctx.lightbox.root.dataset.sourceId = item.sourceId
|
||
ctx.lightbox.root.hidden = false
|
||
ctx.lightbox.close.focus({ preventScroll: true })
|
||
}
|
||
|
||
function clearCardDragTargets(ctx, except = null) {
|
||
for (const slot of ctx.cardPool) {
|
||
if (slot.card !== except) slot.card.classList.remove('bil-drag-target')
|
||
}
|
||
}
|
||
|
||
function clearInternalDragTarget(ctx) {
|
||
if (!ctx) return
|
||
clearCardDragTargets(ctx)
|
||
ctx.dragIntent = null
|
||
if (ctx.dropIndicator) ctx.dropIndicator.hidden = true
|
||
}
|
||
|
||
function renderInternalDragTarget(ctx, intent) {
|
||
clearCardDragTargets(ctx)
|
||
ctx.dropIndicator.hidden = true
|
||
if (!intent) return
|
||
if (intent.type === 'card') {
|
||
const target = ctx.cardPool.find((slot) => slot.itemIndex === intent.targetIndex)
|
||
target?.card.classList.add('bil-drag-target')
|
||
return
|
||
}
|
||
const indicator = ctx.dropIndicator
|
||
indicator.hidden = false
|
||
if (intent.orientation === 'horizontal') {
|
||
Object.assign(indicator.style, {
|
||
left: `${intent.left}px`,
|
||
top: `${intent.top - 2}px`,
|
||
width: `${ctx.lastMetrics?.width || ctx.list.clientWidth}px`,
|
||
height: '4px'
|
||
})
|
||
return
|
||
}
|
||
Object.assign(indicator.style, {
|
||
left: `${intent.left - 2}px`,
|
||
top: `${intent.top}px`,
|
||
width: '4px',
|
||
height: `${intent.height}px`
|
||
})
|
||
}
|
||
|
||
function internalDragIntentAt(ctx, clientX, clientY) {
|
||
if (!ctx.draggedId || !canReorderConveyor(ctx)) return null
|
||
const point = marqueeContentPoint(ctx, clientX, clientY)
|
||
return calculateGalleryDropIntent(
|
||
ctx.visibleItems?.length || 0,
|
||
getGalleryMetrics(ctx),
|
||
point.x,
|
||
point.y
|
||
)
|
||
}
|
||
|
||
function folderViewId(sourceId, folderPath = '') {
|
||
return `folder:${sourceId}:${normalizeRelativeSubfolder(folderPath)}`
|
||
}
|
||
|
||
function folderTabLabel(source, folderPath) {
|
||
return folderPath ? relativeName(folderPath) : source.name
|
||
}
|
||
|
||
function createFolderTabElement(node, viewId, source, folderPath) {
|
||
const ctx = node.__bil
|
||
const shell = document.createElement('div')
|
||
shell.className = 'bil-tab-shell'
|
||
const tab = document.createElement('button')
|
||
tab.className = 'bil-tab'
|
||
tab.type = 'button'
|
||
tab.id = `${ctx.tabSetId}-${viewId.replace(/[^a-zA-Z0-9_-]/g, '-')}`
|
||
tab.setAttribute('role', 'tab')
|
||
tab.setAttribute('aria-controls', ctx.list.id)
|
||
const label = document.createElement('span')
|
||
label.className = 'bil-tab-label'
|
||
label.textContent = folderTabLabel(source, folderPath)
|
||
const fullPath = folderPath ? `${source.name}/${folderPath}` : source.name
|
||
tab.title = fullPath
|
||
tab.appendChild(label)
|
||
const close = document.createElement('button')
|
||
close.className = 'bil-tab-close'
|
||
close.type = 'button'
|
||
close.textContent = '×'
|
||
close.hidden = false
|
||
close.setAttribute('aria-label', `Close ${fullPath} tab`)
|
||
tab.addEventListener('click', () => switchBrowserView(node, viewId))
|
||
close.addEventListener('click', (event) => {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
closeFolderView(node, viewId)
|
||
restoreGraphCanvasFocus(close, app.canvas?.canvas)
|
||
})
|
||
shell.append(tab, close)
|
||
ctx.tabs.appendChild(shell)
|
||
ctx.folderTabElements.set(viewId, { shell, tab, close, label })
|
||
}
|
||
|
||
function openFolderView(node, sourceId, folderPath = '') {
|
||
const ctx = node.__bil
|
||
const source = ctx?.browser.folderSources.get(sourceId)
|
||
if (!ctx || !source) return
|
||
const normalizedPath = normalizeRelativeSubfolder(folderPath)
|
||
if (normalizedPath && !source.directories.has(normalizedPath)) return
|
||
const viewId = folderViewId(sourceId, normalizedPath)
|
||
if (!ctx.browser.folderViews.has(viewId)) {
|
||
ctx.browser.folderViews.set(viewId, createFolderBrowserState(source, normalizedPath))
|
||
ctx.browser.tabOrder.push(viewId)
|
||
createFolderTabElement(node, viewId, source, normalizedPath)
|
||
}
|
||
switchBrowserView(node, viewId)
|
||
}
|
||
|
||
function addFolderSources(node, sources) {
|
||
const ctx = node.__bil
|
||
if (!ctx || ctx.removed) return 0
|
||
let added = 0
|
||
for (const source of Array.from(sources ?? [])) {
|
||
if (!source?.id || !source?.name || !(source.directories instanceof Set) || !Array.isArray(source.files)) continue
|
||
source.files.sort((left, right) => compareNatural(left.relativePath, right.relativePath))
|
||
ctx.browser.folderSources.set(source.id, source)
|
||
openFolderView(node, source.id, '')
|
||
added += 1
|
||
}
|
||
return added
|
||
}
|
||
|
||
function closeFolderView(node, viewId) {
|
||
const ctx = node.__bil
|
||
const view = ctx?.browser.folderViews.get(viewId)
|
||
if (!ctx || !view) return
|
||
const nextView = chooseViewAfterClose(
|
||
ctx.browser.tabOrder,
|
||
ctx.browser.activeView,
|
||
viewId,
|
||
'input'
|
||
)
|
||
const wasActive = ctx.browser.activeView === viewId
|
||
if (wasActive) {
|
||
switchBrowserView(node, nextView)
|
||
hideUnusedCards(ctx)
|
||
}
|
||
ctx.browser.folderViews.delete(viewId)
|
||
ctx.browser.tabOrder = ctx.browser.tabOrder.filter((entry) => entry !== viewId)
|
||
ctx.folderTabElements.get(viewId)?.shell.remove()
|
||
ctx.folderTabElements.delete(viewId)
|
||
const sourceStillOpen = Array.from(ctx.browser.folderViews.values())
|
||
.some((candidate) => candidate.sourceId === view.sourceId)
|
||
if (!sourceStillOpen) {
|
||
if (ctx.lightbox?.root?.dataset?.sourceId === view.sourceId) ctx.lightbox.hide()
|
||
releaseLocalSourceUrls(ctx, view.sourceId)
|
||
ctx.browser.folderSources.delete(view.sourceId)
|
||
}
|
||
scheduleRenderNode(node, { forceVisibleRows: true })
|
||
}
|
||
|
||
function renderTabs(ctx) {
|
||
const activeView = ctx.browser.activeView
|
||
const inputSelected = activeView === 'input'
|
||
const conveyorSelected = activeView === 'conveyor'
|
||
ctx.conveyorTab.setAttribute('aria-selected', String(conveyorSelected))
|
||
ctx.inputTab.setAttribute('aria-selected', String(inputSelected))
|
||
for (const [viewId, elements] of ctx.folderTabElements) {
|
||
const selected = viewId === activeView
|
||
elements.tab.setAttribute('aria-selected', String(selected))
|
||
elements.shell.classList.toggle('bil-tab-active', selected)
|
||
elements.close.hidden = false
|
||
}
|
||
const activeTab = activeView === 'conveyor'
|
||
? ctx.conveyorTab
|
||
: activeView === 'input'
|
||
? ctx.inputTab
|
||
: ctx.folderTabElements.get(activeView)?.tab
|
||
if (activeTab) {
|
||
ctx.list.setAttribute('aria-labelledby', activeTab.id)
|
||
const activeElement = activeTab.parentElement?.classList.contains('bil-tab-shell')
|
||
? activeTab.parentElement
|
||
: activeTab
|
||
const tabsRect = ctx.tabs.getBoundingClientRect()
|
||
const activeRect = activeElement.getBoundingClientRect()
|
||
if (activeRect.left < tabsRect.left) ctx.tabs.scrollLeft -= tabsRect.left - activeRect.left
|
||
else if (activeRect.right > tabsRect.right) ctx.tabs.scrollLeft += activeRect.right - tabsRect.right
|
||
}
|
||
}
|
||
|
||
function createCardSlot(node, ctx) {
|
||
const card = document.createElement('div')
|
||
card.className = 'bil-card'
|
||
card.setAttribute('role', 'option')
|
||
card.style.display = 'none'
|
||
const slot = {
|
||
card,
|
||
itemId: null,
|
||
item: null,
|
||
itemIndex: -1,
|
||
inputView: null,
|
||
itemKind: '',
|
||
label: '',
|
||
displayPath: '',
|
||
subfolder: '',
|
||
previewUrl: '',
|
||
bindToken: 0,
|
||
layoutKey: '',
|
||
badgeKey: '',
|
||
selected: null,
|
||
focused: null,
|
||
draggable: false
|
||
}
|
||
|
||
card.addEventListener('dragstart', (event) => {
|
||
if (!slot.draggable || !slot.itemId) { event.preventDefault(); return }
|
||
ctx.draggedId = slot.itemId
|
||
event.dataTransfer?.setData('text/plain', slot.itemId)
|
||
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'
|
||
clearInternalDragTarget(ctx)
|
||
})
|
||
card.addEventListener('dragend', () => { ctx.draggedId = null; clearInternalDragTarget(ctx) })
|
||
|
||
const media = document.createElement('div')
|
||
media.className = 'bil-media'
|
||
media.addEventListener('click', (event) => {
|
||
if (!slot.itemId) return
|
||
if (slot.item?.kind === 'folder') {
|
||
openFolderView(node, slot.item.sourceId, slot.item.folderPath)
|
||
return
|
||
}
|
||
selectItemFromClick(node, slot.itemId, event)
|
||
})
|
||
media.addEventListener('dblclick', () => openPreview(node, slot.item))
|
||
const thumb = document.createElement('img')
|
||
thumb.className = 'bil-thumb'
|
||
thumb.loading = 'lazy'
|
||
thumb.decoding = 'async'
|
||
thumb.draggable = false
|
||
const folderIcon = document.createElement('div')
|
||
folderIcon.className = 'bil-folder-icon'
|
||
folderIcon.hidden = true
|
||
const overlay = document.createElement('div')
|
||
overlay.className = 'bil-card-overlay'
|
||
const badge = document.createElement('span')
|
||
badge.className = 'bil-badge'
|
||
overlay.append(badge)
|
||
media.append(thumb, folderIcon, overlay)
|
||
|
||
const footer = document.createElement('div')
|
||
footer.className = 'bil-card-footer'
|
||
const titleRow = document.createElement('div')
|
||
titleRow.className = 'bil-card-title-row'
|
||
const name = document.createElement('div')
|
||
name.className = 'bil-name'
|
||
const indexText = document.createElement('div')
|
||
indexText.className = 'bil-index'
|
||
titleRow.append(name, indexText)
|
||
const actions = document.createElement('div')
|
||
actions.className = 'bil-card-actions'
|
||
const path = document.createElement('div')
|
||
path.className = 'bil-path'
|
||
const pendingBtn = document.createElement('button')
|
||
pendingBtn.className = 'bil-mini-btn'; pendingBtn.type = 'button'; pendingBtn.textContent = '↶'
|
||
const processedBtn = document.createElement('button')
|
||
processedBtn.className = 'bil-mini-btn'; processedBtn.type = 'button'; processedBtn.textContent = '✓'
|
||
const deleteBtn = document.createElement('button')
|
||
deleteBtn.className = 'bil-mini-btn'; deleteBtn.type = 'button'; deleteBtn.textContent = '×'
|
||
const addBtn = document.createElement('button')
|
||
addBtn.className = 'bil-mini-btn'; addBtn.type = 'button'; addBtn.textContent = '+ Add'
|
||
|
||
pendingBtn.addEventListener('click', () => {
|
||
const { state, uiState } = getRenderableState(node)
|
||
const item = state.items.find((entry) => entry.id === slot.itemId)
|
||
if (item) { item.status = 'pending'; updateState(node, state, uiState) }
|
||
})
|
||
processedBtn.addEventListener('click', () => {
|
||
const { state, uiState } = getRenderableState(node)
|
||
const item = state.items.find((entry) => entry.id === slot.itemId)
|
||
if (item) { item.status = 'processed'; item.last_processed_at = Date.now(); updateState(node, state, uiState) }
|
||
})
|
||
deleteBtn.addEventListener('click', () => {
|
||
const { state, uiState } = getRenderableState(node)
|
||
state.items = state.items.filter((entry) => entry.id !== slot.itemId)
|
||
ctx.browser.conveyor.selected.delete(slot.itemId)
|
||
uiState.selected_ids = uiState.selected_ids.filter((id) => id !== slot.itemId)
|
||
delete uiState.source_paths[slot.itemId]
|
||
updateState(node, state, uiState)
|
||
})
|
||
addBtn.addEventListener('click', () => {
|
||
if (!slot.item) return
|
||
if (slot.item.kind === 'folder') openFolderView(node, slot.item.sourceId, slot.item.folderPath)
|
||
else void addLibraryEntries(node, [slot.item])
|
||
})
|
||
actions.append(path, pendingBtn, processedBtn, deleteBtn, addBtn)
|
||
footer.append(titleRow, actions)
|
||
card.append(media, footer)
|
||
Object.assign(slot, { media, thumb, folderIcon, badge, name, indexText, path, pendingBtn, processedBtn, deleteBtn, addBtn })
|
||
return slot
|
||
}
|
||
|
||
function ensureCardPool(node, needed) {
|
||
const ctx = node.__bil
|
||
while (ctx.cardPool.length < needed) {
|
||
const slot = createCardSlot(node, ctx)
|
||
ctx.cardPool.push(slot)
|
||
ctx.listWindow.appendChild(slot.card)
|
||
}
|
||
}
|
||
|
||
function resetCardThumbnail(slot) {
|
||
slot.bindToken += 1
|
||
slot.previewUrl = ''
|
||
slot.thumbnailIdentity = ''
|
||
slot.thumb.onload = null
|
||
slot.thumb.onerror = null
|
||
slot.thumb.classList.remove('bil-thumb-ready', 'bil-thumb-error')
|
||
slot.thumb.removeAttribute('src')
|
||
}
|
||
|
||
function bindCardThumbnail(slot, url) {
|
||
const token = ++slot.bindToken
|
||
slot.previewUrl = url
|
||
slot.thumb.onload = () => {
|
||
if (token !== slot.bindToken || slot.previewUrl !== url) return
|
||
slot.thumb.classList.remove('bil-thumb-error')
|
||
slot.thumb.classList.add('bil-thumb-ready')
|
||
}
|
||
slot.thumb.onerror = () => {
|
||
if (token !== slot.bindToken || slot.previewUrl !== url) return
|
||
slot.thumb.classList.remove('bil-thumb-ready')
|
||
slot.thumb.classList.add('bil-thumb-error')
|
||
}
|
||
slot.thumb.src = url
|
||
}
|
||
|
||
function hideUnusedCards(ctx, start = 0) {
|
||
for (let index = start; index < ctx.cardPool.length; index += 1) {
|
||
const slot = ctx.cardPool[index]
|
||
slot.itemId = null; slot.item = null; slot.itemIndex = -1; slot.inputView = null; slot.itemKind = ''
|
||
slot.layoutKey = ''; slot.badgeKey = ''; slot.selected = null; slot.focused = null
|
||
slot.card.style.display = 'none'
|
||
slot.card.classList.remove('bil-selected', 'bil-focused', 'bil-drag-target', 'bil-folder-card')
|
||
slot.folderIcon.hidden = true
|
||
resetCardThumbnail(slot)
|
||
}
|
||
}
|
||
|
||
function updateCardSlot(node, slot, item, itemIndex, metrics, selected, annotatedCounts, uiState, allowThumbnailLoad) {
|
||
const ctx = node.__bil
|
||
const inputView = isLibraryView(ctx)
|
||
const folderItem = item.kind === 'folder'
|
||
const localItem = item.kind === 'local-image'
|
||
const itemId = getViewItemId(ctx, item)
|
||
const displayPath = inputView ? item.relative_path : getItemDisplayPath(item, uiState)
|
||
const label = item.filename || item.relative_path || displayPath
|
||
const browser = activeBrowser(ctx)
|
||
const thumbnailIdentity = folderItem
|
||
? `folder:${itemId}`
|
||
: `${itemId}:${item.source_version || item.mtime_ns || ''}:${browser.size}`
|
||
if (slot.thumbnailIdentity !== thumbnailIdentity && (slot.previewUrl || slot.thumbnailIdentity)) resetCardThumbnail(slot)
|
||
slot.thumbnailIdentity = thumbnailIdentity
|
||
|
||
const subfolder = inputView ? (item.subfolder || (localItem ? 'Selected folder' : 'input root')) : ''
|
||
const staticContentChanged = slot.itemIndex !== itemIndex ||
|
||
slot.inputView !== inputView ||
|
||
slot.itemKind !== item.kind ||
|
||
slot.label !== label ||
|
||
slot.displayPath !== displayPath ||
|
||
slot.subfolder !== subfolder
|
||
slot.itemId = itemId; slot.item = item
|
||
slot.itemIndex = itemIndex; slot.inputView = inputView; slot.itemKind = item.kind || ''
|
||
slot.label = label; slot.displayPath = displayPath; slot.subfolder = subfolder
|
||
if (slot.card.style.display !== 'flex') slot.card.style.display = 'flex'
|
||
const layoutKey = `${itemIndex}:${metrics.columns}:${metrics.cardWidth}:${metrics.cardHeight}`
|
||
if (slot.layoutKey !== layoutKey) {
|
||
const row = Math.floor(itemIndex / metrics.columns)
|
||
const column = itemIndex % metrics.columns
|
||
const left = column * (metrics.cardWidth + CARD_GAP)
|
||
const top = row * metrics.rowStride
|
||
slot.card.style.width = `${metrics.cardWidth}px`
|
||
slot.card.style.height = `${metrics.cardHeight}px`
|
||
slot.card.style.transform = `translate3d(${left}px, ${top}px, 0)`
|
||
slot.layoutKey = layoutKey
|
||
}
|
||
const isSelected = selected.has(itemId)
|
||
if (slot.selected !== isSelected) {
|
||
slot.selected = isSelected
|
||
slot.card.classList.toggle('bil-selected', isSelected)
|
||
slot.card.setAttribute('aria-selected', String(isSelected))
|
||
}
|
||
const isFocused = browser.focusedId === itemId
|
||
if (slot.focused !== isFocused) {
|
||
slot.focused = isFocused
|
||
slot.card.classList.toggle('bil-focused', isFocused)
|
||
}
|
||
if (staticContentChanged) {
|
||
slot.card.title = displayPath
|
||
slot.name.textContent = label
|
||
slot.indexText.textContent = `#${itemIndex + 1}`
|
||
slot.path.textContent = inputView ? subfolder : displayPath
|
||
slot.path.title = slot.path.textContent
|
||
slot.thumb.alt = label
|
||
}
|
||
slot.card.classList.toggle('bil-folder-card', folderItem)
|
||
slot.folderIcon.hidden = !folderItem
|
||
slot.thumb.hidden = folderItem
|
||
const draggable = !inputView && canReorderConveyor(ctx)
|
||
if (slot.draggable !== draggable) {
|
||
slot.draggable = draggable
|
||
slot.card.draggable = draggable
|
||
}
|
||
if (slot.pendingBtn.hidden !== inputView) slot.pendingBtn.hidden = inputView
|
||
if (slot.processedBtn.hidden !== inputView) slot.processedBtn.hidden = inputView
|
||
if (slot.deleteBtn.hidden !== inputView) slot.deleteBtn.hidden = inputView
|
||
if (slot.addBtn.hidden === inputView) slot.addBtn.hidden = !inputView
|
||
if (inputView) slot.addBtn.textContent = folderItem ? 'Open' : '+ Add'
|
||
if (inputView) {
|
||
const count = localItem || folderItem ? 0 : annotatedCounts.get(item.relative_path) || 0
|
||
const badgeKey = folderItem ? 'folder' : localItem ? 'local' : `input:${count}`
|
||
if (slot.badgeKey !== badgeKey) {
|
||
slot.badge.className = 'bil-badge bil-count-badge'
|
||
slot.badge.textContent = folderItem ? 'Folder' : localItem ? 'Local' : count ? `In conveyor ×${count}` : 'Input'
|
||
slot.badgeKey = badgeKey
|
||
}
|
||
} else {
|
||
const badgeKey = `conveyor:${item.status}`
|
||
if (slot.badgeKey !== badgeKey) {
|
||
slot.badge.className = `bil-badge bil-badge-${item.status}`
|
||
slot.badge.textContent = item.status
|
||
slot.badgeKey = badgeKey
|
||
}
|
||
}
|
||
if (!folderItem && allowThumbnailLoad && !slot.previewUrl) {
|
||
const url = cachedThumbnailUrl(ctx, item, browser.size)
|
||
if (url) bindCardThumbnail(slot, url)
|
||
}
|
||
}
|
||
|
||
function renderVisibleCards(node) {
|
||
const ctx = node.__bil
|
||
if (!ctx) return
|
||
const items = ctx.visibleItems || []
|
||
if (!items.length) {
|
||
if (ctx.listInner.style.height !== 'auto') ctx.listInner.style.height = 'auto'
|
||
if (ctx.listWindow.style.height !== 'auto') ctx.listWindow.style.height = 'auto'
|
||
activeBrowser(ctx).scrollTop = 0
|
||
if (ctx.pendingScrollRestore?.view === ctx.browser.activeView) ctx.pendingScrollRestore = null
|
||
if (ctx.list.scrollTop) ctx.list.scrollTop = 0
|
||
hideUnusedCards(ctx); ctx.renderedRangeKey = ''; return
|
||
}
|
||
if (!isGalleryViewportMeasurable(ctx.list.clientWidth, ctx.list.clientHeight)) {
|
||
suspendGalleryViewport(ctx)
|
||
return
|
||
}
|
||
const view = ctx.browser.activeView
|
||
if (ctx.galleryViewportSuspended) {
|
||
ctx.galleryViewportSuspended = false
|
||
if (ctx.pendingScrollRestore?.view !== view) {
|
||
ctx.pendingScrollRestore = { view, scrollTop: activeBrowser(ctx).scrollTop }
|
||
}
|
||
}
|
||
const metrics = getGalleryMetrics(ctx)
|
||
ctx.lastMetrics = metrics
|
||
const pendingRestore = ctx.pendingScrollRestore?.view === view
|
||
? ctx.pendingScrollRestore
|
||
: null
|
||
const range = getVisibleCardRange(
|
||
ctx,
|
||
items.length,
|
||
metrics,
|
||
pendingRestore?.scrollTop ?? ctx.list.scrollTop
|
||
)
|
||
const selected = getViewSelectedIds(node)
|
||
const { state, uiState } = getRenderableState(node)
|
||
let annotatedCounts = new Map()
|
||
if (view === 'input') {
|
||
if (ctx.annotatedCountsRevision !== ctx.queueRevision) {
|
||
ctx.annotatedCounts = new Map()
|
||
for (const item of state.items) {
|
||
const path = getInputRelativePath(item)
|
||
if (path) ctx.annotatedCounts.set(path, (ctx.annotatedCounts.get(path) || 0) + 1)
|
||
}
|
||
ctx.annotatedCountsRevision = ctx.queueRevision
|
||
}
|
||
annotatedCounts = ctx.annotatedCounts
|
||
}
|
||
const totalHeight = `${range.totalHeight}px`
|
||
if (ctx.listInner.style.height !== totalHeight) ctx.listInner.style.height = totalHeight
|
||
if (ctx.listWindow.style.height !== totalHeight) ctx.listWindow.style.height = totalHeight
|
||
if (pendingRestore) {
|
||
activeBrowser(ctx).scrollTop = range.scrollTop
|
||
ctx.list.scrollTop = range.scrollTop
|
||
ctx.scrollSampleTop = range.scrollTop
|
||
ctx.scrollSampleAt = globalThis.performance?.now?.() ?? Date.now()
|
||
ctx.pendingScrollRestore = null
|
||
} else if (range.scrollTop !== ctx.list.scrollTop) {
|
||
ctx.list.scrollTop = range.scrollTop
|
||
activeBrowser(ctx).scrollTop = range.scrollTop
|
||
}
|
||
const key = `${ctx.renderVersion}:${ctx.inputVersion}:${view}:${items.length}:${metrics.width}:${metrics.columns}:${metrics.cardHeight}:${range.start}:${range.end}`
|
||
if (ctx.renderedRangeKey === key) return
|
||
const needed = range.end - range.start
|
||
ensureCardPool(node, needed)
|
||
const nextItemIds = items.slice(range.start, range.end).map((item) => getViewItemId(ctx, item, view))
|
||
const assignments = planCardSlotReuse(ctx.cardPool.map((slot) => slot.itemId), nextItemIds)
|
||
const assigned = new Set(assignments)
|
||
ctx.cardPool = [
|
||
...assignments.map((index) => ctx.cardPool[index]),
|
||
...ctx.cardPool.filter((_, index) => !assigned.has(index))
|
||
]
|
||
for (let offset = 0; offset < needed; offset += 1) {
|
||
updateCardSlot(
|
||
node,
|
||
ctx.cardPool[offset],
|
||
items[range.start + offset],
|
||
range.start + offset,
|
||
metrics,
|
||
selected,
|
||
annotatedCounts,
|
||
uiState,
|
||
!ctx.deferThumbnailLoads
|
||
)
|
||
}
|
||
hideUnusedCards(ctx, needed)
|
||
if (ctx.dragIntent) renderInternalDragTarget(ctx, ctx.dragIntent)
|
||
ctx.renderedRangeKey = key
|
||
}
|
||
|
||
function updateFolderOptions(ctx) {
|
||
const select = ctx.folderSelect
|
||
const previous = ctx.browser.input.folder
|
||
const folders = new Set()
|
||
for (const entry of ctx.browser.input.files) {
|
||
const folder = String(entry.subfolder || '')
|
||
if (!folder) continue
|
||
const segments = folder.split('/')
|
||
for (let index = 1; index <= segments.length; index += 1) folders.add(segments.slice(0, index).join('/'))
|
||
}
|
||
select.replaceChildren()
|
||
const all = document.createElement('option'); all.value = 'all'; all.textContent = 'All folders'; select.appendChild(all)
|
||
for (const folder of Array.from(folders).sort(compareNatural)) {
|
||
const option = document.createElement('option'); option.value = folder; option.textContent = folder; select.appendChild(option)
|
||
}
|
||
ctx.browser.input.folder = folders.has(previous) ? previous : 'all'
|
||
select.value = ctx.browser.input.folder
|
||
}
|
||
|
||
function renderGalleryNode(node) {
|
||
const ctx = node.__bil
|
||
if (!ctx) return
|
||
const snapshot = getCurrentState(node)
|
||
cacheRenderableState(node, snapshot.state, snapshot.uiState)
|
||
const { state, uiState } = snapshot
|
||
const validQueueIds = new Set(state.items.map((item) => item.id))
|
||
ctx.browser.conveyor.selected = new Set(
|
||
Array.from(ctx.browser.conveyor.selected).filter((id) => validQueueIds.has(id))
|
||
)
|
||
ctx.visibleItems = getViewItems(node)
|
||
const inputRootView = ctx.browser.activeView === 'input'
|
||
const inputView = isLibraryView(ctx)
|
||
const folderView = isFolderView(ctx)
|
||
const browser = activeBrowser(ctx)
|
||
const pending = countItemsByStatus(state, 'pending')
|
||
const queued = countItemsByStatus(state, 'queued')
|
||
const processed = countItemsByStatus(state, 'processed')
|
||
const next = state.dont_consume ? findNextLoadItem(state) : findFirstByStatus(state, ['pending', 'queued'])
|
||
|
||
ctx.conveyorTab.textContent = `Conveyor ${state.items.length}`
|
||
ctx.inputTab.textContent = `Input Folder ${ctx.browser.input.files.length}`
|
||
renderTabs(ctx)
|
||
if (document.activeElement !== ctx.searchInput) ctx.searchInput.value = browser.query
|
||
ctx.sizeSelect.value = browser.size
|
||
ctx.conveyorFilter.hidden = inputView
|
||
ctx.folderSelect.hidden = !inputRootView
|
||
ctx.conveyorSort.hidden = inputView
|
||
ctx.inputSort.hidden = !inputView
|
||
ctx.applySortBtn.hidden = inputView
|
||
ctx.refreshBtn.hidden = !inputRootView
|
||
ctx.addSelectedInputBtn.hidden = !inputView
|
||
ctx.conveyorFilter.value = ctx.browser.conveyor.filter
|
||
if (inputRootView) ctx.folderSelect.value = ctx.browser.input.folder
|
||
ctx.conveyorSort.value = ctx.browser.conveyor.sort
|
||
if (inputView) ctx.inputSort.value = browser.sort
|
||
const source = folderView ? ctx.browser.folderSources.get(browser.sourceId) : null
|
||
const folderLabel = folderView
|
||
? (browser.folderPath ? `${source?.name || 'Folder'}/${browser.folderPath}` : source?.name || 'Folder')
|
||
: ''
|
||
ctx.summary.textContent = inputRootView
|
||
? `${ctx.visibleItems.length} shown · ${ctx.browser.input.files.length} images${ctx.browser.input.loading ? ' · refreshing…' : ''}${ctx.browser.input.error ? ` · ${ctx.browser.input.error}` : ''}`
|
||
: folderView
|
||
? `${folderLabel} · ${ctx.visibleItems.length} item${ctx.visibleItems.length === 1 ? '' : 's'}${browser.error ? ` · ${browser.error}` : ''}`
|
||
: `${state.items.length} total · ${pending} pending · ${queued} queued · ${processed} processed`
|
||
const focusedIndex = browser.focusedId
|
||
? ctx.visibleItems.findIndex((item) => getViewItemId(ctx, item) === browser.focusedId)
|
||
: -1
|
||
const position = focusedIndex >= 0 ? `${focusedIndex + 1} of ${ctx.visibleItems.length}` : ''
|
||
ctx.nextText.textContent = inputView
|
||
? position
|
||
: `${next ? `Next: ${next.filename || getItemDisplayPath(next, uiState)}${state.dont_consume ? ' · not consuming' : ''}` : 'Next: none'}${position ? ` · ${position}` : ''}`
|
||
renderSelectionContext(node)
|
||
ctx.autoQueueCheckbox.checked = Boolean(state.auto_queue)
|
||
ctx.dontConsumeCheckbox.checked = Boolean(state.dont_consume)
|
||
ctx.canvasDropCheckbox.checked = Boolean(state.catch_canvas_drops)
|
||
ctx.imagesPerExecutionSelect.value = String(normalizeImagesPerExecution(state.images_per_execution))
|
||
|
||
if (!ctx.visibleItems.length) {
|
||
hideUnusedCards(ctx); ctx.renderedRangeKey = ''
|
||
ctx.listInner.style.height = 'auto'; ctx.listWindow.style.height = 'auto'
|
||
browser.scrollTop = 0
|
||
if (ctx.pendingScrollRestore?.view === ctx.browser.activeView) ctx.pendingScrollRestore = null
|
||
if (ctx.list.scrollTop) ctx.list.scrollTop = 0
|
||
if (!ctx.empty) { ctx.empty = document.createElement('div'); ctx.empty.className = 'bil-empty' }
|
||
ctx.empty.textContent = inputRootView
|
||
? (ctx.browser.input.loading ? 'Loading the ComfyUI input folder…' : 'No images match this input-folder view.')
|
||
: folderView
|
||
? 'No images or subfolders match this folder view.'
|
||
: 'Drop images or folders here, click Add images, or browse the Input Folder tab.'
|
||
if (ctx.empty.parentElement !== ctx.listWindow) ctx.listWindow.appendChild(ctx.empty)
|
||
} else {
|
||
ctx.empty?.remove()
|
||
renderVisibleCards(node)
|
||
}
|
||
}
|
||
|
||
async function refreshInputFiles(node, { force = false } = {}) {
|
||
const ctx = node.__bil
|
||
if (!ctx) return
|
||
ctx.inputRequestId += 1
|
||
const requestId = ctx.inputRequestId
|
||
ctx.inputAbortController?.abort()
|
||
const controller = new AbortController()
|
||
ctx.inputAbortController = controller
|
||
ctx.browser.input.loading = true
|
||
ctx.browser.input.error = ''
|
||
scheduleRenderNode(node)
|
||
try {
|
||
const suffix = force ? '?refresh=1' : ''
|
||
const response = await api.fetchApi(`/image-conveyor/input-files${suffix}`, { signal: controller.signal })
|
||
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`)
|
||
const payload = await response.json()
|
||
if (requestId !== ctx.inputRequestId || node.__bil !== ctx || ctx.removed) return
|
||
const files = Array.isArray(payload?.files) ? payload.files.filter((entry) => entry?.relative_path && entry?.filename) : []
|
||
ctx.browser.input.files = files
|
||
ctx.browser.input.loaded = true
|
||
ctx.browser.input.snapshotVersion = Number(payload?.snapshot_version || 0)
|
||
const availablePaths = new Set(files.map((entry) => entry.relative_path))
|
||
ctx.browser.input.selected = new Set(Array.from(ctx.browser.input.selected).filter((path) => availablePaths.has(path)))
|
||
ctx.inputVersion += 1
|
||
updateFolderOptions(ctx)
|
||
} catch (error) {
|
||
if (error?.name !== 'AbortError' && requestId === ctx.inputRequestId) {
|
||
ctx.browser.input.error = 'Refresh failed'
|
||
console.error('Image Conveyor: failed to load the input folder.', error)
|
||
}
|
||
} finally {
|
||
if (requestId === ctx.inputRequestId && node.__bil === ctx && !ctx.removed) {
|
||
ctx.browser.input.loading = false
|
||
scheduleRenderNode(node)
|
||
}
|
||
}
|
||
}
|
||
|
||
function addInputEntries(node, entries) {
|
||
if (!entries.length) return
|
||
const { state, uiState } = getRenderableState(node)
|
||
for (const entry of entries) {
|
||
const item = makeItemFromInputFile(entry)
|
||
if (!item) continue
|
||
state.items.push(item)
|
||
uiState.source_paths[item.id] = entry.relative_path
|
||
}
|
||
updateState(node, state, uiState)
|
||
}
|
||
|
||
async function addLibraryEntries(node, entries) {
|
||
const localEntries = entries.filter((entry) => entry?.kind === 'local-image' && entry.localFile)
|
||
const inputEntries = entries.filter((entry) => entry?.kind !== 'folder' && entry?.kind !== 'local-image')
|
||
if (inputEntries.length) addInputEntries(node, inputEntries)
|
||
if (localEntries.length) {
|
||
const uploadEntries = localEntries.map((entry) => ({
|
||
file: entry.localFile,
|
||
relativeSubfolder: entry.relativeSubfolder
|
||
}))
|
||
const feedbackView = node.__bil?.browser.activeView
|
||
try {
|
||
await uploadViaNode(node, uploadEntries, { feedbackView })
|
||
} catch (error) {
|
||
const ctx = node.__bil
|
||
const browser = ctx ? browserForView(ctx, feedbackView) : null
|
||
if (browser) {
|
||
browser.error = error?.message || 'Import failed'
|
||
scheduleRenderNode(node)
|
||
}
|
||
console.error('Image Conveyor: folder-tab import failed.', error)
|
||
}
|
||
}
|
||
}
|
||
|
||
async function addSelectedLibraryEntries(node) {
|
||
const ctx = node.__bil
|
||
if (!ctx || !isLibraryView(ctx)) return
|
||
const browser = activeBrowser(ctx)
|
||
const selected = browser.selected
|
||
const entries = (ctx.browser.activeView === 'input' ? browser.files : browser.entries)
|
||
.filter((entry) => entry.kind !== 'folder' && selected.has(getViewItemId(ctx, entry)))
|
||
await addLibraryEntries(node, entries)
|
||
}
|
||
|
||
function switchBrowserView(node, view) {
|
||
const ctx = node.__bil
|
||
if (!ctx || ctx.browser.activeView === view) return
|
||
const destination = ctx.browser[view] ?? ctx.browser.folderViews.get(view)
|
||
if (!destination) return
|
||
cancelMarqueeSelection(node)
|
||
const savedScrollTops = Object.fromEntries(ctx.browser.tabOrder.map((viewId) => {
|
||
const browser = ctx.browser[viewId] ?? ctx.browser.folderViews.get(viewId)
|
||
return [viewId, browser?.scrollTop || 0]
|
||
}))
|
||
const plan = planViewScrollSwitch(
|
||
ctx.browser.activeView,
|
||
view,
|
||
isGalleryViewportMeasurable(ctx.list.clientWidth, ctx.list.clientHeight)
|
||
? ctx.list.scrollTop
|
||
: activeBrowser(ctx).scrollTop,
|
||
savedScrollTops,
|
||
ctx.pendingScrollRestore?.view ?? null
|
||
)
|
||
for (const [viewId, scrollTop] of Object.entries(plan.positions)) {
|
||
const browser = ctx.browser[viewId] ?? ctx.browser.folderViews.get(viewId)
|
||
if (browser) browser.scrollTop = scrollTop
|
||
}
|
||
ctx.browser.activeView = view
|
||
ctx.pendingScrollRestore = plan.restore
|
||
ctx.renderedRangeKey = ''
|
||
scheduleRenderNode(node, { forceVisibleRows: true })
|
||
if (view === 'input' && !ctx.browser.input.loaded && !ctx.browser.input.loading) void refreshInputFiles(node)
|
||
}
|
||
|
||
function scrollItemIntoView(node, index) {
|
||
const ctx = node.__bil
|
||
const metrics = getGalleryMetrics(ctx)
|
||
const row = Math.floor(index / metrics.columns)
|
||
const top = row * metrics.rowStride
|
||
const bottom = top + metrics.cardHeight
|
||
if (top < ctx.list.scrollTop) ctx.list.scrollTop = top
|
||
else if (bottom > ctx.list.scrollTop + ctx.list.clientHeight) ctx.list.scrollTop = bottom - ctx.list.clientHeight
|
||
}
|
||
|
||
function isInteractiveWidgetControl(target) {
|
||
return target instanceof Element && Boolean(
|
||
target.closest('button, input, textarea, select, a[href], [contenteditable="true"]')
|
||
)
|
||
}
|
||
|
||
function shouldRetainWidgetFocus(target) {
|
||
const control = target instanceof Element
|
||
? target.closest('input, textarea, select, [contenteditable="true"]')
|
||
: null
|
||
if (!control) return false
|
||
if (control instanceof HTMLInputElement) {
|
||
return !['button', 'checkbox', 'color', 'file', 'radio', 'range', 'reset', 'submit'].includes(control.type)
|
||
}
|
||
return control instanceof HTMLTextAreaElement || control instanceof HTMLSelectElement || control.isContentEditable
|
||
}
|
||
|
||
function consumeGalleryKeyboardEvent(event) {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
event.stopImmediatePropagation?.()
|
||
}
|
||
|
||
function handleGalleryKeyDown(node, event) {
|
||
const ctx = node.__bil
|
||
if (!ctx || isInteractiveWidgetControl(event.target)) return false
|
||
if (isConveyorDeleteShortcut(event) && deleteSelectedConveyorItems(node)) {
|
||
consumeGalleryKeyboardEvent(event)
|
||
return true
|
||
}
|
||
if (event.key === 'Escape' && !ctx.lightbox.root.hidden) {
|
||
consumeGalleryKeyboardEvent(event)
|
||
ctx.lightbox.hide()
|
||
return true
|
||
}
|
||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return false
|
||
const items = ctx.visibleItems || []
|
||
if (!items.length) return false
|
||
const browser = activeBrowser(ctx)
|
||
const itemId = (item) => getViewItemId(ctx, item)
|
||
let index = items.findIndex((item) => itemId(item) === browser.focusedId)
|
||
if (index < 0) index = 0
|
||
const metrics = getGalleryMetrics(ctx)
|
||
const page = Math.max(metrics.columns, Math.floor(ctx.list.clientHeight / metrics.rowStride) * metrics.columns)
|
||
let next = index
|
||
switch (event.key) {
|
||
case 'ArrowLeft': next -= 1; break
|
||
case 'ArrowRight': next += 1; break
|
||
case 'ArrowUp': next -= metrics.columns; break
|
||
case 'ArrowDown': next += metrics.columns; break
|
||
case 'Home': next = 0; break
|
||
case 'End': next = items.length - 1; break
|
||
case 'PageUp': next -= page; break
|
||
case 'PageDown': next += page; break
|
||
case 'Enter':
|
||
consumeGalleryKeyboardEvent(event)
|
||
if (items[index].kind === 'folder') openFolderView(node, items[index].sourceId, items[index].folderPath)
|
||
else openPreview(node, items[index])
|
||
return true
|
||
case ' ': {
|
||
consumeGalleryKeyboardEvent(event)
|
||
if (items[index].kind === 'folder') return true
|
||
const id = itemId(items[index]); const selected = getViewSelectedIds(node)
|
||
setItemSelected(node, id, !selected.has(id), event); return true
|
||
}
|
||
default: return false
|
||
}
|
||
consumeGalleryKeyboardEvent(event)
|
||
next = Math.max(0, Math.min(items.length - 1, next))
|
||
browser.focusedId = itemId(items[next])
|
||
scrollItemIntoView(node, next)
|
||
scheduleRenderNode(node, { viewportOnly: true, forceVisibleRows: true })
|
||
return true
|
||
}
|
||
|
||
function buildGalleryDom(node) {
|
||
ensureStyles()
|
||
const root = document.createElement('div')
|
||
root.className = 'bil-root'
|
||
root.setAttribute('aria-label', 'Image Conveyor browser')
|
||
|
||
const fileInput = document.createElement('input')
|
||
fileInput.type = 'file'
|
||
fileInput.accept = 'image/*,.png,.jpg,.jpeg,.webp,.bmp,.gif,.tif,.tiff,.avif'
|
||
fileInput.multiple = true
|
||
fileInput.hidden = true
|
||
const folderInput = document.createElement('input')
|
||
folderInput.type = 'file'
|
||
folderInput.multiple = true
|
||
folderInput.hidden = true
|
||
folderInput.setAttribute('webkitdirectory', '')
|
||
folderInput.setAttribute('directory', '')
|
||
|
||
const header = document.createElement('div')
|
||
header.className = 'bil-header'
|
||
const tabs = document.createElement('div')
|
||
tabs.className = 'bil-tabs'
|
||
tabs.setAttribute('role', 'tablist')
|
||
const conveyorTab = document.createElement('button')
|
||
conveyorTab.className = 'bil-tab'; conveyorTab.type = 'button'; conveyorTab.setAttribute('role', 'tab')
|
||
const inputTab = document.createElement('button')
|
||
inputTab.className = 'bil-tab'; inputTab.type = 'button'; inputTab.setAttribute('role', 'tab')
|
||
const tabSetId = makeId()
|
||
conveyorTab.id = `${tabSetId}-conveyor-tab`
|
||
inputTab.id = `${tabSetId}-input-tab`
|
||
conveyorTab.title = 'Conveyor'
|
||
inputTab.title = 'ComfyUI Input Folder'
|
||
tabs.append(conveyorTab, inputTab)
|
||
const addImagesBtn = document.createElement('button')
|
||
addImagesBtn.className = 'bil-btn bil-add-btn'; addImagesBtn.type = 'button'; addImagesBtn.textContent = '+ Add images'
|
||
const addFoldersBtn = document.createElement('button')
|
||
addFoldersBtn.className = 'bil-btn bil-add-btn'; addFoldersBtn.type = 'button'; addFoldersBtn.textContent = '+ Add folders'
|
||
addFoldersBtn.title = 'Browse folders as tabs without importing their images'
|
||
header.append(tabs)
|
||
|
||
const browserbar = document.createElement('div')
|
||
browserbar.className = 'bil-browserbar'
|
||
const searchInput = document.createElement('input')
|
||
searchInput.className = 'bil-input'; searchInput.type = 'search'; searchInput.placeholder = 'Search images…'
|
||
const conveyorFilter = document.createElement('select')
|
||
conveyorFilter.className = 'bil-select'
|
||
;[['all', 'All'], ['pending', 'Pending'], ['queued', 'Queued'], ['processed', 'Processed']].forEach(([value, label]) => {
|
||
const option = document.createElement('option'); option.value = value; option.textContent = label; conveyorFilter.appendChild(option)
|
||
})
|
||
const folderSelect = document.createElement('select')
|
||
folderSelect.className = 'bil-select'; folderSelect.hidden = true
|
||
const conveyorSort = document.createElement('select')
|
||
conveyorSort.className = 'bil-select'
|
||
;[
|
||
['manual', 'Manual order'], ['name_asc', 'Name ↑'], ['name_desc', 'Name ↓'],
|
||
['added_newest', 'Newest'], ['added_oldest', 'Oldest'], ['status', 'Status']
|
||
].forEach(([value, label]) => { const option = document.createElement('option'); option.value = value; option.textContent = label; conveyorSort.appendChild(option) })
|
||
const inputSort = document.createElement('select')
|
||
inputSort.className = 'bil-select'; inputSort.hidden = true
|
||
;[['name_asc', 'Name ↑'], ['name_desc', 'Name ↓'], ['newest', 'Newest'], ['oldest', 'Oldest']].forEach(([value, label]) => {
|
||
const option = document.createElement('option'); option.value = value; option.textContent = label; inputSort.appendChild(option)
|
||
})
|
||
const sizeSelect = document.createElement('select')
|
||
sizeSelect.className = 'bil-select bil-size-select'
|
||
;[['small', 'Small'], ['medium', 'Medium'], ['large', 'Large']].forEach(([value, label]) => {
|
||
const option = document.createElement('option'); option.value = value; option.textContent = label; sizeSelect.appendChild(option)
|
||
})
|
||
browserbar.append(searchInput, conveyorFilter, folderSelect, conveyorSort, inputSort, sizeSelect)
|
||
|
||
const secondary = document.createElement('div')
|
||
secondary.className = 'bil-header'
|
||
const applySortBtn = document.createElement('button')
|
||
applySortBtn.className = 'bil-btn'; applySortBtn.type = 'button'; applySortBtn.textContent = 'Apply queue sort'
|
||
const refreshBtn = document.createElement('button')
|
||
refreshBtn.className = 'bil-btn'; refreshBtn.type = 'button'; refreshBtn.textContent = 'Refresh'; refreshBtn.hidden = true
|
||
const addSelectedInputBtn = document.createElement('button')
|
||
addSelectedInputBtn.className = 'bil-btn'; addSelectedInputBtn.type = 'button'; addSelectedInputBtn.textContent = 'Add selected'; addSelectedInputBtn.hidden = true
|
||
secondary.append(applySortBtn, addImagesBtn, addFoldersBtn, refreshBtn, addSelectedInputBtn)
|
||
|
||
const summaryRow = document.createElement('div')
|
||
summaryRow.className = 'bil-summary'
|
||
const summary = document.createElement('div')
|
||
const nextText = document.createElement('div')
|
||
summaryRow.append(summary, nextText)
|
||
|
||
const contextBar = document.createElement('div')
|
||
contextBar.className = 'bil-contextbar'; contextBar.hidden = true
|
||
const contextLabel = document.createElement('span'); contextLabel.className = 'bil-context-label'
|
||
const setPendingBtn = document.createElement('button'); setPendingBtn.className = 'bil-btn'; setPendingBtn.type = 'button'; setPendingBtn.textContent = 'Pending'
|
||
const setProcessedBtn = document.createElement('button'); setProcessedBtn.className = 'bil-btn'; setProcessedBtn.type = 'button'; setProcessedBtn.textContent = 'Done'
|
||
const deleteSelectedBtn = document.createElement('button'); deleteSelectedBtn.className = 'bil-btn'; deleteSelectedBtn.type = 'button'; deleteSelectedBtn.textContent = 'Delete'
|
||
const contextAddBtn = document.createElement('button'); contextAddBtn.className = 'bil-btn'; contextAddBtn.type = 'button'; contextAddBtn.textContent = 'Add to Conveyor'
|
||
const clearSelectionBtn = document.createElement('button'); clearSelectionBtn.className = 'bil-btn'; clearSelectionBtn.type = 'button'; clearSelectionBtn.textContent = 'Clear'
|
||
contextBar.append(contextLabel, setPendingBtn, setProcessedBtn, deleteSelectedBtn, contextAddBtn, clearSelectionBtn)
|
||
|
||
const settings = document.createElement('details')
|
||
settings.className = 'bil-settings'
|
||
const settingsSummary = document.createElement('summary'); settingsSummary.textContent = 'Queue options and bulk tools'
|
||
const settingsRow = document.createElement('div'); settingsRow.className = 'bil-settings-row'
|
||
const makeToggle = (labelText, ariaLabel) => {
|
||
const label = document.createElement('label'); label.className = 'bil-toggle'
|
||
const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.setAttribute('aria-label', ariaLabel)
|
||
const text = document.createElement('span'); text.textContent = labelText
|
||
label.append(checkbox, text); return { label, checkbox }
|
||
}
|
||
const autoQueue = makeToggle('Auto queue all pending', 'Auto queue all pending images')
|
||
const dontConsume = makeToggle("Don't consume", 'Do not consume images')
|
||
const canvasDrop = makeToggle('Catch canvas drops', 'Catch image drops anywhere on the canvas')
|
||
const imagesPerExecutionLabel = document.createElement('label')
|
||
imagesPerExecutionLabel.className = 'bil-toggle'
|
||
const imagesPerExecutionText = document.createElement('span')
|
||
imagesPerExecutionText.textContent = 'Images per execution'
|
||
const imagesPerExecutionSelect = document.createElement('select')
|
||
imagesPerExecutionSelect.className = 'bil-select bil-images-per-execution'
|
||
imagesPerExecutionSelect.setAttribute('aria-label', 'Images per execution')
|
||
imagesPerExecutionSelect.title = 'Number of consecutive Conveyor images returned by each execution. 1 keeps the normal single-image behavior; 2-9 expose additional images through image_2 ... image_9.'
|
||
for (let count = 1; count <= 9; count += 1) {
|
||
const option = document.createElement('option')
|
||
option.value = String(count)
|
||
option.textContent = String(count)
|
||
imagesPerExecutionSelect.appendChild(option)
|
||
}
|
||
imagesPerExecutionLabel.append(imagesPerExecutionText, imagesPerExecutionSelect)
|
||
const selectVisibleBtn = document.createElement('button'); selectVisibleBtn.className = 'bil-btn'; selectVisibleBtn.type = 'button'; selectVisibleBtn.textContent = 'Select all'
|
||
const clearQueuedBtn = document.createElement('button'); clearQueuedBtn.className = 'bil-btn'; clearQueuedBtn.type = 'button'; clearQueuedBtn.textContent = 'Clear queued'
|
||
const clearProcessedBtn = document.createElement('button'); clearProcessedBtn.className = 'bil-btn'; clearProcessedBtn.type = 'button'; clearProcessedBtn.textContent = 'Remove processed'
|
||
const jumpPendingBtn = document.createElement('button'); jumpPendingBtn.className = 'bil-btn'; jumpPendingBtn.type = 'button'; jumpPendingBtn.textContent = 'Jump to next pending'
|
||
const cleanDuplicatesBtn = document.createElement('button'); cleanDuplicatesBtn.className = 'bil-btn'; cleanDuplicatesBtn.type = 'button'; cleanDuplicatesBtn.textContent = 'Clean exact duplicates'
|
||
cleanDuplicatesBtn.title = 'Preview and remove byte-identical redundant files from the legacy input/image_conveyor folder'
|
||
settingsRow.append(autoQueue.label, dontConsume.label, imagesPerExecutionLabel, canvasDrop.label, selectVisibleBtn, clearQueuedBtn, clearProcessedBtn, jumpPendingBtn, cleanDuplicatesBtn)
|
||
settings.append(settingsSummary, settingsRow)
|
||
|
||
const list = document.createElement('div')
|
||
list.className = 'bil-list'
|
||
list.id = `${tabSetId}-panel`
|
||
list.setAttribute('role', 'tabpanel')
|
||
list.setAttribute('aria-labelledby', conveyorTab.id)
|
||
conveyorTab.setAttribute('aria-controls', list.id)
|
||
inputTab.setAttribute('aria-controls', list.id)
|
||
const listInner = document.createElement('div'); listInner.className = 'bil-list-inner'
|
||
const listWindow = document.createElement('div'); listWindow.className = 'bil-list-window'
|
||
listWindow.setAttribute('role', 'listbox')
|
||
listWindow.setAttribute('aria-multiselectable', 'true')
|
||
const selectionMarquee = document.createElement('div')
|
||
selectionMarquee.className = 'bil-selection-marquee'; selectionMarquee.hidden = true
|
||
const dropIndicator = document.createElement('div')
|
||
dropIndicator.className = 'bil-drop-indicator'; dropIndicator.hidden = true
|
||
listInner.append(listWindow, selectionMarquee, dropIndicator); list.appendChild(listInner)
|
||
root.append(fileInput, folderInput, header, browserbar, secondary, summaryRow, contextBar, settings, list)
|
||
|
||
node.__bil = {
|
||
root, tabs, tabSetId, dropzone: addImagesBtn, addImagesBtn, addFoldersBtn, fileInput, folderInput,
|
||
conveyorTab, inputTab, folderTabElements: new Map(),
|
||
searchInput, conveyorFilter, folderSelect, conveyorSort, inputSort, sizeSelect,
|
||
applySortBtn, refreshBtn, addSelectedInputBtn, summary, nextText, contextBar,
|
||
contextLabel, setPendingBtn, setProcessedBtn, deleteSelectedBtn, contextAddBtn,
|
||
cleanDuplicatesBtn,
|
||
autoQueueCheckbox: autoQueue.checkbox, dontConsumeCheckbox: dontConsume.checkbox,
|
||
canvasDropCheckbox: canvasDrop.checkbox, imagesPerExecutionSelect,
|
||
list, listInner, listWindow, selectionMarquee, dropIndicator,
|
||
browser: createBrowserState(), visibleItems: [], cardPool: [],
|
||
draggedId: null, dragIntent: null, empty: null, state: null, uiState: null, renderVersion: 0,
|
||
inputVersion: 0, renderedRangeKey: '', renderFrame: 0, renderViewportOnly: false,
|
||
pendingScrollRestore: null,
|
||
galleryViewportSuspended: false, galleryViewportEpoch: 0,
|
||
deferThumbnailLoads: false, scrollSettleTimer: 0,
|
||
scrollSampleTop: 0, scrollSampleAt: 0,
|
||
listResizeObserver: null, widgetOuterHeight: 0, widgetInnerHeight: 0, widgetWidth: 0,
|
||
resizeAnchorWidgetWidth: 0,
|
||
pointerInside: false, middlePanPointerId: null, documentPasteHandler: null,
|
||
documentMiddlePanMoveHandler: null, documentMiddlePanEndHandler: null,
|
||
documentMarqueeMoveHandler: null, documentMarqueeEndHandler: null,
|
||
documentFocusScopeHandler: null, windowFocusHandler: null,
|
||
filePickerPending: false, filePickerFocusTimer: 0, filePickerFocusFrame: 0,
|
||
inputAbortController: null, inputRequestId: 0,
|
||
searchTimer: 0, lightbox: null, lastMetrics: null, removed: false,
|
||
uploadDepth: 0, dropzoneLabel: '', duplicateCleanupBusy: false,
|
||
clearExternalDragState: null, restoreCanvasShortcutFocus: null, marqueeSelection: null,
|
||
keyboardActive: false,
|
||
queueRevision: 0, annotatedCountsRevision: -1, annotatedCounts: new Map(),
|
||
thumbnailUrlCache: new WeakMap(), localObjectUrls: new Map(), activePickerInput: null
|
||
}
|
||
const ctx = node.__bil
|
||
ctx.lightbox = createLightbox(node)
|
||
updateFolderOptions(ctx)
|
||
|
||
const runUpload = (files) => {
|
||
void uploadViaNode(node, files).catch((error) => {
|
||
console.error('Image Conveyor: import failed.', error)
|
||
ctx.browser.input.error = error?.message || 'Import failed'
|
||
scheduleRenderNode(node)
|
||
})
|
||
}
|
||
const scheduleCanvasShortcutFocusRestore = (focusOwner = null) => {
|
||
if (ctx.removed) return
|
||
if (ctx.filePickerFocusFrame) cancelAnimationFrame(ctx.filePickerFocusFrame)
|
||
ctx.filePickerFocusFrame = requestAnimationFrame(() => {
|
||
ctx.filePickerFocusFrame = 0
|
||
if (ctx.removed) return
|
||
restoreGraphCanvasFocus(focusOwner, app.canvas?.canvas)
|
||
})
|
||
}
|
||
ctx.restoreCanvasShortcutFocus = (focusOwner = document.activeElement) => {
|
||
restoreGraphCanvasFocus(focusOwner, app.canvas?.canvas)
|
||
}
|
||
const restoreFocusAfterFilePicker = () => {
|
||
if (!ctx.filePickerPending || ctx.removed) return
|
||
const pickerInput = ctx.activePickerInput
|
||
ctx.filePickerPending = false
|
||
ctx.activePickerInput = null
|
||
clearTimeout(ctx.filePickerFocusTimer)
|
||
ctx.filePickerFocusTimer = 0
|
||
scheduleCanvasShortcutFocusRestore(pickerInput)
|
||
}
|
||
const schedulePickerFocusRestore = () => {
|
||
if (!ctx.filePickerPending || ctx.removed) return
|
||
clearTimeout(ctx.filePickerFocusTimer)
|
||
ctx.filePickerFocusTimer = setTimeout(restoreFocusAfterFilePicker, 0)
|
||
}
|
||
const openPicker = (pickerInput) => {
|
||
ctx.filePickerPending = true
|
||
ctx.activePickerInput = pickerInput
|
||
try {
|
||
pickerInput.click()
|
||
} catch (error) {
|
||
ctx.filePickerPending = false
|
||
ctx.activePickerInput = null
|
||
throw error
|
||
}
|
||
}
|
||
addImagesBtn.addEventListener('click', () => openPicker(fileInput))
|
||
addFoldersBtn.addEventListener('click', () => openPicker(folderInput))
|
||
fileInput.addEventListener('change', () => {
|
||
const files = fileInput.files
|
||
restoreFocusAfterFilePicker()
|
||
runUpload(files)
|
||
fileInput.value = ''
|
||
})
|
||
fileInput.addEventListener('cancel', restoreFocusAfterFilePicker)
|
||
folderInput.addEventListener('change', () => {
|
||
const sources = makePickerFolderSources(folderInput.files)
|
||
restoreFocusAfterFilePicker()
|
||
folderInput.value = ''
|
||
if (!sources.length) {
|
||
window.alert('No browsable folder was selected. Choose a folder containing files, or drag folders onto the tab bar.')
|
||
return
|
||
}
|
||
addFolderSources(node, sources)
|
||
})
|
||
folderInput.addEventListener('cancel', restoreFocusAfterFilePicker)
|
||
ctx.windowFocusHandler = schedulePickerFocusRestore
|
||
window.addEventListener('focus', ctx.windowFocusHandler)
|
||
conveyorTab.addEventListener('click', () => switchBrowserView(node, 'conveyor'))
|
||
inputTab.addEventListener('click', () => switchBrowserView(node, 'input'))
|
||
refreshBtn.addEventListener('click', () => void refreshInputFiles(node, { force: true }))
|
||
addSelectedInputBtn.addEventListener('click', () => void addSelectedLibraryEntries(node))
|
||
contextAddBtn.addEventListener('click', () => void addSelectedLibraryEntries(node))
|
||
cleanDuplicatesBtn.addEventListener('click', () => void cleanManagedDuplicates(node))
|
||
|
||
searchInput.addEventListener('input', () => {
|
||
clearTimeout(ctx.searchTimer)
|
||
const targetView = ctx.browser.activeView
|
||
const query = searchInput.value
|
||
ctx.searchTimer = setTimeout(() => {
|
||
const targetBrowser = browserForView(ctx, targetView)
|
||
if (!targetBrowser) return
|
||
targetBrowser.query = query
|
||
targetBrowser.scrollTop = 0
|
||
if (ctx.browser.activeView === targetView) list.scrollTop = 0
|
||
scheduleRenderNode(node)
|
||
}, 70)
|
||
})
|
||
conveyorFilter.addEventListener('change', () => { ctx.browser.conveyor.filter = conveyorFilter.value; list.scrollTop = 0; scheduleRenderNode(node) })
|
||
folderSelect.addEventListener('change', () => { ctx.browser.input.folder = folderSelect.value; list.scrollTop = 0; scheduleRenderNode(node) })
|
||
inputSort.addEventListener('change', () => { if (isLibraryView(ctx)) activeBrowser(ctx).sort = inputSort.value; scheduleRenderNode(node) })
|
||
conveyorSort.addEventListener('change', () => { ctx.browser.conveyor.sort = conveyorSort.value })
|
||
sizeSelect.addEventListener('change', () => {
|
||
const items = ctx.visibleItems || []
|
||
const previous = getGalleryMetrics(ctx)
|
||
const anchorIndex = Math.min(items.length - 1, Math.max(0, Math.floor(list.scrollTop / previous.rowStride) * previous.columns))
|
||
const anchorId = items[anchorIndex] ? getViewItemId(ctx, items[anchorIndex]) : null
|
||
activeBrowser(ctx).size = sizeSelect.value
|
||
ctx.renderedRangeKey = ''
|
||
scheduleRenderNode(node, { forceVisibleRows: true })
|
||
requestAnimationFrame(() => {
|
||
if (!anchorId || !node.__bil) return
|
||
const newIndex = (ctx.visibleItems || []).findIndex((item) => getViewItemId(ctx, item) === anchorId)
|
||
if (newIndex >= 0) { const metrics = getGalleryMetrics(ctx); list.scrollTop = Math.floor(newIndex / metrics.columns) * metrics.rowStride }
|
||
})
|
||
})
|
||
|
||
applySortBtn.addEventListener('click', () => {
|
||
const { state, uiState } = getRenderableState(node)
|
||
switch (conveyorSort.value) {
|
||
case 'name_asc': state.items.sort((a, b) => compareNatural(getItemDisplayPath(a, uiState), getItemDisplayPath(b, uiState))); break
|
||
case 'name_desc': state.items.sort((a, b) => compareNatural(getItemDisplayPath(b, uiState), getItemDisplayPath(a, uiState))); break
|
||
case 'added_newest': state.items.sort((a, b) => (b.added_at || 0) - (a.added_at || 0)); break
|
||
case 'added_oldest': state.items.sort((a, b) => (a.added_at || 0) - (b.added_at || 0)); break
|
||
case 'status': state.items.sort((a, b) => itemStatusRank(a.status) - itemStatusRank(b.status) || (a.added_at || 0) - (b.added_at || 0)); break
|
||
default: break
|
||
}
|
||
ctx.browser.conveyor.sort = 'manual'; conveyorSort.value = 'manual'; updateState(node, state, uiState)
|
||
})
|
||
|
||
const mutateSelected = (status) => {
|
||
const { state, uiState } = getRenderableState(node); const selected = ctx.browser.conveyor.selected; const now = Date.now()
|
||
for (const item of state.items) if (selected.has(item.id)) { item.status = status; if (status === 'processed') item.last_processed_at = now }
|
||
updateState(node, state, uiState)
|
||
}
|
||
setPendingBtn.addEventListener('click', () => mutateSelected('pending'))
|
||
setProcessedBtn.addEventListener('click', () => mutateSelected('processed'))
|
||
deleteSelectedBtn.addEventListener('click', () => deleteSelectedConveyorItems(node))
|
||
clearSelectionBtn.addEventListener('click', () => {
|
||
if (isLibraryView(ctx)) {
|
||
activeBrowser(ctx).selected.clear(); renderSelectionContext(node); scheduleRenderNode(node, { viewportOnly: true, forceVisibleRows: true })
|
||
} else {
|
||
ctx.browser.conveyor.selected.clear(); renderSelectionContext(node); scheduleRenderNode(node, { viewportOnly: true, forceVisibleRows: true })
|
||
}
|
||
})
|
||
selectVisibleBtn.addEventListener('click', () => {
|
||
if (isLibraryView(ctx)) {
|
||
const browser = activeBrowser(ctx)
|
||
const entries = ctx.browser.activeView === 'input' ? browser.files : browser.entries
|
||
browser.selected = new Set(entries.filter((item) => item.kind !== 'folder').map((item) => getViewItemId(ctx, item)))
|
||
renderSelectionContext(node); scheduleRenderNode(node, { viewportOnly: true, forceVisibleRows: true })
|
||
} else {
|
||
const { state } = getRenderableState(node); ctx.browser.conveyor.selected = new Set(state.items.map((item) => item.id)); renderSelectionContext(node); scheduleRenderNode(node, { viewportOnly: true, forceVisibleRows: true })
|
||
}
|
||
})
|
||
autoQueue.checkbox.addEventListener('change', () => { const { state, uiState } = getRenderableState(node); state.auto_queue = autoQueue.checkbox.checked; updateState(node, state, uiState) })
|
||
dontConsume.checkbox.addEventListener('change', () => { const { state, uiState } = getRenderableState(node); state.dont_consume = dontConsume.checkbox.checked; updateState(node, state, uiState) })
|
||
imagesPerExecutionSelect.addEventListener('change', () => {
|
||
const { state, uiState } = getRenderableState(node)
|
||
state.images_per_execution = normalizeImagesPerExecution(imagesPerExecutionSelect.value)
|
||
imagesPerExecutionSelect.value = String(state.images_per_execution)
|
||
updateState(node, state, uiState)
|
||
})
|
||
canvasDrop.checkbox.addEventListener('change', () => { const { state, uiState } = getRenderableState(node); state.catch_canvas_drops = canvasDrop.checkbox.checked; updateState(node, state, uiState) })
|
||
clearQueuedBtn.addEventListener('click', () => { const { state, uiState } = getRenderableState(node); for (const item of state.items) if (item.status === 'queued') item.status = 'pending'; updateState(node, state, uiState) })
|
||
clearProcessedBtn.addEventListener('click', () => {
|
||
const { state, uiState } = getRenderableState(node); const kept = state.items.filter((item) => item.status !== 'processed'); const ids = new Set(kept.map((item) => item.id)); state.items = kept
|
||
uiState.selected_ids = uiState.selected_ids.filter((id) => ids.has(id)); uiState.source_paths = Object.fromEntries(Object.entries(uiState.source_paths).filter(([id]) => ids.has(id))); updateState(node, state, uiState)
|
||
})
|
||
jumpPendingBtn.addEventListener('click', () => {
|
||
if (ctx.browser.activeView !== 'conveyor') {
|
||
switchBrowserView(node, 'conveyor')
|
||
}
|
||
ctx.browser.conveyor.query = ''
|
||
ctx.browser.conveyor.filter = 'all'
|
||
ctx.visibleItems = getViewItems(node)
|
||
const index = ctx.visibleItems.findIndex((item) => item.status === 'pending')
|
||
if (index >= 0) {
|
||
ctx.browser.conveyor.focusedId = ctx.visibleItems[index].id
|
||
const metrics = getGalleryMetrics(ctx)
|
||
const scrollTop = Math.floor(index / metrics.columns) * metrics.rowStride
|
||
ctx.browser.conveyor.scrollTop = scrollTop
|
||
ctx.pendingScrollRestore = { view: 'conveyor', scrollTop }
|
||
}
|
||
scheduleRenderNode(node, { forceVisibleRows: true })
|
||
})
|
||
|
||
list.addEventListener('scroll', () => {
|
||
if (
|
||
ctx.galleryViewportSuspended ||
|
||
!isGalleryViewportMeasurable(list.clientWidth, list.clientHeight)
|
||
) {
|
||
suspendGalleryViewport(ctx)
|
||
return
|
||
}
|
||
if (ctx.pendingScrollRestore?.view === ctx.browser.activeView) return
|
||
const now = globalThis.performance?.now?.() ?? Date.now()
|
||
const previousAt = ctx.scrollSampleAt
|
||
const previousTop = ctx.scrollSampleTop
|
||
ctx.scrollSampleTop = list.scrollTop
|
||
ctx.scrollSampleAt = now
|
||
if (previousAt && (ctx.deferThumbnailLoads || isHighVelocityScroll(
|
||
list.scrollTop - previousTop,
|
||
now - previousAt,
|
||
ctx.lastMetrics?.rowStride
|
||
))) {
|
||
ctx.deferThumbnailLoads = true
|
||
clearTimeout(ctx.scrollSettleTimer)
|
||
ctx.scrollSettleTimer = setTimeout(() => {
|
||
if (ctx.removed) return
|
||
ctx.deferThumbnailLoads = false
|
||
ctx.renderedRangeKey = ''
|
||
scheduleRenderNode(node, { viewportOnly: true, forceVisibleRows: true })
|
||
}, FAST_SCROLL_SETTLE_MS)
|
||
}
|
||
activeBrowser(ctx).scrollTop = list.scrollTop
|
||
scheduleRenderNode(node, { viewportOnly: true })
|
||
}, { passive: true })
|
||
ctx.documentFocusScopeHandler = (event) => {
|
||
if (!(event.target instanceof Node) || !root.contains(event.target)) releaseGalleryKeyboardOwnership(node)
|
||
}
|
||
document.addEventListener('pointerdown', ctx.documentFocusScopeHandler, true)
|
||
|
||
if (typeof ResizeObserver === 'function') {
|
||
ctx.listResizeObserver = new ResizeObserver(() => {
|
||
if (!isGalleryViewportMeasurable(list.clientWidth, list.clientHeight)) {
|
||
suspendGalleryViewport(ctx)
|
||
return
|
||
}
|
||
const previousWidgetWidth = ctx.resizeAnchorWidgetWidth || ctx.widgetWidth
|
||
const currentWidgetWidth = ctx.widgetWidth
|
||
ctx.resizeAnchorWidgetWidth = currentWidgetWidth
|
||
const resuming = ctx.galleryViewportSuspended
|
||
if (resuming && ctx.pendingScrollRestore?.view !== ctx.browser.activeView) {
|
||
ctx.pendingScrollRestore = {
|
||
view: ctx.browser.activeView,
|
||
scrollTop: activeBrowser(ctx).scrollTop
|
||
}
|
||
}
|
||
const previous = ctx.lastMetrics
|
||
const items = ctx.visibleItems || []
|
||
const anchorIndex = previous
|
||
? Math.min(items.length - 1, Math.max(0, Math.floor(list.scrollTop / previous.rowStride) * previous.columns))
|
||
: -1
|
||
const anchorId = anchorIndex >= 0
|
||
? getViewItemId(ctx, items[anchorIndex])
|
||
: null
|
||
ctx.renderedRangeKey = ''
|
||
scheduleRenderNode(node, { viewportOnly: true, forceVisibleRows: true })
|
||
if (resuming) return
|
||
if (anchorId && previous && shouldReanchorGalleryResize(
|
||
previousWidgetWidth,
|
||
currentWidgetWidth,
|
||
previous.width,
|
||
Math.floor(list.clientWidth || 0)
|
||
)) {
|
||
const view = ctx.browser.activeView
|
||
const viewportEpoch = ctx.galleryViewportEpoch
|
||
requestAnimationFrame(() => {
|
||
if (
|
||
ctx.removed ||
|
||
ctx.browser.activeView !== view ||
|
||
ctx.galleryViewportEpoch !== viewportEpoch ||
|
||
!isGalleryViewportMeasurable(list.clientWidth, list.clientHeight)
|
||
) return
|
||
const index = (ctx.visibleItems || []).findIndex((item) => getViewItemId(ctx, item) === anchorId)
|
||
if (index >= 0) {
|
||
const metrics = getGalleryMetrics(ctx)
|
||
list.scrollTop = Math.floor(index / metrics.columns) * metrics.rowStride
|
||
}
|
||
})
|
||
}
|
||
})
|
||
ctx.listResizeObserver.observe(list)
|
||
}
|
||
|
||
root.addEventListener('pointerenter', () => { ctx.pointerInside = true })
|
||
root.addEventListener('pointerleave', () => { ctx.pointerInside = false })
|
||
list.addEventListener('pointerdown', (event) => { beginMarqueeSelection(node, event) })
|
||
list.addEventListener('dragover', (event) => {
|
||
if (!ctx.draggedId) return
|
||
const intent = internalDragIntentAt(ctx, event.clientX, event.clientY)
|
||
if (!intent) { clearInternalDragTarget(ctx); return }
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
|
||
ctx.dragIntent = intent
|
||
renderInternalDragTarget(ctx, intent)
|
||
})
|
||
list.addEventListener('dragleave', (event) => {
|
||
if (!ctx.draggedId) return
|
||
if (event.relatedTarget instanceof Node && list.contains(event.relatedTarget)) return
|
||
clearInternalDragTarget(ctx)
|
||
})
|
||
list.addEventListener('drop', (event) => {
|
||
if (!ctx.draggedId) return
|
||
const draggedId = ctx.draggedId
|
||
const intent = internalDragIntentAt(ctx, event.clientX, event.clientY) || ctx.dragIntent
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
ctx.draggedId = null
|
||
clearInternalDragTarget(ctx)
|
||
if (!intent) return
|
||
const { state, uiState } = getRenderableState(node)
|
||
const changed = intent.type === 'card'
|
||
? moveItems(state, draggedId, ctx.visibleItems?.[intent.targetIndex]?.id)
|
||
: moveItemToInsertionIndex(state, draggedId, intent.insertionIndex)
|
||
if (changed) updateState(node, state, uiState)
|
||
})
|
||
root.addEventListener('pointerdown', (event) => {
|
||
claimGalleryKeyboardOwnership(node)
|
||
if (event.button === 1) { if (!app.canvas) return; ctx.middlePanPointerId = event.pointerId; event.preventDefault(); app.canvas.processMouseDown(event); return }
|
||
}, true)
|
||
root.addEventListener('click', (event) => {
|
||
if (shouldRetainWidgetFocus(event.target)) return
|
||
restoreGraphCanvasFocus(document.activeElement, app.canvas?.canvas)
|
||
})
|
||
root.addEventListener('mousedown', (event) => { if (event.button === 1) event.preventDefault() }, true)
|
||
root.addEventListener('auxclick', (event) => { if (event.button === 1) event.preventDefault() }, true)
|
||
ctx.documentMiddlePanMoveHandler = (event) => {
|
||
if (!app.canvas || ctx.middlePanPointerId == null || event.pointerId !== ctx.middlePanPointerId) return
|
||
if ((event.buttons & 4) !== 4) { app.canvas.processMouseUp(event); ctx.middlePanPointerId = null; return }
|
||
event.preventDefault(); app.canvas.processMouseMove(event)
|
||
}
|
||
ctx.documentMiddlePanEndHandler = (event) => {
|
||
if (!app.canvas || ctx.middlePanPointerId == null || event.pointerId !== ctx.middlePanPointerId) return
|
||
app.canvas.processMouseUp(event); ctx.middlePanPointerId = null
|
||
}
|
||
document.addEventListener('pointermove', ctx.documentMiddlePanMoveHandler, true)
|
||
document.addEventListener('pointerup', ctx.documentMiddlePanEndHandler, true)
|
||
document.addEventListener('pointercancel', ctx.documentMiddlePanEndHandler, true)
|
||
ctx.documentMarqueeMoveHandler = (event) => {
|
||
const selection = ctx.marqueeSelection
|
||
if (!selection || event.pointerId !== selection.pointerId) return
|
||
selection.clientX = event.clientX
|
||
selection.clientY = event.clientY
|
||
if (selection.active || Math.hypot(
|
||
event.clientX - selection.anchorClientX,
|
||
event.clientY - selection.anchorClientY
|
||
) >= MARQUEE_DRAG_THRESHOLD) event.preventDefault()
|
||
scheduleMarqueeSelectionFrame(node)
|
||
}
|
||
ctx.documentMarqueeEndHandler = (event) => {
|
||
if (!ctx.marqueeSelection || event.pointerId !== ctx.marqueeSelection.pointerId) return
|
||
finishMarqueeSelection(node, event, event.type === 'pointercancel')
|
||
}
|
||
document.addEventListener('pointermove', ctx.documentMarqueeMoveHandler, true)
|
||
document.addEventListener('pointerup', ctx.documentMarqueeEndHandler, true)
|
||
document.addEventListener('pointercancel', ctx.documentMarqueeEndHandler, true)
|
||
|
||
ctx.documentPasteHandler = (event) => {
|
||
if (event.defaultPrevented || isModifiedPlainTextPaste(event) || shouldIgnoreClipboardPasteTarget(event.target)) return
|
||
if (!(ctx.pointerInside || ctx.keyboardActive || root.contains(document.activeElement))) return
|
||
const files = getClipboardImageFiles(event); if (!files.length) return
|
||
event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation?.(); runUpload(files)
|
||
}
|
||
document.addEventListener('paste', ctx.documentPasteHandler, true)
|
||
|
||
let externalDragDepth = 0
|
||
const isFolderTabDropTarget = (target) => target instanceof Node && tabs.contains(target)
|
||
const setDragActive = (active, folderReady = false, folderHover = false) => {
|
||
root.classList.toggle('bil-dragover', active)
|
||
tabs.classList.toggle('bil-folder-drop-ready', active && folderReady)
|
||
tabs.classList.toggle('bil-folder-drop-hover', active && folderReady && folderHover)
|
||
if (!active) clearCardDragTargets(ctx)
|
||
}
|
||
const clearExternalDragState = () => { externalDragDepth = 0; setDragActive(false) }
|
||
ctx.clearExternalDragState = clearExternalDragState
|
||
root.addEventListener('dragenter', (event) => { if (!consumeExternalFileDrag(event) && !activatePotentialExternalFileDrag(event)) return; externalDragDepth += 1; const folderReady = hasExternalDirectoryDrag(event); setDragActive(true, folderReady, isFolderTabDropTarget(event.target)) }, true)
|
||
root.addEventListener('dragover', (event) => { if (!consumeExternalFileDrag(event) && !activatePotentialExternalFileDrag(event)) return; const folderReady = hasExternalDirectoryDrag(event); setDragActive(true, folderReady, isFolderTabDropTarget(event.target)) }, true)
|
||
root.addEventListener('dragleave', (event) => { if (!(externalDragDepth > 0 || hasExternalFileDrag(event))) return; event.preventDefault(); event.stopPropagation(); externalDragDepth = Math.max(0, externalDragDepth - 1); if (!externalDragDepth) setDragActive(false) }, true)
|
||
root.addEventListener('drop', async (event) => {
|
||
if (!hasExternalFileDrag(event)) { clearExternalDragState(); return }
|
||
const folderTarget = isFolderTabDropTarget(event.target)
|
||
finalizeExternalFileDrag(event)
|
||
const pending = folderTarget ? getDroppedFolderSources(event) : getDroppedImageFiles(event)
|
||
clearExternalDragState()
|
||
claimGalleryKeyboardOwnership(node)
|
||
ctx.restoreCanvasShortcutFocus?.()
|
||
const entries = await pending
|
||
if (folderTarget) {
|
||
if (entries.length) addFolderSources(node, entries)
|
||
else window.alert('Drop one or more folders onto the tab bar. Image files dropped there are not added to the Conveyor.')
|
||
} else if (entries.length) runUpload(entries)
|
||
}, true)
|
||
root.addEventListener('dragend', clearExternalDragState, true)
|
||
|
||
return root
|
||
}
|
||
|
||
/**
|
||
* Uploads the provided files through the node's upload pipeline and appends any created items to the node's state.
|
||
*
|
||
* The node's UI state (`uiState.source_paths`) is updated with normalized source paths for uploaded items, and the node
|
||
* state is written back so the widget reflects the new items.
|
||
*
|
||
* @param {object} node - The ComfyUI node instance that hosts the batch image loader widget.
|
||
* @param {FileList|File[]|Array<{file: File, relativeSubfolder?: string}>} files - Files or normalized file entries to upload.
|
||
* @param {{feedbackView?: string}} [options] - Browser view that receives import errors.
|
||
* @returns {boolean} `true` if one or more files were uploaded and applied to the node state, `false` if the node widget context is missing or no valid image files were provided.
|
||
*/
|
||
async function uploadViaNode(node, files, { feedbackView = 'input' } = {}) {
|
||
const ctx = node.__bil
|
||
if (!ctx) return false
|
||
const validFiles = normalizeUploadFiles(files)
|
||
if (!validFiles.length) return false
|
||
|
||
ctx.uploadDepth += 1
|
||
if (ctx.uploadDepth === 1) ctx.dropzoneLabel = ctx.dropzone.textContent || '+ Add images'
|
||
ctx.dropzone.disabled = true
|
||
ctx.dropzone.textContent = `Importing ${validFiles.length}…`
|
||
try {
|
||
const { uploaded, errors } = await uploadFiles(validFiles)
|
||
if (node.__bil !== ctx || ctx.removed) return false
|
||
if (!uploaded.length && errors.length) {
|
||
throw new Error(errors.length === 1 ? errors[0].error.message : `${errors.length} images failed to import.`)
|
||
}
|
||
const { state, uiState } = getRenderableState(node)
|
||
const inputPosition = ctx.browser?.input?.loaded
|
||
? new Map(ctx.browser.input.files.map((entry, index) => [entry.relative_path, index]))
|
||
: null
|
||
for (const entry of uploaded) {
|
||
const item = makeItemFromUploadResponse(entry)
|
||
if (!item) continue
|
||
state.items.push(item)
|
||
const runtimeSourcePath = normalizeSourcePath(entry?.source_path)
|
||
if (runtimeSourcePath) uiState.source_paths[item.id] = runtimeSourcePath
|
||
if (ctx.browser?.input?.loaded && entry.relative_path) {
|
||
const inputEntry = {
|
||
filename: entry.name,
|
||
subfolder: entry.subfolder || '',
|
||
relative_path: entry.relative_path,
|
||
type: 'input',
|
||
size: Number(entry.size || 0),
|
||
mtime_ns: Number(entry.mtime_ns || 0),
|
||
source_version: String(entry.source_version || '')
|
||
}
|
||
const existingIndex = inputPosition.get(inputEntry.relative_path) ?? -1
|
||
if (existingIndex >= 0) ctx.browser.input.files[existingIndex] = inputEntry
|
||
else {
|
||
inputPosition.set(inputEntry.relative_path, ctx.browser.input.files.length)
|
||
ctx.browser.input.files.push(inputEntry)
|
||
}
|
||
}
|
||
}
|
||
if (ctx.browser?.input?.loaded && uploaded.length) {
|
||
ctx.inputVersion += 1
|
||
updateFolderOptions(ctx)
|
||
}
|
||
const feedbackBrowser = browserForView(ctx, feedbackView) ?? ctx.browser.input
|
||
if (uploaded.length && !errors.length) feedbackBrowser.error = ''
|
||
if (uploaded.length) updateState(node, state, uiState)
|
||
if (errors.length) {
|
||
const firstFailure = errors[0].error.message
|
||
feedbackBrowser.error = errors.length === 1
|
||
? firstFailure
|
||
: `${errors.length} images failed to import. First error: ${firstFailure}`
|
||
console.error('Image Conveyor: some images failed to import.', ...errors.map(({ error }) => error))
|
||
scheduleRenderNode(node)
|
||
}
|
||
return uploaded.length > 0
|
||
} finally {
|
||
ctx.uploadDepth = Math.max(0, ctx.uploadDepth - 1)
|
||
if (ctx.uploadDepth === 0) {
|
||
ctx.dropzone.disabled = false
|
||
ctx.dropzone.textContent = ctx.dropzoneLabel || '+ Add images'
|
||
ctx.dropzoneLabel = ''
|
||
}
|
||
}
|
||
}
|
||
|
||
function initializeNode(node, widget) {
|
||
if (node.__bilInitialized) return widget
|
||
node.__bilInitialized = true
|
||
node.__bilWidget = widget
|
||
|
||
const { stateWidget, uiStateWidget, queueWidget } = getWidgets(node)
|
||
if (!stateWidget || !uiStateWidget || !queueWidget) return widget
|
||
|
||
stateWidget.hidden = true
|
||
stateWidget.options.hidden = true
|
||
|
||
uiStateWidget.hidden = true
|
||
uiStateWidget.options.hidden = true
|
||
uiStateWidget.serialize = false
|
||
|
||
queueWidget.hidden = true
|
||
queueWidget.options.hidden = true
|
||
queueWidget.serialize = false
|
||
|
||
const oldSize = node.size || [420, 700]
|
||
node.setSize?.([Math.max(oldSize[0], MIN_NODE_WIDTH), Math.max(oldSize[1], MIN_NODE_HEIGHT)])
|
||
|
||
attachQueueLifecycle(node)
|
||
autoQueueCoordinator.registerNode(node)
|
||
canvasDropCoordinator.registerNode(node)
|
||
|
||
node.onDragOver = (event) => {
|
||
if (!hasExternalFileDrag(event)) return false
|
||
event.preventDefault?.()
|
||
event.stopPropagation?.()
|
||
event.stopImmediatePropagation?.()
|
||
return true
|
||
}
|
||
|
||
node.onDragDrop = async (event) => {
|
||
if (!consumeExternalFileDrag(event)) return false
|
||
const files = await getDroppedImageFiles(event)
|
||
if (!files.length) return false
|
||
return await uploadViaNode(node, files)
|
||
}
|
||
|
||
node.pasteFile = (file) => {
|
||
const files = normalizeUploadFiles([file])
|
||
if (!files.length) return false
|
||
void uploadViaNode(node, files).catch((error) => console.error('Image Conveyor: paste import failed.', error))
|
||
return true
|
||
}
|
||
|
||
node.pasteFiles = (files) => {
|
||
const validFiles = normalizeUploadFiles(files)
|
||
if (!validFiles.length) return false
|
||
void uploadViaNode(node, validFiles).catch((error) => console.error('Image Conveyor: paste import failed.', error))
|
||
return true
|
||
}
|
||
|
||
chainNodeCallback(node, 'onExecuted', function (output) {
|
||
const payload = output?.batch_image_loader_delta?.[0]
|
||
if (!payload) return
|
||
try {
|
||
applyBackendDelta(node, JSON.parse(payload))
|
||
} catch {
|
||
// ignore malformed UI delta
|
||
}
|
||
})
|
||
|
||
chainNodeCallback(node, 'onConfigure', function () {
|
||
const snapshot = getCurrentState(node, { fromWidgets: true })
|
||
const ctx = node.__bil
|
||
if (ctx) {
|
||
ctx.queueRevision += 1
|
||
ctx.annotatedCountsRevision = -1
|
||
ctx.browser.conveyor.selected = new Set(snapshot.uiState.selected_ids)
|
||
}
|
||
const normalizedStateValue = serializeState(snapshot.state)
|
||
if (stateWidget.value !== normalizedStateValue) {
|
||
setWidgetValue(stateWidget, normalizedStateValue)
|
||
markNodeDirty(node)
|
||
}
|
||
cacheRenderableState(node, snapshot.state, snapshot.uiState)
|
||
queueMicrotask(() => scheduleRenderNode(node))
|
||
})
|
||
|
||
chainNodeCallback(node, 'onResize', function () {
|
||
syncDomWidgetSize(node, widget)
|
||
scheduleRenderNode(node, { viewportOnly: true, forceVisibleRows: true })
|
||
})
|
||
|
||
chainNodeCallback(node, 'onRemoved', function () {
|
||
autoQueueCoordinator.unregisterNode(node)
|
||
canvasDropCoordinator.unregisterNode(node)
|
||
keyboardCoordinator.unregisterNode(node)
|
||
const ctx = node.__bil
|
||
if (!ctx) return
|
||
ctx.removed = true
|
||
ctx.inputRequestId += 1
|
||
if (ctx.documentPasteHandler) {
|
||
document.removeEventListener('paste', ctx.documentPasteHandler, true)
|
||
ctx.documentPasteHandler = null
|
||
}
|
||
if (ctx.documentMiddlePanMoveHandler) {
|
||
document.removeEventListener('pointermove', ctx.documentMiddlePanMoveHandler, true)
|
||
ctx.documentMiddlePanMoveHandler = null
|
||
}
|
||
if (ctx.documentMiddlePanEndHandler) {
|
||
document.removeEventListener('pointerup', ctx.documentMiddlePanEndHandler, true)
|
||
document.removeEventListener('pointercancel', ctx.documentMiddlePanEndHandler, true)
|
||
ctx.documentMiddlePanEndHandler = null
|
||
}
|
||
if (ctx.documentMarqueeMoveHandler) {
|
||
document.removeEventListener('pointermove', ctx.documentMarqueeMoveHandler, true)
|
||
ctx.documentMarqueeMoveHandler = null
|
||
}
|
||
if (ctx.documentMarqueeEndHandler) {
|
||
document.removeEventListener('pointerup', ctx.documentMarqueeEndHandler, true)
|
||
document.removeEventListener('pointercancel', ctx.documentMarqueeEndHandler, true)
|
||
ctx.documentMarqueeEndHandler = null
|
||
}
|
||
ctx.middlePanPointerId = null
|
||
cancelMarqueeSelection(node, false)
|
||
if (ctx.documentFocusScopeHandler) {
|
||
document.removeEventListener('pointerdown', ctx.documentFocusScopeHandler, true)
|
||
ctx.documentFocusScopeHandler = null
|
||
}
|
||
if (ctx.windowFocusHandler) {
|
||
window.removeEventListener('focus', ctx.windowFocusHandler)
|
||
ctx.windowFocusHandler = null
|
||
}
|
||
ctx.filePickerPending = false
|
||
ctx.activePickerInput = null
|
||
clearTimeout(ctx.filePickerFocusTimer)
|
||
ctx.filePickerFocusTimer = 0
|
||
if (ctx.filePickerFocusFrame) cancelAnimationFrame(ctx.filePickerFocusFrame)
|
||
ctx.filePickerFocusFrame = 0
|
||
ctx.restoreCanvasShortcutFocus = null
|
||
ctx.inputAbortController?.abort?.()
|
||
ctx.inputAbortController = null
|
||
clearTimeout(ctx.searchTimer)
|
||
clearTimeout(ctx.scrollSettleTimer)
|
||
ctx.scrollSettleTimer = 0
|
||
ctx.lightbox?.root?.remove?.()
|
||
ctx.listResizeObserver?.disconnect?.()
|
||
ctx.listResizeObserver = null
|
||
ctx.clearExternalDragState?.()
|
||
ctx.clearExternalDragState = null
|
||
for (const slot of ctx.cardPool) resetCardThumbnail(slot)
|
||
ctx.cardPool.length = 0
|
||
ctx.thumbnailUrlCache = new WeakMap()
|
||
for (const entry of ctx.localObjectUrls.values()) URL.revokeObjectURL(entry.url)
|
||
ctx.localObjectUrls.clear()
|
||
ctx.browser.folderViews.clear()
|
||
ctx.browser.folderSources.clear()
|
||
ctx.folderTabElements.clear()
|
||
if (!ctx.renderFrame) return
|
||
cancelAnimationFrame(ctx.renderFrame)
|
||
ctx.renderFrame = 0
|
||
ctx.renderViewportOnly = false
|
||
})
|
||
|
||
const snapshot = getCurrentState(node, { fromWidgets: true })
|
||
node.__bil.browser.conveyor.selected = new Set(snapshot.uiState.selected_ids)
|
||
const normalizedStateValue = serializeState(snapshot.state)
|
||
if (stateWidget.value !== normalizedStateValue) {
|
||
setWidgetValue(stateWidget, normalizedStateValue)
|
||
markNodeDirty(node)
|
||
}
|
||
cacheRenderableState(node, snapshot.state, snapshot.uiState)
|
||
queueMicrotask(() => scheduleRenderNode(node))
|
||
keyboardCoordinator.registerNode(node)
|
||
return widget
|
||
}
|
||
|
||
function maybeInjectWidgetInput(nodeData) {
|
||
if (!NODE_CLASSES.has(nodeData.name)) return
|
||
const required = nodeData?.input?.required
|
||
if (!required || required[CUSTOM_WIDGET_INPUT]) return
|
||
nodeData.input.required = {
|
||
...required,
|
||
[CUSTOM_WIDGET_INPUT]: [CUSTOM_WIDGET_TYPE, {}]
|
||
}
|
||
}
|
||
|
||
app.registerExtension({
|
||
name: EXTENSION_NAME,
|
||
|
||
beforeRegisterNodeDef(_nodeType, nodeData) {
|
||
maybeInjectWidgetInput(nodeData)
|
||
},
|
||
|
||
getCustomWidgets() {
|
||
return {
|
||
[CUSTOM_WIDGET_TYPE](node, inputName) {
|
||
if (node.__bilWidget) {
|
||
return {
|
||
widget: node.__bilWidget,
|
||
minHeight: MIN_WIDGET_HEIGHT,
|
||
minWidth: MIN_NODE_WIDTH
|
||
}
|
||
}
|
||
|
||
const root = buildGalleryDom(node)
|
||
const widget = node.addDOMWidget(inputName, CUSTOM_WIDGET_TYPE, root, {
|
||
getMinHeight: () => MIN_WIDGET_HEIGHT,
|
||
getHeight: () => '100%',
|
||
onHide: () => suspendGalleryViewport(node.__bil),
|
||
onDraw: (domWidget) => syncDomWidgetSize(node, domWidget),
|
||
afterResize: (domWidgetNode) => syncDomWidgetSize(domWidgetNode, widget),
|
||
serialize: false
|
||
})
|
||
syncDomWidgetSize(node, widget)
|
||
widget.serialize = false
|
||
|
||
initializeNode(node, widget)
|
||
|
||
return {
|
||
widget,
|
||
minHeight: MIN_WIDGET_HEIGHT,
|
||
minWidth: MIN_NODE_WIDTH
|
||
}
|
||
}
|
||
}
|
||
}
|
||
})
|