import { eventSource, this_chid, characters, getRequestHeaders, event_types, } from '../../../script.js'; import { groups, selected_group } from '../../group-chats.js'; import { loadFileToDocument, delay, getBase64Async, getSanitizedFilename } from '../../utils.js'; import { loadMovingUIState } from '../../power-user.js'; import { dragElement } from '../../RossAscends-mods.js'; import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; import { SlashCommand } from '../../slash-commands/SlashCommand.js'; import { ARGUMENT_TYPE, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js'; import { DragAndDropHandler } from '../../dragdrop.js'; import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; import { t, translate } from '../../i18n.js'; const extensionName = 'gallery'; const extensionFolderPath = `scripts/extensions/${extensionName}/`; let firstTime = true; // Exposed defaults for future tweaking let thumbnailHeight = 150; let paginationVisiblePages = 10; let paginationMaxLinesPerPage = 2; let galleryMaxRows = 3; // Remove all draggables associated with the gallery $('#movingDivs').on('click', '.dragClose', function () { const relatedId = $(this).data('related-id'); if (!relatedId) return; $(`#movingDivs > .draggable[id="${relatedId}"]`).remove(); }); const CUSTOM_GALLERY_REMOVED_EVENT = 'galleryRemoved'; const mutationObserver = new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.removedNodes.forEach((node) => { if (node instanceof HTMLElement && node.tagName === 'DIV' && node.id === 'gallery') { eventSource.emit(CUSTOM_GALLERY_REMOVED_EVENT); } }); }); }); mutationObserver.observe(document.body, { childList: true, subtree: false, }); const SORT = Object.freeze({ NAME_ASC: { value: 'nameAsc', field: 'name', order: 'asc', label: t`Sort By: Name (A-Z)` }, NAME_DESC: { value: 'nameDesc', field: 'name', order: 'desc', label: t`Sort By: Name (Z-A)` }, DATE_ASC: { value: 'dateAsc', field: 'date', order: 'asc', label: t`Sort By: Date (Oldest First)` }, DATE_DESC: { value: 'dateDesc', field: 'date', order: 'desc', label: t`Sort By: Date (Newest First)` }, }); const defaultSettings = Object.freeze({ folders: {}, sort: SORT.DATE_ASC.value, }); /** * Initializes the settings for the gallery extension. */ function initSettings() { let shouldSave = false; const context = SillyTavern.getContext(); if (!context.extensionSettings.gallery) { context.extensionSettings.gallery = structuredClone(defaultSettings); shouldSave = true; } for (const key of Object.keys(defaultSettings)) { if (!Object.hasOwn(context.extensionSettings.gallery, key)) { context.extensionSettings.gallery[key] = structuredClone(defaultSettings[key]); shouldSave = true; } } if (shouldSave) { context.saveSettingsDebounced(); } } /** * Retrieves the gallery folder for a given character. * @param {import('../../char-data.js').v1CharData} char Character data * @returns {string} The gallery folder for the character */ function getGalleryFolder(char) { return SillyTavern.getContext().extensionSettings.gallery.folders[char?.avatar] ?? char?.name; } /** * Retrieves a list of gallery items based on a given URL. This function calls an API endpoint * to get the filenames and then constructs the item list. * * @param {string} url - The base URL to retrieve the list of images. * @returns {Promise} - Resolves with an array of gallery item objects, rejects on error. */ async function getGalleryItems(url) { const sortValue = getSortOrder(); const sortObj = Object.values(SORT).find(it => it.value === sortValue) ?? SORT.DATE_ASC; const response = await fetch('/api/images/list', { method: 'POST', headers: getRequestHeaders(), body: JSON.stringify({ folder: url, sortField: sortObj.field, sortOrder: sortObj.order, }), }); url = await getSanitizedFilename(url); const data = await response.json(); const items = data.map((file) => ({ src: `user/images/${url}/${file}`, srct: `user/images/${url}/${file}`, title: '', // Optional title for each item })); return items; } /** * Retrieves a list of gallery folders. This function calls an API endpoint * @returns {Promise} - Resolves with an array of gallery folders. */ async function getGalleryFolders() { try { const response = await fetch('/api/images/folders', { method: 'POST', headers: getRequestHeaders(), }); if (!response.ok) { throw new Error(`HTTP error. Status: ${response.status}`); } const data = await response.json(); return data; } catch (error) { console.error('Failed to fetch gallery folders:', error); return []; } } /** * Sets the sort order for the gallery. * @param {string} order Sort order */ function setSortOrder(order) { const context = SillyTavern.getContext(); context.extensionSettings.gallery.sort = order; context.saveSettingsDebounced(); } /** * Retrieves the current sort order for the gallery. * @returns {string} The current sort order for the gallery. */ function getSortOrder() { return SillyTavern.getContext().extensionSettings.gallery.sort ?? defaultSettings.sort; } /** * Initializes a gallery using the provided items and sets up the drag-and-drop functionality. * It uses the nanogallery2 library to display the items and also initializes * event listeners to handle drag-and-drop of files onto the gallery. * * @param {Array} items - An array of objects representing the items to display in the gallery. * @param {string} url - The URL to use when a file is dropped onto the gallery for uploading. * @returns {Promise} - Promise representing the completion of the gallery initialization. */ async function initGallery(items, url) { const nonce = `nonce-${Math.random().toString(36).substring(2, 15)}`; const gallery = $('#dragGallery'); gallery.addClass(nonce); gallery.nanogallery2({ 'items': items, thumbnailWidth: 'auto', thumbnailHeight: thumbnailHeight, paginationVisiblePages: paginationVisiblePages, paginationMaxLinesPerPage: paginationMaxLinesPerPage, galleryMaxRows: galleryMaxRows, galleryPaginationTopButtons: false, galleryNavigationOverlayButtons: true, galleryTheme: { navigationBar: { background: 'none', borderTop: '', borderBottom: '', borderRight: '', borderLeft: '' }, navigationBreadcrumb: { background: '#111', color: '#fff', colorHover: '#ccc', borderRadius: '4px' }, navigationFilter: { color: '#ddd', background: '#111', colorSelected: '#fff', backgroundSelected: '#111', borderRadius: '4px' }, navigationPagination: { background: '#111', color: '#fff', colorHover: '#ccc', borderRadius: '4px' }, thumbnail: { background: '#444', backgroundImage: 'linear-gradient(315deg, #111 0%, #445 90%)', borderColor: '#000', borderRadius: '0px', labelOpacity: 1, labelBackground: 'rgba(34, 34, 34, 0)', titleColor: '#fff', titleBgColor: 'transparent', titleShadow: '', descriptionColor: '#ccc', descriptionBgColor: 'transparent', descriptionShadow: '', stackBackground: '#aaa' }, thumbnailIcon: { padding: '5px', color: '#fff', shadow: '' }, pagination: { background: '#181818', backgroundSelected: '#666', color: '#fff', borderRadius: '2px', shapeBorder: '3px solid var(--SmartThemeQuoteColor)', shapeColor: '#444', shapeSelectedColor: '#aaa' }, }, galleryDisplayMode: 'pagination', fnThumbnailOpen: viewWithDragbox, fnThumbnailInit: function (/** @type {JQuery} */ $thumbnail, /** @type {{src: string}} */ item) { if (!item?.src) return; $thumbnail.attr('title', String(item.src).split('/').pop()); }, }); const dragDropHandler = new DragAndDropHandler(`#dragGallery.${nonce}`, async (files) => { if (!Array.isArray(files) || files.length === 0) { return; } // Upload each file for (const file of files) { await uploadFile(file, url); } // Refresh the gallery const newItems = await getGalleryItems(url); $('#dragGallery').closest('#gallery').remove(); await makeMovable(url); await delay(100); await initGallery(newItems, url); }); const resizeHandler = function () { gallery.nanogallery2('resize'); }; eventSource.on('resizeUI', resizeHandler); eventSource.once(event_types.CHAT_CHANGED, function () { gallery.closest('#gallery').remove(); }); eventSource.once(CUSTOM_GALLERY_REMOVED_EVENT, function () { gallery.nanogallery2('destroy'); dragDropHandler.destroy(); eventSource.removeListener('resizeUI', resizeHandler); }); // Set dropzone height to be the same as the parent gallery.css('height', gallery.parent().css('height')); //let images populate first await delay(100); //unset the height (which must be getting set by the gallery library at some point) gallery.css('height', 'unset'); //force a resize to make images display correctly gallery.nanogallery2('resize'); } /** * Displays a character gallery using the nanogallery2 library. * * This function takes care of: * - Loading necessary resources for the gallery on the first invocation. * - Preparing gallery items based on the character or group selection. * - Handling the drag-and-drop functionality for image upload. * - Displaying the gallery in a popup. * - Cleaning up resources when the gallery popup is closed. * * @returns {Promise} - Promise representing the completion of the gallery display process. */ async function showCharGallery() { // Load necessary files if it's the first time calling the function if (firstTime) { await loadFileToDocument( `${extensionFolderPath}nanogallery2.woff.min.css`, 'css', ); await loadFileToDocument( `${extensionFolderPath}jquery.nanogallery2.min.js`, 'js', ); firstTime = false; toastr.info('Images can also be found in the folder `user/images`', 'Drag and drop images onto the gallery to upload them', { timeOut: 6000 }); } try { let url = selected_group || this_chid; if (!selected_group && this_chid !== undefined) { url = getGalleryFolder(characters[this_chid]); } const items = await getGalleryItems(url); // if there already is a gallery, destroy it and place this one in its place $('#dragGallery').closest('#gallery').remove(); await makeMovable(url); await delay(100); await initGallery(items, url); } catch (err) { console.trace(); console.error(err); } } /** * Uploads a given file to a specified URL. * Once the file is uploaded, it provides a success message using toastr, * destroys the existing gallery, fetches the latest items, and reinitializes the gallery. * * @param {File} file - The file object to be uploaded. * @param {string} url - The URL indicating where the file should be uploaded. * @returns {Promise} - Promise representing the completion of the file upload and gallery refresh. */ async function uploadFile(file, url) { try { // Convert the file to a base64 string const base64Data = await getBase64Async(file); // Create the payload const payload = { image: base64Data, ch_name: url, }; const response = await fetch('/api/images/upload', { method: 'POST', headers: getRequestHeaders(), body: JSON.stringify(payload), }); if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); } const result = await response.json(); toastr.success(t`File uploaded successfully. Saved at: ${result.path}`); } catch (error) { console.error('There was an issue uploading the file:', error); // Replacing alert with toastr error notification toastr.error(t`Failed to upload the file.`); } } /** * Creates a new draggable container based on a template. * This function takes a template with the ID 'generic_draggable_template' and clones it. * The cloned element has its attributes set, a new child div appended, and is made visible on the body. * Additionally, it sets up the element to prevent dragging on its images. * @param {string} url - The URL of the image source. * @returns {Promise} - Promise representing the completion of the draggable container creation. */ async function makeMovable(url) { console.debug('making new container from template'); const id = 'gallery'; const template = $('#generic_draggable_template').html(); const newElement = $(template); newElement.css('background-color', 'var(--SmartThemeBlurTintColor)'); newElement.attr('forChar', id); newElement.attr('id', id); newElement.find('.drag-grabber').attr('id', `${id}header`); const dragTitle = newElement.find('.dragTitle'); dragTitle.addClass('flex-container justifySpaceBetween alignItemsBaseline'); const titleText = document.createElement('span'); titleText.textContent = t`Image Gallery`; dragTitle.append(titleText); const sortSelect = document.createElement('select'); sortSelect.classList.add('gallery-sort-select'); for (const sort of Object.values(SORT)) { const option = document.createElement('option'); option.value = sort.value; option.textContent = sort.label; sortSelect.appendChild(option); } sortSelect.addEventListener('change', async () => { const selectedOption = sortSelect.options[sortSelect.selectedIndex].value; setSortOrder(selectedOption); closeButton.trigger('click'); await showCharGallery(); }); sortSelect.value = getSortOrder(); dragTitle.append(sortSelect); // add no-scrollbar class to this element newElement.addClass('no-scrollbar'); // get the close button and set its id and data-related-id const closeButton = newElement.find('.dragClose'); closeButton.attr('id', `${id}close`); closeButton.attr('data-related-id', `${id}`); const topBarElement = document.createElement('div'); topBarElement.classList.add('flex-container', 'alignItemsCenter'); const onChangeFolder = async (/** @type {Event} */ e) => { if (e instanceof KeyboardEvent && e.key !== 'Enter') { return; } try { const newUrl = await getSanitizedFilename(galleryFolderInput.value); updateGalleryFolder(newUrl); closeButton.trigger('click'); await showCharGallery(); toastr.info(t`Gallery folder changed to ${newUrl}`); galleryFolderInput.value = newUrl; } catch (error) { console.error('Failed to change gallery folder:', error); toastr.error(error?.message || t`Unknown error`, t`Failed to change gallery folder`); } }; const onRestoreFolder = async () => { try { restoreGalleryFolder(); closeButton.trigger('click'); await showCharGallery(); } catch (error) { console.error('Failed to restore gallery folder:', error); toastr.error(error?.message || t`Unknown error`, t`Failed to restore gallery folder`); } }; const galleryFolderInput = document.createElement('input'); galleryFolderInput.type = 'text'; galleryFolderInput.placeholder = t`Folder Name`; galleryFolderInput.title = t`Enter a folder name to change the gallery folder`; galleryFolderInput.value = url; galleryFolderInput.classList.add('text_pole', 'gallery-folder-input', 'flex1'); galleryFolderInput.addEventListener('keyup', onChangeFolder); const galleryFolderAccept = document.createElement('div'); galleryFolderAccept.classList.add('right_menu_button', 'fa-solid', 'fa-check', 'fa-fw'); galleryFolderAccept.title = t`Change gallery folder`; galleryFolderAccept.addEventListener('click', onChangeFolder); const galleryFolderRestore = document.createElement('div'); galleryFolderRestore.classList.add('right_menu_button', 'fa-solid', 'fa-recycle', 'fa-fw'); galleryFolderRestore.title = t`Restore gallery folder`; galleryFolderRestore.addEventListener('click', onRestoreFolder); topBarElement.appendChild(galleryFolderInput); topBarElement.appendChild(galleryFolderAccept); topBarElement.appendChild(galleryFolderRestore); newElement.append(topBarElement); // Populate the gallery folder input with a list of available folders const folders = await getGalleryFolders(); $(galleryFolderInput) .autocomplete({ source: (i, o) => { const term = i.term.toLowerCase(); const filtered = folders.filter(f => f.toLowerCase().includes(term)); o(filtered); }, select: (e, u) => { galleryFolderInput.value = u.item.value; onChangeFolder(e); }, minLength: 0, }) .on('focus', () => $(galleryFolderInput).autocomplete('search', '')); //add a div for the gallery newElement.append('
'); $('#dragGallery').css('display', 'block'); $('#movingDivs').append(newElement); loadMovingUIState(); $(`.draggable[forChar="${id}"]`).css('display', 'block'); dragElement(newElement); $(`.draggable[forChar="${id}"] img`).on('dragstart', (e) => { console.log('saw drag on avatar!'); e.preventDefault(); return false; }); } /** * Sets the gallery folder to a new URL. * @param {string} newUrl - The new URL to set for the gallery folder. */ function updateGalleryFolder(newUrl) { if (!newUrl) { throw new Error('Folder name cannot be empty'); } const context = SillyTavern.getContext(); if (context.groupId) { throw new Error('Cannot change gallery folder in group chat'); } if (context.characterId === undefined) { throw new Error('Character is not selected'); } const avatar = context.characters[context.characterId]?.avatar; const name = context.characters[context.characterId]?.name; if (!avatar) { throw new Error('Character PNG ID is not found'); } if (newUrl === name) { // Default folder name is picked, remove the override delete context.extensionSettings.gallery.folders[avatar]; } else { // Custom folder name is provided, set the override context.extensionSettings.gallery.folders[avatar] = newUrl; } context.saveSettingsDebounced(); } /** * Restores the gallery folder to the default value. */ function restoreGalleryFolder() { const context = SillyTavern.getContext(); if (context.groupId) { throw new Error('Cannot change gallery folder in group chat'); } if (context.characterId === undefined) { throw new Error('Character is not selected'); } const avatar = context.characters[context.characterId]?.avatar; if (!avatar) { throw new Error('Character PNG ID is not found'); } const existingOverride = context.extensionSettings.gallery.folders[avatar]; if (!existingOverride) { throw new Error('No folder override found'); } delete context.extensionSettings.gallery.folders[avatar]; context.saveSettingsDebounced(); } /** * Creates a new draggable image based on a template. * * This function clones a provided template with the ID 'generic_draggable_template', * appends the given image URL, ensures the element has a unique ID, * and attaches the element to the body. After appending, it also prevents * dragging on the appended image. * * @param {string} id - A base identifier for the new draggable element. * @param {string} url - The URL of the image to be added to the draggable element. */ function makeDragImg(id, url) { // Step 1: Clone the template content const template = document.getElementById('generic_draggable_template'); if (!(template instanceof HTMLTemplateElement)) { console.error('The element is not a