| 1 | import { Fuse, localforage } from '../lib.js'; |
| 2 | import { characters, chat_metadata, eventSource, event_types, generateQuietPrompt, getCurrentChatId, getRequestHeaders, getThumbnailUrl, saveMetadata, saveSettingsDebounced, this_chid } from '../script.js'; |
| 3 | import { openThirdPartyExtensionMenu, saveMetadataDebounced } from './extensions.js'; |
| 4 | import { SlashCommand } from './slash-commands/SlashCommand.js'; |
| 5 | import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; |
| 6 | import { createThumbnail, flashHighlight, getBase64Async, stringFormat, debounce, setupScrollToTop, saveBase64AsFile, getFileExtension, sortIgnoreCaseAndAccents } from './utils.js'; |
| 7 | import { debounce_timeout } from './constants.js'; |
| 8 | import { t } from './i18n.js'; |
| 9 | import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js'; |
| 10 | import { groups, selected_group } from './group-chats.js'; |
| 11 | import { humanizedDateTime } from './RossAscends-mods.js'; |
| 12 | import { deleteMediaFromServer } from './chats.js'; |
| 13 | |
| 14 | const BG_METADATA_KEY = 'custom_background'; |
| 15 | const LIST_METADATA_KEY = 'chat_backgrounds'; |
| 16 | |
| 17 | /** @type {Array<{id: string, name: string, thumbnailFile: string}>} */ |
| 18 | let folderList = []; |
| 19 | /** @type {Object.<string, string[]>} filename → folderIds */ |
| 20 | let imageFolderMap = {}; |
| 21 | /** @type {string|null} Currently active folder drill-in, or null for root */ |
| 22 | let activeFolderId = null; |
| 23 | /** @type {Set<string>} Selected system backgrounds for group folder actions */ |
| 24 | const selectedSystemBackgroundFiles = new Set(); |
| 25 | /** @type {boolean} Whether click-to-select mode is active for system backgrounds */ |
| 26 | let isBackgroundSelectionMode = false; |
| 27 | |
| 28 | // A single transparent PNG pixel used as a placeholder for errored backgrounds |
| 29 | const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; |
| 30 | const PNG_PIXEL_BLOB = new Blob([Uint8Array.from(atob(PNG_PIXEL), c => c.charCodeAt(0))], { type: 'image/png' }); |
| 31 | const PLACEHOLDER_IMAGE = `url('data:image/png;base64,${PNG_PIXEL}')`; |
| 32 | |
| 33 | const THUMBNAIL_COLUMNS_MIN = 2; |
| 34 | const THUMBNAIL_COLUMNS_MAX = 8; |
| 35 | const THUMBNAIL_COLUMNS_DEFAULT_DESKTOP = 5; |
| 36 | const THUMBNAIL_COLUMNS_DEFAULT_MOBILE = 3; |
| 37 | |
| 38 | /** |
| 39 | * Storage for frontend-generated background thumbnails. |
| 40 | * This is used to store thumbnails for backgrounds that cannot be generated on the server. |
| 41 | */ |
| 42 | const THUMBNAIL_STORAGE = localforage.createInstance({ name: 'SillyTavern_Thumbnails' }); |
| 43 | |
| 44 | /** |
| 45 | * Cache for thumbnail blob URLs. |
| 46 | * @type {Map<string, string>} |
| 47 | */ |
| 48 | const THUMBNAIL_BLOBS = new Map(); |
| 49 | |
| 50 | const THUMBNAIL_CONFIG = { |
| 51 | width: 160, |
| 52 | height: 90, |
| 53 | }; |
| 54 | |
| 55 | const ANIMATED_BACKGROUND_EXTENSIONS = ['mp4', 'webp', 'gif', 'apng']; |
| 56 | |
| 57 | /** |
| 58 | * Cache for image metadata. |
| 59 | * @type {Map<string, import('../../src/endpoints/image-metadata.js').ImageMetadata>} |
| 60 | */ |
| 61 | const METADATA_CACHE = new Map(); |
| 62 | |
| 63 | /** |
| 64 | * Background source types. |
| 65 | * @readonly |
| 66 | * @enum {number} |
| 67 | */ |
| 68 | const BG_SOURCES = { |
| 69 | GLOBAL: 0, |
| 70 | CHAT: 1, |
| 71 | }; |
| 72 | |
| 73 | /** |
| 74 | * Background sorting options. |
| 75 | * @readonly |
| 76 | * @enum {string} |
| 77 | */ |
| 78 | const BG_SORT_OPTIONS = { |
| 79 | AZ: 'az', |
| 80 | ZA: 'za', |
| 81 | NEWEST: 'newest', |
| 82 | OLDEST: 'oldest', |
| 83 | }; |
| 84 | |
| 85 | /** |
| 86 | * Mapping of background sources to their corresponding tab IDs. |
| 87 | * @readonly |
| 88 | * @type {Record<string, string>} |
| 89 | */ |
| 90 | const BG_TABS = Object.freeze({ |
| 91 | [BG_SOURCES.GLOBAL]: 'bg_global_tab', |
| 92 | [BG_SOURCES.CHAT]: 'bg_chat_tab', |
| 93 | }); |
| 94 | |
| 95 | /** |
| 96 | * Global IntersectionObserver instance for lazy loading backgrounds |
| 97 | * @type {IntersectionObserver|null} |
| 98 | */ |
| 99 | let lazyLoadObserver = null; |
| 100 | |
| 101 | /** |
| 102 | * Cache for the current list of system background filenames. |
| 103 | * Used to re-sort backgrounds without refetching from the server. |
| 104 | * @type {Array<{filename: string, isAnimated: boolean}>} |
| 105 | */ |
| 106 | let cachedSystemBackgrounds = []; |
| 107 | |
| 108 | export let background_settings = { |
| 109 | name: '__transparent.png', |
| 110 | url: generateUrlParameter('__transparent.png', false), |
| 111 | fitting: 'classic', |
| 112 | animation: false, |
| 113 | sortOrder: BG_SORT_OPTIONS.AZ, |
| 114 | }; |
| 115 | |
| 116 | /** |
| 117 | * Sorts an array of background filenames based on the current sort order. |
| 118 | * @param {string[]} backgrounds - Array of background filenames |
| 119 | * @param {boolean} isCustom - Whether these are custom (chat) backgrounds |
| 120 | * @returns {string[]} Sorted array of background filenames |
| 121 | */ |
| 122 | function sortBackgrounds(backgrounds, isCustom = false) { |
| 123 | const sortOrder = background_settings.sortOrder || BG_SORT_OPTIONS.AZ; |
| 124 | |
| 125 | return [...backgrounds].sort((a, b) => { |
| 126 | switch (sortOrder) { |
| 127 | case BG_SORT_OPTIONS.AZ: |
| 128 | return sortIgnoreCaseAndAccents(a, b); |
| 129 | case BG_SORT_OPTIONS.ZA: |
| 130 | return sortIgnoreCaseAndAccents(b, a); |
| 131 | case BG_SORT_OPTIONS.NEWEST: |
| 132 | case BG_SORT_OPTIONS.OLDEST: { |
| 133 | const keyA = isCustom ? a : `backgrounds/${a}`; |
| 134 | const keyB = isCustom ? b : `backgrounds/${b}`; |
| 135 | const metaA = METADATA_CACHE.get(keyA); |
| 136 | const metaB = METADATA_CACHE.get(keyB); |
| 137 | const timestampA = metaA?.addedTimestamp ?? 0; |
| 138 | const timestampB = metaB?.addedTimestamp ?? 0; |
| 139 | // Newest first (descending) or oldest first (ascending) |
| 140 | return sortOrder === BG_SORT_OPTIONS.NEWEST |
| 141 | ? timestampB - timestampA |
| 142 | : timestampA - timestampB; |
| 143 | } |
| 144 | default: |
| 145 | return 0; |
| 146 | } |
| 147 | }); |
| 148 | } |
| 149 | |
| 150 | /** |
| 151 | * Creates a single thumbnail DOM element. The CSS now handles all sizing. |
| 152 | * @param {object} imageData - Data for the image (filename, isCustom, isAnimated). |
| 153 | * @returns {HTMLElement} The created thumbnail element. |
| 154 | */ |
| 155 | function createThumbnailElement(imageData) { |
| 156 | const bg = imageData.filename; |
| 157 | const isCustom = imageData.isCustom; |
| 158 | const isAnimated = imageData.isAnimated ?? false; |
| 159 | |
| 160 | const thumbnail = $('#background_template .bg_example').clone(); |
| 161 | |
| 162 | const clipper = document.createElement('div'); |
| 163 | clipper.className = 'thumbnail-clipper lazy-load-background'; |
| 164 | clipper.style.backgroundImage = PLACEHOLDER_IMAGE; |
| 165 | |
| 166 | // Apply dominant color and aspect ratio as placeholder if available |
| 167 | const metadataKey = isCustom ? bg : `backgrounds/${bg}`; |
| 168 | const metadata = METADATA_CACHE.get(metadataKey); |
| 169 | if (metadata) { |
| 170 | if (metadata.dominantColor) { |
| 171 | clipper.style.backgroundColor = metadata.dominantColor; |
| 172 | } |
| 173 | if (metadata.aspectRatio) { |
| 174 | thumbnail.css('aspect-ratio', metadata.aspectRatio); |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | const titleElement = thumbnail.find('.BGSampleTitle'); |
| 179 | clipper.appendChild(titleElement.get(0)); |
| 180 | thumbnail.append(clipper); |
| 181 | |
| 182 | const url = generateUrlParameter(bg, isCustom); |
| 183 | const title = isCustom ? bg.split('/').pop() : bg; |
| 184 | const friendlyTitle = String(title || '').slice(0, title.lastIndexOf('.')); |
| 185 | |
| 186 | thumbnail.attr('title', title); |
| 187 | thumbnail.attr('bgfile', bg); |
| 188 | thumbnail.attr('custom', String(isCustom)); |
| 189 | thumbnail.attr('animated', String(isAnimated)); |
| 190 | thumbnail.data('url', url); |
| 191 | titleElement.text(friendlyTitle); |
| 192 | |
| 193 | return thumbnail.get(0); |
| 194 | } |
| 195 | |
| 196 | /** |
| 197 | * Applies the thumbnail column count to the CSS and updates button states. |
| 198 | * @param {number} count - The number of columns to display. |
| 199 | */ |
| 200 | function applyThumbnailColumns(count) { |
| 201 | const newCount = Math.max(THUMBNAIL_COLUMNS_MIN, Math.min(count, THUMBNAIL_COLUMNS_MAX)); |
| 202 | background_settings.thumbnailColumns = newCount; |
| 203 | document.documentElement.style.setProperty('--bg-thumb-columns', newCount.toString()); |
| 204 | |
| 205 | $('#bg_thumb_zoom_in').prop('disabled', newCount <= THUMBNAIL_COLUMNS_MIN); |
| 206 | $('#bg_thumb_zoom_out').prop('disabled', newCount >= THUMBNAIL_COLUMNS_MAX); |
| 207 | |
| 208 | saveSettingsDebounced(); |
| 209 | } |
| 210 | |
| 211 | export function loadBackgroundSettings(settings) { |
| 212 | let backgroundSettings = settings.background; |
| 213 | if (!backgroundSettings || !backgroundSettings.name || !backgroundSettings.url) { |
| 214 | backgroundSettings = background_settings; |
| 215 | } |
| 216 | if (!backgroundSettings.fitting) { |
| 217 | backgroundSettings.fitting = 'classic'; |
| 218 | } |
| 219 | if (!Object.hasOwn(backgroundSettings, 'animation')) { |
| 220 | backgroundSettings.animation = false; |
| 221 | } |
| 222 | if (!backgroundSettings.sortOrder) { |
| 223 | backgroundSettings.sortOrder = BG_SORT_OPTIONS.AZ; |
| 224 | } |
| 225 | |
| 226 | // If a value is already saved, use it. Otherwise, determine default based on screen size. |
| 227 | let columns = backgroundSettings.thumbnailColumns; |
| 228 | if (!columns) { |
| 229 | const isNarrowScreen = window.matchMedia('(max-width: 480px)').matches; |
| 230 | columns = isNarrowScreen ? THUMBNAIL_COLUMNS_DEFAULT_MOBILE : THUMBNAIL_COLUMNS_DEFAULT_DESKTOP; |
| 231 | } |
| 232 | background_settings.thumbnailColumns = columns; |
| 233 | background_settings.sortOrder = backgroundSettings.sortOrder; |
| 234 | background_settings.animation = backgroundSettings.animation; |
| 235 | applyThumbnailColumns(background_settings.thumbnailColumns); |
| 236 | |
| 237 | setBackground(backgroundSettings.name, backgroundSettings.url); |
| 238 | setFittingClass(backgroundSettings.fitting); |
| 239 | $('#background_fitting').val(backgroundSettings.fitting); |
| 240 | $('#background_thumbnails_animation').prop('checked', background_settings.animation); |
| 241 | $('#bg-sort').val(background_settings.sortOrder); |
| 242 | highlightSelectedBackground(); |
| 243 | } |
| 244 | |
| 245 | /** |
| 246 | * Sets the background for the current chat and adds it to the list of custom backgrounds. |
| 247 | * @param {{url: string, path:string}} backgroundInfo |
| 248 | */ |
| 249 | async function forceSetBackground(backgroundInfo) { |
| 250 | saveBackgroundMetadata(backgroundInfo.url); |
| 251 | $('#bg1').css('background-image', backgroundInfo.url); |
| 252 | |
| 253 | const list = chat_metadata[LIST_METADATA_KEY] || []; |
| 254 | const bg = backgroundInfo.path; |
| 255 | list.push(bg); |
| 256 | chat_metadata[LIST_METADATA_KEY] = list; |
| 257 | saveMetadataDebounced(); |
| 258 | renderChatBackgrounds(); |
| 259 | highlightNewBackground(bg); |
| 260 | highlightLockedBackground(); |
| 261 | } |
| 262 | |
| 263 | async function onChatChanged() { |
| 264 | const lockedUrl = chat_metadata[BG_METADATA_KEY]; |
| 265 | |
| 266 | $('#bg1').css('background-image', lockedUrl || background_settings.url); |
| 267 | |
| 268 | renderChatBackgrounds(); |
| 269 | highlightLockedBackground(); |
| 270 | highlightSelectedBackground(); |
| 271 | } |
| 272 | |
| 273 | /** |
| 274 | * Checks if a given URL corresponds to a custom background in the current chat's metadata. |
| 275 | * @param {string} fileUrl - The URL to check against the chat's custom backgrounds. |
| 276 | * @returns {boolean} True if the URL corresponds to a custom background, false otherwise. |
| 277 | */ |
| 278 | export function isCustomBackgroundUrl(fileUrl) { |
| 279 | const customBackgrounds = chat_metadata[LIST_METADATA_KEY] || []; |
| 280 | return customBackgrounds.some(bg => bg === fileUrl || generateUrlParameter(bg, true) === fileUrl); |
| 281 | } |
| 282 | |
| 283 | /** |
| 284 | * Gets the client path for a background image, encoding the file name for safe URL usage. |
| 285 | * @param {string} fileUrl File name or URL of the background image |
| 286 | * @returns {string} Client path for the system backgroun |
| 287 | */ |
| 288 | export function getBackgroundPath(fileUrl) { |
| 289 | return `backgrounds/${encodeURIComponent(fileUrl)}`; |
| 290 | } |
| 291 | |
| 292 | /** |
| 293 | * Gets the raw server-side relative path for a background image (no URL encoding). |
| 294 | * Used when communicating paths to the API (stored as plain strings in metadata). |
| 295 | * @param {string} file File name of the background image |
| 296 | * @returns {string} Raw relative path, e.g. "backgrounds/my file.jpg" |
| 297 | */ |
| 298 | function getBackgroundRelativePath(file) { |
| 299 | return `backgrounds/${file}`; |
| 300 | } |
| 301 | |
| 302 | |
| 303 | function highlightLockedBackground() { |
| 304 | $('.bg_example.locked-background').removeClass('locked-background'); |
| 305 | |
| 306 | const lockedBackgroundUrl = chat_metadata[BG_METADATA_KEY]; |
| 307 | |
| 308 | if (lockedBackgroundUrl) { |
| 309 | $('.bg_example').filter(function () { |
| 310 | return $(this).data('url') === lockedBackgroundUrl; |
| 311 | }).addClass('locked-background'); |
| 312 | } |
| 313 | } |
| 314 | |
| 315 | /** |
| 316 | * Locks the background for the current chat |
| 317 | * @param {Event|null} event |
| 318 | */ |
| 319 | function onLockBackgroundClick(event = null) { |
| 320 | if (!getCurrentChatId()) { |
| 321 | toastr.warning(t`Select a chat to lock the background for it`); |
| 322 | return; |
| 323 | } |
| 324 | |
| 325 | // Take the global background's URL and save it to the chat's metadata. |
| 326 | const urlToLock = event ? $(event.target).closest('.bg_example').data('url') : background_settings.url; |
| 327 | saveBackgroundMetadata(urlToLock); |
| 328 | $('#bg1').css('background-image', urlToLock); |
| 329 | |
| 330 | // Update UI states to reflect the new lock. |
| 331 | highlightLockedBackground(); |
| 332 | highlightSelectedBackground(); |
| 333 | } |
| 334 | |
| 335 | /** |
| 336 | * Unlocks the background for the current chat |
| 337 | * @param {Event|null} _event |
| 338 | */ |
| 339 | function onUnlockBackgroundClick(_event = null) { |
| 340 | // Delete the lock from the chat's metadata. |
| 341 | removeBackgroundMetadata(); |
| 342 | |
| 343 | // Revert the view to the current global background. |
| 344 | $('#bg1').css('background-image', background_settings.url); |
| 345 | |
| 346 | // Update UI states to reflect the removal of the lock. |
| 347 | highlightLockedBackground(); |
| 348 | highlightSelectedBackground(); |
| 349 | } |
| 350 | |
| 351 | function isChatBackgroundLocked() { |
| 352 | return chat_metadata[BG_METADATA_KEY]; |
| 353 | } |
| 354 | |
| 355 | function saveBackgroundMetadata(file) { |
| 356 | chat_metadata[BG_METADATA_KEY] = file; |
| 357 | saveMetadataDebounced(); |
| 358 | } |
| 359 | |
| 360 | function removeBackgroundMetadata() { |
| 361 | delete chat_metadata[BG_METADATA_KEY]; |
| 362 | saveMetadataDebounced(); |
| 363 | } |
| 364 | |
| 365 | /** |
| 366 | * Handles the click event for selecting a background. |
| 367 | * @param {JQuery.Event} e Event |
| 368 | */ |
| 369 | function onSelectBackgroundClick(e) { |
| 370 | const bgFile = $(this).attr('bgfile'); |
| 371 | const isCustom = $(this).attr('custom') === 'true'; |
| 372 | if (isBackgroundSelectionMode && !isCustom) { |
| 373 | toggleBackgroundGroupSelection(bgFile); |
| 374 | return; |
| 375 | } |
| 376 | |
| 377 | const backgroundCssUrl = getUrlParameter(this); |
| 378 | const bypassGlobalLock = !isCustom && e.shiftKey; |
| 379 | |
| 380 | if ((isChatBackgroundLocked() || isCustom) && !bypassGlobalLock) { |
| 381 | // If a background is locked, update the locked background directly |
| 382 | saveBackgroundMetadata(backgroundCssUrl); |
| 383 | $('#bg1').css('background-image', backgroundCssUrl); |
| 384 | } else { |
| 385 | // Otherwise, update the global background setting |
| 386 | setBackground(bgFile, backgroundCssUrl); |
| 387 | } |
| 388 | |
| 389 | // Update UI highlights to reflect the changes. |
| 390 | highlightLockedBackground(); |
| 391 | highlightSelectedBackground(); |
| 392 | } |
| 393 | |
| 394 | async function onCopyToSystemBackgroundClick(e) { |
| 395 | e.stopPropagation(); |
| 396 | const bgNames = await getNewBackgroundName(this); |
| 397 | |
| 398 | if (!bgNames) { |
| 399 | return; |
| 400 | } |
| 401 | |
| 402 | const bgFile = await fetch(bgNames.oldBg); |
| 403 | |
| 404 | if (!bgFile.ok) { |
| 405 | toastr.warning('Failed to copy background'); |
| 406 | return; |
| 407 | } |
| 408 | |
| 409 | const blob = await bgFile.blob(); |
| 410 | const file = new File([blob], bgNames.newBg); |
| 411 | const formData = new FormData(); |
| 412 | formData.set('avatar', file); |
| 413 | |
| 414 | await uploadBackground(formData); |
| 415 | |
| 416 | const list = chat_metadata[LIST_METADATA_KEY] || []; |
| 417 | const index = list.indexOf(bgNames.oldBg); |
| 418 | list.splice(index, 1); |
| 419 | saveMetadataDebounced(); |
| 420 | renderChatBackgrounds(); |
| 421 | } |
| 422 | |
| 423 | /** |
| 424 | * Gets a thumbnail for the background from storage or fetches it if not available. |
| 425 | * It caches the thumbnail in local storage and returns a blob URL for the thumbnail. |
| 426 | * If the thumbnail cannot be fetched, it returns a transparent PNG pixel as a fallback. |
| 427 | * @param {string} bg Background URL |
| 428 | * @param {boolean} isCustom Is the background custom? |
| 429 | * @returns {Promise<string>} Blob URL of the thumbnail |
| 430 | */ |
| 431 | async function getThumbnailFromStorage(bg, isCustom) { |
| 432 | const cachedBlobUrl = THUMBNAIL_BLOBS.get(bg); |
| 433 | if (cachedBlobUrl) { |
| 434 | return cachedBlobUrl; |
| 435 | } |
| 436 | |
| 437 | const savedBlob = await THUMBNAIL_STORAGE.getItem(bg); |
| 438 | if (savedBlob) { |
| 439 | const savedBlobUrl = URL.createObjectURL(savedBlob); |
| 440 | THUMBNAIL_BLOBS.set(bg, savedBlobUrl); |
| 441 | return savedBlobUrl; |
| 442 | } |
| 443 | |
| 444 | try { |
| 445 | const url = isCustom ? bg : getBackgroundPath(bg); |
| 446 | const response = await fetch(url, { cache: 'force-cache' }); |
| 447 | if (!response.ok) { |
| 448 | throw new Error('Fetch failed with status: ' + response.status); |
| 449 | } |
| 450 | const imageBlob = await response.blob(); |
| 451 | const imageBase64 = await getBase64Async(imageBlob); |
| 452 | const thumbnailBase64 = await createThumbnail(imageBase64, THUMBNAIL_CONFIG.width, THUMBNAIL_CONFIG.height); |
| 453 | const thumbnailBlob = await fetch(thumbnailBase64).then(res => res.blob()); |
| 454 | await THUMBNAIL_STORAGE.setItem(bg, thumbnailBlob); |
| 455 | const blobUrl = URL.createObjectURL(thumbnailBlob); |
| 456 | THUMBNAIL_BLOBS.set(bg, blobUrl); |
| 457 | return blobUrl; |
| 458 | } catch (error) { |
| 459 | console.error('Error fetching thumbnail, fallback image will be used:', error); |
| 460 | const fallbackBlob = PNG_PIXEL_BLOB; |
| 461 | const fallbackBlobUrl = URL.createObjectURL(fallbackBlob); |
| 462 | THUMBNAIL_BLOBS.set(bg, fallbackBlobUrl); |
| 463 | return fallbackBlobUrl; |
| 464 | } |
| 465 | } |
| 466 | |
| 467 | /** |
| 468 | * Gets the new background name from the user. |
| 469 | * @param {Element} referenceElement |
| 470 | * @returns {Promise<{oldBg: string, newBg: string}>} |
| 471 | * */ |
| 472 | async function getNewBackgroundName(referenceElement) { |
| 473 | const exampleBlock = $(referenceElement).closest('.bg_example'); |
| 474 | const isCustom = exampleBlock.attr('custom') === 'true'; |
| 475 | const oldBg = exampleBlock.attr('bgfile'); |
| 476 | |
| 477 | if (!oldBg) { |
| 478 | console.debug('no bgfile'); |
| 479 | return; |
| 480 | } |
| 481 | |
| 482 | const fileExtension = oldBg.split('.').pop(); |
| 483 | const fileNameBase = isCustom ? oldBg.split('/').pop() : oldBg; |
| 484 | const oldBgExtensionless = fileNameBase.replace(`.${fileExtension}`, ''); |
| 485 | const newBgExtensionless = await Popup.show.input(t`Enter new background name:`, null, oldBgExtensionless); |
| 486 | |
| 487 | if (!newBgExtensionless) { |
| 488 | console.debug('no new_bg_extensionless'); |
| 489 | return; |
| 490 | } |
| 491 | |
| 492 | const newBg = `${newBgExtensionless}.${fileExtension}`; |
| 493 | |
| 494 | if (oldBgExtensionless === newBgExtensionless) { |
| 495 | console.debug('new_bg === old_bg'); |
| 496 | return; |
| 497 | } |
| 498 | |
| 499 | return { oldBg, newBg }; |
| 500 | } |
| 501 | |
| 502 | async function onRenameBackgroundClick(e) { |
| 503 | e.stopPropagation(); |
| 504 | |
| 505 | const bgNames = await getNewBackgroundName(this); |
| 506 | |
| 507 | if (!bgNames) { |
| 508 | return; |
| 509 | } |
| 510 | |
| 511 | const data = { old_bg: bgNames.oldBg, new_bg: bgNames.newBg }; |
| 512 | const response = await fetch('/api/backgrounds/rename', { |
| 513 | method: 'POST', |
| 514 | headers: getRequestHeaders(), |
| 515 | body: JSON.stringify(data), |
| 516 | cache: 'no-cache', |
| 517 | }); |
| 518 | |
| 519 | if (response.ok) { |
| 520 | await getBackgrounds(); |
| 521 | highlightNewBackground(bgNames.newBg); |
| 522 | } else { |
| 523 | toastr.warning('Failed to rename background'); |
| 524 | } |
| 525 | } |
| 526 | |
| 527 | async function onDeleteBackgroundClick(e) { |
| 528 | e.stopPropagation(); |
| 529 | const bgToDelete = $(this).closest('.bg_example'); |
| 530 | const url = bgToDelete.data('url'); |
| 531 | const isCustom = bgToDelete.attr('custom') === 'true'; |
| 532 | const deleteFromServerId = 'delete_bg_from_server'; |
| 533 | /** @type {import('./popup.js').CustomPopupInput[]} */ |
| 534 | const customInputs = [{ |
| 535 | type: 'checkbox', |
| 536 | label: t`Also delete file from server`, |
| 537 | id: deleteFromServerId, |
| 538 | defaultState: true, |
| 539 | }]; |
| 540 | let deleteFromServer = false; |
| 541 | const confirm = await Popup.show.confirm(t`Delete the background?`, null, { |
| 542 | customInputs: isCustom ? customInputs : [], |
| 543 | onClose: (popup) => { |
| 544 | if (isCustom) { |
| 545 | deleteFromServer = Boolean(popup?.inputResults?.get(deleteFromServerId) ?? false); |
| 546 | } |
| 547 | }, |
| 548 | }); |
| 549 | const bg = bgToDelete.attr('bgfile'); |
| 550 | |
| 551 | if (confirm) { |
| 552 | // If it's not custom, it's a built-in background. Delete it from the server |
| 553 | if (!isCustom) { |
| 554 | await delBackground(bg); |
| 555 | // Remove from cache to prevent reappearing on sort change |
| 556 | const cacheIndex = cachedSystemBackgrounds.findIndex(s => s.filename === bg); |
| 557 | if (cacheIndex !== -1) { |
| 558 | cachedSystemBackgrounds.splice(cacheIndex, 1); |
| 559 | } |
| 560 | } else { |
| 561 | const list = chat_metadata[LIST_METADATA_KEY] || []; |
| 562 | const index = list.indexOf(bg); |
| 563 | list.splice(index, 1); |
| 564 | } |
| 565 | |
| 566 | if (bg === background_settings.name || url === chat_metadata[BG_METADATA_KEY]) { |
| 567 | const siblingSelector = '.bg_example'; |
| 568 | const nextBg = bgToDelete.next(siblingSelector); |
| 569 | const prevBg = bgToDelete.prev(siblingSelector); |
| 570 | |
| 571 | if (nextBg.length > 0) { |
| 572 | nextBg.trigger('click'); |
| 573 | } else if (prevBg.length > 0) { |
| 574 | prevBg.trigger('click'); |
| 575 | } else { |
| 576 | const anyOtherBg = $('.bg_example').not(bgToDelete).first(); |
| 577 | if (anyOtherBg.length > 0) { |
| 578 | anyOtherBg.trigger('click'); |
| 579 | } |
| 580 | } |
| 581 | } |
| 582 | |
| 583 | // Remove from local image list so it doesn't reappear on re-render |
| 584 | const deletedBg = bgToDelete.attr('bgfile'); |
| 585 | if (deletedBg) { |
| 586 | const cachedIdx = cachedSystemBackgrounds.findIndex(img => img.filename === deletedBg); |
| 587 | if (cachedIdx !== -1) cachedSystemBackgrounds.splice(cachedIdx, 1); |
| 588 | selectedSystemBackgroundFiles.delete(deletedBg); |
| 589 | |
| 590 | // Update folder map and clear folder thumbnail if it referenced this image |
| 591 | if (imageFolderMap[deletedBg]) { |
| 592 | delete imageFolderMap[deletedBg]; |
| 593 | } |
| 594 | for (const folder of folderList) { |
| 595 | if (folder.thumbnailFile === deletedBg) { |
| 596 | folder.thumbnailFile = ''; |
| 597 | } |
| 598 | } |
| 599 | renderFolderGrid(); |
| 600 | } |
| 601 | |
| 602 | bgToDelete.remove(); |
| 603 | |
| 604 | if (url === chat_metadata[BG_METADATA_KEY]) { |
| 605 | removeBackgroundMetadata(); |
| 606 | } |
| 607 | |
| 608 | if (isCustom) { |
| 609 | if (deleteFromServer) { |
| 610 | await deleteMediaFromServer(bg); |
| 611 | } |
| 612 | renderChatBackgrounds(); |
| 613 | await saveMetadata(); |
| 614 | } |
| 615 | |
| 616 | highlightLockedBackground(); |
| 617 | highlightSelectedBackground(); |
| 618 | syncGroupSelectionUi(); |
| 619 | } |
| 620 | } |
| 621 | |
| 622 | const autoBgPrompt = 'Ignore previous instructions and choose a location ONLY from the provided list that is the most suitable for the current scene. Do not output any other text:\n{0}'; |
| 623 | |
| 624 | async function autoBackgroundCommand() { |
| 625 | /** @type {HTMLElement[]} */ |
| 626 | const bgTitles = Array.from(document.querySelectorAll('#bg_menu_content .BGSampleTitle')); |
| 627 | const options = bgTitles.map(x => ({ element: x, text: x.innerText.trim() })).filter(x => x.text.length > 0); |
| 628 | if (options.length == 0) { |
| 629 | toastr.warning('No backgrounds to choose from. Please upload some images to the "backgrounds" folder.'); |
| 630 | return ''; |
| 631 | } |
| 632 | |
| 633 | const list = options.map(option => `- ${option.text}`).join('\n'); |
| 634 | const prompt = stringFormat(autoBgPrompt, list); |
| 635 | const reply = await generateQuietPrompt({ quietPrompt: prompt }); |
| 636 | const fuse = new Fuse(options, { keys: ['text'] }); |
| 637 | const bestMatch = fuse.search(reply, { limit: 1 }); |
| 638 | |
| 639 | if (bestMatch.length == 0) { |
| 640 | for (const option of options) { |
| 641 | if (String(reply).toLowerCase().includes(option.text.toLowerCase())) { |
| 642 | console.debug('Fallback choosing background:', option); |
| 643 | option.element.click(); |
| 644 | return ''; |
| 645 | } |
| 646 | } |
| 647 | |
| 648 | toastr.warning('No match found. Please try again.'); |
| 649 | return ''; |
| 650 | } |
| 651 | |
| 652 | console.debug('Automatically choosing background:', bestMatch); |
| 653 | bestMatch[0].item.element.click(); |
| 654 | return ''; |
| 655 | } |
| 656 | |
| 657 | /** |
| 658 | * Renders the system backgrounds gallery. |
| 659 | * @param {Array<{filename: string, isAnimated: boolean}>} [backgrounds] - Optional filtered list of backgrounds with metadata. |
| 660 | */ |
| 661 | function renderSystemBackgrounds(backgrounds) { |
| 662 | const sourceList = backgrounds || []; |
| 663 | const container = $('#bg_menu_content'); |
| 664 | container.empty(); |
| 665 | |
| 666 | if (sourceList.length === 0) { |
| 667 | syncGroupSelectionUi(); |
| 668 | return; |
| 669 | } |
| 670 | |
| 671 | const sortedList = sortBackgrounds(sourceList.map(bg => bg.filename), false); |
| 672 | const metadataByFilename = new Map(sourceList.map(bg => [bg.filename, bg])); |
| 673 | sortedList.forEach(filename => { |
| 674 | const bg = metadataByFilename.get(filename); |
| 675 | const imageData = { filename, isCustom: false, isAnimated: bg?.isAnimated ?? false }; |
| 676 | const thumbnail = createThumbnailElement(imageData); |
| 677 | container.append(thumbnail); |
| 678 | }); |
| 679 | |
| 680 | syncGroupSelectionUi(); |
| 681 | activateLazyLoader(); |
| 682 | } |
| 683 | |
| 684 | /** |
| 685 | * Renders the chat-specific (custom) backgrounds gallery. |
| 686 | * @param {string[]} [backgrounds] - Optional filtered list of backgrounds. |
| 687 | */ |
| 688 | function renderChatBackgrounds(backgrounds) { |
| 689 | const sourceList = backgrounds ?? (chat_metadata[LIST_METADATA_KEY] || []); |
| 690 | const container = $('#bg_custom_content'); |
| 691 | container.empty(); |
| 692 | $('#bg_chat_hint').toggle(!sourceList.length); |
| 693 | |
| 694 | if (sourceList.length === 0) return; |
| 695 | |
| 696 | const sortedList = sortBackgrounds(sourceList, true); |
| 697 | sortedList.forEach(bg => { |
| 698 | // For custom backgrounds, infer isAnimated from extension since we don't have server metadata |
| 699 | const isAnimated = isAnimatedBackgroundExtension(bg); |
| 700 | const imageData = { filename: bg, isCustom: true, isAnimated }; |
| 701 | const thumbnail = createThumbnailElement(imageData); |
| 702 | container.append(thumbnail); |
| 703 | }); |
| 704 | |
| 705 | activateLazyLoader(); |
| 706 | } |
| 707 | |
| 708 | export async function getBackgrounds() { |
| 709 | const response = await fetch('/api/backgrounds/all', { |
| 710 | method: 'POST', |
| 711 | headers: getRequestHeaders(), |
| 712 | body: JSON.stringify({}), |
| 713 | }); |
| 714 | if (response.ok) { |
| 715 | const { images, config } = await response.json(); |
| 716 | Object.assign(THUMBNAIL_CONFIG, config); |
| 717 | cachedSystemBackgrounds = images; |
| 718 | const existingFiles = new Set(images.map(x => x.filename)); |
| 719 | for (const selectedFile of selectedSystemBackgroundFiles) { |
| 720 | if (!existingFiles.has(selectedFile)) { |
| 721 | selectedSystemBackgroundFiles.delete(selectedFile); |
| 722 | } |
| 723 | } |
| 724 | |
| 725 | // Load folders first so getFilteredImages() works correctly in folder view |
| 726 | await loadFolders(); |
| 727 | |
| 728 | await preloadImageMetadata(); |
| 729 | |
| 730 | // Render only filtered images if inside a folder, otherwise all |
| 731 | renderSystemBackgrounds(getFilteredImages()); |
| 732 | highlightSelectedBackground(); |
| 733 | } |
| 734 | } |
| 735 | |
| 736 | /** |
| 737 | * Preloads all image metadata to use dominant colors as placeholders. |
| 738 | * @return {Promise<void>} |
| 739 | */ |
| 740 | async function preloadImageMetadata() { |
| 741 | try { |
| 742 | const response = await fetch('/api/image-metadata/all', { |
| 743 | method: 'POST', |
| 744 | headers: getRequestHeaders(), |
| 745 | body: JSON.stringify({ prefix: 'backgrounds/' }), |
| 746 | }); |
| 747 | if (response.ok) { |
| 748 | const data = await response.json(); |
| 749 | if (data?.images) { |
| 750 | METADATA_CACHE.clear(); |
| 751 | for (const [path, metadata] of Object.entries(data.images)) { |
| 752 | METADATA_CACHE.set(path, metadata); |
| 753 | } |
| 754 | } |
| 755 | } |
| 756 | } catch (error) { |
| 757 | console.error('[ImageMetadata] Failed to preload metadata:', error); |
| 758 | } |
| 759 | } |
| 760 | |
| 761 | /** |
| 762 | * Loads folder data from the server (separate from image loading). |
| 763 | */ |
| 764 | async function loadFolders() { |
| 765 | try { |
| 766 | const response = await fetch('/api/backgrounds/folders', { |
| 767 | method: 'POST', |
| 768 | headers: getRequestHeaders(), |
| 769 | body: JSON.stringify({}), |
| 770 | }); |
| 771 | if (response.ok) { |
| 772 | const data = await response.json(); |
| 773 | folderList = data.folders || []; |
| 774 | imageFolderMap = data.imageFolderMap || {}; |
| 775 | |
| 776 | // Auto-assign thumbnail for folders that don't have one, then persist |
| 777 | const allImages = cachedSystemBackgrounds.map(img => img.filename); |
| 778 | /** @type {{id: string, thumbnailFile: string}[]} */ |
| 779 | const thumbnailUpdates = []; |
| 780 | for (const folder of folderList) { |
| 781 | if (!folder.thumbnailFile) { |
| 782 | const firstImage = allImages.find(img => { |
| 783 | const fids = imageFolderMap[img]; |
| 784 | return fids && fids.includes(folder.id); |
| 785 | }); |
| 786 | if (firstImage) { |
| 787 | folder.thumbnailFile = firstImage; |
| 788 | thumbnailUpdates.push({ id: folder.id, thumbnailFile: firstImage }); |
| 789 | } |
| 790 | } |
| 791 | } |
| 792 | if (thumbnailUpdates.length > 0) { |
| 793 | await fetch('/api/image-metadata/folders/set-thumbnails', { |
| 794 | method: 'POST', |
| 795 | headers: getRequestHeaders(), |
| 796 | body: JSON.stringify({ updates: thumbnailUpdates }), |
| 797 | }).catch(err => console.debug('Auto-thumbnail save failed:', err)); |
| 798 | } |
| 799 | |
| 800 | renderFolderGrid(); |
| 801 | } |
| 802 | } catch (error) { |
| 803 | console.error('Error loading folders:', error); |
| 804 | } |
| 805 | } |
| 806 | |
| 807 | /** |
| 808 | * Renders the folder grid inside #bg_folder_grid. |
| 809 | */ |
| 810 | function renderFolderGrid() { |
| 811 | const container = $('#bg_folder_grid'); |
| 812 | container.empty(); |
| 813 | |
| 814 | if (folderList.length === 0 && !activeFolderId) { |
| 815 | return; |
| 816 | } |
| 817 | |
| 818 | for (const folder of folderList) { |
| 819 | const tile = createFolderTileElement(folder); |
| 820 | container.append(tile); |
| 821 | } |
| 822 | } |
| 823 | |
| 824 | /** |
| 825 | * Creates a single folder tile DOM element. |
| 826 | * @param {{id: string, name: string, thumbnailFile: string}} folder |
| 827 | * @returns {HTMLElement} |
| 828 | */ |
| 829 | function createFolderTileElement(folder) { |
| 830 | const tile = $('#bg_folder_tile_template .bg_folder_tile').clone(); |
| 831 | tile.attr('data-folder-id', folder.id); |
| 832 | tile.find('.bg_folder_tile_name').text(folder.name); |
| 833 | |
| 834 | // Set cover image (async, update when resolved) |
| 835 | getFolderCoverUrl(folder).then(coverUrl => { |
| 836 | if (coverUrl) { |
| 837 | tile.find('.bg_folder_tile_cover').css('background-image', `url("${coverUrl}")`); |
| 838 | } |
| 839 | }); |
| 840 | |
| 841 | return tile.get(0); |
| 842 | } |
| 843 | |
| 844 | /** |
| 845 | * Gets the cover image URL for a folder. |
| 846 | * Uses thumbnailFile if set, otherwise falls back to the first image in the folder. |
| 847 | * @param {{id: string, name: string, thumbnailFile: string}} folder |
| 848 | * @returns {Promise<string|null>} |
| 849 | */ |
| 850 | async function getFolderCoverUrl(folder) { |
| 851 | const file = folder.thumbnailFile || cachedSystemBackgrounds.find(img => { |
| 852 | const fids = imageFolderMap[img.filename]; |
| 853 | return fids && fids.includes(folder.id); |
| 854 | })?.filename; |
| 855 | if (!file) return null; |
| 856 | |
| 857 | if (isAnimatedBackgroundExtension(file) && !background_settings.animation) { |
| 858 | return getThumbnailFromStorage(file, false); |
| 859 | } |
| 860 | return getThumbnailUrl('bg', file); |
| 861 | } |
| 862 | |
| 863 | /** |
| 864 | * Gets images filtered by the active folder. |
| 865 | * @returns {Array<{filename: string, isAnimated: boolean}>} |
| 866 | */ |
| 867 | function getFilteredImages() { |
| 868 | if (!activeFolderId) return cachedSystemBackgrounds; |
| 869 | return cachedSystemBackgrounds.filter(img => { |
| 870 | const fids = imageFolderMap[img.filename]; |
| 871 | return fids && fids.includes(activeFolderId); |
| 872 | }); |
| 873 | } |
| 874 | |
| 875 | /** |
| 876 | * Drills into a folder — hides folder grid, shows breadcrumb, filters images. |
| 877 | * @param {string} folderId |
| 878 | */ |
| 879 | function onFolderDrillIn(folderId) { |
| 880 | const folder = folderList.find(f => f.id === folderId); |
| 881 | if (!folder) return; |
| 882 | |
| 883 | clearBackgroundGroupSelection(); |
| 884 | activeFolderId = folderId; |
| 885 | $('#Backgrounds').addClass('in-folder-view'); |
| 886 | |
| 887 | // Hide folder grid, show breadcrumb |
| 888 | $('#bg_folder_grid').hide(); |
| 889 | $('#bg_folder_breadcrumb').show(); |
| 890 | $('#bg_current_folder_name').text(folder.name); |
| 891 | |
| 892 | // Render only this folder's images |
| 893 | renderSystemBackgrounds(getFilteredImages()); |
| 894 | highlightSelectedBackground(); |
| 895 | } |
| 896 | |
| 897 | /** |
| 898 | * Returns to the root folder overview. |
| 899 | */ |
| 900 | function onBackToFolders() { |
| 901 | clearBackgroundGroupSelection(); |
| 902 | activeFolderId = null; |
| 903 | $('#Backgrounds').removeClass('in-folder-view'); |
| 904 | |
| 905 | // Show folder grid, hide breadcrumb |
| 906 | $('#bg_folder_grid').show(); |
| 907 | $('#bg_folder_breadcrumb').hide(); |
| 908 | $('#bg_current_folder_name').text(''); |
| 909 | |
| 910 | // Show all images |
| 911 | renderSystemBackgrounds(getFilteredImages()); |
| 912 | highlightSelectedBackground(); |
| 913 | } |
| 914 | |
| 915 | /** |
| 916 | * Refreshes click-to-select and group action UI state. |
| 917 | */ |
| 918 | function syncGroupSelectionUi() { |
| 919 | const selectedCount = selectedSystemBackgroundFiles.size; |
| 920 | const isGlobalTab = getActiveBackgroundTab() === BG_SOURCES.GLOBAL; |
| 921 | const showAddButton = isGlobalTab && isBackgroundSelectionMode && selectedCount > 0; |
| 922 | const showRemoveFromCurrentFolderButton = isGlobalTab && Boolean(activeFolderId) && isBackgroundSelectionMode && selectedCount > 0; |
| 923 | |
| 924 | $('#Backgrounds').toggleClass('bg-selection-mode', isBackgroundSelectionMode); |
| 925 | $('#bg_selection_mode_button').toggleClass('active', isBackgroundSelectionMode); |
| 926 | $('#bg_group_select_count').text(selectedCount > 0 ? ` (${selectedCount})` : '').toggle(selectedCount > 0); |
| 927 | |
| 928 | $('#bg_group_add_to_folder_button').toggle(showAddButton); |
| 929 | $('#bg_folder_remove_selected_button').toggle(showRemoveFromCurrentFolderButton); |
| 930 | |
| 931 | $('#bg_menu_content .bg_example').each(function () { |
| 932 | const bgFile = String($(this).attr('bgfile') || ''); |
| 933 | $(this).toggleClass('folder-group-selected', selectedSystemBackgroundFiles.has(bgFile)); |
| 934 | }); |
| 935 | } |
| 936 | |
| 937 | /** |
| 938 | * Enables/disables click-to-select mode for system backgrounds. |
| 939 | * @param {boolean} enabled |
| 940 | */ |
| 941 | function setBackgroundSelectionMode(enabled) { |
| 942 | isBackgroundSelectionMode = enabled; |
| 943 | if (!enabled) { |
| 944 | selectedSystemBackgroundFiles.clear(); |
| 945 | } |
| 946 | // Clear any open mobile menus |
| 947 | $('#bg_menu_content .bg_example.mobile-menu-open').removeClass('mobile-menu-open'); |
| 948 | syncGroupSelectionUi(); |
| 949 | } |
| 950 | |
| 951 | /** |
| 952 | * Toggles selected state of a system background for group folder actions. |
| 953 | * @param {string} bgFile |
| 954 | */ |
| 955 | function toggleBackgroundGroupSelection(bgFile) { |
| 956 | if (!bgFile) return; |
| 957 | if (selectedSystemBackgroundFiles.has(bgFile)) { |
| 958 | selectedSystemBackgroundFiles.delete(bgFile); |
| 959 | } else { |
| 960 | selectedSystemBackgroundFiles.add(bgFile); |
| 961 | } |
| 962 | syncGroupSelectionUi(); |
| 963 | } |
| 964 | |
| 965 | /** |
| 966 | * Clears all selected system backgrounds for group folder actions. |
| 967 | */ |
| 968 | function clearBackgroundGroupSelection() { |
| 969 | selectedSystemBackgroundFiles.clear(); |
| 970 | syncGroupSelectionUi(); |
| 971 | } |
| 972 | |
| 973 | /** |
| 974 | * Updates selection/folder action control visibility for the active tab. |
| 975 | */ |
| 976 | function updateGroupFolderControlsVisibility() { |
| 977 | const isGlobalTab = getActiveBackgroundTab() === BG_SOURCES.GLOBAL; |
| 978 | $('#bg_selection_mode_button').toggle(isGlobalTab); |
| 979 | |
| 980 | if (!isGlobalTab && isBackgroundSelectionMode) { |
| 981 | setBackgroundSelectionMode(false); |
| 982 | return; |
| 983 | } |
| 984 | syncGroupSelectionUi(); |
| 985 | } |
| 986 | |
| 987 | /** |
| 988 | * Shows a folder selection popup and returns the selected folder id. |
| 989 | * @param {string} headingText |
| 990 | * @returns {Promise<string[]|null>} Array of selected folder IDs, or null if cancelled |
| 991 | */ |
| 992 | async function selectFoldersForGroupAction(headingText) { |
| 993 | if (folderList.length === 0) { |
| 994 | toastr.info(t`Create a folder first`); |
| 995 | return null; |
| 996 | } |
| 997 | |
| 998 | const contentEl = document.createElement('div'); |
| 999 | const heading = document.createElement('h3'); |
| 1000 | heading.textContent = headingText; |
| 1001 | contentEl.appendChild(heading); |
| 1002 | |
| 1003 | for (const folder of folderList) { |
| 1004 | const label = document.createElement('label'); |
| 1005 | label.className = 'checkbox_label flexGap5'; |
| 1006 | label.style.margin = '4px 0'; |
| 1007 | |
| 1008 | const checkbox = document.createElement('input'); |
| 1009 | checkbox.type = 'checkbox'; |
| 1010 | checkbox.dataset.folderId = folder.id; |
| 1011 | |
| 1012 | const span = document.createElement('span'); |
| 1013 | span.textContent = folder.name; |
| 1014 | |
| 1015 | label.appendChild(checkbox); |
| 1016 | label.appendChild(span); |
| 1017 | contentEl.appendChild(label); |
| 1018 | } |
| 1019 | |
| 1020 | const content = $(contentEl); |
| 1021 | const result = await callGenericPopup(content, POPUP_TYPE.CONFIRM, '', { |
| 1022 | okButton: t`Apply`, |
| 1023 | cancelButton: t`Cancel`, |
| 1024 | allowVerticalScrolling: true, |
| 1025 | leftAlign: true, |
| 1026 | }); |
| 1027 | if (!result) return null; |
| 1028 | |
| 1029 | const selectedIds = []; |
| 1030 | content.find('input[type="checkbox"]:checked').each(function () { |
| 1031 | selectedIds.push($(this).data('folderId')); |
| 1032 | }); |
| 1033 | return selectedIds.length > 0 ? selectedIds : null; |
| 1034 | } |
| 1035 | |
| 1036 | /** |
| 1037 | * Sends a folder assign/unassign request and updates local imageFolderMap state. |
| 1038 | * @param {string[]} bgFiles - Background filenames to update |
| 1039 | * @param {string} folderId - Target folder ID |
| 1040 | * @param {boolean} isRemove - Whether to remove (unassign) or add (assign) |
| 1041 | */ |
| 1042 | async function updateFolderAssignments(bgFiles, folderId, isRemove) { |
| 1043 | const paths = bgFiles.map(getBackgroundRelativePath); |
| 1044 | const endpoint = isRemove ? '/api/image-metadata/folders/unassign' : '/api/image-metadata/folders/assign'; |
| 1045 | |
| 1046 | const response = await fetch(endpoint, { |
| 1047 | method: 'POST', |
| 1048 | headers: getRequestHeaders(), |
| 1049 | body: JSON.stringify({ id: folderId, paths }), |
| 1050 | }); |
| 1051 | |
| 1052 | if (!response.ok) { |
| 1053 | throw new Error(`Folder ${isRemove ? 'unassign' : 'assign'} failed: ${response.status}`); |
| 1054 | } |
| 1055 | |
| 1056 | for (const bgFile of bgFiles) { |
| 1057 | const currentFolderIds = imageFolderMap[bgFile] || []; |
| 1058 | if (isRemove) { |
| 1059 | const nextFolderIds = currentFolderIds.filter(id => id !== folderId); |
| 1060 | if (nextFolderIds.length > 0) { |
| 1061 | imageFolderMap[bgFile] = nextFolderIds; |
| 1062 | } else { |
| 1063 | delete imageFolderMap[bgFile]; |
| 1064 | } |
| 1065 | } else if (!currentFolderIds.includes(folderId)) { |
| 1066 | imageFolderMap[bgFile] = [...currentFolderIds, folderId]; |
| 1067 | } |
| 1068 | } |
| 1069 | } |
| 1070 | |
| 1071 | /** |
| 1072 | * Adds selected system backgrounds to a chosen folder. |
| 1073 | */ |
| 1074 | async function onAddSelectedToFolder() { |
| 1075 | if (getActiveBackgroundTab() !== BG_SOURCES.GLOBAL) { |
| 1076 | toastr.warning(t`Folder actions are only available in the Global tab`); |
| 1077 | return; |
| 1078 | } |
| 1079 | |
| 1080 | const bgFiles = Array.from(selectedSystemBackgroundFiles); |
| 1081 | if (bgFiles.length === 0) { |
| 1082 | toastr.info(t`Select one or more backgrounds first`); |
| 1083 | return; |
| 1084 | } |
| 1085 | |
| 1086 | const folderIds = await selectFoldersForGroupAction(t`Add selected backgrounds to folders`); |
| 1087 | if (!folderIds) return; |
| 1088 | |
| 1089 | try { |
| 1090 | let totalAdded = 0; |
| 1091 | for (const folderId of folderIds) { |
| 1092 | const actionableBgFiles = bgFiles.filter(bgFile => { |
| 1093 | const currentFolderIds = imageFolderMap[bgFile] || []; |
| 1094 | return !currentFolderIds.includes(folderId); |
| 1095 | }); |
| 1096 | if (actionableBgFiles.length > 0) { |
| 1097 | await updateFolderAssignments(actionableBgFiles, folderId, false); |
| 1098 | totalAdded += actionableBgFiles.length; |
| 1099 | } |
| 1100 | } |
| 1101 | |
| 1102 | renderFolderGrid(); |
| 1103 | |
| 1104 | if (activeFolderId) { |
| 1105 | renderSystemBackgrounds(getFilteredImages()); |
| 1106 | highlightSelectedBackground(); |
| 1107 | } |
| 1108 | |
| 1109 | setBackgroundSelectionMode(false); |
| 1110 | if (totalAdded > 0) { |
| 1111 | toastr.success(t`Added backgrounds to ${folderIds.length} folder(s)`); |
| 1112 | } else { |
| 1113 | toastr.info(t`Selected backgrounds are already in the chosen folders`); |
| 1114 | } |
| 1115 | } catch (error) { |
| 1116 | console.error('Error adding selected backgrounds to folder:', error); |
| 1117 | toastr.error(t`Failed to update folder assignment`); |
| 1118 | } |
| 1119 | } |
| 1120 | |
| 1121 | /** |
| 1122 | * Removes selected system backgrounds from the currently drilled-in folder. |
| 1123 | */ |
| 1124 | async function onRemoveSelectedFromCurrentFolder() { |
| 1125 | if (getActiveBackgroundTab() !== BG_SOURCES.GLOBAL) { |
| 1126 | toastr.warning(t`Folder actions are only available in the Global tab`); |
| 1127 | return; |
| 1128 | } |
| 1129 | |
| 1130 | if (!activeFolderId) { |
| 1131 | toastr.info(t`Open a folder first`); |
| 1132 | return; |
| 1133 | } |
| 1134 | |
| 1135 | const bgFiles = Array.from(selectedSystemBackgroundFiles); |
| 1136 | if (bgFiles.length === 0) { |
| 1137 | toastr.info(t`Select one or more backgrounds first`); |
| 1138 | return; |
| 1139 | } |
| 1140 | |
| 1141 | try { |
| 1142 | await updateFolderAssignments(bgFiles, activeFolderId, true); |
| 1143 | renderFolderGrid(); |
| 1144 | renderSystemBackgrounds(getFilteredImages()); |
| 1145 | highlightSelectedBackground(); |
| 1146 | setBackgroundSelectionMode(false); |
| 1147 | toastr.success(t`Removed ${bgFiles.length} background(s) from folder`); |
| 1148 | } catch (error) { |
| 1149 | console.error('Error removing selected backgrounds from current folder:', error); |
| 1150 | toastr.error(t`Failed to update folder assignment`); |
| 1151 | } |
| 1152 | } |
| 1153 | |
| 1154 | /** |
| 1155 | * Creates a new folder via API. |
| 1156 | */ |
| 1157 | async function onCreateFolder() { |
| 1158 | const currentTab = getActiveBackgroundTab(); |
| 1159 | if (currentTab !== BG_SOURCES.GLOBAL) { |
| 1160 | toastr.warning(t`Folders can only be created in the Global tab`); |
| 1161 | return; |
| 1162 | } |
| 1163 | |
| 1164 | const name = await Popup.show.input(t`Enter folder name:`); |
| 1165 | if (!name || !name.trim()) return; |
| 1166 | |
| 1167 | try { |
| 1168 | const response = await fetch('/api/image-metadata/folders/create', { |
| 1169 | method: 'POST', |
| 1170 | headers: getRequestHeaders(), |
| 1171 | body: JSON.stringify({ name: name.trim() }), |
| 1172 | }); |
| 1173 | if (response.ok) { |
| 1174 | const folder = await response.json(); |
| 1175 | folderList.push(folder); |
| 1176 | renderFolderGrid(); |
| 1177 | toastr.success(t`Folder created: ${folder.name}`); |
| 1178 | } |
| 1179 | } catch (error) { |
| 1180 | console.error('Error creating folder:', error); |
| 1181 | toastr.error(t`Failed to create folder`); |
| 1182 | } |
| 1183 | } |
| 1184 | |
| 1185 | /** |
| 1186 | * Renames a folder via API. |
| 1187 | * @param {string} folderId |
| 1188 | */ |
| 1189 | async function onRenameFolder(folderId) { |
| 1190 | const folder = folderList.find(f => f.id === folderId); |
| 1191 | if (!folder) return; |
| 1192 | |
| 1193 | const newName = await Popup.show.input(t`Enter new folder name:`, null, folder.name); |
| 1194 | if (!newName || !newName.trim() || newName.trim() === folder.name) return; |
| 1195 | |
| 1196 | try { |
| 1197 | const response = await fetch('/api/image-metadata/folders/update', { |
| 1198 | method: 'POST', |
| 1199 | headers: getRequestHeaders(), |
| 1200 | body: JSON.stringify({ id: folderId, name: newName.trim() }), |
| 1201 | }); |
| 1202 | if (response.ok) { |
| 1203 | folder.name = newName.trim(); |
| 1204 | renderFolderGrid(); |
| 1205 | toastr.success(t`Folder renamed`); |
| 1206 | } |
| 1207 | } catch (error) { |
| 1208 | console.error('Error renaming folder:', error); |
| 1209 | toastr.error(t`Failed to rename folder`); |
| 1210 | } |
| 1211 | } |
| 1212 | |
| 1213 | /** |
| 1214 | * Deletes a folder via API. |
| 1215 | * @param {string} folderId |
| 1216 | */ |
| 1217 | async function onDeleteFolder(folderId) { |
| 1218 | const folder = folderList.find(f => f.id === folderId); |
| 1219 | if (!folder) return; |
| 1220 | |
| 1221 | const confirm = await Popup.show.confirm(t`Delete folder "${folder.name}"?`, t`Images will not be deleted, only the folder grouping.`); |
| 1222 | if (!confirm) return; |
| 1223 | |
| 1224 | try { |
| 1225 | const response = await fetch('/api/image-metadata/folders/delete', { |
| 1226 | method: 'POST', |
| 1227 | headers: getRequestHeaders(), |
| 1228 | body: JSON.stringify({ id: folderId }), |
| 1229 | }); |
| 1230 | if (response.ok) { |
| 1231 | folderList = folderList.filter(f => f.id !== folderId); |
| 1232 | // Clean imageFolderMap |
| 1233 | for (const fids of Object.values(imageFolderMap)) { |
| 1234 | const idx = fids.indexOf(folderId); |
| 1235 | if (idx !== -1) fids.splice(idx, 1); |
| 1236 | } |
| 1237 | // If we were inside this folder, go back |
| 1238 | if (activeFolderId === folderId) { |
| 1239 | onBackToFolders(); |
| 1240 | } |
| 1241 | renderFolderGrid(); |
| 1242 | toastr.success(t`Folder deleted`); |
| 1243 | } |
| 1244 | } catch (error) { |
| 1245 | console.error('Error deleting folder:', error); |
| 1246 | toastr.error(t`Failed to delete folder`); |
| 1247 | } |
| 1248 | } |
| 1249 | |
| 1250 | /** |
| 1251 | * Shows a folder assignment popup for an image. |
| 1252 | * @param {string} bgFile - The background filename |
| 1253 | */ |
| 1254 | async function onAssignToFolder(bgFile) { |
| 1255 | if (folderList.length === 0) { |
| 1256 | toastr.info(t`Create a folder first`); |
| 1257 | return; |
| 1258 | } |
| 1259 | |
| 1260 | const currentFolderIds = imageFolderMap[bgFile] || []; |
| 1261 | |
| 1262 | // Build checkbox inputs for Popup using DOM construction (avoids HTML injection) |
| 1263 | const contentEl = document.createElement('div'); |
| 1264 | const heading = document.createElement('h3'); |
| 1265 | heading.textContent = t`Assign to folders`; |
| 1266 | contentEl.appendChild(heading); |
| 1267 | |
| 1268 | for (const f of folderList) { |
| 1269 | const label = document.createElement('label'); |
| 1270 | label.className = 'checkbox_label flexGap5'; |
| 1271 | label.style.margin = '4px 0'; |
| 1272 | |
| 1273 | const checkbox = document.createElement('input'); |
| 1274 | checkbox.type = 'checkbox'; |
| 1275 | checkbox.dataset.folderId = f.id; |
| 1276 | checkbox.checked = currentFolderIds.includes(f.id); |
| 1277 | |
| 1278 | const span = document.createElement('span'); |
| 1279 | span.textContent = f.name; |
| 1280 | |
| 1281 | label.appendChild(checkbox); |
| 1282 | label.appendChild(span); |
| 1283 | contentEl.appendChild(label); |
| 1284 | } |
| 1285 | |
| 1286 | const content = $(contentEl); |
| 1287 | |
| 1288 | const result = await callGenericPopup(content, POPUP_TYPE.CONFIRM, '', { okButton: t`Save`, cancelButton: t`Cancel` }); |
| 1289 | if (!result) return; |
| 1290 | |
| 1291 | // Determine which folders were toggled on/off |
| 1292 | const toAssign = []; |
| 1293 | const toUnassign = []; |
| 1294 | content.find('input[type="checkbox"]').each(function () { |
| 1295 | const fid = $(this).data('folder-id'); |
| 1296 | const isChecked = $(this).prop('checked'); |
| 1297 | const wasChecked = currentFolderIds.includes(fid); |
| 1298 | if (isChecked && !wasChecked) toAssign.push(fid); |
| 1299 | if (!isChecked && wasChecked) toUnassign.push(fid); |
| 1300 | }); |
| 1301 | |
| 1302 | try { |
| 1303 | for (const fid of toAssign) { |
| 1304 | await updateFolderAssignments([bgFile], fid, false); |
| 1305 | } |
| 1306 | for (const fid of toUnassign) { |
| 1307 | await updateFolderAssignments([bgFile], fid, true); |
| 1308 | } |
| 1309 | |
| 1310 | renderFolderGrid(); |
| 1311 | |
| 1312 | // Re-render filtered image list if currently inside a folder view |
| 1313 | if (activeFolderId) { |
| 1314 | renderSystemBackgrounds(getFilteredImages()); |
| 1315 | highlightSelectedBackground(); |
| 1316 | } |
| 1317 | |
| 1318 | toastr.success(t`Folder assignment updated`); |
| 1319 | } catch (error) { |
| 1320 | console.error('Error assigning to folder:', error); |
| 1321 | toastr.error(t`Failed to update folder assignment`); |
| 1322 | } |
| 1323 | } |
| 1324 | |
| 1325 | /** |
| 1326 | * Sets an image as the folder cover. |
| 1327 | * @param {string} bgFile - The background filename |
| 1328 | */ |
| 1329 | async function onSetFolderCover(bgFile) { |
| 1330 | if (!activeFolderId) return; |
| 1331 | |
| 1332 | try { |
| 1333 | const response = await fetch('/api/image-metadata/folders/update', { |
| 1334 | method: 'POST', |
| 1335 | headers: getRequestHeaders(), |
| 1336 | body: JSON.stringify({ id: activeFolderId, thumbnailFile: bgFile }), |
| 1337 | }); |
| 1338 | if (response.ok) { |
| 1339 | const folder = folderList.find(f => f.id === activeFolderId); |
| 1340 | if (folder) { |
| 1341 | folder.thumbnailFile = bgFile; |
| 1342 | // Update the DOM tile cover image |
| 1343 | const coverUrl = await getFolderCoverUrl(folder); |
| 1344 | if (coverUrl) { |
| 1345 | $(`.bg_folder_tile[data-folder-id="${folder.id}"] .bg_folder_tile_cover`) |
| 1346 | .css('background-image', `url('${coverUrl}')`); |
| 1347 | } |
| 1348 | } |
| 1349 | toastr.success(t`Folder cover updated`); |
| 1350 | } |
| 1351 | } catch (error) { |
| 1352 | console.error('Error setting folder cover:', error); |
| 1353 | toastr.error(t`Failed to set folder cover`); |
| 1354 | } |
| 1355 | } |
| 1356 | |
| 1357 | function activateLazyLoader() { |
| 1358 | // Disconnect previous observer to prevent memory leaks |
| 1359 | if (lazyLoadObserver) { |
| 1360 | lazyLoadObserver.disconnect(); |
| 1361 | lazyLoadObserver = null; |
| 1362 | } |
| 1363 | |
| 1364 | const lazyLoadElements = document.querySelectorAll('.lazy-load-background'); |
| 1365 | |
| 1366 | const options = { |
| 1367 | root: null, |
| 1368 | rootMargin: '200px', |
| 1369 | threshold: 0.01, |
| 1370 | }; |
| 1371 | |
| 1372 | lazyLoadObserver = new IntersectionObserver((entries, observer) => { |
| 1373 | entries.forEach(entry => { |
| 1374 | if (entry.target instanceof HTMLElement && entry.isIntersecting) { |
| 1375 | const clipper = entry.target; |
| 1376 | const parentThumbnail = clipper.closest('.bg_example'); |
| 1377 | |
| 1378 | if (parentThumbnail) { |
| 1379 | const bg = parentThumbnail.getAttribute('bgfile'); |
| 1380 | const isCustom = parentThumbnail.getAttribute('custom') === 'true'; |
| 1381 | const isAnimated = parentThumbnail.getAttribute('animated') === 'true'; |
| 1382 | resolveImageUrl(bg, isCustom, isAnimated) |
| 1383 | .then(url => { clipper.style.backgroundImage = url; }) |
| 1384 | .catch(() => { clipper.style.backgroundImage = PLACEHOLDER_IMAGE; }); |
| 1385 | } |
| 1386 | |
| 1387 | clipper.classList.remove('lazy-load-background'); |
| 1388 | observer.unobserve(clipper); |
| 1389 | } |
| 1390 | }); |
| 1391 | }, options); |
| 1392 | |
| 1393 | lazyLoadElements.forEach(element => { |
| 1394 | lazyLoadObserver.observe(element); |
| 1395 | }); |
| 1396 | } |
| 1397 | |
| 1398 | /** |
| 1399 | * Gets the CSS URL of the background |
| 1400 | * @param {Element} block |
| 1401 | * @returns {string} URL of the background |
| 1402 | */ |
| 1403 | function getUrlParameter(block) { |
| 1404 | return $(block).closest('.bg_example').data('url'); |
| 1405 | } |
| 1406 | |
| 1407 | function generateUrlParameter(bg, isCustom) { |
| 1408 | return isCustom ? `url("${encodeURI(bg)}")` : `url("${getBackgroundPath(bg)}")`; |
| 1409 | } |
| 1410 | |
| 1411 | function isAnimatedBackgroundExtension(fileName) { |
| 1412 | const fileExtension = fileName.split('.').pop().toLowerCase(); |
| 1413 | return ANIMATED_BACKGROUND_EXTENSIONS.includes(fileExtension); |
| 1414 | } |
| 1415 | |
| 1416 | /** |
| 1417 | * Resolves the image URL for the background. |
| 1418 | * @param {string} bg Background file name |
| 1419 | * @param {boolean} isCustom Is a custom background |
| 1420 | * @param {boolean|null} [isAnimated=null] Is the background animated (from metadata). If null, infers from extension. |
| 1421 | * @returns {Promise<string>} CSS URL of the background |
| 1422 | */ |
| 1423 | async function resolveImageUrl(bg, isCustom, isAnimated = null) { |
| 1424 | // If isAnimated is not provided (null), fall back to extension-based heuristic |
| 1425 | let animated = isAnimated; |
| 1426 | if (animated === null) { |
| 1427 | animated = isAnimatedBackgroundExtension(bg); |
| 1428 | } |
| 1429 | |
| 1430 | const thumbnailUrl = animated && !background_settings.animation |
| 1431 | ? await getThumbnailFromStorage(bg, isCustom) |
| 1432 | : isCustom |
| 1433 | ? bg |
| 1434 | : getThumbnailUrl('bg', bg); |
| 1435 | |
| 1436 | return `url("${thumbnailUrl}")`; |
| 1437 | } |
| 1438 | |
| 1439 | async function setBackground(bg, url) { |
| 1440 | // Only change the visual background if one is not locked for the current chat. |
| 1441 | if (!isChatBackgroundLocked()) { |
| 1442 | $('#bg1').css('background-image', url); |
| 1443 | } |
| 1444 | background_settings.name = bg; |
| 1445 | background_settings.url = url; |
| 1446 | saveSettingsDebounced(); |
| 1447 | } |
| 1448 | |
| 1449 | async function delBackground(bg) { |
| 1450 | await fetch('/api/backgrounds/delete', { |
| 1451 | method: 'POST', |
| 1452 | headers: getRequestHeaders(), |
| 1453 | body: JSON.stringify({ |
| 1454 | bg: bg, |
| 1455 | }), |
| 1456 | }); |
| 1457 | |
| 1458 | await THUMBNAIL_STORAGE.removeItem(bg); |
| 1459 | if (THUMBNAIL_BLOBS.has(bg)) { |
| 1460 | URL.revokeObjectURL(THUMBNAIL_BLOBS.get(bg)); |
| 1461 | THUMBNAIL_BLOBS.delete(bg); |
| 1462 | } |
| 1463 | } |
| 1464 | |
| 1465 | /** |
| 1466 | * Background upload handler. |
| 1467 | * @param {Event} e Event |
| 1468 | * @returns {Promise<void>} |
| 1469 | */ |
| 1470 | async function onBackgroundUploadSelected(e) { |
| 1471 | const input = e.currentTarget; |
| 1472 | |
| 1473 | if (!(input instanceof HTMLInputElement)) { |
| 1474 | console.error('Invalid input element for background upload'); |
| 1475 | return; |
| 1476 | } |
| 1477 | |
| 1478 | for (const file of input.files) { |
| 1479 | if (file.size === 0) { |
| 1480 | continue; |
| 1481 | } |
| 1482 | |
| 1483 | const formData = new FormData(); |
| 1484 | formData.append('avatar', file); |
| 1485 | |
| 1486 | await convertFileIfVideo(formData); |
| 1487 | switch (getActiveBackgroundTab()) { |
| 1488 | case BG_SOURCES.GLOBAL: |
| 1489 | await uploadBackground(formData); |
| 1490 | break; |
| 1491 | case BG_SOURCES.CHAT: |
| 1492 | await uploadChatBackground(formData); |
| 1493 | break; |
| 1494 | default: |
| 1495 | console.error('Unknown background source type'); |
| 1496 | continue; |
| 1497 | } |
| 1498 | } |
| 1499 | |
| 1500 | // Allow re-uploading the same file again by clearing the input value |
| 1501 | input.value = ''; |
| 1502 | } |
| 1503 | |
| 1504 | /** |
| 1505 | * Converts a video file to an animated webp format if the file is a video. |
| 1506 | * @param {FormData} formData |
| 1507 | * @returns {Promise<void>} |
| 1508 | */ |
| 1509 | async function convertFileIfVideo(formData) { |
| 1510 | const file = formData.get('avatar'); |
| 1511 | if (!(file instanceof File)) { |
| 1512 | return; |
| 1513 | } |
| 1514 | if (!file.type.startsWith('video/')) { |
| 1515 | return; |
| 1516 | } |
| 1517 | if (typeof globalThis.convertVideoToAnimatedWebp !== 'function') { |
| 1518 | toastr.warning(t`Click here to install the Video Background Loader extension`, t`Video background uploads require a downloadable add-on`, { |
| 1519 | timeOut: 0, |
| 1520 | extendedTimeOut: 0, |
| 1521 | onclick: () => openThirdPartyExtensionMenu('https://github.com/SillyTavern/Extension-VideoBackgroundLoader'), |
| 1522 | }); |
| 1523 | return; |
| 1524 | } |
| 1525 | |
| 1526 | let toastMessage = jQuery(); |
| 1527 | try { |
| 1528 | toastMessage = toastr.info(t`Preparing video for upload. This may take several minutes.`, t`Please wait`, { timeOut: 0, extendedTimeOut: 0 }); |
| 1529 | const sourceBuffer = await file.arrayBuffer(); |
| 1530 | const convertedBuffer = await globalThis.convertVideoToAnimatedWebp({ buffer: new Uint8Array(sourceBuffer), name: file.name }); |
| 1531 | const convertedFileName = file.name.replace(/\.[^/.]+$/, '.webp'); |
| 1532 | const convertedFile = new File([new Uint8Array(convertedBuffer)], convertedFileName, { type: 'image/webp' }); |
| 1533 | formData.set('avatar', convertedFile); |
| 1534 | toastMessage.remove(); |
| 1535 | } catch (error) { |
| 1536 | formData.delete('avatar'); |
| 1537 | toastMessage.remove(); |
| 1538 | console.error('Error converting video to animated webp:', error); |
| 1539 | toastr.error(t`Error converting video to animated webp`); |
| 1540 | } |
| 1541 | } |
| 1542 | |
| 1543 | /** |
| 1544 | * Uploads a background to the server |
| 1545 | * @param {FormData} formData |
| 1546 | */ |
| 1547 | async function uploadBackground(formData) { |
| 1548 | try { |
| 1549 | if (!formData.has('avatar')) { |
| 1550 | console.log('No file provided. Background upload cancelled.'); |
| 1551 | return; |
| 1552 | } |
| 1553 | |
| 1554 | const response = await fetch('/api/backgrounds/upload', { |
| 1555 | method: 'POST', |
| 1556 | headers: getRequestHeaders({ omitContentType: true }), |
| 1557 | body: formData, |
| 1558 | cache: 'no-cache', |
| 1559 | }); |
| 1560 | |
| 1561 | if (!response.ok) { |
| 1562 | throw new Error('Failed to upload background'); |
| 1563 | } |
| 1564 | |
| 1565 | const bg = await response.text(); |
| 1566 | setBackground(bg, generateUrlParameter(bg, false)); |
| 1567 | await getBackgrounds(); |
| 1568 | highlightNewBackground(bg); |
| 1569 | } catch (error) { |
| 1570 | console.error('Error uploading background:', error); |
| 1571 | } |
| 1572 | } |
| 1573 | |
| 1574 | /** |
| 1575 | * Upload a chat background using a FormData object. |
| 1576 | * @param {FormData} formData FormData containing the background file |
| 1577 | * @returns {Promise<void>} |
| 1578 | */ |
| 1579 | async function uploadChatBackground(formData) { |
| 1580 | try { |
| 1581 | if (!getCurrentChatId()) { |
| 1582 | toastr.warning(t`Select a chat to upload a background for it`); |
| 1583 | return; |
| 1584 | } |
| 1585 | if (!formData.has('avatar')) { |
| 1586 | console.log('No file provided. Chat background upload cancelled.'); |
| 1587 | return; |
| 1588 | } |
| 1589 | |
| 1590 | const file = formData.get('avatar'); |
| 1591 | if (!(file instanceof File)) { |
| 1592 | console.error('Invalid file type for chat background upload'); |
| 1593 | return; |
| 1594 | } |
| 1595 | |
| 1596 | const imageDataUri = await getBase64Async(file); |
| 1597 | const base64Data = imageDataUri.split(',')[1]; |
| 1598 | const extension = getFileExtension(file); |
| 1599 | const characterName = selected_group |
| 1600 | ? groups.find(g => g.id === selected_group)?.id?.toString() |
| 1601 | : characters[this_chid]?.name; |
| 1602 | const filename = `${characterName}_${humanizedDateTime()}`; |
| 1603 | const imagePath = await saveBase64AsFile(base64Data, characterName, filename, extension); |
| 1604 | |
| 1605 | const list = chat_metadata[LIST_METADATA_KEY] || []; |
| 1606 | list.push(imagePath); |
| 1607 | chat_metadata[LIST_METADATA_KEY] = list; |
| 1608 | await saveMetadata(); |
| 1609 | renderChatBackgrounds(); |
| 1610 | highlightNewBackground(imagePath); |
| 1611 | highlightLockedBackground(); |
| 1612 | highlightSelectedBackground(); |
| 1613 | } catch (error) { |
| 1614 | console.error('Error uploading chat background:', error); |
| 1615 | } |
| 1616 | } |
| 1617 | |
| 1618 | /** |
| 1619 | * @param {string} bg |
| 1620 | */ |
| 1621 | function highlightNewBackground(bg) { |
| 1622 | const newBg = $(`.bg_example[bgfile="${bg}"]`); |
| 1623 | const scrollOffset = newBg.offset().top - newBg.parent().offset().top; |
| 1624 | $('#Backgrounds').scrollTop(scrollOffset); |
| 1625 | flashHighlight(newBg); |
| 1626 | } |
| 1627 | |
| 1628 | /** |
| 1629 | * Sets the fitting class for the background element |
| 1630 | * @param {string} fitting Fitting type |
| 1631 | */ |
| 1632 | function setFittingClass(fitting) { |
| 1633 | const backgrounds = $('#bg1'); |
| 1634 | for (const option of ['cover', 'contain', 'stretch', 'center']) { |
| 1635 | backgrounds.toggleClass(option, option === fitting); |
| 1636 | } |
| 1637 | background_settings.fitting = fitting; |
| 1638 | } |
| 1639 | |
| 1640 | function highlightSelectedBackground() { |
| 1641 | $('.bg_example.selected-background').removeClass('selected-background'); |
| 1642 | |
| 1643 | // The "selected" highlight should always reflect the global background setting. |
| 1644 | const activeUrl = background_settings.url; |
| 1645 | |
| 1646 | if (activeUrl) { |
| 1647 | // Find the thumbnail whose data-url attribute matches the active URL |
| 1648 | $('.bg_example').filter(function () { |
| 1649 | return $(this).data('url') === activeUrl; |
| 1650 | }).addClass('selected-background'); |
| 1651 | } |
| 1652 | } |
| 1653 | |
| 1654 | function onBackgroundFilterInput() { |
| 1655 | const filterValue = String($('#bg-filter').val()).toLowerCase(); |
| 1656 | $('#bg_menu_content > .bg_example, #bg_custom_content > .bg_example').each(function () { |
| 1657 | const $bg = $(this); |
| 1658 | const title = $bg.attr('title') || ''; |
| 1659 | const hasMatch = title.toLowerCase().includes(filterValue); |
| 1660 | $bg.toggle(hasMatch); |
| 1661 | }); |
| 1662 | |
| 1663 | // Show/hide folder tiles based on whether folder name matches the filter |
| 1664 | if (!activeFolderId) { |
| 1665 | $('#bg_folder_grid .bg_folder_tile').each(function () { |
| 1666 | const $tile = $(this); |
| 1667 | const folderId = $tile.attr('data-folder-id'); |
| 1668 | if (!folderId || !filterValue) { |
| 1669 | $tile.show(); |
| 1670 | return; |
| 1671 | } |
| 1672 | const folder = folderList.find(f => f.id === folderId); |
| 1673 | const folderName = folder ? folder.name.toLowerCase() : ''; |
| 1674 | $tile.toggle(folderName.includes(filterValue)); |
| 1675 | }); |
| 1676 | } |
| 1677 | } |
| 1678 | |
| 1679 | const debouncedOnBackgroundFilterInput = debounce(onBackgroundFilterInput, debounce_timeout.standard); |
| 1680 | |
| 1681 | /** |
| 1682 | * Gets the active background tab source. |
| 1683 | * @returns {BG_SOURCES} Active background tab source |
| 1684 | */ |
| 1685 | export function getActiveBackgroundTab() { |
| 1686 | const tabs = $('#bg_tabs'); |
| 1687 | if (!tabs.length || !tabs.data('ui-tabs')) { |
| 1688 | return BG_SOURCES.GLOBAL; |
| 1689 | } |
| 1690 | return tabs.tabs('option', 'active'); |
| 1691 | } |
| 1692 | |
| 1693 | export function initBackgrounds() { |
| 1694 | eventSource.on(event_types.CHAT_CHANGED, onChatChanged); |
| 1695 | eventSource.on(event_types.FORCE_SET_BACKGROUND, forceSetBackground); |
| 1696 | |
| 1697 | // Folder event handlers |
| 1698 | $(document) |
| 1699 | .on('click', '.bg_folder_tile:not(.bg_new_folder_tile)', function (e) { |
| 1700 | if ($(e.target).closest('.jg-button').length) return; // let button handler run |
| 1701 | const folderId = $(this).attr('data-folder-id'); |
| 1702 | if (folderId) onFolderDrillIn(folderId); |
| 1703 | }) |
| 1704 | .on('click', '#bg_add_folder_button', function () { |
| 1705 | onCreateFolder(); |
| 1706 | }) |
| 1707 | .on('click', '#bg_back_to_folders', function () { |
| 1708 | onBackToFolders(); |
| 1709 | }) |
| 1710 | .on('click', '.bg_folder_tile [data-action="rename-folder"]', function (e) { |
| 1711 | e.stopPropagation(); |
| 1712 | const folderId = $(this).closest('.bg_folder_tile').attr('data-folder-id'); |
| 1713 | if (folderId) onRenameFolder(folderId); |
| 1714 | }) |
| 1715 | .on('click', '.bg_folder_tile [data-action="delete-folder"]', function (e) { |
| 1716 | e.stopPropagation(); |
| 1717 | const folderId = $(this).closest('.bg_folder_tile').attr('data-folder-id'); |
| 1718 | if (folderId) onDeleteFolder(folderId); |
| 1719 | }) |
| 1720 | .on('click', '.bg_folder_tile .mobile-only-menu-toggle', function (e) { |
| 1721 | e.stopPropagation(); |
| 1722 | const $context = $(this).closest('.bg_folder_tile'); |
| 1723 | const wasOpen = $context.hasClass('mobile-menu-open'); |
| 1724 | // Close all other open menus before opening a new one. |
| 1725 | $('.bg_folder_tile.mobile-menu-open').removeClass('mobile-menu-open'); |
| 1726 | $('.bg_example.mobile-menu-open').removeClass('mobile-menu-open'); |
| 1727 | if (!wasOpen) { |
| 1728 | $context.addClass('mobile-menu-open'); |
| 1729 | } |
| 1730 | }); |
| 1731 | |
| 1732 | $(document) |
| 1733 | .off('click', '.bg_example').on('click', '.bg_example', onSelectBackgroundClick) |
| 1734 | .off('click', '.bg_example .mobile-only-menu-toggle').on('click', '.bg_example .mobile-only-menu-toggle', function (e) { |
| 1735 | e.stopPropagation(); |
| 1736 | const $context = $(this).closest('.bg_example'); |
| 1737 | const wasOpen = $context.hasClass('mobile-menu-open'); |
| 1738 | // Close all other open menus before opening a new one. |
| 1739 | $('.bg_example.mobile-menu-open').removeClass('mobile-menu-open'); |
| 1740 | $('.bg_folder_tile.mobile-menu-open').removeClass('mobile-menu-open'); |
| 1741 | if (!wasOpen) { |
| 1742 | $context.addClass('mobile-menu-open'); |
| 1743 | } |
| 1744 | }) |
| 1745 | .off('blur', '.bg_example.mobile-menu-open').on('blur', '.bg_example.mobile-menu-open', function () { |
| 1746 | if (!$(this).is(':focus-within')) { |
| 1747 | $(this).removeClass('mobile-menu-open'); |
| 1748 | } |
| 1749 | }) |
| 1750 | .off('click', '.jg-button').on('click', '.jg-button', function (e) { |
| 1751 | e.stopPropagation(); |
| 1752 | if (isBackgroundSelectionMode && $(this).closest('#bg_menu_content').length) { |
| 1753 | return; |
| 1754 | } |
| 1755 | const action = $(this).data('action'); |
| 1756 | |
| 1757 | switch (action) { |
| 1758 | case 'lock': |
| 1759 | onLockBackgroundClick.call(this, e.originalEvent); |
| 1760 | break; |
| 1761 | case 'unlock': |
| 1762 | onUnlockBackgroundClick.call(this, e.originalEvent); |
| 1763 | break; |
| 1764 | case 'edit': |
| 1765 | onRenameBackgroundClick.call(this, e.originalEvent); |
| 1766 | break; |
| 1767 | case 'delete': |
| 1768 | onDeleteBackgroundClick.call(this, e.originalEvent); |
| 1769 | break; |
| 1770 | case 'copy': |
| 1771 | onCopyToSystemBackgroundClick.call(this, e.originalEvent); |
| 1772 | break; |
| 1773 | case 'folder': { |
| 1774 | const bgEl = $(this).closest('.bg_example'); |
| 1775 | if (bgEl.attr('custom') === 'true') break; // Only system backgrounds |
| 1776 | const bgFile = bgEl.attr('bgfile'); |
| 1777 | if (bgFile) onAssignToFolder(bgFile); |
| 1778 | break; |
| 1779 | } |
| 1780 | case 'set-cover': { |
| 1781 | const bgEl = $(this).closest('.bg_example'); |
| 1782 | if (bgEl.attr('custom') === 'true') break; // Only system backgrounds |
| 1783 | const bgFile = bgEl.attr('bgfile'); |
| 1784 | if (bgFile) onSetFolderCover(bgFile); |
| 1785 | break; |
| 1786 | } |
| 1787 | } |
| 1788 | }); |
| 1789 | |
| 1790 | $('#bg_thumb_zoom_in').on('click', () => { |
| 1791 | applyThumbnailColumns(background_settings.thumbnailColumns - 1); |
| 1792 | }); |
| 1793 | $('#bg_thumb_zoom_out').on('click', () => { |
| 1794 | applyThumbnailColumns(background_settings.thumbnailColumns + 1); |
| 1795 | }); |
| 1796 | $('#auto_background').on('click', autoBackgroundCommand); |
| 1797 | $('#bg_selection_mode_button').on('click', () => setBackgroundSelectionMode(!isBackgroundSelectionMode)); |
| 1798 | $('#bg_group_add_to_folder_button').on('click', onAddSelectedToFolder); |
| 1799 | $('#bg_folder_remove_selected_button').on('click', onRemoveSelectedFromCurrentFolder); |
| 1800 | $('#add_bg_button').on('change', (e) => onBackgroundUploadSelected(e.originalEvent)); |
| 1801 | $('#bg-filter').on('input', () => debouncedOnBackgroundFilterInput()); |
| 1802 | $('#bg-sort').on('change', function () { |
| 1803 | background_settings.sortOrder = String($(this).val()); |
| 1804 | saveSettingsDebounced(); |
| 1805 | // Re-render both galleries with new sort order (respecting active folder filter) |
| 1806 | renderSystemBackgrounds(getFilteredImages()); |
| 1807 | renderChatBackgrounds(); |
| 1808 | highlightSelectedBackground(); |
| 1809 | highlightLockedBackground(); |
| 1810 | // Re-apply any active search filter |
| 1811 | onBackgroundFilterInput(); |
| 1812 | }); |
| 1813 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 1814 | name: 'lockbg', |
| 1815 | callback: () => { |
| 1816 | onLockBackgroundClick(); |
| 1817 | return ''; |
| 1818 | }, |
| 1819 | aliases: ['bglock'], |
| 1820 | helpString: 'Locks a background for the currently selected chat', |
| 1821 | })); |
| 1822 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 1823 | name: 'unlockbg', |
| 1824 | callback: () => { |
| 1825 | onUnlockBackgroundClick(); |
| 1826 | return ''; |
| 1827 | }, |
| 1828 | aliases: ['bgunlock'], |
| 1829 | helpString: 'Unlocks a background for the currently selected chat', |
| 1830 | })); |
| 1831 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 1832 | name: 'autobg', |
| 1833 | callback: autoBackgroundCommand, |
| 1834 | aliases: ['bgauto'], |
| 1835 | helpString: 'Automatically changes the background based on the chat context using the AI request prompt', |
| 1836 | })); |
| 1837 | |
| 1838 | $('#background_fitting').on('input', function () { |
| 1839 | background_settings.fitting = String($(this).val()); |
| 1840 | setFittingClass(background_settings.fitting); |
| 1841 | saveSettingsDebounced(); |
| 1842 | }); |
| 1843 | |
| 1844 | $('#background_thumbnails_animation').on('input', async function () { |
| 1845 | background_settings.animation = !!$(this).prop('checked'); |
| 1846 | saveSettingsDebounced(); |
| 1847 | |
| 1848 | // Refresh background thumbnails |
| 1849 | await getBackgrounds(); |
| 1850 | await onChatChanged(); |
| 1851 | }); |
| 1852 | |
| 1853 | Object.values(BG_TABS).forEach(tabId => { |
| 1854 | setupScrollToTop({ |
| 1855 | scrollContainerId: tabId, |
| 1856 | buttonId: 'bg-scroll-top', |
| 1857 | drawerId: 'Backgrounds', |
| 1858 | }); |
| 1859 | }); |
| 1860 | |
| 1861 | $('#bg_tabs').tabs(); |
| 1862 | $('#bg_tabs').on('tabsactivate', () => updateGroupFolderControlsVisibility()); |
| 1863 | updateGroupFolderControlsVisibility(); |
| 1864 | syncGroupSelectionUi(); |
| 1865 | } |