diff --git a/public/index.html b/public/index.html index 751cce6b3..bca9823f9 100644 --- a/public/index.html +++ b/public/index.html @@ -4319,7 +4319,9 @@
- +
+ +
diff --git a/public/script.js b/public/script.js index 38ed270cd..6cf82dc27 100644 --- a/public/script.js +++ b/public/script.js @@ -279,6 +279,7 @@ export { default_ch_mes, extension_prompt_types, mesForShowdownParse, + characterGroupOverlay, printCharacters, isOdd, countOccurrences, @@ -1343,19 +1344,19 @@ async function printCharacters(fullRefresh = false) { favsToHotswap(); } +export function characterToEntity(character, id) { + return { item: character, id, type: 'character' }; +} + +export function groupToEntity(group) { + return { item: group, id: group.id, type: 'group' }; +} + +export function tagToEntity(tag) { + return { item: structuredClone(tag), id: tag.id, type: 'tag', entities: [] }; +} + export function getEntitiesList({ doFilter = false, doSort = true } = {}) { - function characterToEntity(character, id) { - return { item: character, id, type: 'character' }; - } - - function groupToEntity(group) { - return { item: group, id: group.id, type: 'group' }; - } - - function tagToEntity(tag) { - return { item: structuredClone(tag), id: tag.id, type: 'tag', entities: [] }; - } - let entities = [ ...characters.map((item, index) => characterToEntity(item, index)), ...groups.map(item => groupToEntity(item)), diff --git a/public/scripts/BulkEditOverlay.js b/public/scripts/BulkEditOverlay.js index d11b56aa9..c2d3e44e8 100644 --- a/public/scripts/BulkEditOverlay.js +++ b/public/scripts/BulkEditOverlay.js @@ -10,6 +10,8 @@ import { getPastCharacterChats, getRequestHeaders, printCharacters, + buildAvatarList, + characterToEntity, } from '../script.js'; import { favsToHotswap } from './RossAscends-mods.js'; @@ -194,7 +196,8 @@ class BulkTagPopupHandler {

Modify tags of ${characterIds.length} characters

- This popup allows you to modify the mutual tags of all selected characters. + Add or remove the mutual tags of all selected characters. +

@@ -233,7 +236,8 @@ class BulkTagPopupHandler { document.body.insertAdjacentHTML('beforeend', this.#getHtml(characterIds)); - this.mutualTags = this.getMutualTags(characterIds); + const entities = characterIds.map(id => characterToEntity(characters[id], id)).filter(entity => entity.item !== undefined); + buildAvatarList($('#bulk_tags_avatars_block'), entities); // Print the tag list with all mutuable tags, marking them as removable. That is the initial fill printTagList($('#bulkTagList'), { tags: () => this.getMutualTags(characterIds), tagOptions: { removable: true } }); @@ -257,7 +261,7 @@ class BulkTagPopupHandler { } // Find mutual tags for multiple characters - const allTags = characterIds.map(c => getTagsList(getTagKeyForEntity(c))); + const allTags = characterIds.map(cid => getTagsList(getTagKeyForEntity(cid))); const mutualTags = allTags.reduce((mutual, characterTags) => mutual.filter(tag => characterTags.some(cTag => cTag.id === tag.id)) ); @@ -345,6 +349,7 @@ class BulkEditOverlay { static selectModeClass = 'group_overlay_mode_select'; static selectedClass = 'character_selected'; static legacySelectedClass = 'bulk_select_checkbox'; + static bulkSelectedCountId = 'bulkSelectedCount'; static longPressDelay = 2500; @@ -353,6 +358,17 @@ class BulkEditOverlay { #stateChangeCallbacks = []; #selectedCharacters = []; + /** + * @typedef {object} LastSelected - An object noting the last selected character and its state. + * @property {string} [characterId] - The character id of the last selected character. + * @property {boolean} [select] - The selected state of the last selected character. true if it was selected, false if it was deselected. + */ + + /** + * @type {LastSelected} - An object noting the last selected character and its state. + */ + lastSelected = { characterId: undefined, select: undefined }; + /** * Locks other pointer actions when the context menu is open * @@ -588,27 +604,80 @@ class BulkEditOverlay { event.stopPropagation(); const character = event.currentTarget; - const characterId = character.getAttribute('chid'); - const alreadySelected = this.selectedCharacters.includes(characterId); + if (!this.#contextMenuOpen && !this.#cancelNextToggle) { + if (event.shiftKey) { + // Shift click might have selected text that we don't want to. Unselect it. + document.getSelection().removeAllRanges(); - const legacyBulkEditCheckbox = character.querySelector('.' + BulkEditOverlay.legacySelectedClass); - - // Only toggle when context menu is closed and wasn't just closed. - if (!this.#contextMenuOpen && !this.#cancelNextToggle) - if (alreadySelected) { - character.classList.remove(BulkEditOverlay.selectedClass); - if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = false; - this.dismissCharacter(characterId); + this.handleShiftClick(character); } else { - character.classList.add(BulkEditOverlay.selectedClass); - if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = true; - this.selectCharacter(characterId); + this.toggleSingleCharacter(character); } + } this.#cancelNextToggle = false; }; + handleShiftClick = (currentCharacter) => { + const characterId = currentCharacter.getAttribute('chid'); + const select = !this.selectedCharacters.includes(characterId); + + if (this.lastSelected.characterId && this.lastSelected.select !== undefined) { + // Only if select state and the last select state match we execute the range select + if (select === this.lastSelected.select) { + this.selectCharactersInRange(currentCharacter, select); + } + } + }; + + toggleSingleCharacter = (character, { markState = true } = {}) => { + const characterId = character.getAttribute('chid'); + + const select = !this.selectedCharacters.includes(characterId); + const legacyBulkEditCheckbox = character.querySelector('.' + BulkEditOverlay.legacySelectedClass); + + if (select) { + character.classList.add(BulkEditOverlay.selectedClass); + if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = true; + this.selectCharacter(characterId); + } else { + character.classList.remove(BulkEditOverlay.selectedClass); + if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = false; + this.dismissCharacter(characterId); + } + + this.updateSelectedCount(); + + if (markState) { + this.lastSelected.characterId = characterId; + this.lastSelected.select = select; + } + }; + + updateSelectedCount = (countOverride = undefined) => { + const count = countOverride ?? this.selectedCharacters.length; + $(`#${BulkEditOverlay.bulkSelectedCountId}`).text(count).attr('title', `${count} characters selected`); + }; + + selectCharactersInRange = (currentCharacter, select) => { + const currentCharacterId = currentCharacter.getAttribute('chid'); + const characters = Array.from(document.querySelectorAll('#' + BulkEditOverlay.containerId + ' .' + BulkEditOverlay.characterClass)); + + const startIndex = characters.findIndex(c => c.getAttribute('chid') === this.lastSelected.characterId); + const endIndex = characters.findIndex(c => c.getAttribute('chid') === currentCharacterId); + + for (let i = Math.min(startIndex, endIndex); i <= Math.max(startIndex, endIndex); i++) { + const character = characters[i]; + const characterId = character.getAttribute('chid'); + const isCharacterSelected = this.selectedCharacters.includes(characterId); + + if (select && !isCharacterSelected || !select && isCharacterSelected) { + this.toggleSingleCharacter(character, { markState: currentCharacterId == i }); + } + } + }; + handleContextMenuShow = (event) => { event.preventDefault(); CharacterContextMenu.show(...this.#getContextMenuPosition(event)); diff --git a/public/scripts/bulk-edit.js b/public/scripts/bulk-edit.js index 7cb0d17b9..95d0d79bb 100644 --- a/public/scripts/bulk-edit.js +++ b/public/scripts/bulk-edit.js @@ -1,4 +1,4 @@ -import { characters, getCharacters, handleDeleteCharacter, callPopup } from '../script.js'; +import { characters, getCharacters, handleDeleteCharacter, callPopup, characterGroupOverlay } from '../script.js'; import { BulkEditOverlay, BulkEditOverlayState } from './BulkEditOverlay.js'; @@ -6,18 +6,20 @@ let is_bulk_edit = false; const enableBulkEdit = () => { enableBulkSelect(); - (new BulkEditOverlay()).selectState(); - // show the delete button - $('#bulkDeleteButton').show(); + characterGroupOverlay.selectState(); + // show the bulk edit option buttons + $('.bulkEditOptionElement').show(); is_bulk_edit = true; + characterGroupOverlay.updateSelectedCount(0); }; const disableBulkEdit = () => { disableBulkSelect(); - (new BulkEditOverlay()).browseState(); - // hide the delete button - $('#bulkDeleteButton').hide(); + characterGroupOverlay.browseState(); + // hide the bulk edit option buttons + $('.bulkEditOptionElement').hide(); is_bulk_edit = false; + characterGroupOverlay.updateSelectedCount(0); }; const toggleBulkEditMode = (isBulkEdit) => { @@ -41,6 +43,32 @@ function onEditButtonClick() { toggleBulkEditMode(is_bulk_edit); } +/** + * Toggles the select state of all characters in bulk edit mode to selected. If all are selected, they'll be deselected. + */ +function onSelectAllButtonClick() { + console.log('Bulk select all button clicked'); + const characters = Array.from(document.querySelectorAll('#' + BulkEditOverlay.containerId + ' .' + BulkEditOverlay.characterClass)); + let atLeastOneSelected = false; + for (const character of characters) { + const checked = $(character).find('.bulk_select_checkbox:checked').length > 0; + if (!checked) { + characterGroupOverlay.toggleSingleCharacter(character); + atLeastOneSelected = true; + } + } + + if (!atLeastOneSelected) { + // If none was selected, trigger click on all to deselect all of them + for(const character of characters) { + const checked = $(character).find('.bulk_select_checkbox:checked') ?? false; + if (checked) { + characterGroupOverlay.toggleSingleCharacter(character); + } + } + } +} + /** * Deletes the character with the given chid. * @@ -89,6 +117,10 @@ async function onDeleteButtonClick() { */ function enableBulkSelect() { $('#rm_print_characters_block .character_select').each((i, el) => { + // Prevent checkbox from adding multiple times (because of stage change callback) + if ($(el).find('.bulk_select_checkbox').length > 0) { + return; + } const checkbox = $(''); checkbox.on('change', () => { // Do something when the checkbox is changed @@ -115,5 +147,6 @@ function disableBulkSelect() { */ jQuery(() => { $('#bulkEditButton').on('click', onEditButtonClick); + $('#bulkSelectAllButton').on('click', onSelectAllButtonClick); $('#bulkDeleteButton').on('click', onDeleteButtonClick); }); diff --git a/public/style.css b/public/style.css index 6817bcc45..9efcbc55f 100644 --- a/public/style.css +++ b/public/style.css @@ -2126,6 +2126,10 @@ grammarly-extension { background-color: var(--crimson-hover); } +#bulk_tags_avatars_block { + max-height: 70vh; +} + #dialogue_popup_input { margin: 10px 0; width: 100%;