| 1 | import { |
| 2 | eventSource, |
| 3 | this_chid, |
| 4 | characters, |
| 5 | getRequestHeaders, |
| 6 | event_types, |
| 7 | animation_duration, |
| 8 | animation_easing, |
| 9 | } from '../../../script.js'; |
| 10 | import { groups, selected_group } from '../../group-chats.js'; |
| 11 | import { loadFileToDocument, delay, getBase64Async, getSanitizedFilename, saveBase64AsFile, getFileExtension, getVideoThumbnail, clamp } from '../../utils.js'; |
| 12 | import { loadMovingUIState } from '../../power-user.js'; |
| 13 | import { dragElement } from '../../RossAscends-mods.js'; |
| 14 | import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; |
| 15 | import { SlashCommand } from '../../slash-commands/SlashCommand.js'; |
| 16 | import { ARGUMENT_TYPE, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js'; |
| 17 | import { DragAndDropHandler } from '../../dragdrop.js'; |
| 18 | import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 19 | import { t, translate } from '../../i18n.js'; |
| 20 | import { Popup } from '../../popup.js'; |
| 21 | import { deleteMediaFromServer } from '../../chats.js'; |
| 22 | import { MEDIA_REQUEST_TYPE, VIDEO_EXTENSIONS } from '../../constants.js'; |
| 23 | |
| 24 | const isVideo = (/** @type {string} */ url) => VIDEO_EXTENSIONS.some(ext => new RegExp(`.${ext}$`, 'i').test(url)); |
| 25 | const extensionName = 'gallery'; |
| 26 | const extensionFolderPath = `scripts/extensions/${extensionName}/`; |
| 27 | let firstTime = true; |
| 28 | let deleteModeActive = false; |
| 29 | |
| 30 | |
| 31 | // Remove all draggables associated with the gallery |
| 32 | $('#movingDivs').on('click', '.dragClose', function () { |
| 33 | const relatedId = $(this).data('related-id'); |
| 34 | if (!relatedId) return; |
| 35 | const relatedElement = $(`#movingDivs > .draggable[id="${relatedId}"]`); |
| 36 | relatedElement.transition({ |
| 37 | opacity: 0, |
| 38 | duration: animation_duration, |
| 39 | easing: animation_easing, |
| 40 | complete: () => { |
| 41 | relatedElement.remove(); |
| 42 | }, |
| 43 | }); |
| 44 | }); |
| 45 | |
| 46 | const CUSTOM_GALLERY_REMOVED_EVENT = 'galleryRemoved'; |
| 47 | |
| 48 | const mutationObserver = new MutationObserver((mutations) => { |
| 49 | mutations.forEach((mutation) => { |
| 50 | mutation.removedNodes.forEach((node) => { |
| 51 | if (node instanceof HTMLElement && node.tagName === 'DIV' && node.id === 'gallery') { |
| 52 | eventSource.emit(CUSTOM_GALLERY_REMOVED_EVENT); |
| 53 | } |
| 54 | }); |
| 55 | }); |
| 56 | }); |
| 57 | |
| 58 | mutationObserver.observe(document.body, { |
| 59 | childList: true, |
| 60 | subtree: false, |
| 61 | }); |
| 62 | |
| 63 | const SORT = Object.freeze({ |
| 64 | NAME_ASC: { value: 'nameAsc', field: 'name', order: 'asc', label: t`Name (A-Z)` }, |
| 65 | NAME_DESC: { value: 'nameDesc', field: 'name', order: 'desc', label: t`Name (Z-A)` }, |
| 66 | DATE_DESC: { value: 'dateDesc', field: 'date', order: 'desc', label: t`Newest` }, |
| 67 | DATE_ASC: { value: 'dateAsc', field: 'date', order: 'asc', label: t`Oldest` }, |
| 68 | }); |
| 69 | |
| 70 | const defaultSettings = Object.freeze({ |
| 71 | folders: {}, |
| 72 | sort: SORT.DATE_ASC.value, |
| 73 | }); |
| 74 | |
| 75 | /** |
| 76 | * Initializes the settings for the gallery extension. |
| 77 | */ |
| 78 | function initSettings() { |
| 79 | let shouldSave = false; |
| 80 | const context = SillyTavern.getContext(); |
| 81 | if (!context.extensionSettings.gallery) { |
| 82 | context.extensionSettings.gallery = structuredClone(defaultSettings); |
| 83 | shouldSave = true; |
| 84 | } |
| 85 | for (const key of Object.keys(defaultSettings)) { |
| 86 | if (!Object.hasOwn(context.extensionSettings.gallery, key)) { |
| 87 | context.extensionSettings.gallery[key] = structuredClone(defaultSettings[key]); |
| 88 | shouldSave = true; |
| 89 | } |
| 90 | } |
| 91 | if (shouldSave) { |
| 92 | context.saveSettingsDebounced(); |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | /** |
| 97 | * Retrieves the gallery folder for a given character. |
| 98 | * @param {Character} char Character data |
| 99 | * @returns {string} The gallery folder for the character |
| 100 | */ |
| 101 | function getGalleryFolder(char) { |
| 102 | return SillyTavern.getContext().extensionSettings.gallery.folders[char?.avatar] ?? char?.name; |
| 103 | } |
| 104 | |
| 105 | /** |
| 106 | * Retrieves a list of gallery items based on a given URL. This function calls an API endpoint |
| 107 | * to get the filenames and then constructs the item list. |
| 108 | * |
| 109 | * @param {string} url - The base URL to retrieve the list of images. |
| 110 | * @returns {Promise<Array>} - Resolves with an array of gallery item objects, rejects on error. |
| 111 | */ |
| 112 | async function getGalleryItems(url) { |
| 113 | const sortValue = getSortOrder(); |
| 114 | const sortObj = Object.values(SORT).find(it => it.value === sortValue) ?? SORT.DATE_ASC; |
| 115 | const response = await fetch('/api/images/list', { |
| 116 | method: 'POST', |
| 117 | headers: getRequestHeaders(), |
| 118 | body: JSON.stringify({ |
| 119 | folder: url, |
| 120 | sortField: sortObj.field, |
| 121 | sortOrder: sortObj.order, |
| 122 | type: MEDIA_REQUEST_TYPE.IMAGE | MEDIA_REQUEST_TYPE.VIDEO, |
| 123 | }), |
| 124 | }); |
| 125 | |
| 126 | url = await getSanitizedFilename(url); |
| 127 | |
| 128 | const data = await response.json(); |
| 129 | const items = []; |
| 130 | |
| 131 | for (const file of data) { |
| 132 | const item = { |
| 133 | src: `user/images/${url}/${file}`, |
| 134 | srct: `user/images/${url}/${file}`, |
| 135 | title: '', // Optional title for each item |
| 136 | }; |
| 137 | |
| 138 | if (isVideo(file)) { |
| 139 | try { |
| 140 | // 150px of max height with some allowance for various aspect ratios |
| 141 | const maxSide = Math.round(150 * 1.5); |
| 142 | item.srct = await getVideoThumbnail(item.src, maxSide, maxSide); |
| 143 | } catch (error) { |
| 144 | console.error('Failed to generate video thumbnail for gallery:', error); |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | items.push(item); |
| 149 | } |
| 150 | |
| 151 | return items; |
| 152 | } |
| 153 | |
| 154 | /** |
| 155 | * Retrieves a list of gallery folders. This function calls an API endpoint |
| 156 | * @returns {Promise<string[]>} - Resolves with an array of gallery folders. |
| 157 | */ |
| 158 | async function getGalleryFolders() { |
| 159 | try { |
| 160 | const response = await fetch('/api/images/folders', { |
| 161 | method: 'POST', |
| 162 | headers: getRequestHeaders({ omitContentType: true }), |
| 163 | }); |
| 164 | |
| 165 | if (!response.ok) { |
| 166 | throw new Error(`HTTP error. Status: ${response.status}`); |
| 167 | } |
| 168 | const data = await response.json(); |
| 169 | return data; |
| 170 | } catch (error) { |
| 171 | console.error('Failed to fetch gallery folders:', error); |
| 172 | return []; |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | /** |
| 177 | * Deletes a gallery item based on the provided URL. |
| 178 | * @param {string} url - The URL of the image to be deleted. |
| 179 | */ |
| 180 | async function deleteGalleryItem(url) { |
| 181 | const isDeleted = await deleteMediaFromServer(url, false); |
| 182 | if (isDeleted) { |
| 183 | toastr.success(t`Image deleted successfully.`); |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | /** |
| 188 | * Sets the sort order for the gallery. |
| 189 | * @param {string} order Sort order |
| 190 | */ |
| 191 | function setSortOrder(order) { |
| 192 | const context = SillyTavern.getContext(); |
| 193 | context.extensionSettings.gallery.sort = order; |
| 194 | context.saveSettingsDebounced(); |
| 195 | } |
| 196 | |
| 197 | /** |
| 198 | * Retrieves the current sort order for the gallery. |
| 199 | * @returns {string} The current sort order for the gallery. |
| 200 | */ |
| 201 | function getSortOrder() { |
| 202 | return SillyTavern.getContext().extensionSettings.gallery.sort ?? defaultSettings.sort; |
| 203 | } |
| 204 | |
| 205 | /** |
| 206 | * Initializes a gallery using the provided items and sets up the drag-and-drop functionality. |
| 207 | * It uses the nanogallery2 library to display the items and also initializes |
| 208 | * event listeners to handle drag-and-drop of files onto the gallery. |
| 209 | * |
| 210 | * @param {Array<Object>} items - An array of objects representing the items to display in the gallery. |
| 211 | * @param {string} url - The URL to use when a file is dropped onto the gallery for uploading. |
| 212 | * @returns {Promise<void>} - Promise representing the completion of the gallery initialization. |
| 213 | */ |
| 214 | async function initGallery(items, url) { |
| 215 | // Exposed defaults for future tweaking |
| 216 | const thumbnailHeight = 150; |
| 217 | const paginationVisiblePages = 5; |
| 218 | const paginationMaxLinesPerPage = 2; |
| 219 | const galleryMaxRows = clamp(Math.floor((window.innerHeight * 0.9 - 75) / thumbnailHeight), 1, 10); |
| 220 | |
| 221 | const nonce = `nonce-${Math.random().toString(36).substring(2, 15)}`; |
| 222 | const gallery = $('#dragGallery'); |
| 223 | gallery.addClass(nonce); |
| 224 | gallery.nanogallery2({ |
| 225 | 'items': items, |
| 226 | thumbnailWidth: 'auto', |
| 227 | thumbnailHeight: thumbnailHeight, |
| 228 | paginationVisiblePages: paginationVisiblePages, |
| 229 | paginationMaxLinesPerPage: paginationMaxLinesPerPage, |
| 230 | galleryMaxRows: galleryMaxRows, |
| 231 | galleryPaginationTopButtons: false, |
| 232 | galleryNavigationOverlayButtons: true, |
| 233 | galleryPaginationMode: 'rectangles', |
| 234 | galleryTheme: { |
| 235 | navigationBar: { background: 'none', borderTop: '', borderBottom: '', borderRight: '', borderLeft: '' }, |
| 236 | navigationBreadcrumb: { background: '#111', color: '#fff', colorHover: '#ccc', borderRadius: '4px' }, |
| 237 | navigationFilter: { color: '#ddd', background: '#111', colorSelected: '#fff', backgroundSelected: '#111', borderRadius: '4px' }, |
| 238 | navigationPagination: { background: '#111', color: '#fff', colorHover: '#ccc', borderRadius: '4px' }, |
| 239 | 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' }, |
| 240 | thumbnailIcon: { padding: '5px', color: '#fff', shadow: '' }, |
| 241 | pagination: { background: '#181818', backgroundSelected: '#666', color: '#fff', borderRadius: '2px', shapeBorder: '3px solid var(--SmartThemeQuoteColor)', shapeColor: '#444', shapeSelectedColor: '#aaa' }, |
| 242 | }, |
| 243 | galleryDisplayMode: 'pagination', |
| 244 | fnThumbnailOpen: viewWithDragbox, |
| 245 | fnThumbnailInit: function (/** @type {JQuery<HTMLElement>} */ $thumbnail, /** @type {{src: string}} */ item) { |
| 246 | if (!item?.src) return; |
| 247 | $thumbnail.attr('title', String(item.src).split('/').pop()); |
| 248 | }, |
| 249 | }); |
| 250 | |
| 251 | const dragDropHandler = new DragAndDropHandler(`#dragGallery.${nonce}`, async (files) => { |
| 252 | if (!Array.isArray(files) || files.length === 0) { |
| 253 | return; |
| 254 | } |
| 255 | |
| 256 | // Upload each file |
| 257 | for (const file of files) { |
| 258 | await uploadFile(file, url); |
| 259 | } |
| 260 | |
| 261 | // Refresh the gallery |
| 262 | const newItems = await getGalleryItems(url); |
| 263 | $('#dragGallery').closest('#gallery').remove(); |
| 264 | await makeMovable(url); |
| 265 | await delay(100); |
| 266 | await initGallery(newItems, url); |
| 267 | }); |
| 268 | |
| 269 | const resizeHandler = function () { |
| 270 | gallery.nanogallery2('resize'); |
| 271 | }; |
| 272 | |
| 273 | eventSource.on('resizeUI', resizeHandler); |
| 274 | |
| 275 | eventSource.once(event_types.CHAT_CHANGED, function () { |
| 276 | gallery.closest('#gallery').remove(); |
| 277 | }); |
| 278 | |
| 279 | eventSource.once(CUSTOM_GALLERY_REMOVED_EVENT, function () { |
| 280 | gallery.nanogallery2('destroy'); |
| 281 | dragDropHandler.destroy(); |
| 282 | eventSource.removeListener('resizeUI', resizeHandler); |
| 283 | }); |
| 284 | |
| 285 | // Set dropzone height to be the same as the parent |
| 286 | gallery.css('height', gallery.parent().css('height')); |
| 287 | |
| 288 | //let images populate first |
| 289 | await delay(100); |
| 290 | //unset the height (which must be getting set by the gallery library at some point) |
| 291 | gallery.css('height', 'unset'); |
| 292 | //force a resize to make images display correctly |
| 293 | gallery.nanogallery2('resize'); |
| 294 | } |
| 295 | |
| 296 | /** |
| 297 | * Displays a character gallery using the nanogallery2 library. |
| 298 | * |
| 299 | * This function takes care of: |
| 300 | * - Loading necessary resources for the gallery on the first invocation. |
| 301 | * - Preparing gallery items based on the character or group selection. |
| 302 | * - Handling the drag-and-drop functionality for image upload. |
| 303 | * - Displaying the gallery in a popup. |
| 304 | * - Cleaning up resources when the gallery popup is closed. |
| 305 | * |
| 306 | * @returns {Promise<void>} - Promise representing the completion of the gallery display process. |
| 307 | */ |
| 308 | async function showCharGallery(deleteModeState = false) { |
| 309 | // Load necessary files if it's the first time calling the function |
| 310 | if (firstTime) { |
| 311 | await loadFileToDocument( |
| 312 | `${extensionFolderPath}nanogallery2.woff.min.css`, |
| 313 | 'css', |
| 314 | ); |
| 315 | await loadFileToDocument( |
| 316 | `${extensionFolderPath}jquery.nanogallery2.min.js`, |
| 317 | 'js', |
| 318 | ); |
| 319 | firstTime = false; |
| 320 | toastr.info('Images can also be found in the folder `user/images`', 'Drag and drop images onto the gallery to upload them', { timeOut: 6000 }); |
| 321 | } |
| 322 | |
| 323 | try { |
| 324 | deleteModeActive = deleteModeState; |
| 325 | let url = selected_group || this_chid; |
| 326 | if (!selected_group && this_chid !== undefined) { |
| 327 | url = getGalleryFolder(characters[this_chid]); |
| 328 | } |
| 329 | |
| 330 | const items = await getGalleryItems(url); |
| 331 | // if there already is a gallery, destroy it and place this one in its place |
| 332 | $('#dragGallery').closest('#gallery').remove(); |
| 333 | await makeMovable(url); |
| 334 | await delay(100); |
| 335 | await initGallery(items, url); |
| 336 | } catch (err) { |
| 337 | console.error(err); |
| 338 | } |
| 339 | } |
| 340 | |
| 341 | /** |
| 342 | * Uploads a given file to a specified URL. |
| 343 | * Once the file is uploaded, it provides a success message using toastr, |
| 344 | * destroys the existing gallery, fetches the latest items, and reinitializes the gallery. |
| 345 | * |
| 346 | * @param {File} file - The file object to be uploaded. |
| 347 | * @param {string} url - The URL indicating where the file should be uploaded. |
| 348 | * @returns {Promise<void>} - Promise representing the completion of the file upload and gallery refresh. |
| 349 | */ |
| 350 | async function uploadFile(file, url) { |
| 351 | try { |
| 352 | // Convert the file to a base64 string |
| 353 | const fileBase64 = await getBase64Async(file); |
| 354 | const base64Data = fileBase64.split(',')[1]; |
| 355 | const extension = getFileExtension(file); |
| 356 | const path = await saveBase64AsFile(base64Data, url, '', extension); |
| 357 | |
| 358 | toastr.success(t`File uploaded successfully. Saved at: ${path}`); |
| 359 | } catch (error) { |
| 360 | console.error('There was an issue uploading the file:', error); |
| 361 | |
| 362 | // Replacing alert with toastr error notification |
| 363 | toastr.error(t`Failed to upload the file.`); |
| 364 | } |
| 365 | } |
| 366 | |
| 367 | /** |
| 368 | * Creates a new draggable container based on a template. |
| 369 | * This function takes a template with the ID 'generic_draggable_template' and clones it. |
| 370 | * The cloned element has its attributes set, a new child div appended, and is made visible on the body. |
| 371 | * Additionally, it sets up the element to prevent dragging on its images. |
| 372 | * @param {string} url - The URL of the image source. |
| 373 | * @returns {Promise<void>} - Promise representing the completion of the draggable container creation. |
| 374 | */ |
| 375 | async function makeMovable(url) { |
| 376 | console.debug('making new container from template'); |
| 377 | const id = 'gallery'; |
| 378 | const template = $('#generic_draggable_template').html(); |
| 379 | const newElement = $(template); |
| 380 | newElement.css({ 'background-color': 'var(--SmartThemeBlurTintColor)', 'opacity': 0 }); |
| 381 | newElement.attr('forChar', id); |
| 382 | newElement.attr('id', id); |
| 383 | newElement.find('.drag-grabber').attr('id', `${id}header`); |
| 384 | const dragTitle = newElement.find('.dragTitle'); |
| 385 | dragTitle.addClass('flex-container justifySpaceBetween alignItemsBaseline'); |
| 386 | const titleText = document.createElement('span'); |
| 387 | titleText.textContent = t`Image Gallery`; |
| 388 | dragTitle.append(titleText); |
| 389 | |
| 390 | // Create a container for the controls |
| 391 | const controlsContainer = document.createElement('div'); |
| 392 | controlsContainer.classList.add('flex-container', 'alignItemsCenter'); |
| 393 | |
| 394 | const sortSelect = document.createElement('select'); |
| 395 | sortSelect.classList.add('gallery-sort-select'); |
| 396 | |
| 397 | for (const sort of Object.values(SORT)) { |
| 398 | const option = document.createElement('option'); |
| 399 | option.value = sort.value; |
| 400 | option.textContent = sort.label; |
| 401 | sortSelect.appendChild(option); |
| 402 | } |
| 403 | |
| 404 | sortSelect.addEventListener('change', async () => { |
| 405 | const selectedOption = sortSelect.options[sortSelect.selectedIndex].value; |
| 406 | setSortOrder(selectedOption); |
| 407 | closeButton.trigger('click'); |
| 408 | await showCharGallery(); |
| 409 | }); |
| 410 | |
| 411 | sortSelect.value = getSortOrder(); |
| 412 | controlsContainer.appendChild(sortSelect); |
| 413 | |
| 414 | // Create the "Add Image" button |
| 415 | const addImageButton = document.createElement('div'); |
| 416 | addImageButton.classList.add('menu_button', 'menu_button_icon', 'interactable'); |
| 417 | addImageButton.title = 'Add Image'; |
| 418 | addImageButton.innerHTML = '<i class="fa-solid fa-plus fa-fw"></i><div>Add Image</div>'; |
| 419 | |
| 420 | // Create a hidden file input |
| 421 | const fileInput = document.createElement('input'); |
| 422 | fileInput.type = 'file'; |
| 423 | fileInput.accept = 'image/*,video/*'; |
| 424 | fileInput.multiple = true; |
| 425 | fileInput.style.display = 'none'; |
| 426 | |
| 427 | // Trigger file input when the button is clicked |
| 428 | addImageButton.addEventListener('click', () => { |
| 429 | fileInput.click(); |
| 430 | }); |
| 431 | |
| 432 | // Handle file selection |
| 433 | fileInput.addEventListener('change', async () => { |
| 434 | const files = fileInput.files; |
| 435 | if (files.length > 0) { |
| 436 | for (const file of files) { |
| 437 | await uploadFile(file, url); |
| 438 | } |
| 439 | // Refresh the gallery |
| 440 | closeButton.trigger('click'); |
| 441 | await showCharGallery(); |
| 442 | } |
| 443 | }); |
| 444 | |
| 445 | controlsContainer.appendChild(addImageButton); |
| 446 | dragTitle.append(controlsContainer); |
| 447 | newElement.append(fileInput); // Append hidden file input to the main element |
| 448 | |
| 449 | // add no-scrollbar class to this element |
| 450 | newElement.addClass('no-scrollbar'); |
| 451 | |
| 452 | // get the close button and set its id and data-related-id |
| 453 | const closeButton = newElement.find('.dragClose'); |
| 454 | closeButton.attr('id', `${id}close`); |
| 455 | closeButton.attr('data-related-id', `${id}`); |
| 456 | |
| 457 | const topBarElement = document.createElement('div'); |
| 458 | topBarElement.classList.add('flex-container', 'alignItemsCenter'); |
| 459 | |
| 460 | const onChangeFolder = async (/** @type {Event} */ e) => { |
| 461 | if (e instanceof KeyboardEvent && e.key !== 'Enter') { |
| 462 | return; |
| 463 | } |
| 464 | |
| 465 | try { |
| 466 | const newUrl = await getSanitizedFilename(galleryFolderInput.value); |
| 467 | updateGalleryFolder(newUrl); |
| 468 | closeButton.trigger('click'); |
| 469 | await showCharGallery(); |
| 470 | toastr.info(t`Gallery folder changed to ${newUrl}`); |
| 471 | galleryFolderInput.value = newUrl; |
| 472 | } catch (error) { |
| 473 | console.error('Failed to change gallery folder:', error); |
| 474 | toastr.error(error?.message || t`Unknown error`, t`Failed to change gallery folder`); |
| 475 | } |
| 476 | }; |
| 477 | |
| 478 | const onRestoreFolder = async () => { |
| 479 | try { |
| 480 | restoreGalleryFolder(); |
| 481 | closeButton.trigger('click'); |
| 482 | await showCharGallery(); |
| 483 | } catch (error) { |
| 484 | console.error('Failed to restore gallery folder:', error); |
| 485 | toastr.error(error?.message || t`Unknown error`, t`Failed to restore gallery folder`); |
| 486 | } |
| 487 | }; |
| 488 | |
| 489 | const galleryFolderInput = document.createElement('input'); |
| 490 | galleryFolderInput.type = 'text'; |
| 491 | galleryFolderInput.placeholder = t`Folder Name`; |
| 492 | galleryFolderInput.title = t`Enter a folder name to change the gallery folder`; |
| 493 | galleryFolderInput.value = url; |
| 494 | galleryFolderInput.classList.add('text_pole', 'gallery-folder-input', 'flex1'); |
| 495 | galleryFolderInput.addEventListener('keyup', onChangeFolder); |
| 496 | |
| 497 | const galleryFolderAccept = document.createElement('div'); |
| 498 | galleryFolderAccept.classList.add('right_menu_button', 'fa-solid', 'fa-check', 'fa-fw'); |
| 499 | galleryFolderAccept.title = t`Change gallery folder`; |
| 500 | galleryFolderAccept.addEventListener('click', onChangeFolder); |
| 501 | |
| 502 | const galleryDeleteMode = document.createElement('div'); |
| 503 | galleryDeleteMode.classList.add('right_menu_button', 'fa-solid', 'fa-trash', 'fa-fw'); |
| 504 | galleryDeleteMode.classList.toggle('warning', deleteModeActive); |
| 505 | galleryDeleteMode.title = t`Delete mode`; |
| 506 | galleryDeleteMode.addEventListener('click', () => { |
| 507 | deleteModeActive = !deleteModeActive; |
| 508 | galleryDeleteMode.classList.toggle('warning', deleteModeActive); |
| 509 | if (deleteModeActive) { |
| 510 | toastr.info(t`Delete mode is ON. Click on images you want to delete.`); |
| 511 | } |
| 512 | }); |
| 513 | |
| 514 | const galleryFolderRestore = document.createElement('div'); |
| 515 | galleryFolderRestore.classList.add('right_menu_button', 'fa-solid', 'fa-recycle', 'fa-fw'); |
| 516 | galleryFolderRestore.title = t`Restore gallery folder`; |
| 517 | galleryFolderRestore.addEventListener('click', onRestoreFolder); |
| 518 | |
| 519 | topBarElement.appendChild(galleryFolderInput); |
| 520 | topBarElement.appendChild(galleryFolderAccept); |
| 521 | topBarElement.appendChild(galleryDeleteMode); |
| 522 | topBarElement.appendChild(galleryFolderRestore); |
| 523 | newElement.append(topBarElement); |
| 524 | |
| 525 | // Populate the gallery folder input with a list of available folders |
| 526 | const folders = await getGalleryFolders(); |
| 527 | $(galleryFolderInput) |
| 528 | .autocomplete({ |
| 529 | source: (i, o) => { |
| 530 | const term = i.term.toLowerCase(); |
| 531 | const filtered = folders.filter(f => f.toLowerCase().includes(term)); |
| 532 | o(filtered); |
| 533 | }, |
| 534 | select: (e, u) => { |
| 535 | galleryFolderInput.value = u.item.value; |
| 536 | onChangeFolder(e); |
| 537 | }, |
| 538 | minLength: 0, |
| 539 | }) |
| 540 | .on('focus', () => $(galleryFolderInput).autocomplete('search', '')); |
| 541 | |
| 542 | //add a div for the gallery |
| 543 | newElement.append('<div id="dragGallery"></div>'); |
| 544 | |
| 545 | $('#dragGallery').css('display', 'block'); |
| 546 | |
| 547 | $('#movingDivs').append(newElement); |
| 548 | |
| 549 | loadMovingUIState(); |
| 550 | $(`.draggable[forChar="${id}"]`).css('display', 'block'); |
| 551 | dragElement(newElement); |
| 552 | newElement.transition({ |
| 553 | opacity: 1, |
| 554 | duration: animation_duration, |
| 555 | easing: animation_easing, |
| 556 | }); |
| 557 | |
| 558 | $(`.draggable[forChar="${id}"] img`).on('dragstart', (e) => { |
| 559 | console.log('saw drag on avatar!'); |
| 560 | e.preventDefault(); |
| 561 | return false; |
| 562 | }); |
| 563 | } |
| 564 | |
| 565 | /** |
| 566 | * Sets the gallery folder to a new URL. |
| 567 | * @param {string} newUrl - The new URL to set for the gallery folder. |
| 568 | */ |
| 569 | function updateGalleryFolder(newUrl) { |
| 570 | if (!newUrl) { |
| 571 | throw new Error('Folder name cannot be empty'); |
| 572 | } |
| 573 | const context = SillyTavern.getContext(); |
| 574 | if (context.groupId) { |
| 575 | throw new Error('Cannot change gallery folder in group chat'); |
| 576 | } |
| 577 | if (context.characterId === undefined) { |
| 578 | throw new Error('Character is not selected'); |
| 579 | } |
| 580 | const avatar = context.characters[context.characterId]?.avatar; |
| 581 | const name = context.characters[context.characterId]?.name; |
| 582 | if (!avatar) { |
| 583 | throw new Error('Character PNG ID is not found'); |
| 584 | } |
| 585 | if (newUrl === name) { |
| 586 | // Default folder name is picked, remove the override |
| 587 | delete context.extensionSettings.gallery.folders[avatar]; |
| 588 | } else { |
| 589 | // Custom folder name is provided, set the override |
| 590 | context.extensionSettings.gallery.folders[avatar] = newUrl; |
| 591 | } |
| 592 | context.saveSettingsDebounced(); |
| 593 | } |
| 594 | |
| 595 | /** |
| 596 | * Restores the gallery folder to the default value. |
| 597 | */ |
| 598 | function restoreGalleryFolder() { |
| 599 | const context = SillyTavern.getContext(); |
| 600 | if (context.groupId) { |
| 601 | throw new Error('Cannot change gallery folder in group chat'); |
| 602 | } |
| 603 | if (context.characterId === undefined) { |
| 604 | throw new Error('Character is not selected'); |
| 605 | } |
| 606 | const avatar = context.characters[context.characterId]?.avatar; |
| 607 | if (!avatar) { |
| 608 | throw new Error('Character PNG ID is not found'); |
| 609 | } |
| 610 | const existingOverride = context.extensionSettings.gallery.folders[avatar]; |
| 611 | if (!existingOverride) { |
| 612 | throw new Error('No folder override found'); |
| 613 | } |
| 614 | delete context.extensionSettings.gallery.folders[avatar]; |
| 615 | context.saveSettingsDebounced(); |
| 616 | } |
| 617 | |
| 618 | /** |
| 619 | * Creates a new draggable image based on a template. |
| 620 | * |
| 621 | * This function clones a provided template with the ID 'generic_draggable_template', |
| 622 | * appends the given image URL, ensures the element has a unique ID, |
| 623 | * and attaches the element to the body. After appending, it also prevents |
| 624 | * dragging on the appended image. |
| 625 | * |
| 626 | * @param {string} id - A base identifier for the new draggable element. |
| 627 | * @param {string} url - The URL of the image to be added to the draggable element. |
| 628 | */ |
| 629 | function makeDragImg(id, url) { |
| 630 | // Step 1: Clone the template content |
| 631 | const template = document.getElementById('generic_draggable_template'); |
| 632 | |
| 633 | if (!(template instanceof HTMLTemplateElement)) { |
| 634 | console.error('The element is not a <template> tag'); |
| 635 | return; |
| 636 | } |
| 637 | |
| 638 | const newElement = document.importNode(template.content, true); |
| 639 | |
| 640 | // Step 2: Append the given image |
| 641 | const mediaElement = isVideo(url) |
| 642 | ? document.createElement('video') |
| 643 | : document.createElement('img'); |
| 644 | mediaElement.src = url; |
| 645 | if (mediaElement instanceof HTMLVideoElement) { |
| 646 | mediaElement.controls = true; |
| 647 | mediaElement.autoplay = true; |
| 648 | } |
| 649 | |
| 650 | let uniqueId = `draggable_${id}`; |
| 651 | const draggableElem = /** @type {HTMLElement} */ (newElement.querySelector('.draggable')); |
| 652 | if (draggableElem) { |
| 653 | draggableElem.appendChild(mediaElement); |
| 654 | |
| 655 | // Find a unique id for the draggable element |
| 656 | |
| 657 | let counter = 1; |
| 658 | while (document.getElementById(uniqueId)) { |
| 659 | uniqueId = `draggable_${id}_${counter}`; |
| 660 | counter++; |
| 661 | } |
| 662 | draggableElem.id = uniqueId; |
| 663 | |
| 664 | // Add the galleryImageDraggable to have unique class |
| 665 | draggableElem.classList.add('galleryImageDraggable'); |
| 666 | |
| 667 | // Ensure that the newly added element is displayed as block |
| 668 | draggableElem.style.display = 'block'; |
| 669 | //and has no padding unlike other non-zoomed-avatar draggables |
| 670 | draggableElem.style.padding = '0'; |
| 671 | |
| 672 | // Add an id to the close button |
| 673 | // If the close button exists, set related-id |
| 674 | const closeButton = /** @type {HTMLElement} */ (draggableElem.querySelector('.dragClose')); |
| 675 | if (closeButton) { |
| 676 | closeButton.id = `${uniqueId}close`; |
| 677 | closeButton.dataset.relatedId = uniqueId; |
| 678 | } |
| 679 | |
| 680 | // Find the .drag-grabber and set its matching unique ID |
| 681 | const dragGrabber = draggableElem.querySelector('.drag-grabber'); |
| 682 | if (dragGrabber) { |
| 683 | dragGrabber.id = `${uniqueId}header`; // appending _header to make it match the parent's unique ID |
| 684 | } |
| 685 | } |
| 686 | |
| 687 | // Step 3: Attach it to the movingDivs container |
| 688 | document.getElementById('movingDivs').appendChild(newElement); |
| 689 | |
| 690 | // Step 4: Call dragElement and loadMovingUIState |
| 691 | const appendedElement = document.getElementById(uniqueId); |
| 692 | if (appendedElement) { |
| 693 | var elmntName = $(appendedElement); |
| 694 | loadMovingUIState(); |
| 695 | dragElement(elmntName); |
| 696 | |
| 697 | // Prevent dragging the image |
| 698 | $(`#${uniqueId} img`).on('dragstart', (e) => { |
| 699 | console.log('saw drag on avatar!'); |
| 700 | e.preventDefault(); |
| 701 | return false; |
| 702 | }); |
| 703 | } else { |
| 704 | console.error('Failed to append the template content or retrieve the appended content.'); |
| 705 | } |
| 706 | } |
| 707 | |
| 708 | /** |
| 709 | * Sanitizes a given ID to ensure it can be used as an HTML ID. |
| 710 | * This function replaces spaces and non-word characters with dashes. |
| 711 | * It also removes any non-ASCII characters. |
| 712 | * @param {string} id - The ID to be sanitized. |
| 713 | * @returns {string} - The sanitized ID. |
| 714 | */ |
| 715 | function sanitizeHTMLId(id) { |
| 716 | // Replace spaces and non-word characters |
| 717 | id = id.replace(/\s+/g, '-') |
| 718 | .replace(/[^\x00-\x7F]/g, '-') |
| 719 | .replace(/\W/g, ''); |
| 720 | |
| 721 | return id; |
| 722 | } |
| 723 | |
| 724 | /** |
| 725 | * Processes a list of items (containing URLs) and creates a draggable box for the first item. |
| 726 | * |
| 727 | * If the provided list of items is non-empty, it takes the URL of the first item, |
| 728 | * derives an ID from the URL, and uses the makeDragImg function to create |
| 729 | * a draggable image element based on that ID and URL. |
| 730 | * |
| 731 | * @param {Array} items - A list of items where each item has a responsiveURL method that returns a URL. |
| 732 | */ |
| 733 | function viewWithDragbox(items) { |
| 734 | if (items && items.length > 0) { |
| 735 | const url = items[0].responsiveURL(); // Get the URL of the clicked image/video |
| 736 | if (deleteModeActive) { |
| 737 | Popup.show.confirm(t`Are you sure you want to delete this image?`, url) |
| 738 | .then(async (confirmed) => { |
| 739 | if (!confirmed) { |
| 740 | return; |
| 741 | } |
| 742 | deleteGalleryItem(url).then(() => showCharGallery(deleteModeActive)); |
| 743 | }); |
| 744 | } else { |
| 745 | // ID should just be the last part of the URL, removing the extension |
| 746 | const id = sanitizeHTMLId(url.substring(url.lastIndexOf('/') + 1, url.lastIndexOf('.'))); |
| 747 | makeDragImg(id, url); |
| 748 | } |
| 749 | } |
| 750 | } |
| 751 | |
| 752 | |
| 753 | // Registers a simple command for opening the char gallery. |
| 754 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 755 | name: 'show-gallery', |
| 756 | aliases: ['sg'], |
| 757 | callback: () => { |
| 758 | showCharGallery(); |
| 759 | return ''; |
| 760 | }, |
| 761 | helpString: 'Shows the gallery.', |
| 762 | })); |
| 763 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 764 | name: 'list-gallery', |
| 765 | aliases: ['lg'], |
| 766 | callback: listGalleryCommand, |
| 767 | returns: 'list of images', |
| 768 | namedArgumentList: [ |
| 769 | SlashCommandNamedArgument.fromProps({ |
| 770 | name: 'char', |
| 771 | description: 'character name', |
| 772 | typeList: [ARGUMENT_TYPE.STRING], |
| 773 | enumProvider: commonEnumProviders.characters('character'), |
| 774 | }), |
| 775 | SlashCommandNamedArgument.fromProps({ |
| 776 | name: 'group', |
| 777 | description: 'group name', |
| 778 | typeList: [ARGUMENT_TYPE.STRING], |
| 779 | enumProvider: commonEnumProviders.characters('group'), |
| 780 | }), |
| 781 | ], |
| 782 | helpString: 'List images in the gallery of the current char / group or a specified char / group.', |
| 783 | })); |
| 784 | |
| 785 | async function listGalleryCommand(args) { |
| 786 | try { |
| 787 | let url = args.char ?? (args.group ? groups.find(it => it.name == args.group)?.id : null) ?? (selected_group || this_chid); |
| 788 | if (!args.char && !args.group && !selected_group && this_chid !== undefined) { |
| 789 | url = getGalleryFolder(characters[this_chid]); |
| 790 | } |
| 791 | |
| 792 | const items = await getGalleryItems(url); |
| 793 | return JSON.stringify(items.map(it => it.src)); |
| 794 | } catch (err) { |
| 795 | console.error(err); |
| 796 | } |
| 797 | return JSON.stringify([]); |
| 798 | } |
| 799 | |
| 800 | function addGalleryWandButton() { |
| 801 | const showGalleryContainer = document.getElementById('gallery_wand_container') || document.getElementById('extensionsMenu'); |
| 802 | if (!(showGalleryContainer instanceof HTMLElement)) { |
| 803 | return; |
| 804 | } |
| 805 | const showGalleryButton = document.createElement('div'); |
| 806 | showGalleryButton.id = 'show_gallery_wand_button'; |
| 807 | showGalleryButton.classList.add('list-group-item', 'flex-container', 'flexGap5'); |
| 808 | const showGalleryIcon = document.createElement('div'); |
| 809 | showGalleryIcon.classList.add('fa-solid', 'fa-sd-card', 'extensionsMenuExtensionButton'); |
| 810 | const showGalleryText = document.createElement('span'); |
| 811 | showGalleryText.textContent = translate('Show Gallery'); |
| 812 | showGalleryButton.appendChild(showGalleryIcon); |
| 813 | showGalleryButton.appendChild(showGalleryText); |
| 814 | showGalleryButton.addEventListener('click', () => { |
| 815 | showCharGallery(); |
| 816 | }); |
| 817 | showGalleryContainer.appendChild(showGalleryButton); |
| 818 | } |
| 819 | |
| 820 | // On extension load, ensure the settings are initialized |
| 821 | export async function init() { |
| 822 | initSettings(); |
| 823 | eventSource.on(event_types.CHARACTER_RENAMED, (oldAvatar, newAvatar) => { |
| 824 | const context = SillyTavern.getContext(); |
| 825 | const galleryFolder = context.extensionSettings.gallery.folders[oldAvatar]; |
| 826 | if (galleryFolder) { |
| 827 | context.extensionSettings.gallery.folders[newAvatar] = galleryFolder; |
| 828 | delete context.extensionSettings.gallery.folders[oldAvatar]; |
| 829 | context.saveSettingsDebounced(); |
| 830 | } |
| 831 | }); |
| 832 | eventSource.on(event_types.CHARACTER_DELETED, (data) => { |
| 833 | const avatar = data?.character?.avatar; |
| 834 | if (!avatar) return; |
| 835 | const context = SillyTavern.getContext(); |
| 836 | delete context.extensionSettings.gallery.folders[avatar]; |
| 837 | context.saveSettingsDebounced(); |
| 838 | }); |
| 839 | eventSource.on(event_types.CHARACTER_MANAGEMENT_DROPDOWN, (selectedOptionId) => { |
| 840 | if (selectedOptionId === 'show_char_gallery') { |
| 841 | showCharGallery(); |
| 842 | } |
| 843 | }); |
| 844 | |
| 845 | // Add an option to the dropdown |
| 846 | $('#char-management-dropdown').append( |
| 847 | $('<option>', { |
| 848 | id: 'show_char_gallery', |
| 849 | text: translate('Show Gallery'), |
| 850 | }), |
| 851 | ); |
| 852 | addGalleryWandButton(); |
| 853 | } |