Files
Cohee d2b2b1b4a6 fix: require long press to open swipe picker on phones (#5382)
* fix: require long press to open swipe picker on phones

* fix: clarify parameter description in assignLorebookToChat function

* fix: update event parameter type in onSwipeCounterClick to include TouchEvent

* fix: update event parameter types in onSwipeCounterClick and addLongPressEvent
2026-03-31 20:08:56 +03:00

445 lines
18 KiB
JavaScript

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 { isMobile } from './RossAscends-mods.js';
import { getTokenCountAsync } from './tokenizers.js';
import { addLongPressEvent, clamp, copyText, 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');
block.find('.select_chat_actions').removeClass('gap10px');
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-fw 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-fw 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();
});
// Add expand/collapse toggle
const expandCheckboxId = `swipe_picker_expand_${messageId}_${index}`;
const expandCheckbox = document.createElement('input');
expandCheckbox.type = 'checkbox';
expandCheckbox.id = expandCheckboxId;
expandCheckbox.classList.add('swipe_picker_expand_toggle');
block[0].prepend(expandCheckbox);
const expandLabel = document.createElement('label');
expandLabel.htmlFor = expandCheckboxId;
expandLabel.classList.add('swipe_picker_expand_label', 'fa-solid', 'fa-fw', 'fa-chevron-down');
expandLabel.title = t`Expand/Collapse`;
expandLabel.setAttribute('data-i18n', '[title]Expand/Collapse');
expandLabel.addEventListener('click', (event) => event.stopPropagation());
// Add copy button
const copyButton = document.createElement('div');
copyButton.classList.add('swipe_picker_copy', 'fa-solid', 'fa-fw', 'fa-copy');
copyButton.title = t`Copy`;
copyButton.setAttribute('data-i18n', '[title]Copy');
copyButton.addEventListener('click', async (event) => {
event.preventDefault();
event.stopPropagation();
await copyText(swipeText);
toastr.info(t`Copied!`, '', { timeOut: 2000 });
});
// Insert new buttons before the branch button
branchButton.before(expandLabel, copyButton);
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 ? swipeText : 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}`,
}],
large: true,
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() {
/**
* Click handler for opening the swipe picker when clicking on the swipe counter.
* @param {JQuery.Event | Event} e Event object
*/
async function onSwipeCounterClick(e) {
e.preventDefault();
e.stopPropagation();
const mesId = Number($(this).closest('.mes').attr('mesid'));
await openSwipePicker(mesId);
}
if (isMobile()) {
addLongPressEvent('.swipes-counter.swipe-picker-enabled', onSwipeCounterClick);
} else {
$(document).on('click', '.swipes-counter.swipe-picker-enabled', onSwipeCounterClick);
}
$(document).on('keydown', '.swipes-counter.swipe-picker-enabled', async function (e) {
if (e.key !== ' ') {
return;
}
onSwipeCounterClick.call(this, e);
});
$(document).on('click', '.mes_swipe_picker', async function (e) {
e.preventDefault();
e.stopPropagation();
const mesId = Number($(this).closest('.mes').attr('mesid'));
await openSwipePicker(mesId);
});
}