Blame Raw
Cohee · 51ad27fb · · 853 lines (31.8 KB)
2 contributors
1import {
2 eventSource,
3 this_chid,
4 characters,
5 getRequestHeaders,
6 event_types,
7 animation_duration,
8 animation_easing,
9} from '../../../script.js';
10import { groups, selected_group } from '../../group-chats.js';
11import { loadFileToDocument, delay, getBase64Async, getSanitizedFilename, saveBase64AsFile, getFileExtension, getVideoThumbnail, clamp } from '../../utils.js';
12import { loadMovingUIState } from '../../power-user.js';
13import { dragElement } from '../../RossAscends-mods.js';
14import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
15import { SlashCommand } from '../../slash-commands/SlashCommand.js';
16import { ARGUMENT_TYPE, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
17import { DragAndDropHandler } from '../../dragdrop.js';
18import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
19import { t, translate } from '../../i18n.js';
20import { Popup } from '../../popup.js';
21import { deleteMediaFromServer } from '../../chats.js';
22import { MEDIA_REQUEST_TYPE, VIDEO_EXTENSIONS } from '../../constants.js';
23
24const isVideo = (/** @type {string} */ url) => VIDEO_EXTENSIONS.some(ext => new RegExp(`.${ext}$`, 'i').test(url));
25const extensionName = 'gallery';
26const extensionFolderPath = `scripts/extensions/${extensionName}/`;
27let firstTime = true;
28let 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
46const CUSTOM_GALLERY_REMOVED_EVENT = 'galleryRemoved';
47
48const 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
58mutationObserver.observe(document.body, {
59 childList: true,
60 subtree: false,
61});
62
63const 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
70const defaultSettings = Object.freeze({
71 folders: {},
72 sort: SORT.DATE_ASC.value,
73});
74
75/**
76 * Initializes the settings for the gallery extension.
77 */
78function 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 */
101function 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 */
112async 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 */
158async 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 */
180async 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 */
191function 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 */
201function 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 */
214async 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 */
308async 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 */
350async 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 */
375async 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 */
569function 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 */
598function 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 */
629function 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 */
715function 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 */
733function 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.
754SlashCommandParser.addCommandObject(SlashCommand.fromProps({
755 name: 'show-gallery',
756 aliases: ['sg'],
757 callback: () => {
758 showCharGallery();
759 return '';
760 },
761 helpString: 'Shows the gallery.',
762}));
763SlashCommandParser.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
785async 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
800function 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
821export 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}