Files
JaredTherriault-ComfyUI-JNodes/web/js/ImageDrawer/Contexts.js
T

2482 lines
71 KiB
JavaScript

// Defines the various contexts (tabs) for the Image Drawer sidebar, such as feed, favourites, lora, embeddings, etc.
// Each context filters and presents image/model data differently. Uses the ComfyUI app and api for data retrieval.
import { $el } from "/scripts/ui.js";
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import * as ExtraNetworks from "./ImageListChildElements/ExtraNetworks.js";
import * as ImageElements from "./ImageListChildElements/ImageElements.js";
import { getMetaData } from "./ImageListChildElements/ImageListChildElementUtils.js";
import * as SortTypes from "../common/SortTypes.js"
import { utilitiesInstance } from "../common/Utilities.js"
import {
createLabeledCheckboxToggle, createLabeledSliderRange, createVideoPlaybackOptionsFlyout,
options_LabeledCheckboxToggle, options_LabeledSliderRange, setting_ModelCardAspectRatio,
setting_FavouritesDirectory,
ImageDrawerConfigSetting,
setting_FeedExcludedNodeTypes, setting_bFeedExcludedNodeTypesDenyToggle
} from "../common/SettingsManager.js";
import { SearchableDropDown } from "../common/SearchableDropDown.js";
export function initializeContexts(imageDrawerInstance) {
return {
feed: new ContextFeed(imageDrawerInstance),
temp: new ContextTemp(imageDrawerInstance),
favoourites: new ContextFavourites(imageDrawerInstance),
input: new ContextInput(imageDrawerInstance),
output: new ContextOutput(imageDrawerInstance),
lora: new ContextLora(imageDrawerInstance),
embeddings: new ContextEmbeddings(imageDrawerInstance),
preview: new ContextPreview(imageDrawerInstance),
metadata: new ContextMetadataViewer(imageDrawerInstance),
//savedPrompts: new ContextSavedPrompts(imageDrawerInstance),
};
};
export class ImageDrawerContextCache {
constructor(scrollLevel, searchBarText, imageListElements, sortType, customContextCacheData = null) {
this.scrollLevel = scrollLevel;
this.searchBarText = searchBarText;
this.imageListElements = imageListElements;
this.sortType = sortType;
this.customContextCacheData = customContextCacheData;
}
};
export class ImageDrawerContext {
constructor(name, tooltip, imageDrawerInstance) {
this.name = name;
this.tooltip = tooltip;
this.imageDrawerInstance = imageDrawerInstance;
this.cache = null;
this.setting_LastSelectedSorting = new ImageDrawerConfigSetting(`ImageDrawer_LastSelectedSorting_Context_${this.constructor.name}_Instance_${this.imageDrawerInstance.getIndex()}`, this.getDefaultSortType());
}
hasCache() {
return this.cache != null;
}
setCache(newCache) {
if (!(newCache instanceof ImageDrawerContextCache)) {
console.error("Invalid cache type. Expected ImageDrawerContextCache.");
return;
}
this.cache = newCache;
}
makeCache() {
const imageDrawerSearchInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerSearch");
const imageDrawerListSortingInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerListSorting");
const imageDrawerListInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerList");
const childNodesArray = Array.from(imageDrawerListInstance.getImageListChildren());
const newCache =
new ImageDrawerContextCache(
imageDrawerListInstance.getImageListScrollLevel(), imageDrawerSearchInstance.getSearchText(),
childNodesArray, imageDrawerListSortingInstance.getCurrentSortTypeName());
this.setCache(newCache);
}
async switchToContext(bSkipRestore = false) {
const imageDrawerListSortingInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerListSorting");
imageDrawerListSortingInstance.stopAutomaticShuffle();
const imageDrawerListInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerList");
const imageListElement = imageDrawerListInstance.getImageListElement();
const mainWidget = this.getMainWidget();
const bUseImageList = mainWidget == imageListElement;
if (imageDrawerListInstance.getImageListContainerElement().firstChild) {
imageDrawerListInstance.getImageListContainerElement().removeChild(imageDrawerListInstance.getImageListContainerElement().firstChild);
}
imageDrawerListInstance.getImageListContainerElement().appendChild(mainWidget);
const bSuccessfulRestore = bSkipRestore || bUseImageList ? await this.checkAndRestoreContextCache(bUseImageList) : false;
if (!bSuccessfulRestore) {
const imageDrawerSearchInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerSearch");
imageDrawerSearchInstance.clearAndExecuteSearch(); // Reset search if no cache
}
const imageDrawerMainInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerMain");
imageDrawerMainInstance.setContextToolbarWidget(await this.makeToolbar());
return bSuccessfulRestore;
}
async makeToolbar() {
const container = $el("div", { //Inner container so it can maintain 'flex' display attribute
style: {
alignItems: 'normal',
display: 'flex',
gap: '.5rem',
flex: '0 1 100%',
justifyContent: 'flex-end',
}
});
const scrollToTopButton = $el("button.JNodes-image-drawer-btn", {
textContent: "⏫",
title: "Jump to top of list",
onclick: async () => {
const imageDrawerListInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerList");
imageDrawerListInstance.getImageListContainerElement().scrollTop = 0;
scrollToTopButton.blur();
},
style: {
width: "fit-content",
padding: '3px',
},
});
container.appendChild(scrollToTopButton);
return container;
}
async checkAndRestoreContextCache(bUseImageList) {
if (this.hasCache()) {
if (bUseImageList && this.cache.imageListElements.length > 0) {
const imageDrawerListInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerList");
const imageDrawerSearchInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerSearch");
const imageDrawerListSortingInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerListSorting");
// Replace children
imageDrawerListInstance.replaceImageListChildren(this.cache.imageListElements);
// Execute Search
imageDrawerSearchInstance.setSearchTextAndExecute(this.cache.searchBarText);
// Restore sort type
imageDrawerListSortingInstance.setOptionSelectedFromOptionName(this.cache.sortType);
// Restore scroll level
imageDrawerListInstance.setImageListScrollLevel(this.cache.scrollLevel);
return true;
}
}
return false;
}
onRequestShowInFileManager(item) {
if (item && item.showInFileManager) {
item.showInFileManager();
}
}
onRequestSingleDeletion(item) {
if (item && item.deleteItem) {
item.deleteItem();
}
}
onRequestBatchDeletion() {
const batchSelectionManagerInstance = this.imageDrawerInstance.getComponentByName("BatchSelectionManager");
for (const child of batchSelectionManagerInstance.getValidSelectedItems()) {
this.onRequestSingleDeletion(child);
}
}
onRequestSingleRemoval(item) {
if (item && item.removeItemFromImageList) {
item.removeItemFromImageList();
}
}
onRequestBatchRemoval() {
const batchSelectionManagerInstance = this.imageDrawerInstance.getComponentByName("BatchSelectionManager");
for (const child of batchSelectionManagerInstance.getValidSelectedItems()) {
this.onRequestSingleRemoval(child);
}
}
onRequestSingleFavourite(item) {
if (item && item.copyItem) {
item.copyItem(setting_FavouritesDirectory.value);
}
}
onRequestBatchFavourite() {
const batchSelectionManagerInstance = this.imageDrawerInstance.getComponentByName("BatchSelectionManager");
for (const child of batchSelectionManagerInstance.getValidSelectedItems()) {
this.onRequestSingleFavourite(child);
}
}
// Override this function to use your own widget instead of the default image list grid.
getMainWidget() {
const imageDrawerListInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerList");
return imageDrawerListInstance.getImageListElement();
}
getSupportedSortTypes() {
return [SortTypes.SortTypeFilename, SortTypes.SortTypeDate, SortTypes.SortTypeShuffle];
}
getDesiredSortType() {
let desiredType = this.cache?.sortType;
const imageDrawerListSortingInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerListSorting");
if (!desiredType) {
try {
const lastSelectedType = JSON.parse(this.setting_LastSelectedSorting.value);
desiredType = imageDrawerListSortingInstance.getSortTypeObjectFromClassName(lastSelectedType.name, lastSelectedType.bIsAscending);
} catch {
// pass
}
}
if (!desiredType) {
desiredType = this.getDefaultSortType();
}
const supportedTypes = this.getSupportedSortTypes();
let desiredTypeObject;
if (desiredType && typeof desiredType === 'object' && typeof desiredType.name === 'string' && desiredType.sortImageList) {
desiredTypeObject = desiredType;
} else if (desiredType && typeof desiredType === 'object' && desiredType.type) {
desiredTypeObject = imageDrawerListSortingInstance.getSortTypeObjectFromClassType(desiredType.type, desiredType.bIsAscending);
} else {
desiredTypeObject = imageDrawerListSortingInstance.getSortTypeObjectFromName(desiredType);
}
if (desiredTypeObject && supportedTypes.includes(desiredTypeObject.constructor)) {
return desiredTypeObject;
}
const defaultType = this.getDefaultSortType();
return imageDrawerListSortingInstance.getSortTypeObjectFromClassType(defaultType.type, defaultType.bIsAscending);
}
getDefaultSortType() {
return { type: SortTypes.SortTypeFilename, bIsAscending: true };
}
setLastSelectedSorting(sortType) {
const value = JSON.stringify({ name: sortType.constructor.name, bIsAscending: sortType.bIsAscending });
this.setting_LastSelectedSorting.value = value;
return value;
}
shouldCancelAsyncOperation() {
const imageDrawerContextSelectorInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerContextSelector");
return imageDrawerContextSelectorInstance.getCurrentContextObject() != this; // By default, cancel async operations if the selected context has changed
}
}
export class ContextClearable extends ImageDrawerContext {
async onClearClicked() { }
async makeToolbar() {
// Remove all images from the list
let clearButton = $el("button.JNodes-image-drawer-btn", {
textContent: "Clear",
onclick: async () => {
await this.onClearClicked();
clearButton.blur();
},
style: {
width: "fit-content",
padding: '3px',
},
});
const finalWidget = await super.makeToolbar();
finalWidget.insertBefore(clearButton, finalWidget.firstChild);
const flyoutHandle = createVideoPlaybackOptionsFlyout().handle;
finalWidget.insertBefore(flyoutHandle, finalWidget.firstChild);
return finalWidget;
}
getSupportedSortTypes() {
const NewSortTypes = [
SortTypes.SortTypeFileSize, SortTypes.SortTypeImageWidth,
SortTypes.SortTypeImageHeight, SortTypes.SortTypeImageAspectRatio, SortTypes.SortTypeFileType
];
return super.getSupportedSortTypes().concat(NewSortTypes);
}
}
export class ContextRefreshable extends ImageDrawerContext {
async onRefreshClicked() {
const imageDrawerListSortingInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerListSorting");
imageDrawerListSortingInstance.stopAutomaticShuffle();
imageDrawerListSortingInstance.sortWithCurrentType();
}
async makeToolbar() {
// Refresh button
let refreshButton = $el("button.JNodes-image-drawer-btn", {
textContent: "Refresh",
onclick: async () => {
await this.onRefreshClicked();
refreshButton.blur();
},
style: {
width: "fit-content",
padding: '3px',
},
});
const finalWidget = await super.makeToolbar();
finalWidget.insertBefore(refreshButton, finalWidget.firstChild);
return finalWidget;
}
}
const SUBDIRECTORY_PLACEHOLDER = "Please select a subdirectory";
export class ContextModel extends ContextRefreshable {
constructor(name, description, imageDrawerInstance, type) {
super(name, description, imageDrawerInstance);
this.type = type;
this.selectedSubdirectory = SUBDIRECTORY_PLACEHOLDER;
this.subdirectorySelector = null;
}
async getModels(bForceRefresh = false, signal = undefined) { }
async updateSubdirectorySelectorOptions() {
const lastSelectedValue = this.subdirectorySelector.data.getSelectedOptionName();
this.subdirectorySelector.data.clearOptions();
const loadingIndicatorText = "Loading...";
this.subdirectorySelector.data.addOptionUnique(loadingIndicatorText, loadingIndicatorText);
this.subdirectorySelector.data.setOptionSelected(loadingIndicatorText);
const subdirectoriesResponse = await api.fetchApi(
`/jnodes_list_model_subdirectories?type=${this.type}`, { method: "GET", cache: "no-store" });
let subdirectories;
try {
let decodedString = await utilitiesInstance.decodeReadableStream(subdirectoriesResponse.body);
decodedString = utilitiesInstance.sanitizeMetadataForJson(decodedString);
const asJson = JSON.parse(decodedString);
if (asJson.success && asJson.payload) {
subdirectories = asJson.payload;
}
} catch (e) {
console.error(`Could not get list of subdirectories for "${this.type}": ${e}`)
}
this.subdirectorySelector.data.clearOptions();
this.subdirectorySelector.data.addOptionUnique(SUBDIRECTORY_PLACEHOLDER, SUBDIRECTORY_PLACEHOLDER);
this.subdirectorySelector.data.addOptionUnique('/root', '');
for (let directoryIndex = 0; directoryIndex < subdirectories.length; directoryIndex++) {
const result = subdirectories[directoryIndex];
if (result === "") { continue; } // skip root (already added above)
this.subdirectorySelector.data.addOptionUnique(result, result);
}
this.subdirectorySelector.data.setOptionSelected(SUBDIRECTORY_PLACEHOLDER);
this.subdirectorySelector.data.setFilterTextAndExecuteSearch("");
}
async loadModels(bForceRefresh = false) {
// Abort any previous in-flight request
if (this._fetchAbortController) {
this._fetchAbortController.abort();
await api.fetchApi('/jnodes_request_task_cancellation', { method: "POST" });
}
const abortController = new AbortController();
this._fetchAbortController = abortController;
const cancelButton = $el("button.JNodes-image-drawer-btn", {
textContent: 'Cancel',
onclick: async () => {
abortController.abort();
await api.fetchApi(
'/jnodes_request_task_cancellation', { method: "POST" });
cancelButton.textContent = 'Canceling...';
},
style: {
width: "fit-content",
padding: '3px',
},
});
// Add loading indicator
const imageDrawerListInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerList");
await imageDrawerListInstance.replaceImageListChildren([
$el('div', [
$el("label", { textContent: `Loading ${this.name}...` }),
cancelButton
])
]);
// Get models
let modelDicts;
try {
modelDicts = await this.getModels(bForceRefresh, abortController.signal);
} catch (e) {
if (e.name === 'AbortError') {
await imageDrawerListInstance.replaceImageListChildren([$el("label", { textContent: "Cancelled." })]);
return;
}
console.error(`Could not load models for "${this.name}": ${e}`);
await imageDrawerListInstance.replaceImageListChildren([$el("label", { textContent: "Error loading models." })]);
return;
}
if (this.shouldCancelAsyncOperation()) { return; }
const newModels = [];
// Prepare Models
const modelKeys = Object.keys(modelDicts);
if (modelKeys.length > 0) {
let count = 0;
let maxCount = 0;
let promises = [];
for (const modelKey of modelKeys) {
if (this.shouldCancelAsyncOperation()) { break; }
if (maxCount > 0 && count > maxCount) { break; }
let element = await ExtraNetworks.createExtraNetworkCard(modelKey, modelDicts[modelKey], this.type, this.imageDrawerInstance);
if (element == undefined) {
console.log("Attempting to add undefined element for model named: " + modelKey + " with dict: " + JSON.stringify(modelDicts[modelKey]));
}
promises.push(imageDrawerListInstance.prepareElementForAdd(element));
newModels.push(element);
count++;
}
await Promise.all(promises);
await imageDrawerListInstance.replaceImageListChildren(newModels);
}
else {
await imageDrawerListInstance.replaceImageListChildren([$el("label", { textContent: "No models were found." })]);
}
}
async switchToContext() {
if (!await super.switchToContext()) {
await this.updateSubdirectorySelectorOptions();
if (this.cache?.customContextCacheData?.selectedSubdirectory !== undefined) {
this.subdirectorySelector.data.setOptionSelected(this.cache.customContextCacheData.selectedSubdirectory);
const restoredOption = this.subdirectorySelector.data.getSelectedOptionElement();
this.selectedSubdirectory = restoredOption ? restoredOption.value : "";
}
if (this.cache?.customContextCacheData?.subdirectorySearchToken) {
this.subdirectorySelector.data.setFilterTextAndExecuteSearch(this.cache.customContextCacheData.subdirectorySearchToken);
}
if (this.cache?.customContextCacheData?.subdirectorySelectorScrollAmount) {
this.subdirectorySelector.data.setScrollAmount(this.cache.customContextCacheData.subdirectorySelectorScrollAmount);
}
if (this.selectedSubdirectory && this.selectedSubdirectory !== SUBDIRECTORY_PLACEHOLDER) {
await this.loadModels();
} else {
const imageDrawerListInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerList");
await imageDrawerListInstance.replaceImageListChildren([$el("label", { textContent: "Please select a subdirectory to load models." })]);
}
return;
}
await this.updateSubdirectorySelectorOptions();
if (this.cache?.customContextCacheData?.selectedSubdirectory !== undefined) {
this.subdirectorySelector.data.setOptionSelected(this.cache.customContextCacheData.selectedSubdirectory);
const restoredOption = this.subdirectorySelector.data.getSelectedOptionElement();
this.selectedSubdirectory = restoredOption ? restoredOption.value : "";
}
if (this.cache?.customContextCacheData?.subdirectorySearchToken) {
this.subdirectorySelector.data.setFilterTextAndExecuteSearch(this.cache.customContextCacheData.subdirectorySearchToken);
}
if (this.cache?.customContextCacheData?.subdirectorySelectorScrollAmount) {
this.subdirectorySelector.data.setScrollAmount(this.cache.customContextCacheData.subdirectorySelectorScrollAmount);
}
}
async makeToolbar() {
const container = await super.makeToolbar();
let options = new options_LabeledSliderRange();
options.labelTextContent = 'Card Aspect Ratio:'
options.id = 'ModelCardAspectRatioSlider';
options.value = setting_ModelCardAspectRatio.value;
options.min = 0.1;
options.max = 2;
options.step = 0.01;
options.bIncludeValueLabel = true;
options.valueLabelFractionalDigits = 2;
options.oninput = async (e) => {
setting_ModelCardAspectRatio.value = e.target.valueAsNumber;
const imageDrawerListInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerList");
for (let element of imageDrawerListInstance.getImageListChildren()) {
if (element.classList.contains('extraNetworksCard')) {
element.style.aspectRatio = setting_ModelCardAspectRatio.value;
}
}
};
const slider = $el('div',
{
style: {
width: '50%'
}
}, [
createLabeledSliderRange(options),
]
);
container.insertBefore(slider, container.firstChild);
const searchableDropDown = new SearchableDropDown();
this.subdirectorySelector = searchableDropDown.createSearchableDropDown();
this.subdirectorySelector.style.width = "50%";
this.subdirectorySelector.addEventListener("selectoption", async () => {
const selectedOption = this.subdirectorySelector.data.getSelectedOptionElement();
this.selectedSubdirectory = selectedOption.value;
if (this.selectedSubdirectory === SUBDIRECTORY_PLACEHOLDER) {
const imageDrawerListInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerList");
await imageDrawerListInstance.replaceImageListChildren([$el("label", { textContent: "Please select a subdirectory to load models." })]);
return;
}
await this.loadModels(true);
});
container.insertBefore(this.subdirectorySelector, container.firstChild);
return container;
}
makeCache() {
super.makeCache();
this.cache.customContextCacheData = {
selectedSubdirectory: this.subdirectorySelector?.data?.getSelectedOptionName(),
subdirectorySearchToken: this.subdirectorySelector?.data?.getFilterText(),
subdirectorySelectorScrollAmount: this.subdirectorySelector?.data?.getLastScrollAmount()
};
}
async onRefreshClicked() {
await this.loadModels(true);
super.onRefreshClicked();
}
onRequestSingleDeletion(item) {
// Unimplemented
}
onRequestBatchDeletion() {
// Unimplemented
}
onRequestSingleRemoval(item) {
// Unimplemented
}
onRequestBatchRemoval() {
// Unimplemented
}
getSupportedSortTypes() {
return super.getSupportedSortTypes().concat([SortTypes.SortTypeFriendlyName, SortTypes.SortTypePath]);
}
getDefaultSortType() {
return { type: SortTypes.SortTypeFriendlyName, bIsAscending: true };
}
}
export class ContextSubdirectoryExplorer extends ContextRefreshable {
constructor(name, description, imageDrawerInstance, directoryName, bShouldForceLoad = false) {
super(name, description, imageDrawerInstance);
this.rootDirectoryName = directoryName;
this.rootDirectoryDisplayName = '/root';
this.bIncludeSubdirectories = false;
this.fileList = null;
this.subdirectorySelector = null;
this.bShouldForceLoad = bShouldForceLoad; // Whether or not to lazy load. Lazy load = !bShouldForceLoad
}
async updateSubdirectorySelectorOptions() {
// Fill out combo box options based on paths
const lastSelectedValue = this.subdirectorySelector.data.getSelectedOptionName(); // Cache last selection
this.subdirectorySelector.data.clearOptions();
// Indicate loading
const loadingIndicatorText = "Loading...";
this.subdirectorySelector.data.addOptionUnique(loadingIndicatorText, loadingIndicatorText);
this.subdirectorySelector.data.setOptionSelected(loadingIndicatorText);
const subdirectoriesResponse = await api.fetchApi(
`/jnodes_list_comfyui_subdirectories?root_directory=${this.rootDirectoryName}`, { method: "GET", cache: "no-store" });
let subdirectories;
try {
// Decode into a string
let decodedString = await utilitiesInstance.decodeReadableStream(subdirectoriesResponse.body);
decodedString = utilitiesInstance.sanitizeMetadataForJson(decodedString);
const asJson = JSON.parse(decodedString);
if (asJson.success && asJson.payload) {
subdirectories = asJson.payload;
}
} catch (e) {
console.error(`Could not get list of files when loading "${this.rootDirectoryName}": ${e}`)
}
this.subdirectorySelector.data.clearOptions();
this.subdirectorySelector.data.addOptionUnique(SUBDIRECTORY_PLACEHOLDER, SUBDIRECTORY_PLACEHOLDER);
this.subdirectorySelector.data.addOptionUnique(this.rootDirectoryDisplayName, '');
for (let directoryIndex = 0; directoryIndex < subdirectories.length; directoryIndex++) {
const result = subdirectories[directoryIndex];
if (result === "") { continue; }
this.subdirectorySelector.data.addOptionUnique(result, result);
}
this.subdirectorySelector.data.setOptionSelected(SUBDIRECTORY_PLACEHOLDER);
this.subdirectorySelector.data.setFilterTextAndExecuteSearch("");
}
// Get the image paths in the folder or directory specified at this.folderName
// as well as all subdirectories then load the images in a given subdirectory
async fetchFolderItems(selectedSubdirectory = "", bRefreshOptions = false) {
// Abort any previous in-flight request
if (this._fetchAbortController) {
this._fetchAbortController.abort();
await api.fetchApi('/jnodes_request_task_cancellation', { method: "POST" });
}
const imageDrawerListSortingInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerListSorting");
imageDrawerListSortingInstance.stopAutomaticShuffle();
const imageDrawerListInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerList");
const withOrWithout = this.bIncludeSubdirectories ? "with" : "without";
const abortController = new AbortController();
this._fetchAbortController = abortController;
const cancelButton = $el("button.JNodes-image-drawer-btn", {
textContent: 'Cancel',
onclick: async () => {
abortController.abort();
await api.fetchApi(
'/jnodes_request_task_cancellation', { method: "POST" });
cancelButton.textContent = 'Canceling...';
},
style: {
width: "fit-content",
padding: '3px',
},
});
imageDrawerListInstance.replaceImageListChildren(
[
$el('div', [
$el("label", {
textContent:
`Loading directory '${selectedSubdirectory || this.rootDirectoryName}' ${withOrWithout} subdirectories...`
}),
cancelButton
])
]
);
let allItems;
try {
allItems = await api.fetchApi(
'/jnodes_get_comfyui_subdirectory_images' +
`?root_directory=${this.rootDirectoryName}` +
`&selected_subdirectory=${selectedSubdirectory}` +
`&recursive=${this.bIncludeSubdirectories}`, { cache: "no-store", signal: abortController.signal });
} catch (e) {
if (e.name === 'AbortError') {
await imageDrawerListInstance.replaceImageListChildren([$el("label", { textContent: "Cancelled." })]);
return;
}
console.error(`Could not get list of images when loading "${this.rootDirectoryName}": ${e}`);
this.fileList = [];
await this.loadImagesInFolder(selectedSubdirectory);
return;
}
let decodedString;
try {
// Decode into a string
decodedString = await utilitiesInstance.decodeReadableStream(allItems.body);
decodedString = utilitiesInstance.sanitizeMetadataForJson(decodedString);
const asJson = JSON.parse(decodedString);
this.fileList = asJson.payload;
} catch (e) {
console.error(`Could not get list of images when loading "${this.rootDirectoryName}": ${e}`)
this.fileList = []; // Set an empty list on failure. This allows the function to complete without further failures.
}
// Load root folder if no path is specified (even if there are no images within)
await this.loadImagesInFolder(selectedSubdirectory);
if (bRefreshOptions) {
const lastSelectedName = this.subdirectorySelector.data.getSelectedOptionName();
await this.updateSubdirectorySelectorOptions();
if (lastSelectedName && this.subdirectorySelector.data.hasOption(lastSelectedName)) {
this.subdirectorySelector.data.setOptionSelected(lastSelectedName);
}
}
}
async loadImagesInFolder(selectedSubdirectory) {
const imageDrawerListInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerList");
if (!this.fileList || this.fileList.length == 0) {
await imageDrawerListInstance.replaceImageListChildren(
[
$el('div', [
$el("label", {
textContent:
`No images or videos found in '${selectedSubdirectory == "" ? this.rootDirectoryDisplayName : selectedSubdirectory}'`
}),
//cancelButton
])
]
);
return;
}
if (this.shouldCancelAsyncOperation()) { imageDrawerListInstance.clearImageListChildren(); return; }
let elements = [];
let elementPreparationPromises = [];
const createElementFromFile = async (file) => {
let element = await ImageElements.createImageElementFromFileInfo({
filename: file.item,
file: file,
type: this.rootDirectoryName,
subdirectory: file.subdirectory ? selectedSubdirectory ? `${selectedSubdirectory}/${file.subdirectory}` : file.subdirectory : selectedSubdirectory,
bShouldForceLoad: this.bShouldForceLoad,
bShouldSort: false,
bShouldApplySearch: false,
}, this.imageDrawerInstance);
if (element !== undefined) {
elementPreparationPromises.push(imageDrawerListInstance.prepareElementForAdd(element));
elements.push(element);
} else {
console.log(`Attempted to add undefined image element in ${this.name}`);
}
};
let elementCreationPromises = [];
for (let fileIndex = 0; fileIndex < this.fileList.length; fileIndex++) {
if (this.shouldCancelAsyncOperation()) { break; }
const file = this.fileList[fileIndex];
// Push the promise to the array
elementCreationPromises.push(createElementFromFile(file));
}
// Wait for all promises to resolve
if (this.shouldCancelAsyncOperation()) { imageDrawerListInstance.clearImageListChildren(); return; }
await Promise.all(elementCreationPromises);
if (this.shouldCancelAsyncOperation()) { imageDrawerListInstance.clearImageListChildren(); return; }
await Promise.all(elementPreparationPromises);
imageDrawerListInstance.replaceImageListChildren(elements);
const imageDrawerListSortingInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerListSorting");
imageDrawerListSortingInstance.sortWithCurrentType();
const imageDrawerSearchInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerSearch");
imageDrawerSearchInstance.executeSearchWithEnteredSearchText();
}
async makeToolbar() {
const container = await super.makeToolbar();
const flyoutHandle = createVideoPlaybackOptionsFlyout().handle;
container.insertBefore(flyoutHandle, container.firstChild);
flyoutHandle.determineTransformLayout(); // Call immediately after parenting to avoid first caling being from the center
let includeSubdirectoriesToggleOptions = new options_LabeledCheckboxToggle();
includeSubdirectoriesToggleOptions.labelTextContent = 'Include Subdirectories';
includeSubdirectoriesToggleOptions.id = 'IncludeSubdirectoriesToggle';
includeSubdirectoriesToggleOptions.checked = this.bIncludeSubdirectories;
includeSubdirectoriesToggleOptions.oninput = async (e) => {
this.bIncludeSubdirectories = e.target.checked;
const selectedOption = this.subdirectorySelector.data.getSelectedOptionElement();
const selectedValue = selectedOption.value;
if (selectedValue === SUBDIRECTORY_PLACEHOLDER) {
return;
}
await this.fetchFolderItems(selectedValue);
}
const IncludeSubdirectoriesToggle = createLabeledCheckboxToggle(includeSubdirectoriesToggleOptions);
container.insertBefore(IncludeSubdirectoriesToggle, container.firstChild);
const searchableDropDown = new SearchableDropDown();
this.subdirectorySelector = searchableDropDown.createSearchableDropDown();
this.subdirectorySelector.style.width = "100%";
this.subdirectorySelector.addEventListener("selectoption", async () => {
// Force subdirectory inclusion off to avoid OOM - user must opt-in explicitly each time
this.bIncludeSubdirectories = false;
IncludeSubdirectoriesToggle.getMainElement().checked = false;
const selectedOption = this.subdirectorySelector.data.getSelectedOptionElement();
const selectedValue = selectedOption.value;
if (selectedValue === SUBDIRECTORY_PLACEHOLDER) {
const imageDrawerListInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerList");
await imageDrawerListInstance.replaceImageListChildren([$el("label", { textContent: "Please select a subdirectory to load images." })]);
return;
}
await this.fetchFolderItems(selectedValue);
});
container.insertBefore(this.subdirectorySelector, container.firstChild);
return container;
}
makeCache() {
super.makeCache();
this.cache.customContextCacheData = {
selectedSubdirectory: this.subdirectorySelector?.data?.getSelectedOptionName(), subdirectorySearchToken: this.subdirectorySelector?.data?.getFilterText(),
subdirectorySelectorScrollAmount: this.subdirectorySelector?.data?.getLastScrollAmount()
};
}
async switchToContext() {
if (!await super.switchToContext()) {
await this.updateSubdirectorySelectorOptions();
const imageDrawerListInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerList");
await imageDrawerListInstance.replaceImageListChildren([$el("label", { textContent: "Please select a subdirectory to load images." })]);
return;
}
await this.updateSubdirectorySelectorOptions(); // If we're not calling fetchFolderItems because we're restoring a cache, call updateSubdirectorySelector
if (this.cache?.customContextCacheData?.selectedSubdirectory) { // Restore subdirectory selection from custom cache data
this.subdirectorySelector.data.setOptionSelected(this.cache.customContextCacheData.selectedSubdirectory);
}
if (this.cache?.customContextCacheData?.subdirectorySearchToken) {
this.subdirectorySelector.data.setFilterTextAndExecuteSearch(this.cache.customContextCacheData.subdirectorySearchToken);
}
if (this.cache?.customContextCacheData?.subdirectorySelectorScrollAmount) {
this.subdirectorySelector.data.setScrollAmount(this.cache.customContextCacheData.subdirectorySelectorScrollAmount);
}
}
async onRefreshClicked() {
const selectedValue = this.subdirectorySelector.data.getSelectedOptionElement().value;
if (selectedValue !== SUBDIRECTORY_PLACEHOLDER) {
const bRefreshOptions = true;
await this.fetchFolderItems(selectedValue, bRefreshOptions);
}
await super.onRefreshClicked();
}
getSupportedSortTypes() {
const NewSortTypes = [
SortTypes.SortTypeFileSize, SortTypes.SortTypeImageWidth, SortTypes.SortTypeImageHeight,
SortTypes.SortTypeImageAspectRatio, SortTypes.SortTypeFileType, SortTypes.SortTypePath
];
return super.getSupportedSortTypes().concat(NewSortTypes);
}
}
export class ContextFeed extends ContextClearable {
constructor(imageDrawerInstance) {
super("Feed", "The latest generations from this web session (cleared on page refresh)", imageDrawerInstance);
this.feedImages = [];
// Automatically update feed if it's the active context
api.addEventListener("executed", async ({ detail }) => {
const outImages = detail?.output?.images;
if (outImages) {
const node = app.graph.getNodeById(detail.node);
const excludedTypes = setting_FeedExcludedNodeTypes.value.split(",").map(s => s.trim()).filter(s => s.length > 0);
const nodeType = node.type;
if (setting_bFeedExcludedNodeTypesDenyToggle.value) {
// Allow list: only include nodes whose type is in the list
if (!excludedTypes.includes(nodeType)) { return; }
} else {
// Deny list: exclude nodes whose type is in the list
if (excludedTypes.includes(nodeType)) { return; }
}
for (const src of outImages) {
// Always add feed images to the record, but only add thumbs to the imageList if
// we're currently in feed mode. Otherwise they'll be added when switching to feed.
src.file_age = Date.now(); // Get time of creation since the last epoch, in milliseconds. For sorting.
this.feedImages.push(src);
}
const imageDrawerContextSelectorInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerContextSelector");
if (imageDrawerContextSelectorInstance.getCurrentContextName() == this.name) {
await this.addNewUncachedFeedImages();
}
}
});
}
async addNewUncachedFeedImages(bShouldSort = true, bShouldApplySearch = true) {
const imageDrawerListInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerList");
const imageListLength = imageDrawerListInstance.getImageListChildren().length;
if (imageListLength < this.feedImages.length) {
let fragment = document.createDocumentFragment();
for (let imageIndex = imageListLength; imageIndex < this.feedImages.length; imageIndex++) {
if (this.shouldCancelAsyncOperation()) { break; }
let fileInfo = this.feedImages[imageIndex];
fileInfo.file = { ...fileInfo };
fileInfo.bShouldForceLoad = true; // Don't lazy load
const element = await ImageElements.createImageElementFromFileInfo(fileInfo, this.imageDrawerInstance);
if (element == undefined) { console.log(`Attempting to add undefined image element in ${this.name}`); }
fragment.appendChild(element);
imageDrawerListInstance.prepareElementForAdd(element);
}
imageDrawerListInstance.getImageListElement().appendChild(fragment);
if (bShouldSort) {
const imageDrawerListSortingInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerListSorting");
imageDrawerListSortingInstance.sortWithCurrentType();
}
if (bShouldApplySearch) {
const imageDrawerSearchInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerSearch");
imageDrawerSearchInstance.executeSearchWithEnteredSearchText();
}
}
}
async switchToContext() {
if (!await super.switchToContext()) {
const imageDrawerListInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerList");
imageDrawerListInstance.clearImageListChildren();
}
await this.addNewUncachedFeedImages();
}
async onClearClicked() {
const imageDrawerListInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerList");
imageDrawerListInstance.clearImageListChildren();
this.feedImages = [];
}
onRequestSingleDeletion(item) {
this.removeItemFromFeed(item);
if (item && item.deleteItem) {
const bRemoveFromImageList = false; // Don't do it again, that was handled above
item.deleteItem(bRemoveFromImageList);
}
}
onRequestBatchDeletion() {
const imageDrawerListInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerList");
for (const child of imageDrawerListInstance.getImageListChildren()) {
this.onRequestSingleDeletion(child);
}
}
onRequestSingleRemoval(item) {
this.removeItemFromFeed(item);
}
onRequestBatchRemoval() {
const imageDrawerListInstance = this.imageDrawerInstance.getComponentByName("ImageDrawerList");
for (const child of imageDrawerListInstance.getImageListChildren()) {
this.onRequestSingleRemoval(child);
}
}
getDefaultSortType() {
return { type: SortTypes.SortTypeDate, bIsAscending: false };
}
removeItemFromFeed(item) {
super.onRequestSingleRemoval(item);
if (item?.fileInfo) {
this.feedImages = this.feedImages.filter(src => src !== item.fileInfo); // Remove matching feed sources
}
}
}
export class ContextTemp extends ContextSubdirectoryExplorer {
constructor(imageDrawerInstance) {
const bShouldForceLoad = true; // These need to be searchable by meta data
super("Temp / History", "The generations you've created since the last comfyUI server restart", imageDrawerInstance, "temp", bShouldForceLoad);
}
getDefaultSortType() {
return { type: SortTypes.SortTypeDate, bIsAscending: false };
}
}
export class ContextFavourites extends ContextSubdirectoryExplorer {
constructor(imageDrawerInstance) {
const bShouldForceLoad = true; // These need to be searchable by meta data
// Update rootDirectoryName when this setting changes
setting_FavouritesDirectory.setOnChange((newValue) => { this.rootDirectoryName = newValue; })
super("Favourites", "The generations you've copied to the Favourites directory defined in Settings > JNodes Settings", imageDrawerInstance, setting_FavouritesDirectory.value, bShouldForceLoad);
}
}
export class ContextInput extends ContextSubdirectoryExplorer {
constructor(imageDrawerInstance) {
super("Input", "Images and videos found in your input folder", imageDrawerInstance, "input");
}
}
export class ContextOutput extends ContextSubdirectoryExplorer {
constructor(imageDrawerInstance) {
const bShouldForceLoad = true; // These need to be searchable by meta data
super("Output", "Images and videos found in your output folder", imageDrawerInstance, "output", bShouldForceLoad);
}
}
export class ContextLora extends ContextModel {
constructor(imageDrawerInstance) {
super("Lora / Lycoris", "Lora and Lycoris models found in your Lora directory", imageDrawerInstance, "loras");
}
async getModels(bForceRefresh = false, signal = undefined) {
return await ExtraNetworks.getLoras(bForceRefresh, this.selectedSubdirectory, signal);
}
}
export class ContextEmbeddings extends ContextModel {
constructor(imageDrawerInstance) {
super("Embeddings / Textual Inversions", "Embedding/textual inversion models found in your embeddings directory", imageDrawerInstance, "embeddings");
}
async getModels(bForceRefresh = false, signal = undefined) {
return await ExtraNetworks.getEmbeddings(bForceRefresh, this.selectedSubdirectory, signal);
}
}
export class ContextSavedPrompts extends ContextSubdirectoryExplorer {
constructor(imageDrawerInstance) {
super(
"Saved Prompts",
"Images and videos found in the JNodes/saved_prompts folder and its subdirectories. Title comes from filename",
imageDrawerInstance,
"JNodes/saved_prompts");
}
}
export class ContextMetadataViewer extends ImageDrawerContext {
constructor(imageDrawerInstance) {
super(
"Metadata Viewer",
"Read and display metadata from an image or video",
imageDrawerInstance);
}
async switchToContext() {
if (!await super.switchToContext()) {
if (this.mainWidget) {
return;
}
this.makeMainWidget();
} else {
if (this.viewingItem) {
this._refreshPreviewFromBlob(this.viewingItem, this.container.firstChild);
}
}
}
makeMainWidget() {
if (!this.container) {
this.container = $el("div", {
style: {
height: "100%",
width: "100%",
}
});
}
this.makeDefaultDragAndDropAreaContent();
this.mainWidget = $el("div", {
textContent: "Metadata Viewer",
style: {
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "20px",
fontFamily: "sans-serif",
position: "relative",
fontWeight: "bold",
fontSize: "24px",
textAlign: "center",
marginBottom: "10px",
height: "97%", // Avoids inner scrolling
},
});
// Drag-and-drop listeners
this.mainWidget.addEventListener("dragover", (e) => {
e.preventDefault();
this.dropOverlay.textContent = "Drop here to extract metadata!";
this.dropOverlay.style.opacity = 1;
});
this.mainWidget.addEventListener("dragleave", () => {
this.dropOverlay.style.opacity = 0;
});
this.mainWidget.addEventListener("drop", async (e) => {
e.preventDefault();
// Temporarily disable comfy file handling
const handleFileFunction = app.handleFile;
app.handleFile = (src) => {};
this.dropOverlay.textContent = "Processing file metadata...";
let payload = null;
if (e.dataTransfer) {
let bIsBinary = false;
if (e.dataTransfer.files.length > 0) { // For file drag and drop
const file = e.dataTransfer.files[0];
console.log("Dropped: " + JSON.stringify(file));
if (file && (file.type.startsWith("image/") || file.type.startsWith("video/"))) {
payload = file;
bIsBinary = true;
}
} else if (e.dataTransfer.items.length > 0) { // For image drawer drag and drop
const item = e.dataTransfer.items[0];
console.log("Dropped: " + JSON.stringify(item));
if (item && item.type == "text/jnodes_image_drawer_payload") {
payload = item;
}
}
if (payload) {
await this.handleDrop(payload, bIsBinary);
} else {
this.dropOverlay.textContent = "Please drop a valid image or video file.";
setTimeout( () => {
this.dropOverlay.style.opacity = 0;
}, 1000);
}
}
// Reenable comfy file handling
app.handleFile = handleFileFunction;
});
this.dropOverlay = $el("div", {
style: {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
backgroundColor: "rgba(0, 0, 0, 0.95)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: "20px",
zIndex: 1000,
pointerEvents: "none",
opacity: 0,
transition: "opacity 0.3s ease",
}
});
this.mainWidget.appendChild(this.container);
this.mainWidget.appendChild(this.dropOverlay);
}
makeDefaultDragAndDropAreaContent() {
this.dragAndDropArea = $el("div", {
style: {
width: "100%",
height: "200px",
border: "2px dashed #aaa",
borderRadius: "10px",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#666",
fontSize: "16px",
transition: "border-color 0.3s, background-color 0.3s",
marginTop: "20px",
cursor: "pointer",
},
});
this.dropMessage = $el("span", { textContent: "Drag & drop an image or video here to view metadata, or use 'Send To...' on any imange/video/model from the image drawer.",
style: { padding: "5%"}
});
this.dragAndDropArea.appendChild(this.dropMessage);
this.setContent(this.dragAndDropArea); // Add area to container
}
async handleDrop(payload, bIsBinary) {
console.log("Dropped: " + JSON.stringify(payload));
let filenameItem;
let fileUrlItem;
if (bIsBinary) {
// file = payload.getAsFile();
const fileInfo = payload.name ? { filename: payload.name } : null;
await this.setImageOrVideo(payload, true, fileInfo);
} else if (payload.type == 'text/jnodes_image_drawer_payload') { // a payload specific to media from the drawer
let bSuccessfulLoad = false;
// Create a promise to encapsulate the asynchronous operation
const loadItemAsString = (payload) => {
return new Promise((resolve, reject) => {
payload.getAsString(async (value) => {
if (value) {
try {
const jnodesPayload = JSON.parse(value);
let href = `/jnodes_view_image?`;
if (jnodesPayload.imageHref) { // Use existing href if it exists
href = jnodesPayload.imageHref;
} else { // Otherwise construct it
if (jnodesPayload.filename) {
href += `filename=${encodeURIComponent(jnodesPayload.filename)}&`;
}
if (jnodesPayload.type) {
href += `type=${jnodesPayload.type}&`;
}
if (jnodesPayload.subdirectory || jnodesPayload.subfolder) {
href += `subfolder=${encodeURIComponent(jnodesPayload.subdirectory || jnodesPayload.subfolder || "")}&`;
}
href += `t=${+new Date()}`; // Add Timestamp
}
const response = await fetch(href);
const blob = await response.blob();
if (blob) {
const fileInfo = {
filename: jnodesPayload.filename,
type: jnodesPayload.type,
subfolder: jnodesPayload.subdirectory || jnodesPayload.subfolder || "",
};
this.setImageOrVideo(blob, true, fileInfo);
}
// Resolve the promise with the result of updateNode()
resolve(bSuccess);
} catch (e) {
console.error(`Error getting file from dropped item: ${e}`);
// Reject the promise in case of an error
reject(e);
}
} else {
// Resolve with false if value is empty
resolve(false);
}
});
});
};
// Wait for the return of the Promise
await loadItemAsString(payload)
.then((success) => {
bSuccessfulLoad = success;
})
.catch((error) => {
console.error('Error loading item as string:', error);
});
if (bSuccessfulLoad) {
return true; // Early out for jnodes_image_drawer_payload
}
} else if (payload.type == 'application/x-moz-file-promise-url') { // Firefox specific pair fallback
if (!fileUrlItem) { fileUrlItem = payload; }
} else if (payload.type == 'application/x-moz-file-promise-dest-filename') { // Firefox specific pair fallback
if (!filenameItem) { filenameItem = payload; }
}
// Manually get filename and load file (Mozilla) if
if (fileUrlItem) {
let filename;
if (filenameItem) {
await filenameItem.getAsString(async (value) => {
if (value) { filename = value; }
});
}
fileUrlItem.getAsString(async (value) => {
let file = await utilitiesInstance.loadFileFromURL(value);
if (!filename) {
// Set filename from url maybe
filename = `${Math.random()}.${file.type.split('/')[1]}`;
}
file.filename = filename;
if (file) {
return conditionallyUploadFile(file);
} else {
console.error("Error getting file from dropped item.");
}
});
}
}
removeContent() {
while (this.container && this.container.firstChild) {
this.container.removeChild(this.container.firstChild);
}
}
setContent(inWidget) {
if (this.container && inWidget) {
this.widget = inWidget;
this.removeContent();
this.container.appendChild(inWidget);
}
}
_createTableRow(key, value, bCreateCopyButton, bCreateEditButton, bCreateDeleteButton) {
const row = $el("tr");
// Container for key text + edit button
const keyContainer = $el("div", {
style: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "10px",
width: "100%",
}
});
let keyText = $el("span", {
textContent: key,
style: {
wordBreak: "break-word",
flex: "1",
}
});
keyContainer.appendChild(keyText);
if (bCreateEditButton) {
const editKeyButton = $el("button", {
textContent: "✏️",
title: "Edit key",
style: {
background: "none",
border: "none",
color: "#fff",
cursor: "pointer",
fontSize: "16px",
padding: "0",
margin: "0",
opacity: "0.6",
}
});
editKeyButton.addEventListener("click", () => {
const input = $el("input", {
type: "text",
value: keyText.textContent,
style: {
flex: "1",
padding: "4px",
borderRadius: "4px",
border: "1px solid #ccc",
}
});
const save = () => {
keyText.textContent = input.value;
keyContainer.replaceChild(keyText, input);
};
keyContainer.replaceChild(input, keyText);
input.focus();
input.addEventListener("blur", save);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") input.blur();
});
});
keyContainer.appendChild(editKeyButton);
}
const keyCell = $el("td", {
style: {
padding: "8px",
borderBottom: "1px solid #eee",
fontWeight: "bold",
width: "40%",
color: "#fff",
backgroundColor: "rgba(0, 0, 0, 0.4)",
}
});
keyCell.appendChild(keyContainer);
// Container for value + button
const valueContainer = $el("div", {
style: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "10px",
}
});
const valueText = $el("span", {
textContent: utilitiesInstance.decodeUnicodeForeignLanguageText(value !== null && value !== undefined ? JSON.stringify(value) : ""),
style: {
wordBreak: "break-word",
flex: "1",
}
});
valueContainer.appendChild(valueText);
if (bCreateCopyButton) {
const copyButton = $el("button", {
textContent: "📋",
title: "Copy to clipboard",
style: {
background: "none",
border: "none",
color: "#fff",
cursor: "pointer",
fontSize: "16px",
padding: "0",
margin: "0",
opacity: "0.6",
}
});
copyButton.addEventListener("click", () => {
navigator.clipboard.writeText(valueText.textContent).then(() => {
copyButton.textContent = "✅";
setTimeout(() => (copyButton.textContent = "📋"), 1000);
});
});
valueContainer.appendChild(copyButton);
}
// ✏️ Edit button
if (bCreateEditButton) {
const editButton = $el("button", {
textContent: "✏️",
title: "Edit value",
style: {
background: "none",
border: "none",
color: "#fff",
cursor: "pointer",
fontSize: "16px",
padding: "0",
margin: "0",
opacity: "0.6",
}
});
editButton.addEventListener("click", () => {
// Replace valueText with input
const input = $el("input", {
type: "text",
value: valueText.textContent,
style: {
flex: "1",
padding: "4px",
borderRadius: "4px",
border: "1px solid #ccc",
}
});
// Replace and focus
valueContainer.replaceChild(input, valueText);
input.focus();
// Save on blur or enter
const save = () => {
valueText.textContent = input.value;
valueContainer.replaceChild(valueText, input);
};
input.addEventListener("blur", save);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
input.blur(); // triggers save
}
});
});
valueContainer.appendChild(editButton);
}
// 🗑️ Delete button
if (bCreateDeleteButton) {
const deleteButton = $el("button", {
textContent: "🗑️",
title: "Delete row",
style: {
background: "none",
border: "none",
color: "#fff",
cursor: "pointer",
fontSize: "16px",
padding: "0",
margin: "0",
opacity: "0.6",
}
});
deleteButton.confirmState = false;
deleteButton.addEventListener("click", () => {
if (deleteButton.confirmState) {
row.remove();
} else {
deleteButton.confirmState = true;
deleteButton.textContent = "🗑️?";
setTimeout(() => {
deleteButton.confirmState = false;
deleteButton.textContent = "🗑️";
}, 2000);
}
});
valueContainer.appendChild(deleteButton);
}
const valueCell = $el("td", {
style: {
padding: "8px",
borderBottom: "1px solid #eee",
color: "#fff",
backgroundColor: "rgba(0, 0, 0, 0.4)",
}
});
valueCell.appendChild(valueContainer);
row.appendChild(keyCell);
row.appendChild(valueCell);
return row;
}
_collectMetadataFromTable(tbody) {
const metadata = {};
for (const row of tbody.children) {
const keyCell = row.children[0];
const valueCell = row.children[1];
if (!keyCell || !valueCell) continue;
const keySpan = keyCell.querySelector("span");
const valueSpan = valueCell.querySelector("span");
if (!keySpan || !valueSpan) continue;
const key = keySpan.textContent.trim();
let value = valueSpan.textContent.trim();
try {
value = JSON.parse(value);
} catch {
// keep as string
}
metadata[key] = value;
}
return metadata;
}
async _handleEditMetadata(metadata) {
if (!this.currentFileInfo) {
return { success: false, error: "No file path available for saving" };
}
const { filename, type, subfolder } = this.currentFileInfo;
let href = `/jnodes_edit_image_metadata?`;
if (filename) href += `filename=${encodeURIComponent(filename)}&`;
if (type) href += `type=${encodeURIComponent(type)}&`;
if (subfolder) href += `subfolder=${encodeURIComponent(subfolder)}&`;
const response = await fetch(href, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
metadata: metadata,
format: this.viewingItem?.type || "",
}),
});
const result = await response.json();
return result;
}
_makeMediaPreview(inImageOrVideo) {
this.previewElement = null;
if (inImageOrVideo.type.startsWith("image/")) {
this.previewElement = $el("img", {
src: this.imageOrVideoUrl,
style: {
maxHeight: "10vh",
marginBottom: "10px",
borderRadius: "8px",
boxShadow: "0 0 10px rgba(0, 0, 0, 0.5)",
}
});
} else if (inImageOrVideo.type.startsWith("video/")) {
this.previewElement = $el("video", {
src: this.imageOrVideoUrl,
controls: true,
autoplay: true,
muted: true,
loop: true,
style: {
maxHeight: "10vh",
marginBottom: "10px",
borderRadius: "8px",
boxShadow: "0 0 10px rgba(0, 0, 0, 0.5)",
}
});
}
}
_refreshPreviewFromBlob(inImageOrVideo, container) {
if (this.previewElement) {
this.previewElement.remove();
this.previewElement = null;
}
if (this.imageOrVideoUrl) {
URL.revokeObjectURL(this.imageOrVideoUrl);
}
this.imageOrVideoUrl = URL.createObjectURL(inImageOrVideo);
this._makeMediaPreview(inImageOrVideo);
if (this.previewElement) {
if (container.firstChild?.nextSibling) {
container.insertBefore(this.previewElement, container.firstChild?.nextSibling);
} else {
container.appendChild(this.previewElement);
}
}
}
async _generateMetadataWidgetFromImageOrVideo(inImageOrVideo, inFileInfo = null) {
let metadata = null;
try {
metadata = await getMetaData(inImageOrVideo, inImageOrVideo.type);
} catch {
// pass
}
if (metadata) {
const container = $el("div", {
style: {
display: "flex",
flexDirection: "column",
alignItems: "center",
width: "100%",
color: "#fff",
height: "100%",
}
});
// Title
const title = $el("h3", {
textContent: inImageOrVideo.name || "",
style: {
margin: "10px 0",
fontSize: "22px",
color: "#fff",
}
});
container.appendChild(title);
// Media preview
this._refreshPreviewFromBlob(inImageOrVideo, container);
// Metadata table
const table = $el("table", {
style: {
width: "100%",
borderCollapse: "collapse",
fontSize: "14px",
}
});
const tbody = $el("tbody");
let entries = Object.entries(metadata);
entries.sort(([keyA], [keyB]) => {
if (keyA === "workflow") return 1;
if (keyB === "workflow") return -1;
if (keyA === "prompt") return 1;
if (keyB === "prompt") return -1;
return keyA.localeCompare(keyB);
});
const bCreateCopyButton = true;
const isImageType = inImageOrVideo && inImageOrVideo.type && inImageOrVideo.type.startsWith("image/");
const bCreateEditButton = isImageType;
const bCreateDeleteButton = isImageType;
for (const [key, value] of entries) {
if (key !== "parameters") {
const row = this._createTableRow(
key, value, bCreateCopyButton, bCreateEditButton, bCreateDeleteButton);
tbody.appendChild(row);
}
}
table.appendChild(tbody);
// Save button for image metadata edits
if (isImageType) {
const saveContainer = $el("div", {
style: {
display: "flex",
alignItems: "center",
gap: "8px",
width: "100%",
margin: "10px 0",
}
});
const saveButton = $el("button", {
textContent: "Save Metadata",
style: {
padding: "8px 16px",
borderRadius: "4px",
border: "1px solid #4a4",
backgroundColor: "#2a5a2a",
color: "#fff",
cursor: "pointer",
fontWeight: "bold",
}
});
const saveStatus = $el("span", {
style: {
color: "#aaa",
fontSize: "13px",
}
});
saveButton.addEventListener("click", async () => {
saveButton.disabled = true;
saveButton.textContent = "Saving...";
saveStatus.textContent = "";
try {
const metadata = this._collectMetadataFromTable(tbody);
const result = await this._handleEditMetadata(metadata);
if (result.success) {
saveStatus.textContent = "Saved!";
saveStatus.style.color = "#4a4";
} else {
saveStatus.textContent = result.error || "Save failed";
saveStatus.style.color = "#a44";
}
} catch (e) {
saveStatus.textContent = `Error: ${e.message}`;
saveStatus.style.color = "#a44";
} finally {
saveButton.disabled = false;
saveButton.textContent = "Save Metadata";
setTimeout(() => { saveStatus.textContent = ""; }, 3000);
}
});
saveContainer.appendChild(saveButton);
saveContainer.appendChild(saveStatus);
container.appendChild(saveContainer);
}
// Create a container to hold input + button side-by-side
const filterContainer = $el("div", {
style: {
display: "flex",
alignItems: "center",
gap: "8px",
width: "100%",
margin: "10px 0",
}
});
// Text input
const filterInput = $el("input", {
type: "text",
placeholder: "Filter metadata...",
style: {
flexGrow: "1",
padding: "8px",
borderRadius: "4px",
border: "1px solid #ccc",
boxSizing: "border-box",
}
});
// Clear "X" button
const clearButton = $el("button", {
textContent: "❌",
title: "Clear filter",
style: {
padding: "6px 10px",
borderRadius: "4px",
border: "1px solid #ccc",
backgroundColor: "#500",
color: "#fff",
cursor: "pointer",
}
});
clearButton.addEventListener("click", () => {
filterInput.value = "";
filterInput.dispatchEvent(new Event("input"));
});
// Toggle button
let filterMode = "ANY"; // default mode
const filterToggleButton = $el("button", {
textContent: "ANY",
style: {
padding: "8px 12px",
borderRadius: "4px",
border: "1px solid #ccc",
backgroundColor: "#222",
color: "#fff",
cursor: "pointer",
}
});
filterToggleButton.addEventListener("click", () => {
filterMode = filterMode === "ANY" ? "ALL" : "ANY";
filterToggleButton.textContent = filterMode;
filterInput.dispatchEvent(new Event("input")); // reapply filter
});
filterInput.addEventListener("input", () => {
const query = filterInput.value.toLowerCase();
const terms = query.split(" ").filter(Boolean);
Array.from(tbody.children).forEach((row) => {
const key = row.children[0]?.textContent.toLowerCase() || "";
const value = row.children[1]?.textContent.toLowerCase() || "";
const combined = key + " " + value;
let matches = true;
if (terms.length > 0) {
if (filterMode === "ANY") {
matches = terms.some(term => combined.includes(term));
} else if (filterMode === "ALL") {
matches = terms.every(term => combined.includes(term));
}
}
row.style.display = matches ? "" : "none";
});
});
filterContainer.appendChild(filterInput);
filterContainer.appendChild(clearButton);
filterContainer.appendChild(filterToggleButton);
// Scrollable wrapper for the table
const scrollContainer = $el("div", {
style: {
overflowY: "auto",
width: "100%",
padding: "10px",
border: "1px solid rgba(255, 255, 255, 0.2)",
borderRadius: "6px",
backgroundColor: "rgba(0, 0, 0, 0.3)",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.2)",
marginTop: "10px",
}
});
scrollContainer.appendChild(table);
container.appendChild(filterContainer);
container.appendChild(scrollContainer);
return container;
}
return null;
}
async setImageOrVideo(inImageOrVideoBlob, bUpdateOverlayWidget = false, inFileInfo = null) {
let widget;
try {
widget = await this._generateMetadataWidgetFromImageOrVideo(inImageOrVideoBlob, inFileInfo);
} catch {
// pass
}
if (widget) {
this.viewingItem = inImageOrVideoBlob;
this.currentFileInfo = inFileInfo;
this.setContent(widget);
if (bUpdateOverlayWidget) {
this.dropOverlay.style.opacity = 0;
}
} else {
console.warn("setImageOrVideo: Iamge or video has no metadata!");
if (bUpdateOverlayWidget) {
this.dropOverlay.style.opacity = 1;
this.dropOverlay.textContent = "No metadata...";
setTimeout( () => {
this.dropOverlay.style.opacity = 0;
}, 1000);
}
}
}
_renderMetadataTree(data, collectedTbodyList = []) {
const container = $el("div", {
style: {
display: "flex",
flexDirection: "column",
width: "100%",
color: "#fff",
}
});
const table = $el("table", {
style: {
width: "100%",
borderCollapse: "collapse",
fontSize: "14px",
}
});
const tbody = $el("tbody");
collectedTbodyList.push(tbody); // ✅ collect for filtering
for (const [key, value] of Object.entries(data)) {
const row = $el("tr");
const keyCell = $el("td", {
textContent: key,
style: {
padding: "6px",
fontWeight: "bold",
verticalAlign: "top",
width: "40%",
backgroundColor: "rgba(255,255,255,0.05)",
borderBottom: "1px solid rgba(255,255,255,0.1)"
}
});
const valueCell = $el("td", {
style: {
padding: "6px",
borderBottom: "1px solid rgba(255,255,255,0.1)",
wordBreak: "break-word",
whiteSpace: "normal",
overflowWrap: "anywhere",
maxWidth: "40%",
}
});
if (typeof value === "object" && value !== null) {
const toggle = $el("details", {
open: false,
style: {
cursor: "pointer"
}
});
const summary = $el("summary", {
textContent: Array.isArray(value) ? `Array [${value.length}]` : "Object",
style: {
marginBottom: "4px",
color: "#ccc"
}
});
toggle.appendChild(summary);
toggle.appendChild(this._renderMetadataTree(value, collectedTbodyList)); // recursive pass
valueCell.appendChild(toggle);
} else {
valueCell.innerHTML = utilitiesInstance.sanitizeHTML(String(value));
}
row.appendChild(keyCell);
row.appendChild(valueCell);
tbody.appendChild(row);
}
table.appendChild(tbody);
container.appendChild(table);
return container;
}
async _generateMetadataWidgetFromModel(inModelInfo, inImageOrVideo = null) {
const container = $el("div", {
style: {
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "10px",
color: "#fff",
width: "100%",
height: "100%",
}
});
if (!inModelInfo?.familiars?.familiar_infos) return null;
// Title
const title = $el("h3", {
textContent: inModelInfo.name || "",
style: {
margin: "10px 0",
fontSize: "22px",
color: "#fff",
}
});
container.appendChild(title);
// Media preview
if (inImageOrVideo) {
this._refreshPreviewFromBlob(inImageOrVideo, container);
}
// Create a container to hold input + button side-by-side
const filterContainer = $el("div", {
style: {
display: "flex",
alignItems: "center",
gap: "8px",
width: "100%",
margin: "10px 0",
}
});
// Text input
const filterInput = $el("input", {
type: "text",
placeholder: "Filter metadata...",
style: {
flexGrow: "1",
padding: "8px",
borderRadius: "4px",
border: "1px solid #ccc",
boxSizing: "border-box",
}
});
// Clear "X" button
const clearButton = $el("button", {
textContent: "❌",
title: "Clear filter",
style: {
padding: "6px 10px",
borderRadius: "4px",
border: "1px solid #ccc",
backgroundColor: "#500",
color: "#fff",
cursor: "pointer",
}
});
clearButton.addEventListener("click", () => {
filterInput.value = "";
filterInput.dispatchEvent(new Event("input"));
});
// Toggle button
let filterMode = "ANY"; // default mode
const filterToggleButton = $el("button", {
textContent: "ANY",
style: {
padding: "8px 12px",
borderRadius: "4px",
border: "1px solid #ccc",
backgroundColor: "#222",
color: "#fff",
cursor: "pointer",
}
});
filterToggleButton.addEventListener("click", () => {
filterMode = filterMode === "ANY" ? "ALL" : "ANY";
filterToggleButton.textContent = filterMode;
filterInput.dispatchEvent(new Event("input")); // reapply filter
});
const collectedTbodyList = [];
filterInput.addEventListener("input", () => {
const query = filterInput.value.toLowerCase();
const terms = query.split(" ").filter(Boolean);
for (const tbody of collectedTbodyList) {
Array.from(tbody.children).forEach((row) => {
const key = row.children[0]?.textContent.toLowerCase() || "";
const value = row.children[1]?.textContent.toLowerCase() || "";
const combined = key + " " + value;
let matches = true;
if (terms.length > 0) {
if (filterMode === "ANY") {
matches = terms.some(term => combined.includes(term));
} else if (filterMode === "ALL") {
matches = terms.every(term => combined.includes(term));
}
}
row.style.display = matches ? "" : "none";
});
}
});
filterContainer.appendChild(filterInput);
filterContainer.appendChild(clearButton);
filterContainer.appendChild(filterToggleButton);
container.appendChild(filterContainer);
const scrollContainer = $el("div", {
style: {
maxHeight: "100%",
overflowY: "auto",
width: "100%",
padding: "10px",
border: "1px solid rgba(255, 255, 255, 0.2)",
borderRadius: "6px",
backgroundColor: "rgba(0, 0, 0, 0.3)",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.2)",
marginTop: "10px",
}
});
for (const info of inModelInfo.familiars.familiar_infos) {
const section = $el("details", {
open: false,
style: {
border: "1px solid rgba(255,255,255,0.2)",
borderRadius: "6px",
backgroundColor: "rgba(0, 0, 0, 0.4)",
width: "100%"
}
});
const summary = $el("summary", {
textContent: info.file_name || "Unnamed Info File",
style: {
fontSize: "16px",
fontWeight: "bold",
margin: "10px",
cursor: "pointer",
}
});
section.appendChild(summary);
let parsed = null;
let rendered;
try {
parsed = JSON.parse(info.loaded_text);
rendered = this._renderMetadataTree(parsed, collectedTbodyList); // ✅ pass in the list
} catch {
rendered = $el("pre", {
textContent: info.loaded_text,
style: {
whiteSpace: "pre-wrap",
wordBreak: "break-word",
fontSize: "13px",
lineHeight: "1.4"
}
});
}
section.appendChild(rendered);
scrollContainer.appendChild(section);
}
container.appendChild(scrollContainer);
return container;
}
// Only inModelInfo is necessary, inImageOrVideo is optional and purely visual
async setModel(inModelInfo, inImageOrVideo = null, bUpdateOverlayWidget = false) {
let widget;
try {
widget = await this._generateMetadataWidgetFromModel(inModelInfo, inImageOrVideo);
} catch {
// pass
}
if (widget) {
this.viewingItem = inModelInfo;
this.setContent(widget);
if (bUpdateOverlayWidget) {
this.dropOverlay.style.opacity = 0;
}
} else {
console.warn("setModel: Model has no metadata!");
if (bUpdateOverlayWidget) {
this.dropOverlay.style.opacity = 1;
this.dropOverlay.textContent = "No metadata...";
setTimeout( () => {
this.dropOverlay.style.opacity = 0;
}, 1000);
}
}
}
getSupportedSortTypes() {
return [];
}
getMainWidget() {
if (!this.mainWidget) {
this.makeMainWidget();
}
return this.mainWidget;
}
}
export class ContextPreview extends ImageDrawerContext {
constructor(imageDrawerInstance) {
super(
"Inference Preview",
"Show the current preview of whatever is being generated, the same as what would show on a sampler node",
imageDrawerInstance
);
}
async switchToContext() {
if (!await super.switchToContext()) {
if (!this.link) {
this.createPreviewElement();
}
api.addEventListener("b_preview", ({ detail }) => {
if (this.animateInterval) { return; }
this.canvas.style.display = "none";
this.img.style.display = "unset";
this.show(URL.createObjectURL(detail), app.runningNodeId);
});
api.addEventListener('VHS_latentpreview', ({ detail }) => {
let setting = app.ui.settings.getSettingValue("VHS.LatentPreview");
if (!setting) {
return;
}
this.nodeId = detail.id;
if (this.nodeId == null) {
return;
}
let previewNode = app.graph.getNodeById(this.nodeId);
if (!previewNode?.widgets) { return; }
let previewWidget = previewNode.widgets.find((w) => w.name == "vhslatentpreview");
if (!previewWidget) { return; }
this.canvas.style.display = "unset";
this.img.style.display = "none";
let ctx;
if (this.animateInterval) {
clearInterval(this.animateInterval);
}
this.animateInterval = setInterval(() => {
if (!this) return;
if (detail.id != this.nodeId) {
clearInterval(this.animateInterval);
this.animateInterval = undefined;
return;
}
if (!ctx) {
ctx = this.canvas.getContext("2d");
}
this.canvas.style.aspectRatio = previewWidget.aspectRatio;
ctx.drawImage(previewWidget.element, 0, 0, this.canvas.width, this.canvas.height);
}, 1000/detail.rate);
});
}
}
show(src, node) {
this.img.src = src;
this.nodeId = Number(node);
}
createPreviewElement() {
this.canvas = $el("canvas", { style: { display: "none", width: "100%" } });
this.img = $el("img", { style: { display: "none", width: "100%" } });
this.link = $el(
"a",
{
href: "#",
onclick: (e) => {
e.stopPropagation();
e.preventDefault();
const node = app.graph.getNodeById(this.nodeId);
if (!node) return;
app.canvas.centerOnNode(node);
app.canvas.setZoom(1);
},
}, [
this.canvas, this.img
]
);
}
getSupportedSortTypes() {
return [];
}
getMainWidget() {
if (!this.link) {
this.createPreviewElement();
}
return this.link;
}
}