Files
melMass-comfy_mtb/web/mtb_input_output_sidebar.js
T
Mel Massadian a33af90b6d feat(mtb_input_output_sidebar): ✨ refactor pagination into reusable function
- Extract pagination setup logic into initPagination() function for reuse
- Initialize observer as global variable to manage lifecycle across resets
- Call initPagination() after content resets to properly reinitialize infinite scroll
- Add observer cleanup in widget cleanup to prevent memory leaks
- Safely handle observer disconnect with null checks to avoid errors
2025-11-09 12:44:22 +00:00

689 lines
21 KiB
JavaScript

/// <reference path="../types/typedefs.js" />
import { app } from '../../scripts/app.js'
import { api } from '../../scripts/api.js'
import * as mtb_ui from './mtb_ui.js'
import * as shared from './comfy_shared.js'
import {
// defineCSSClass,
ensureMTBStyles,
makeElement,
makeSelect,
makeSlider,
renderSidebar,
} from './mtb_ui.js'
// pagination state
let pageOffset = 0
let isLoadingPage = false
let hasMorePages = true
let pageSizeCache = undefined
let observer = null
// These are "global" variables mostly meant to sync user settings.
let currentWidth = 200
let saltUrls =
app.extensionManager.setting.get('mtb.io-sidebar.salt_urls') || false
let targetWidth =
app.extensionManager.setting.get('mtb.io-sidebar.img-size') || 512
let currentMode = 'input'
let subfolder = ''
let currentSort = 'None'
const IMAGE_NODES = ['LoadImage', 'VHS_LoadImagePath']
const VIDEO_NODES = ['VHS_LoadVideo']
const PROCESSED_PROMPT_IDS = new Set()
const updateImage = (node, image) => {
if (IMAGE_NODES.includes(node.type)) {
const w = node.widgets?.find((w) => w.name === 'image')
if (w) {
w.value = image
w.callback()
}
} else if (VIDEO_NODES.includes(node.type)) {
const w = node.widgets?.find((w) => w.name === 'video')
if (w) {
node.updateParameters({ filename: image }, true)
}
} else {
console.warn('No method to update', node.type)
}
}
/**
* Converts a result item to a request url.
* @param {ResultItem} resultItem
* @returns {string} - The request URL.
*/
const resultItemToQuery = (resultItem) => {
const res = [
`/mtb/view?filename=${resultItem.filename}`,
`type=${resultItem.type}`,
`subfolder=${resultItem.subfolder}`,
'preview=',
]
if (targetWidth > 0) {
res.splice(1, 0, `width=${targetWidth}`)
}
return res.join('&')
}
/**
* Retrieves the unique prompt ID from a history task item.
* @param {HistoryTaskItem} historyTaskItem
* @returns {string} - The prompt ID.
*/
const getPromptId = (historyTaskItem) => `${historyTaskItem.prompt[1]}`
/**
* Process and return any new/unseen outputs from the most recent history item.
* @param {HistoryTaskItem} mostRecentTask - The most recent history task item.
* @returns {Object<string, string>} - A map of task outputs URLs.
*/
const getNewOutputUrls = (mostRecentTask) => {
if (!mostRecentTask) return
const promptId = getPromptId(mostRecentTask)
if (PROCESSED_PROMPT_IDS.has(promptId)) return
const urls = {}
for (const nodeOutputs of Object.values(mostRecentTask.outputs)) {
const { images, audio, animated } = nodeOutputs
if (images) {
const imageOutputs = Object.values(nodeOutputs.images)
imageOutputs.forEach(
(resultItem) =>
(urls[resultItem.filename] = resultItemToQuery(resultItem)),
)
}
// Can process `animated` and `audio` outputs here.
}
const foundNewOutputs = Object.keys(urls).length > 0
if (!foundNewOutputs) return null
PROCESSED_PROMPT_IDS.add(promptId)
return urls
}
/** Fetch history and update the grid with any new ouput images. */
const updateOutputsGrid = async () => {
try {
const history = await api.getHistory(/** maxSize: */ 1)
const mostRcentTask = history.History[0]
const newUrls = getNewOutputUrls(mostRcentTask)
if (newUrls) {
const imgGrid = document.querySelector('.mtb_img_grid')
getImgsFromUrls(newUrls, imgGrid, { prepend: true })
}
} catch (error) {
console.error('Error fetching history:', error)
}
}
const getImgsFromUrls = (urls, target, options = { prepend: false }) => {
const imgs = []
if (urls === undefined) {
return imgs
}
const elem = currentMode === 'video' ? 'video' : 'img'
for (const [key, url] of Object.entries(urls)) {
const a = makeElement(elem)
a.src = url
a.width = currentWidth
if (elem === 'img') {
a.loading = 'lazy'
a.decoding = 'async'
} else {
// video
a.preload = 'metadata'
}
if (currentMode === 'input') {
a.onclick = (_e) => {
if (subfolder !== '') {
app.extensionManager.toast.add({
severity: 'warn',
summary: 'Subfolder not supported',
detail: "The LoadImage node doesn't support subfolders",
life: 5000,
})
return
}
const selected = app.canvas.selected_nodes
if (selected && Object.keys(selected).length === 0) {
app.extensionManager.toast.add({
severity: 'warn',
summary: 'No node selected!',
detail:
'For now the only action when clicking images in the sidebar is to set the image on all selected LoadImage nodes.',
life: 5000,
})
return
}
for (const [_id, node] of Object.entries(app.canvas.selected_nodes)) {
updateImage(node, key)
}
}
} else if (currentMode === 'output') {
a.onclick = async (_e) => {
const params = new URLSearchParams()
params.set('filename', key)
params.set('type', 'output')
if (subfolder) params.set('subfolder', subfolder)
params.set('workflow', 'true')
const url = `/mtb/view?${params.toString()}`
try {
const res = await api.fetchApi(url)
if (!res?.ok) throw new Error(`Request failed (${res?.status})`)
const data = await res.json()
const workflow = data?.workflow || data?.prompt
if (!workflow) {
app.extensionManager.toast.add({
severity: 'warn',
summary: 'No workflow in image',
detail: 'This file does not contain embedded workflow metadata.',
life: 5000,
})
return
}
// Try to import via File first; fallback to blob URL
const jsonText = JSON.stringify(workflow, null, 2)
const blob = new Blob([jsonText], { type: 'application/json' })
const suggestedName = `${(key || 'workflow').replace(/\.[^.]+$/, '')}-workflow.json`
let loaded = false
try {
const file = new File([blob], suggestedName, {
type: 'application/json',
})
await app.handleFile(file)
loaded = true
} catch (_err) {
const blobUrl = URL.createObjectURL(blob)
try {
await app.handleFile(blobUrl)
loaded = true
} finally {
URL.revokeObjectURL(blobUrl)
}
}
if (loaded) {
app.extensionManager.toast.add({
severity: 'success',
summary: 'Workflow loaded',
detail: 'Imported workflow from image metadata.',
life: 3000,
})
}
} catch (err) {
console.error('Failed to load workflow from output image:', err)
app.extensionManager.toast.add({
severity: 'error',
summary: 'Import failed',
detail: String(err?.message || err),
life: 6000,
})
}
}
} else {
a.autoplay = true
a.muted = true
a.loop = true
a.onclick = (_e) => {
const selected = app.canvas.selected_nodes
if (selected && Object.keys(selected).length === 0) {
app.extensionManager.toast.add({
severity: 'warn',
summary: 'No node selected!',
detail:
"For now the only action when clicking videos in the sidebar is to set the video on all selected 'Load Video (Upload)' nodes.",
life: 5000,
})
return
}
for (const [_id, node] of Object.entries(app.canvas.selected_nodes)) {
updateImage(node, key)
}
}
}
imgs.push(a)
}
if (target !== undefined) {
if (options.prepend) target.prepend(...imgs)
else target.append(...imgs)
}
return imgs
}
const getModes = async () => {
const inputs = await shared.runAction('getUserImageFolders')
return inputs
}
const getUrls = async (subfolder, countOverride, offsetOverride) => {
const count =
typeof countOverride === 'number'
? countOverride
: (await api.getSetting('mtb.io-sidebar.count')) || 1000
const offset = typeof offsetOverride === 'number' ? offsetOverride : pageOffset
pageSizeCache = count
// console.debug('Sidebar count', count, 'offset', offset)
if (currentMode === 'video') {
const output = await shared.runAction(
'getUserVideos',
targetWidth,
count,
offset,
currentSort,
)
return output || {}
}
const output = await shared.runAction(
'getUserImages',
currentMode,
targetWidth,
count,
offset,
currentSort,
false,
subfolder,
saltUrls,
)
return output || {}
}
/**
* Initialize pagination (loader, end-of-results message, sentinel, and IntersectionObserver)
* for the provided grid container. Safely disconnects any previous observer.
* @param {HTMLElement} imgGrid
*/
function initPagination(imgGrid) {
// disconnect any existing observer to avoid duplicates
if (observer) {
try {
observer.disconnect()
} catch { }
observer = null
}
// create footer UI elements
const loader = makeElement('div', {}, imgGrid)
Object.assign(loader.style, {
display: 'none',
width: '100%',
padding: '8px 0',
textAlign: 'center',
color: 'var(--mtb-text, #ccc)',
fontSize: '12px',
})
loader.textContent = 'Loading…'
const endMsg = makeElement('div', {}, imgGrid)
Object.assign(endMsg.style, {
display: 'none',
width: '100%',
padding: '8px 0',
textAlign: 'center',
color: 'var(--mtb-text, #888)',
fontSize: '12px',
})
endMsg.textContent = 'No more items'
const sentinel = makeElement('div', {}, imgGrid)
sentinel.style.height = '1px'
sentinel.style.width = '100%'
sentinel.style.marginTop = '1px'
const loadNextPage = async () => {
if (isLoadingPage || !hasMorePages) return
isLoadingPage = true
loader.style.display = 'block'
try {
const nextUrls = await getUrls(subfolder, undefined, pageOffset)
const keys = Object.keys(nextUrls || {})
if (!keys.length) {
hasMorePages = false
if (observer) observer.disconnect()
loader.style.display = 'none'
endMsg.style.display = 'block'
return
}
getImgsFromUrls(nextUrls, imgGrid)
if (pageSizeCache != null) pageOffset += pageSizeCache
// keep footer elements and sentinel at the bottom
imgGrid.appendChild(loader)
imgGrid.appendChild(endMsg)
imgGrid.appendChild(sentinel)
} catch (e) {
console.error('Failed to load next page:', e)
hasMorePages = false
if (observer) observer.disconnect()
loader.style.display = 'none'
endMsg.style.display = 'block'
} finally {
isLoadingPage = false
if (hasMorePages) loader.style.display = 'none'
}
}
observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) loadNextPage()
}
})
observer.observe(sentinel)
}
//NOTE: do not load if using the old ui
if (window?.__COMFYUI_FRONTEND_VERSION__) {
// NOTE: removed this for now since I'm not actually exposing anything a client
// cannot already access from "/view"...
// let exposed = false
const sidebar_extension = {
name: 'mtb.io-sidebar',
settings: [
{
id: 'mtb.io-sidebar.count',
category: ['mtb', 'Input & Output Sidebar', 'count'],
name: 'Number of images to fetch',
type: 'number',
defaultValue: 1000,
tooltip:
"This setting affects the input/output sidebar to determine how many images to fetch per pagination (pagination is not yet supported so for now it's the static total)",
},
{
id: 'mtb.io-sidebar.salt_urls',
category: ['mtb', 'Input & Output Sidebar', 'salt_urls'],
name: 'Salt URLs',
type: 'boolean',
defaultValue: false,
onChange: (n, o) => {
saltUrls = n
},
tooltip:
'Adds a random query parameter to every urls to always invalidate caching.',
},
{
id: 'mtb.io-sidebar.img-size',
category: ['mtb', 'Input & Output Sidebar', 'img-size'],
name: 'Resize width of shown images',
defaultValue: 512,
type: (name, setter, value, attrs) => {
targetWidth = value
const container = mtb_ui.makeElement('div', {
display: 'flex',
alignItems: 'center',
gap: '8px',
})
console.log({ name, setter, value, attrs })
const baseId = name.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()
const checkboxId = `${baseId}-checkbox`
const numberInputId = `${baseId}-number`
const isCheckedInitially = value !== -1
// TODO: better way to get defaultValue?
const defaultValue = 512
const initialNumberValue = isCheckedInitially ? value : defaultValue
console.log('recreate')
const checkbox = mtb_ui.makeElement(
// harder to match styles (.p-toggleswitch-input)
// since it uses a div synced to the input...
'input',
{},
container,
)
checkbox.type = 'checkbox'
checkbox.id = checkboxId
checkbox.checked = isCheckedInitially
const numberInput = mtb_ui.makeElement(
'input.p-inputtext',
{},
container,
)
numberInput.type = 'number'
numberInput.id = numberInputId
numberInput.value = initialNumberValue
numberInput.disabled = !isCheckedInitially
numberInput.min = 128
checkbox.addEventListener('change', () => {
let valToSet = -1
if (checkbox.checked) {
numberInput.disabled = false
valToSet = Number.parseInt(numberInput.value, 10)
if (Number.isNaN(valToSet) || valToSet < numberInput.min) {
valToSet = defaultValue
numberInput.value = valToSet
}
} else {
numberInput.disabled = true
}
setter(valToSet)
})
numberInput.addEventListener('input', () => {
if (checkbox.checked) {
const numValue = Number.parseInt(numberInput.value, 10)
if (!Number.isNaN(numValue) && numberInput.value !== '') {
setter(numValue)
}
}
})
return container
},
tooltip:
"If browsing large folders it's recommended to use this to avoid overflow/crash of the webpage. Image will get resized to this target width on the server before being sent to the client.",
},
{
id: 'mtb.io-sidebar.sort',
category: ['mtb', 'Input & Output Sidebar', 'sort'],
name: 'Default sort mode',
type: 'combo',
onChange: (v) => {
// alert(`Sort is now ${v}`)
currentSort = v
},
defaultValue: 'Modified',
// tooltip: "It's recommended to keep it at 512px",
options: [
'None',
'Modified',
'Modified-Reverse',
'Name',
'Name-Reverse',
],
},
{
id: 'mtb.io-sidebar.notice',
category: ['mtb', 'Input & Output Sidebar', 'sort'],
name: ' ',
type: (name, setter, value, attrs) => {
const container = mtb_ui.makeElement('div')
const notice =
'## Important\nIf you make **any** edits here you need to toggle off and back on the sidebar for it to take effect.'
if (window.MTB?.mdParser) {
MTB.mdParser.parse(notice).then((e) => {
container.innerHTML = e
})
} else {
shared.ensureMarkdownParser((p) => {
p.parse(notice).then((e) => {
container.innerHTML = e
})
})
}
return container
},
},
],
init: () => {
let handle
const version = window?.__COMFYUI_FRONTEND_VERSION__
console.log(`%c ${version}`, 'background: orange; color: white;')
ensureMTBStyles()
app.extensionManager.registerSidebarTab({
id: 'mtb-inputs-outputs',
icon: 'pi pi-images',
title: 'Input & Outputs',
tooltip: 'MTB: Browse inputs and outputs directories.',
type: 'custom',
// this is run everytime the tab's diplay is toggled on.
render: async (el) => {
if (handle) {
handle.unregister()
handle = undefined
}
if (el.parentNode) {
el.parentNode.style.overflowY = 'clip'
}
const allModes = await getModes()
const input_modes = allModes.input.map((m) => `input - ${m}`)
const output_modes = allModes.output.map((m) => `output - ${m}`)
//- reset pagination state for fresh render
pageOffset = 0
isLoadingPage = false
hasMorePages = true
const urls = await getUrls(undefined, undefined, 0)
if (pageSizeCache != null) pageOffset += pageSizeCache
let imgs = {}
const cont = makeElement('div.mtb_sidebar')
const imgGrid = makeElement('div.mtb_img_grid')
const selector = makeSelect(
['input', 'output', 'video', ...output_modes, ...input_modes],
currentMode,
)
selector.addEventListener('change', async (e) => {
let newMode = e.target.value
let changed = false
let newSub = ''
if (newMode !== 'input' && newMode !== 'output') {
if (newMode.startsWith('input - ')) {
newSub = newMode.replace('input - ', '')
newMode = 'input'
} else if (newMode.startsWith('output - ')) {
newSub = newMode.replace('output - ', '')
newMode = 'output'
}
}
changed = newMode !== currentMode || newSub !== subfolder
currentMode = newMode
subfolder = newSub
if (changed) {
imgGrid.innerHTML = ''
//- reset pagination on mode change
pageOffset = 0
isLoadingPage = false
hasMorePages = true
const urls = await getUrls(subfolder, undefined, 0)
if (pageSizeCache != null) pageOffset += pageSizeCache
if (urls) {
imgs = getImgsFromUrls(urls, imgGrid)
}
// re-init pagination after content reset
initPagination(imgGrid)
}
})
const imgTools = makeElement('div.mtb_tools')
const orderSelect = makeSelect(
['None', 'Modified', 'Modified-Reverse', 'Name', 'Name-Reverse'],
currentSort,
)
orderSelect.addEventListener('change', async (e) => {
const newSort = e.target.value
const changed = newSort !== currentSort
currentSort = newSort
if (changed) {
imgGrid.innerHTML = ''
//- reset pagination on sort change
pageOffset = 0
isLoadingPage = false
hasMorePages = true
const urls = await getUrls(subfolder, undefined, 0)
if (pageSizeCache != null) pageOffset += pageSizeCache
if (urls) {
imgs = getImgsFromUrls(urls, imgGrid)
}
// re-init pagination after content reset
initPagination(imgGrid)
}
})
const sizeSlider = makeSlider(64, 1024, currentWidth, 1)
imgTools.appendChild(orderSelect)
imgTools.appendChild(sizeSlider)
imgs = getImgsFromUrls(urls, imgGrid)
// Setup infinite pagination for the initial render
initPagination(imgGrid)
let pendingWidth = null
let rafToken = null
sizeSlider.addEventListener('input', (e) => {
pendingWidth = e.target.value
if (rafToken) return
rafToken = requestAnimationFrame(() => {
rafToken = null
if (pendingWidth == null) return
currentWidth = pendingWidth
pendingWidth = null
for (const img of imgs) {
img.style.width = `${currentWidth}px`
}
})
})
handle = renderSidebar(el, cont, [selector, imgGrid, imgTools])
app.api.addEventListener('status', async () => {
if (currentMode !== 'output') return
updateOutputsGrid()
})
},
destroy: () => {
if (handle) {
handle.unregister()
handle = undefined
app.api.removeEventListener('status')
}
// Attempt to disconnect any stray observers to avoid retained callbacks
if (observer) observer.disconnect()
},
})
},
}
app.registerExtension(sidebar_extension)
}