feat(ui): add interface to manage message swipe history (#5304)

* feat(ui): add popup to jump to a specific swipe

Adds a new "Jump to swipe history" button to message actions and makes
the swipe counter clickable on the latest message. This opens a
searchable popup allowing users to quickly find and jump to a
specific alternate swipe without having to click through them
sequentially.

- Adds a searchable swipe picker popup menu
- Makes the swipe counter interactive when multiple swipes exist
- Adds a dedicated swipe picker button to message controls

* fix(ui): hide swipe picker when there are no swipes

Ensures the newly added swipe picker button is only shown when a
message has multiple swipes available. It explicitly hides the
button for non-swipeable messages or messages with a single swipe.

* feat(ui): redesign swipe picker with direct id input

Replace text-based search with a numeric input for direct swipe navigation.
Update popup layout with a sticky header and improved scrolling behavior.
Sync input value with the currently selected swipe in the list.
Refactor styling to align with chat selection components.

* feat(ui): allow branching from specific swipes via picker

- Enable swipe picker for historical messages to inspect alternate swipes
- Add branch button to picker entries to create new chats from specific swipes
- Update saveChat and createBranch to accept chat snapshots
- Restrict swipe jumping to the active message only

* refactor(logic): consolidate swipe sync logic and simplify helpers

Update `syncSwipeToMes` to accept a target message object, enabling its
use in the bookmarks module and removing the duplicate
`applySwipeToSnapshot` function.

Also simplify `canOpenSwipePickerForMessage` and
`canJumpToSwipeForMessage` signatures by removing the redundant message
parameter.

* refactor(a11y): support dynamic roles via classes

Introduce a managed role system in the accessibility script to handle
elements that dynamically gain or lose interactive states. The mutation
observer now watches for class attribute changes and automatically
applies or clears roles (e.g., `role="button"`) using active selectors.

Updated the swipe counter to rely on this centralized system by toggling
an `.interactable` class instead of manually modifying tabindex and role
attributes. Removed the redundant 'Enter' keydown handler for the swipe
counter to prevent duplicate trigger events.

* fix(ui): compute missing token counts in swipe picker

Update renderSwipeList to asynchronously calculate token counts when
missing from swipe metadata. Introduce SWIPE_SOURCE.SWIPE_PICKER to
correctly identify swipes triggered from the picker and bypass
generation checks.

* feat(ui): enable deleting specific swipes via swipe picker

- Adds a delete button to swipe picker entries, allowing removal of specific message versions.
- Refactors deletion logic to handle removing non-current swipes without triggering animations and correctly updates indices.
- Includes confirmation dialogs and improves input focus behavior.

* refactor:Delete process inline to button click processor

* feat: universal swipe inspection and picker improvements

- Permit opening the swipe browser on any chat entry to review past generations.
- Parallelize the retrieval of token statistics to speed up list rendering.
- Format message metrics (length and tokens) into a single, concise string.
- Update the `getBranchChatSnapshot` API to accept an options object.
- Register swipe list items as interactable elements for keyboard control.
- Apply styling to prevent text highlighting on picker entries.

* fix:remove unused CSS

* fix: fix disabled styling for swipe delete button

Remove tooltips and prevent hover animations or glow effects when the
delete button is disabled in the swipe picker. Update CSS to enforce
default cursor and fixed opacity on hover for the disabled state.

* remove: Unused CSS

* Extract swipe-picker.js module

* Revert to manual ARIA role management

* Avoid scrollIntoView and scroll on open

* Fix keyboard interaction in past chats menu

* Fix a11y attribute

* fix: call refreshSwipeButtons when deleting not selected swipe

---------

Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
This commit is contained in:
awaae
2026-03-26 09:28:48 +09:00
committed by GitHub
parent 94139f465e
commit b04c974407
8 changed files with 590 additions and 40 deletions
+1
View File
@@ -7365,6 +7365,7 @@
<div title="Toggle media display style" class="mes_button mes_media_gallery fa-solid fa-photo-film" data-i18n="[title]Toggle media display style"></div>
<div title="Toggle media display style" class="mes_button mes_media_list fa-solid fa-table-cells-large" data-i18n="[title]Toggle media display style"></div>
<div title="Embed file or image" class="mes_button mes_embed fa-solid fa-paperclip" data-i18n="[title]Embed file or image"></div>
<div title="Jump to swipe history" class="mes_button mes_swipe_picker fa-solid fa-bookmark" data-i18n="[title]Jump to swipe history" style="display: none;"></div>
<div title="Create checkpoint" class="mes_button mes_create_bookmark fa-regular fa-solid fa-flag-checkered" data-i18n="[title]Create checkpoint"></div>
<div title="Create branch" class="mes_button mes_create_branch fa-regular fa-code-branch" data-i18n="[title]Create Branch"></div>
<div title="Copy" class="mes_button mes_copy fa-solid fa-copy " data-i18n="[title]Copy"></div>
+67 -17
View File
@@ -286,6 +286,7 @@ import { MacroEngine } from './scripts/macros/engine/MacroEngine.js';
import { addChatBackupsBrowser } from './scripts/chat-backups.js';
import { onboardingExperimentalMacroEngine } from './scripts/macros/engine/MacroDiagnostics.js';
import { compressRequest, setRequestCompressionConfig } from './scripts/request-compression.js';
import { canJumpToSwipeForMessage, canOpenSwipePickerForMessage, initSwipePicker } from './scripts/swipe-picker.js';
// API OBJECT FOR EXTERNAL WIRING
globalThis.SillyTavern = {
@@ -776,6 +777,7 @@ async function firstLoadInit() {
initDataMaid();
initItemizedPrompts();
initAccessibility();
initSwipePicker();
addDebugFunctions();
doDailyExtensionUpdatesCheck();
await eventSource.emit(event_types.APP_INITIALIZED);
@@ -6847,20 +6849,24 @@ export function syncMesToSwipe(messageId = null) {
* If the swipe data is invalid in some way, this function will exit out without doing anything.
* @param {number?} [messageId=null] - The ID of the message to sync with the swipe data. If no ID is given, the last message is used.
* @param {number?} [swipeId=null] - The ID of the swipe to sync. If no ID is given, the current swipe ID in the message object is used.
* @param {ChatMessage?} [targetMessage=null] - The message object to sync instead of resolving one from `chat`.
* @returns {boolean} Whether the swipe data was successfully synced to the message
*/
export function syncSwipeToMes(messageId = null, swipeId = null) {
if (!chat.length) {
export function syncSwipeToMes(messageId = null, swipeId = null, targetMessage = null) {
if (!targetMessage && !chat.length) {
return false;
}
if (!targetMessage) {
const targetMessageId = messageId ?? chat.length - 1;
if (targetMessageId >= chat.length || targetMessageId < 0) {
console.warn(`[syncSwipeToMes] Invalid message ID: ${messageId}`);
return false;
}
const targetMessage = chat[targetMessageId];
targetMessage = chat[targetMessageId];
}
if (!targetMessage) {
return false;
}
@@ -7283,10 +7289,11 @@ export function saveChatDebounced() {
* @param {object} [options.withMetadata] Additional metadata to save with the chat
* @param {number} [options.mesId] The message ID to save the chat up to
* @param {boolean} [options.force] Force the saving despite the integrity check result
* @param {ChatMessage[]} [options.chatData] Chat snapshot to save instead of the current in-memory chat
*
* @returns {Promise<void>}
*/
export async function saveChat({ chatName, withMetadata, mesId, force = false } = {}) {
export async function saveChat({ chatName, withMetadata, mesId, force = false, chatData = undefined } = {}) {
if (selected_group) {
toastr.error(t`Operation was aborted to prevent data corruption.`, t`saveChat called for a group chat`);
throw new Error('saveChat called for a group chat');
@@ -7312,7 +7319,9 @@ export async function saveChat({ chatName, withMetadata, mesId, force = false }
characters[this_chid].date_last_chat = Date.now();
const trimmedChat = (mesId !== undefined && mesId >= 0 && mesId < chat.length)
const trimmedChat = Array.isArray(chatData)
? chatData
: (mesId !== undefined && mesId >= 0 && mesId < chat.length)
? chat.slice(0, Number(mesId) + 1)
: chat.slice();
@@ -9019,7 +9028,22 @@ export async function updateSwipeCounter(mesId, { message = undefined, messageEl
const swipeCounterText = formatSwipeCounter((message?.swipe_id + 1), message?.swipes?.length);
const swipeCounter = messageElement.find('.swipes-counter');
swipeCounter.text(swipeCounterText).prop('hidden', false);
const swipePickerButton = messageElement.find('.mes_swipe_picker');
const canOpenSwipePicker = canOpenSwipePickerForMessage(mesId);
const canJumpToSwipe = canJumpToSwipeForMessage(mesId);
swipeCounter
.text(swipeCounterText)
.prop('hidden', false)
.toggleClass('swipe-picker-enabled', canOpenSwipePicker)
.toggleClass(INTERACTABLE_CONTROL_CLASS, canOpenSwipePicker)
.attr('role', canOpenSwipePicker ? 'button' : null)
.attr('title', canJumpToSwipe ? t`Click to jump to a swipe` : canOpenSwipePicker ? t`Click to view swipe history` : null);
swipePickerButton.toggle(canOpenSwipePicker);
if (!canOpenSwipePicker) {
swipeCounter.removeAttr('tabindex');
}
}
/**
@@ -9148,6 +9172,8 @@ export function refreshSwipeButtons(updateCounters = false, fade = true) {
const isLastSwipe = (message?.swipes?.length ?? 1) - 1 <= (message?.swipe_id ?? 0);
const hasSwipes = (message?.swipes?.length > 1);
const overswipe = getOverswipeBehavior(messageId, message);
const swipePickerButton = $(div).find('.mes_swipe_picker');
const canOpenSwipePicker = canOpenSwipePickerForMessage(messageId);
// Chevrons should always be shown on pristine greetings: https://github.com/SillyTavern/SillyTavern/pull/4712#issuecomment-3557893373
const pristineGreeting = overswipe == OVERSWIPE_BEHAVIOR.PRISTINE_GREETING;
@@ -9161,12 +9187,14 @@ export function refreshSwipeButtons(updateCounters = false, fade = true) {
//If there's only one swipe, the left arrow should not be shown.
div.classList.toggle('swipes_visible', hasSwipes || pristineGreeting);
swipePickerButton.toggle(canOpenSwipePicker);
//updateSwipeCounter does not need to be awaited, It can run a bit later.
if (updateCounters) updateSwipeCounter(messageId, { message, messageElement: $(div) });
} else {
//Hide all messages that are not swipeable.
div.classList.remove('swipes_visible', 'last_swipe');
$(div).find('.mes_swipe_picker').toggle(canOpenSwipePickerForMessage(messageId));
}
});
}
@@ -9200,10 +9228,13 @@ export function hideSwipeButtons({ hideCounters = false } = {}) {
* @returns {Promise<number>|undefined} - The ID of the new swipe after deletion.
*/
export async function deleteSwipe(swipeId = null, messageId = chat.length - 1) {
if (swipeId && (isNaN(swipeId) || swipeId < 0)) {
toastr.warning(t`Invalid swipe ID: ${swipeId + 1}`);
if (swipeId != null) {
swipeId = Number(swipeId);
if (!Number.isInteger(swipeId) || swipeId < 0) {
toastr.warning(t`Invalid swipe ID.`);
return;
}
}
const message = chat[messageId];
if (!message || !Array.isArray(message.swipes) || !message.swipes.length) {
@@ -9216,7 +9247,8 @@ export async function deleteSwipe(swipeId = null, messageId = chat.length - 1) {
return;
}
swipeId = swipeId ?? message.swipe_id;
swipeId = Number(swipeId ?? message.swipe_id);
const currentSwipeId = clamp(Number(message.swipe_id ?? 0), 0, message.swipes.length - 1);
if (swipeId < 0 || swipeId >= message.swipes.length) {
toastr.warning(t`Invalid swipe ID: ${swipeId + 1}`);
@@ -9229,17 +9261,35 @@ export async function deleteSwipe(swipeId = null, messageId = chat.length - 1) {
message.swipe_info.splice(swipeId, 1);
}
// Select the next swipe, or the one before if it was the last one
const newSwipeId = Math.min(swipeId, message.swipes.length - 1);
let newSwipeId;
if (swipeId < currentSwipeId) {
newSwipeId = currentSwipeId - 1;
} else if (swipeId > currentSwipeId) {
newSwipeId = currentSwipeId;
} else {
// Select the next swipe, or the one before if it was the last one.
newSwipeId = Math.min(swipeId, message.swipes.length - 1);
}
chat_metadata.tainted = true;
messageId = Number(messageId);
swipeId = Number(swipeId);
message.swipe_id = newSwipeId;
await eventSource.emit(event_types.MESSAGE_SWIPE_DELETED, { messageId, swipeId, newSwipeId });
let direction = (swipeId <= newSwipeId) ? SWIPE_DIRECTION.RIGHT : SWIPE_DIRECTION.LEFT;
//Animate swipe and swap dispayed message.
if (swipeId === currentSwipeId) {
const direction = (swipeId <= newSwipeId) ? SWIPE_DIRECTION.RIGHT : SWIPE_DIRECTION.LEFT;
// Animate swipe and swap displayed message when the currently visible swipe was deleted.
await swipe(null, direction, { source: SWIPE_SOURCE.DELETE, repeated: false, forceMesId: messageId, forceSwipeId: newSwipeId });
} else {
await updateSwipeCounter(messageId);
if (messageId !== chat.length - 1) {
await updateSwipeCounter(chat.length - 1);
}
refreshSwipeButtons();
saveChatDebounced();
}
await saveChatConditional();
@@ -9785,7 +9835,7 @@ function formatSwipeCounter(current, total) {
* @param {SwipeEvent} event Event.
* @param {SWIPE_DIRECTION} direction The direction to swipe.
* @param {object} params Additional parameters.
* @param {import('./scripts/constants.js').SWIPE_SOURCE} [params.source] The source of the swipe event. null, 'keyboard', 'auto_swipe', 'back' or 'delete'.
* @param {import('./scripts/constants.js').SWIPE_SOURCE} [params.source] The source of the swipe event.
* @param {boolean} [params.repeated] Is the swipe event repeated.
* @param {ChatMessage} [params.message=chat[chat.length - 1]] The chat message to swipe.
* @param {number} [params.forceMesId] The message id to swipe.
@@ -9811,7 +9861,7 @@ export async function swipe(event, direction, { source, repeated, message = chat
const mesId = Number(forceMesId ?? event?.currentTarget?.closest('.mes')?.getAttribute('mesid') ?? messageIndex ?? chat.length - 1);
if ([SWIPE_SOURCE.DELETE, SWIPE_SOURCE.BACK, SWIPE_SOURCE.AUTO_SWIPE, SWIPE_SOURCE.SLASH_COMMAND].includes(source)) {
if ([SWIPE_SOURCE.DELETE, SWIPE_SOURCE.BACK, SWIPE_SOURCE.AUTO_SWIPE, SWIPE_SOURCE.SLASH_COMMAND, SWIPE_SOURCE.SWIPE_PICKER].includes(source)) {
console.info(`The ${direction} swipe source on message #${mesId} is ${source}, Most checks have been bypassed. `);
} else {
//Only show an error if swipes are not hidden and a message is generating.
@@ -10271,7 +10321,7 @@ export async function swipe(event, direction, { source, repeated, message = chat
* Handles the swipe to the left event.
* @param {SwipeEvent} [event] Event.
* @param {object} params Additional parameters.
* @param {import('./scripts/constants.js').SWIPE_SOURCE} [params.source] The source of the swipe event. null, 'keyboard', 'auto_swipe', 'back' or 'delete'.
* @param {import('./scripts/constants.js').SWIPE_SOURCE} [params.source] The source of the swipe event.
* @param {boolean} [params.repeated] Is the swipe event repeated.
* @param {object} [params.message] The chat message to swipe.
*/
@@ -10284,7 +10334,7 @@ export async function swipe_left(event, { source, repeated, message } = {}) {
* Handles the swipe to the right event.
* @param {SwipeEvent} [event] Event.
* @param {object} params Additional parameters.
* @param {import('./scripts/constants.js').SWIPE_SOURCE} [params.source] The source of the swipe event. null, 'keyboard', 'auto_swipe', 'back' or 'delete'.
* @param {import('./scripts/constants.js').SWIPE_SOURCE} [params.source] The source of the swipe event.
* @param {boolean} [params.repeated] Is the swipe event repeated.
* @param {object} [params.message] The chat message to swipe.
*/
+43 -5
View File
@@ -2,6 +2,7 @@ import {
characters,
saveChat,
system_message_types,
syncSwipeToMes,
this_chid,
openCharacterChat,
chat_metadata,
@@ -161,8 +162,28 @@ async function saveBookmarkMenu() {
return await createNewBookmark(chat.length - 1);
}
/**
* Builds the branch chat snapshot, optionally selecting a specific swipe for the target message.
* @param {number} mesId
* @param {{swipeId?: number|null}} [options={}]
* @returns {ChatMessage[]|null}
*/
function getBranchChatSnapshot(mesId, { swipeId = null } = {}) {
const snapshot = structuredClone(chat.slice(0, Number(mesId) + 1));
if (swipeId === null) {
return snapshot;
}
if (!syncSwipeToMes(null, swipeId, snapshot[mesId])) {
return null;
}
return snapshot;
}
// Export is used by Timelines extension. Do not remove.
export async function createBranch(mesId) {
export async function createBranch(mesId, { swipeId = null } = {}) {
if (!chat.length) {
toastr.warning('The chat is empty.', 'Branch creation failed');
return;
@@ -176,6 +197,12 @@ export async function createBranch(mesId) {
const lastMes = chat[mesId];
const mainChatName = (getCurrentChatDetails()).sessionName;
const newMetadata = { main_chat: mainChatName };
const selectedSwipeId = swipeId === null ? null : Number(swipeId);
if (selectedSwipeId !== null && (!Number.isInteger(selectedSwipeId) || selectedSwipeId < 0 || selectedSwipeId >= (lastMes?.swipes?.length ?? 0))) {
toastr.warning('Invalid swipe ID.', 'Branch creation failed');
return;
}
function buildBranchName(name, i) {
// Strip off existing suffixes, then build new name
@@ -192,10 +219,16 @@ export async function createBranch(mesId) {
return;
}
const branchChatSnapshot = getBranchChatSnapshot(mesId, { swipeId: selectedSwipeId });
if (!branchChatSnapshot) {
toastr.warning('Could not prepare the selected swipe for branching.', 'Branch creation failed');
return;
}
if (selected_group) {
await saveGroupBookmarkChat(selected_group, name, newMetadata, mesId);
await saveGroupBookmarkChat(selected_group, name, newMetadata, mesId, branchChatSnapshot);
} else {
await saveChat({ chatName: name, withMetadata: newMetadata, mesId });
await saveChat({ chatName: name, withMetadata: newMetadata, mesId, chatData: branchChatSnapshot });
}
// append to branches list if it exists
// otherwise create it
@@ -410,15 +443,20 @@ export async function convertSoloToGroupChat() {
/**
* Creates a new branch from the message with the given ID
* @param {number} mesId Message ID
* @param {{swipeId?: number|null}} [options={}] Branch options
* @returns {Promise<string?>} Branch file name
*/
export async function branchChat(mesId) {
export async function branchChat(mesId, { swipeId = null } = {}) {
if (this_chid === undefined && !selected_group) {
toastr.info('No character selected.', 'Create Branch');
return null;
}
const fileName = await createBranch(mesId);
const fileName = await createBranch(mesId, { swipeId });
if (!fileName) {
return null;
}
await saveItemizedPrompts(fileName);
if (selected_group) {
+1
View File
@@ -176,6 +176,7 @@ export const SWIPE_SOURCE = {
BACK: 'back',
AUTO_SWIPE: 'auto_swipe',
SLASH_COMMAND: 'slash_command',
SWIPE_PICKER: 'swipe_picker',
};
/**
+5 -2
View File
@@ -2352,9 +2352,10 @@ export async function importGroupChat(formData, { refresh = true } = {}) {
* @param {string} name Name of the chat to save
* @param {ChatMetadata?} metadata New metadata to save with the chat
* @param {number|undefined} mesId Optional message ID to trim the chat up to
* @param {ChatMessage[]|undefined} chatData Optional chat snapshot to save instead of the current in-memory chat
* @returns {Promise<void>} Promise that resolves when the group chat is saved
*/
export async function saveGroupBookmarkChat(groupId, name, metadata, mesId) {
export async function saveGroupBookmarkChat(groupId, name, metadata, mesId, chatData = undefined) {
const group = groups.find(x => x.id === groupId);
if (!group) {
@@ -2371,7 +2372,9 @@ export async function saveGroupBookmarkChat(groupId, name, metadata, mesId) {
};
/** @type {ChatMessage[]} */
const trimmedChat = (mesId !== undefined && mesId >= 0 && mesId < chat.length)
const trimmedChat = Array.isArray(chatData)
? chatData
: (mesId !== undefined && mesId >= 0 && mesId < chat.length)
? chat.slice(0, Number(mesId) + 1)
: chat;
+6
View File
@@ -8,6 +8,7 @@ const interactableSelectors = [
'.inline-drawer-icon', // Buttons/icons inside the drawer menus
'.paginationjs-pages li a', // Pagination buttons
'.group_select, .character_select, .bogus_folder_select', // Cards to select char, group or folder in character list and other places
'.swipe_picker_block', // Swipe picker entries in the swipe history popup
'.avatar-container', // Persona list blocks
'.tag .tag_remove', // Remove button in removable tags
'.bg_example', // Background elements in the background menu
@@ -20,6 +21,11 @@ const interactableSelectors = [
'.select2_choice_clickable+span.select2-container .select2-selection__choice__display', // select2 control elements if they are meant to be clickable
'.avatar_load_preview', // Char display avatar selection
'.bg_tabs_list .bg_tab_button', // Background tabs
'.select_chat_block', // The blocks to select a past chat in the past chats menu
'.select_chat_block .exportRawChatButton', // Export raw chat button in the past chats menu
'.select_chat_block .exportChatButton', // Export chat button in the past chats menu
'.select_chat_block .PastChat_cross', // Delete chat button in the past chats menu
'.select_chat_block .renameChatButton', // The button to rename a past chat in the past chats menu
];
if (CSS.supports('selector(:has(*))')) {
+403
View File
@@ -0,0 +1,403 @@
import { branchChat } from './bookmarks.js';
import { SWIPE_DIRECTION, SWIPE_SOURCE } from './constants.js';
import { t } from './i18n.js';
import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js';
import { power_user } from './power-user.js';
import { getTokenCountAsync } from './tokenizers.js';
import { clamp, timestampToMoment } from './utils.js';
import { chat, deleteSwipe, ensureSwipes, isMessageSwipeable, isSwipingAllowed, swipe, syncMesToSwipe } from '/script.js';
/**
* Returns whether a swipe picker can be opened for the message.
* Unlike message swiping, this supports historical AI messages for inspection and branching.
* @param {number} messageId
* @returns {boolean}
*/
export function canOpenSwipePickerForMessage(messageId) {
const message = chat[messageId];
if (!message) {
return false;
}
if (ensureSwipes(message)) {
syncMesToSwipe(messageId);
}
return Boolean(
message?.swipes?.length > 1 &&
!message?.is_user &&
!(message?.extra?.isSmallSys) &&
!(message?.extra?.swipeable === false),
);
}
/**
* Returns whether the picker can actively jump to a different swipe.
* Historical AI messages can open the picker, but only the currently swipeable message may jump.
* @param {number} messageId
* @returns {boolean}
*/
export function canJumpToSwipeForMessage(messageId) {
const message = chat[messageId];
return canOpenSwipePickerForMessage(messageId) && isSwipingAllowed() && isMessageSwipeable(messageId, message);
}
/**
* Opens a popup for viewing or jumping to a specific swipe on a message.
* @param {number} messageId
* @returns {Promise<void>}
*/
async function openSwipePicker(messageId) {
const message = chat[messageId];
if (!canOpenSwipePickerForMessage(messageId)) {
toastr.info(t`This message has no alternate swipes yet.`, t`Jump to Swipe`);
return;
}
const canJumpToSwipe = canJumpToSwipeForMessage(messageId);
let selectedSwipeId = clamp(Number(message.swipe_id ?? 0), 0, message.swipes.length - 1);
const swipeIdInputId = `swipe_picker_id_${messageId}`;
const wrapper = document.createElement('div');
wrapper.classList.add('flex-container', 'flexFlowColumn', 'flexNoGap', 'wide100p', 'flex1', 'overflowHidden');
const header = document.createElement('div');
header.classList.add('swipe_picker_header', 'flex-container', 'alignItemsCenter', 'justifySpaceBetween', 'gap10px');
const description = document.createElement('h3');
description.classList.add('margin0', 'justifyLeft');
description.textContent = t`Swipe Selection`;
header.appendChild(description);
wrapper.appendChild(header);
const listContainer = document.createElement('div');
listContainer.classList.add('swipe_picker_div', 'flex1', 'marginTop10');
wrapper.appendChild(listContainer);
/** @type {Popup} */
let popup;
/** @type {HTMLInputElement} */
let swipeIdInput;
/** @type {number|null} */
let branchActionSwipeId = null;
function syncSwipeIdInput() {
if (swipeIdInput) {
swipeIdInput.value = String(selectedSwipeId + 1);
}
}
function setSelectedSwipe(nextSwipeId) {
selectedSwipeId = clamp(Number(nextSwipeId), 0, message.swipes.length - 1);
listContainer.querySelectorAll('.swipe_picker_block').forEach((element) => {
const isSelected = Number(element.getAttribute('data-swipe-id')) === selectedSwipeId;
if (isSelected) {
element.setAttribute('highlight', 'true');
} else {
element.removeAttribute('highlight');
}
});
syncSwipeIdInput();
}
function scrollToSelectedSwipe() {
const swipeBlock = listContainer.querySelector(`.swipe_picker_block[data-swipe-id="${selectedSwipeId}"]`);
if (swipeBlock instanceof HTMLElement) {
const scrollParent = swipeBlock.closest('.swipe_picker_div');
if (scrollParent instanceof HTMLElement) {
const blockRect = swipeBlock.getBoundingClientRect();
const parentRect = scrollParent.getBoundingClientRect();
if (blockRect.top < parentRect.top) {
scrollParent.scrollTop -= (parentRect.top - blockRect.top) + 5;
} else if (blockRect.bottom > parentRect.bottom) {
scrollParent.scrollTop += (blockRect.bottom - parentRect.bottom) + 5;
}
}
}
}
function canDeleteSwipeFromPicker(swipeId) {
if ((message?.swipes?.length ?? 0) <= 1) {
return false;
}
const currentSwipeId = clamp(Number(message.swipe_id ?? 0), 0, message.swipes.length - 1);
return canJumpToSwipe || swipeId !== currentSwipeId;
}
async function renderSwipeList() {
const swipeBlocks = await Promise.all(message.swipes.map(async (swipe, index) => {
const swipeText = String(swipe ?? '');
const template = $('#past_chat_template .select_chat_block_wrapper').clone();
const block = template.find('.select_chat_block');
block.removeClass('select_chat_block').addClass('swipe_picker_block');
const branchButton = template.find('.exportRawChatButton');
const deleteButton = template.find('.PastChat_cross');
const swipeInfo = Array.isArray(message.swipe_info) ? message.swipe_info[index] : null;
const sendDate = swipeInfo?.send_date ? timestampToMoment(swipeInfo.send_date).format('lll') : '';
const previewText = swipeText.replace(/\s+/g, ' ').trim();
const tokenCount = swipeInfo?.extra?.token_count ?? await getTokenCountAsync(swipeText, 0);
const canDeleteSwipe = canDeleteSwipeFromPicker(index);
const swipeDetails = [];
if (previewText) {
swipeDetails.push(`${previewText.length} ${t`chars`}`);
}
if (tokenCount) {
swipeDetails.push(`${tokenCount}t`);
}
block.attr({
file_name: `swipe-${index + 1}`,
'data-swipe-id': index,
});
template.find('.renameChatButton, .exportChatButton').remove();
branchButton
.removeAttr('data-format')
.attr({
title: t`Create Branch`,
'data-i18n': '[title]Create Branch',
})
.removeClass('exportRawChatButton fa-solid fa-file-export')
.addClass('swipe_picker_branch mes_button fa-regular fa-code-branch')
.on('click', async (event) => {
event.preventDefault();
event.stopPropagation();
setSelectedSwipe(index);
branchActionSwipeId = index;
await popup.completeCancelled();
});
deleteButton
.removeAttr('file_name')
.attr('aria-disabled', String(!canDeleteSwipe))
.removeClass('fa-skull')
.addClass('swipe_picker_delete fa-trash-can')
.toggleClass('hoverglow', canDeleteSwipe)
.toggleClass('disabled', !canDeleteSwipe)
.each(function () {
if (canDeleteSwipe) {
$(this)
.attr({
title: t`Delete Swipe`,
'data-i18n': '[title]Delete Swipe',
});
} else {
$(this)
.removeAttr('title')
.removeAttr('data-i18n');
}
})
.off('click')
.on('click', async (event) => {
event.preventDefault();
event.stopPropagation();
if (!canDeleteSwipe) {
return;
}
const nextSelectedSwipeId = index < selectedSwipeId
? selectedSwipeId - 1
: index > selectedSwipeId
? selectedSwipeId
: Math.min(selectedSwipeId, message.swipes.length - 2);
if (power_user.confirm_message_delete) {
const result = await callGenericPopup(t`Are you sure you want to delete swipe #${index + 1}?`, POPUP_TYPE.CONFIRM, null, {
okButton: t`Delete Swipe`,
cancelButton: t`Cancel`,
});
if (result !== POPUP_RESULT.AFFIRMATIVE) {
return;
}
}
const newSwipeId = await deleteSwipe(index, messageId);
if (!Number.isInteger(newSwipeId)) {
return;
}
selectedSwipeId = clamp(nextSelectedSwipeId, 0, message.swipes.length - 1);
if (swipeIdInput instanceof HTMLInputElement) {
swipeIdInput.max = String(message.swipes.length);
}
await renderSwipeList();
});
template.find('.select_chat_block_filename').text(`#${index + 1}${index === Number(message.swipe_id ?? 0) ? ` ${t`[Current]`}` : ''}`);
template.find('.chat_messages_date').text(sendDate);
template.find('.chat_file_size').text(swipeDetails.length ? `(${swipeDetails[0]}${swipeDetails.length > 1 ? ',' : ')'}` : '');
template.find('.chat_messages_num').text(swipeDetails.length > 1 ? `${swipeDetails.slice(1).join(', ')})` : '');
template.find('.select_chat_block_mes').text(previewText || t`(empty swipe)`);
block.on('click', () => setSelectedSwipe(index));
block.on('dblclick', async () => {
if (!canJumpToSwipe) {
return;
}
setSelectedSwipe(index);
await popup.completeAffirmative();
});
return template[0];
}));
listContainer.replaceChildren(...swipeBlocks);
setSelectedSwipe(selectedSwipeId);
if (swipeBlocks.length === 0) {
const empty = document.createElement('div');
empty.classList.add('textAlignCenter', 'opacity50p', 'padding10');
empty.textContent = t`No swipes available.`;
listContainer.replaceChildren(empty);
}
}
popup = new Popup(wrapper, POPUP_TYPE.CONFIRM, '', {
okButton: canJumpToSwipe ? t`Go` : false,
cancelButton: false,
customInputs: [{
id: swipeIdInputId,
label: t`Swipe ID`,
type: 'text',
defaultState: String(selectedSwipeId + 1),
tooltip: `1-${message.swipes.length}`,
}],
wider: true,
allowVerticalScrolling: true,
onOpen: function () {
scrollToSelectedSwipe();
if (swipeIdInput instanceof HTMLInputElement) {
swipeIdInput.focus();
swipeIdInput.select();
}
},
onClosing: function (popup) {
if (popup.result !== POPUP_RESULT.AFFIRMATIVE) {
return true;
}
const swipeIdInput = popup.dlg.querySelector(`#${swipeIdInputId}`);
const targetSwipeNumber = Number.parseInt(String(swipeIdInput instanceof HTMLInputElement ? swipeIdInput.value : '').trim(), 10);
if (!Number.isInteger(targetSwipeNumber) || targetSwipeNumber < 1 || targetSwipeNumber > message.swipes.length) {
toastr.warning(t`Enter a swipe ID between 1 and ${message.swipes.length}.`, t`Jump to Swipe`);
if (swipeIdInput instanceof HTMLInputElement) {
swipeIdInput.focus();
swipeIdInput.select();
}
return false;
}
setSelectedSwipe(targetSwipeNumber - 1);
return true;
},
});
popup.dlg.classList.add('swipe_picker_popup');
popup.closeButton.style.display = 'block';
popup.closeButton.classList.add('opacity50p', 'hoverglow', 'fontsize120p');
popup.closeButton.style.position = 'static';
popup.closeButton.style.top = 'auto';
popup.closeButton.style.right = 'auto';
popup.closeButton.style.width = 'auto';
popup.closeButton.style.height = 'auto';
popup.closeButton.style.padding = '0';
popup.closeButton.style.filter = 'none';
header.appendChild(popup.closeButton);
swipeIdInput = popup.dlg.querySelector(`#${swipeIdInputId}`);
const swipeIdLabel = popup.dlg.querySelector(`label[for="${swipeIdInputId}"]`);
if (swipeIdLabel instanceof HTMLLabelElement) {
swipeIdLabel.classList.add('flex-container', 'alignItemsCenter', 'justifyCenter', 'gap10px', 'margin0');
popup.buttonControls.insertBefore(swipeIdLabel, canJumpToSwipe ? popup.okButton : popup.buttonControls.firstChild);
popup.inputControls.style.display = 'none';
}
if (swipeIdInput instanceof HTMLInputElement) {
swipeIdInput.type = 'number';
swipeIdInput.min = '1';
swipeIdInput.max = String(message.swipes.length);
swipeIdInput.step = '1';
swipeIdInput.inputMode = 'numeric';
swipeIdInput.classList.add('flex1', 'width100px', 'textAlignCenter');
swipeIdInput.setAttribute('autofocus', '');
syncSwipeIdInput();
swipeIdInput.addEventListener('input', function () {
const nextSwipeId = Number.parseInt(this.value, 10);
if (!Number.isInteger(nextSwipeId) || nextSwipeId < 1 || nextSwipeId > message.swipes.length) {
return;
}
setSelectedSwipe(nextSwipeId - 1);
scrollToSelectedSwipe();
});
swipeIdInput.addEventListener('blur', function () {
syncSwipeIdInput();
});
}
await renderSwipeList();
const popupResult = await popup.show();
if (branchActionSwipeId !== null) {
await branchChat(messageId, { swipeId: branchActionSwipeId });
return;
}
if (popupResult !== POPUP_RESULT.AFFIRMATIVE) {
return;
}
if (!canJumpToSwipe) {
return;
}
const targetSwipeId = clamp(selectedSwipeId, 0, message.swipes.length - 1);
const currentSwipeId = clamp(Number(message.swipe_id ?? 0), 0, message.swipes.length - 1);
if (targetSwipeId === currentSwipeId) {
toastr.info(t`Already showing swipe #${targetSwipeId + 1}.`, t`Jump to Swipe`);
return;
}
const direction = targetSwipeId > currentSwipeId ? SWIPE_DIRECTION.RIGHT : SWIPE_DIRECTION.LEFT;
await swipe(null, direction, { source: SWIPE_SOURCE.SWIPE_PICKER, forceMesId: messageId, forceSwipeId: targetSwipeId });
}
export function initSwipePicker() {
$(document).on('click', '.swipes-counter.swipe-picker-enabled', async function (e) {
e.preventDefault();
e.stopPropagation();
const mesId = Number($(this).closest('.mes').attr('mesid'));
await openSwipePicker(mesId);
});
$(document).on('keydown', '.swipes-counter.swipe-picker-enabled', async function (e) {
if (e.key !== ' ') {
return;
}
e.preventDefault();
e.stopPropagation();
const mesId = Number($(this).closest('.mes').attr('mesid'));
await openSwipePicker(mesId);
});
$(document).on('click', '.mes_swipe_picker', async function (e) {
e.preventDefault();
e.stopPropagation();
const mesId = Number($(this).closest('.mes').attr('mesid'));
await openSwipePicker(mesId);
});
}
+54 -6
View File
@@ -1278,6 +1278,29 @@ body .panelControlBar {
pointer-events: auto;
}
.swipes-counter.swipe-picker-enabled {
cursor: pointer;
}
.swipes-counter.swipe-picker-enabled:hover,
.swipes-counter.swipe-picker-enabled:focus-visible {
opacity: 0.7;
}
.swipe_picker_header {
position: sticky;
top: 0;
z-index: 1;
}
.swipe_picker_popup .popup-body,
.swipe_picker_popup .popup-content {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
body.swipeAllMessages .mes:not(.last_mes) .swipes-counter {
/* Avoid expensive DOM queries */
@@ -4725,13 +4748,15 @@ h5 {
cursor: pointer;
}
#select_chat_div {
#select_chat_div,
.swipe_picker_div {
padding: 0;
height: 100%;
overflow-y: auto;
}
#select_chat_div hr {
#select_chat_div hr,
.swipe_picker_div hr {
margin: 0;
}
@@ -4739,21 +4764,30 @@ h5 {
cursor: pointer;
}
.select_chat_block {
.select_chat_block,
.swipe_picker_block {
border-radius: 5px;
margin-top: 5px;
border: 1px solid var(--SmartThemeBorderColor);
padding: 5px 7px;
}
.select_chat_block:hover {
.select_chat_block:hover,
.swipe_picker_block:hover {
background-color: var(--white30a);
}
.select_chat_block[highlight] {
.select_chat_block[highlight],
.swipe_picker_block[highlight] {
background-color: var(--cobalt30a);
}
.swipe_picker_block {
cursor: pointer;
-webkit-user-select: none;
user-select: none;
}
.select_chat_block .avatar {
grid-row: span 2;
}
@@ -4790,13 +4824,27 @@ h5 {
}
.PastChat_cross:hover {
.PastChat_cross:not(.disabled):hover {
color: red;
filter: drop-shadow(0 0 2px red);
-webkit-animation: infinite-spinning 1s ease-out 0s infinite normal;
animation: infinite-spinning 1s ease-out 0s infinite normal;
}
.swipe_picker_delete.disabled {
cursor: default !important;
opacity: 0.2;
}
.swipe_picker_delete.disabled:hover {
cursor: default !important;
color: inherit;
filter: none;
-webkit-animation: none;
animation: none;
opacity: 0.2 !important;
}
#export_character_div {
display: grid;
grid-template-columns: 340px auto;