Merge pull request #1392 from LenAnderson/qr-context
Add context menus for Quick Replies
This commit is contained in:
commit
7841f3d91f
|
@ -0,0 +1,21 @@
|
|||
<div id="quickReply_contextMenuEditor_template">
|
||||
<div class="quickReply_contextMenuEditor">
|
||||
<h3><strong>Quick Reply Context Menu Editor</strong></h3>
|
||||
<div id="quickReply_contextMenuEditor_content">
|
||||
<template id="quickReply_contextMenuEditor_itemTemplate">
|
||||
<div class="quickReplyContextMenuEditor_item flex-container alignitemscenter" data-order="0">
|
||||
<span class="drag-handle ui-sortable-handle">☰</span>
|
||||
<select class="quickReply_contextMenuEditor_preset"></select>
|
||||
<label class="flex-container" title="When enabled, the current Quick Reply will be sent together with (before) the clicked QR from the context menu.">
|
||||
Chaining:
|
||||
<input type="checkbox" class="quickReply_contextMenuEditor_chaining">
|
||||
</label>
|
||||
<span class="quickReply_contextMenuEditor_remove menu_button menu_button_icon fa-solid fa-trash-can" title="Remove entry"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="quickReply_contextMenuEditor_actions">
|
||||
<span id="quickReply_contextMenuEditor_addPreset" class="menu_button menu_button_icon fa-solid fa-plus" title="Add preset to context menu"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -2,6 +2,9 @@ import { saveSettingsDebounced, callPopup, getRequestHeaders, substituteParams }
|
|||
import { getContext, extension_settings } from "../../extensions.js";
|
||||
import { initScrollHeight, resetScrollHeight, getSortableDelay } from "../../utils.js";
|
||||
import { executeSlashCommands, registerSlashCommand } from "../../slash-commands.js";
|
||||
import { ContextMenu } from "./src/ContextMenu.js";
|
||||
import { MenuItem } from "./src/MenuItem.js";
|
||||
import { MenuHeader } from "./src/MenuHeader.js";
|
||||
|
||||
export { MODULE_NAME };
|
||||
|
||||
|
@ -99,10 +102,69 @@ function onQuickReplyInput(id) {
|
|||
|
||||
function onQuickReplyLabelInput(id) {
|
||||
extension_settings.quickReply.quickReplySlots[id - 1].label = $(`#quickReply${id}Label`).val();
|
||||
$(`#quickReply${id}`).text(String($(`#quickReply${id}Label`).val()));
|
||||
addQuickReplyBar();
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
async function onQuickReplyContextMenuChange(id) {
|
||||
extension_settings.quickReply.quickReplySlots[id - 1].contextMenu = JSON.parse($(`#quickReplyContainer > [data-order="${id}"]`).attr('data-contextMenu'))
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
async function onQuickReplyCtxButtonClick(id) {
|
||||
const editorHtml = $(await $.get('scripts/extensions/quick-reply/contextMenuEditor.html'));
|
||||
const popupResult = callPopup(editorHtml, "confirm", undefined, { okButton: "Save", wide:false, large:false, rows: 1 });
|
||||
const qr = extension_settings.quickReply.quickReplySlots[id - 1];
|
||||
if (!qr.contextMenu) {
|
||||
qr.contextMenu = [];
|
||||
}
|
||||
/**@type {HTMLTemplateElement}*/
|
||||
const tpl = document.querySelector('#quickReply_contextMenuEditor_itemTemplate');
|
||||
const fillPresetSelect = (select, item) => {
|
||||
[{name:'Select a preset', value:''}, ...presets].forEach(preset=>{
|
||||
const opt = document.createElement('option'); {
|
||||
opt.value = preset.value ?? preset.name;
|
||||
opt.textContent = preset.name;
|
||||
opt.selected = preset.name == item.preset;
|
||||
select.append(opt);
|
||||
}
|
||||
});
|
||||
};
|
||||
const addCtxItem = (item, idx) => {
|
||||
const dom = tpl.content.cloneNode(true);
|
||||
const ctxItem = dom.querySelector('.quickReplyContextMenuEditor_item');
|
||||
ctxItem.setAttribute('data-order', idx);
|
||||
const select = ctxItem.querySelector('.quickReply_contextMenuEditor_preset');
|
||||
fillPresetSelect(select, item);
|
||||
dom.querySelector('.quickReply_contextMenuEditor_chaining').checked = item.chain;
|
||||
$('.quickReply_contextMenuEditor_remove', ctxItem).on('click', ()=>ctxItem.remove());
|
||||
document.querySelector('#quickReply_contextMenuEditor_content').append(ctxItem);
|
||||
}
|
||||
[...qr.contextMenu, {}].forEach((item,idx)=>{
|
||||
addCtxItem(item, idx)
|
||||
});
|
||||
$('#quickReply_contextMenuEditor_addPreset').on('click', ()=>{
|
||||
addCtxItem({}, document.querySelector('#quickReply_contextMenuEditor_content').children.length);
|
||||
});
|
||||
|
||||
$('#quickReply_contextMenuEditor_content').sortable({
|
||||
delay: getSortableDelay(),
|
||||
stop: ()=>{},
|
||||
});
|
||||
|
||||
if (await popupResult) {
|
||||
qr.contextMenu = Array.from(document.querySelectorAll('#quickReply_contextMenuEditor_content > .quickReplyContextMenuEditor_item'))
|
||||
.map(item=>({
|
||||
preset: item.querySelector('.quickReply_contextMenuEditor_preset').value,
|
||||
chain: item.querySelector('.quickReply_contextMenuEditor_chaining').checked,
|
||||
}))
|
||||
.filter(item=>item.preset);
|
||||
$(`#quickReplyContainer[data-order="${id}"]`).attr('data-contextMenu', JSON.stringify(qr.contextMenu));
|
||||
updateQuickReplyPreset();
|
||||
onQuickReplyLabelInput(id);
|
||||
}
|
||||
}
|
||||
|
||||
async function onQuickReplyEnabledInput() {
|
||||
let isEnabled = $(this).prop('checked')
|
||||
extension_settings.quickReply.quickReplyEnabled = !!isEnabled;
|
||||
|
@ -129,13 +191,15 @@ async function onAutoInputInject() {
|
|||
}
|
||||
|
||||
async function sendQuickReply(index) {
|
||||
const existingText = $("#send_textarea").val();
|
||||
const prompt = extension_settings.quickReply.quickReplySlots[index]?.mes || '';
|
||||
|
||||
await performQuickReply(prompt, index);
|
||||
}
|
||||
async function performQuickReply(prompt, index) {
|
||||
if (!prompt) {
|
||||
console.warn(`Quick reply slot ${index} is empty! Aborting.`);
|
||||
return;
|
||||
}
|
||||
const existingText = $("#send_textarea").val();
|
||||
|
||||
let newText;
|
||||
|
||||
|
@ -170,6 +234,41 @@ async function sendQuickReply(index) {
|
|||
}
|
||||
|
||||
|
||||
function buildContextMenu(qr, chainMes=null, hierarchy=[], labelHierarchy=[]) {
|
||||
const tree = {
|
||||
label: qr.label,
|
||||
mes: (chainMes&&qr.mes ? `${chainMes} | ` : '') + qr.mes,
|
||||
children: [],
|
||||
};
|
||||
qr.contextMenu?.forEach(ctxItem=>{
|
||||
let chain = ctxItem.chain;
|
||||
let subName = ctxItem.preset;
|
||||
const sub = presets.find(it=>it.name == subName);
|
||||
if (sub) {
|
||||
// prevent circular references
|
||||
if (hierarchy.indexOf(sub.name) == -1) {
|
||||
const nextHierarchy = [...hierarchy, sub.name];
|
||||
const nextLabelHierarchy = [...labelHierarchy, tree.label];
|
||||
tree.children.push(new MenuHeader(sub.name));
|
||||
sub.quickReplySlots.forEach(subQr=>{
|
||||
const subInfo = buildContextMenu(subQr, chain?tree.mes:null, nextHierarchy, nextLabelHierarchy);
|
||||
tree.children.push(new MenuItem(
|
||||
subInfo.label,
|
||||
subInfo.mes,
|
||||
(evt)=>{
|
||||
evt.stopPropagation();
|
||||
performQuickReply(subInfo.mes.replace(/%%parent(-\d+)?%%/g, (_, index)=>{
|
||||
return nextLabelHierarchy.slice(parseInt(index ?? '-1'))[0];
|
||||
}));
|
||||
},
|
||||
subInfo.children,
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return tree;
|
||||
}
|
||||
function addQuickReplyBar() {
|
||||
$('#quickReplyBar').remove();
|
||||
let quickReplyButtonHtml = '';
|
||||
|
@ -177,7 +276,11 @@ function addQuickReplyBar() {
|
|||
for (let i = 0; i < extension_settings.quickReply.numberOfSlots; i++) {
|
||||
let quickReplyMes = extension_settings.quickReply.quickReplySlots[i]?.mes || '';
|
||||
let quickReplyLabel = extension_settings.quickReply.quickReplySlots[i]?.label || '';
|
||||
quickReplyButtonHtml += `<div title="${quickReplyMes}" class="quickReplyButton" data-index="${i}" id="quickReply${i + 1}">${quickReplyLabel}</div>`;
|
||||
let expander = '';
|
||||
if (extension_settings.quickReply.quickReplySlots[i]?.contextMenu?.length) {
|
||||
expander = '<span class="ctx-expander" title="Open context menu">⋮</span>';
|
||||
}
|
||||
quickReplyButtonHtml += `<div title="${quickReplyMes}" class="quickReplyButton" data-index="${i}" id="quickReply${i + 1}">${quickReplyLabel}${expander}</div>`;
|
||||
}
|
||||
|
||||
const quickReplyBarFullHtml = `
|
||||
|
@ -194,6 +297,27 @@ function addQuickReplyBar() {
|
|||
let index = $(this).data('index');
|
||||
sendQuickReply(index);
|
||||
});
|
||||
$('.quickReplyButton > .ctx-expander').on('click', function (evt) {
|
||||
evt.stopPropagation();
|
||||
let index = $(this.closest('.quickReplyButton')).data('index');
|
||||
const qr = extension_settings.quickReply.quickReplySlots[index];
|
||||
if (qr.contextMenu?.length) {
|
||||
evt.preventDefault();
|
||||
const tree = buildContextMenu(qr);
|
||||
const menu = new ContextMenu(tree.children);
|
||||
menu.show(evt);
|
||||
}
|
||||
})
|
||||
$('.quickReplyButton').on('contextmenu', function (evt) {
|
||||
let index = $(this).data('index');
|
||||
const qr = extension_settings.quickReply.quickReplySlots[index];
|
||||
if (qr.contextMenu?.length) {
|
||||
evt.preventDefault();
|
||||
const tree = buildContextMenu(qr);
|
||||
const menu = new ContextMenu(tree.children);
|
||||
menu.show(evt);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function moduleWorker() {
|
||||
|
@ -337,9 +461,10 @@ function generateQuickReplyElements() {
|
|||
for (let i = 1; i <= extension_settings.quickReply.numberOfSlots; i++) {
|
||||
let itemNumber = i + 1
|
||||
quickReplyHtml += `
|
||||
<div class="flex-container alignitemscenter" data-order="${i}"}>
|
||||
<div class="flex-container alignitemscenter" data-order="${i}">
|
||||
<span class="drag-handle ui-sortable-handle">☰</span>
|
||||
<input class="text_pole wide30p" id="quickReply${i}Label" placeholder="(Button label)">
|
||||
<span class="menu_button menu_button_icon" id="quickReply${i}CtxButton" title="Configure context menu">⋮</span>
|
||||
<textarea id="quickReply${i}Mes" placeholder="(Custom message or /command)" class="text_pole widthUnset flex1 autoSetHeight" rows="2"></textarea>
|
||||
</div>
|
||||
`;
|
||||
|
@ -350,6 +475,8 @@ function generateQuickReplyElements() {
|
|||
for (let i = 1; i <= extension_settings.quickReply.numberOfSlots; i++) {
|
||||
$(`#quickReply${i}Mes`).on('input', function () { onQuickReplyInput(i); });
|
||||
$(`#quickReply${i}Label`).on('input', function () { onQuickReplyLabelInput(i); });
|
||||
$(`#quickReply${i}CtxButton`).on('click', function () { onQuickReplyCtxButtonClick(i); });
|
||||
$(`#quickReplyContainer > [data-order="${i}"]`).attr('data-contextMenu', JSON.stringify(extension_settings.quickReply.quickReplySlots[i-1]?.contextMenu??[]));
|
||||
}
|
||||
|
||||
$('.quickReplySettings .inline-drawer-toggle').off('click').on('click', function () {
|
||||
|
@ -411,6 +538,7 @@ function saveQROrder() {
|
|||
//rebuild the extension_Settings array based on new order
|
||||
i = 1
|
||||
$('#quickReplyContainer').children().each(function () {
|
||||
onQuickReplyContextMenuChange(i)
|
||||
onQuickReplyLabelInput(i)
|
||||
onQuickReplyInput(i)
|
||||
i++
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
import { MenuItem } from "./MenuItem.js";
|
||||
|
||||
export class ContextMenu {
|
||||
/**@type {MenuItem[]}*/ itemList = [];
|
||||
/**@type {Boolean}*/ isActive = false;
|
||||
|
||||
/**@type {HTMLElement}*/ root;
|
||||
/**@type {HTMLElement}*/ menu;
|
||||
|
||||
|
||||
|
||||
|
||||
constructor(/**@type {MenuItem[]}*/items) {
|
||||
this.itemList = items;
|
||||
items.forEach(item=>{
|
||||
item.onExpand = ()=>{
|
||||
items.filter(it=>it!=item)
|
||||
.forEach(it=>it.collapse());
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.root) {
|
||||
const blocker = document.createElement('div'); {
|
||||
this.root = blocker;
|
||||
blocker.classList.add('ctx-blocker');
|
||||
blocker.addEventListener('click', ()=>this.hide());
|
||||
const menu = document.createElement('ul'); {
|
||||
this.menu = menu;
|
||||
menu.classList.add('list-group');
|
||||
menu.classList.add('ctx-menu');
|
||||
this.itemList.forEach(it=>menu.append(it.render()));
|
||||
blocker.append(menu);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.root;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
show({clientX, clientY}) {
|
||||
if (this.isActive) return;
|
||||
this.isActive = true;
|
||||
this.render();
|
||||
this.menu.style.bottom = `${window.innerHeight - clientY}px`;
|
||||
this.menu.style.left = `${clientX}px`;
|
||||
document.body.append(this.root);
|
||||
}
|
||||
hide() {
|
||||
if (this.root) {
|
||||
this.root.remove();
|
||||
}
|
||||
this.isActive = false;
|
||||
}
|
||||
toggle(/**@type {PointerEvent}*/evt) {
|
||||
if (this.isActive) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show(evt);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import { MenuItem } from "./MenuItem.js";
|
||||
import { SubMenu } from "./SubMenu.js";
|
||||
|
||||
export class MenuHeader extends MenuItem {
|
||||
constructor(/**@type {String}*/label) {
|
||||
super(label, null, null);
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
if (!this.root) {
|
||||
const item = document.createElement('li'); {
|
||||
this.root = item;
|
||||
item.classList.add('list-group-item');
|
||||
item.classList.add('ctx-header');
|
||||
item.append(this.label);
|
||||
}
|
||||
}
|
||||
return this.root;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
import { SubMenu } from "./SubMenu.js";
|
||||
|
||||
export class MenuItem {
|
||||
/**@type {String}*/ label;
|
||||
/**@type {Object}*/ value;
|
||||
/**@type {Function}*/ callback;
|
||||
/**@type {MenuItem[]}*/ childList = [];
|
||||
/**@type {SubMenu}*/ subMenu;
|
||||
/**@type {Boolean}*/ isForceExpanded = false;
|
||||
|
||||
/**@type {HTMLElement}*/ root;
|
||||
|
||||
/**@type {Function}*/ onExpand;
|
||||
|
||||
|
||||
|
||||
|
||||
constructor(/**@type {String}*/label, /**@type {Object}*/value, /**@type {function}*/callback, /**@type {MenuItem[]}*/children=[]) {
|
||||
this.label = label;
|
||||
this.value = value;
|
||||
this.callback = callback;
|
||||
this.childList = children;
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
if (!this.root) {
|
||||
const item = document.createElement('li'); {
|
||||
this.root = item;
|
||||
item.classList.add('list-group-item');
|
||||
item.classList.add('ctx-item');
|
||||
item.title = this.value;
|
||||
if (this.callback) {
|
||||
item.addEventListener('click', (evt)=>this.callback(evt, this));
|
||||
}
|
||||
item.append(this.label);
|
||||
if (this.childList.length > 0) {
|
||||
item.classList.add('ctx-has-children');
|
||||
const sub = new SubMenu(this.childList);
|
||||
this.subMenu = sub;
|
||||
const trigger = document.createElement('div'); {
|
||||
trigger.classList.add('ctx-expander');
|
||||
trigger.textContent = '⋮';
|
||||
trigger.addEventListener('click', (evt)=>{
|
||||
evt.stopPropagation();
|
||||
this.toggle();
|
||||
});
|
||||
item.append(trigger);
|
||||
}
|
||||
item.addEventListener('mouseover', ()=>sub.show(item));
|
||||
item.addEventListener('mouseleave', ()=>sub.hide());
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.root;
|
||||
}
|
||||
|
||||
|
||||
expand() {
|
||||
this.subMenu?.show(this.root);
|
||||
if (this.onExpand) {
|
||||
this.onExpand();
|
||||
}
|
||||
}
|
||||
collapse() {
|
||||
this.subMenu?.hide();
|
||||
}
|
||||
toggle() {
|
||||
if (this.subMenu.isActive) {
|
||||
this.expand();
|
||||
} else {
|
||||
this.collapse();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
import { MenuItem } from "./MenuItem.js";
|
||||
|
||||
export class SubMenu {
|
||||
/**@type {MenuItem[]}*/ itemList = [];
|
||||
/**@type {Boolean}*/ isActive = false;
|
||||
|
||||
/**@type {HTMLElement}*/ root;
|
||||
|
||||
|
||||
|
||||
|
||||
constructor(/**@type {MenuItem[]}*/items) {
|
||||
this.itemList = items;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.root) {
|
||||
const menu = document.createElement('ul'); {
|
||||
this.root = menu;
|
||||
menu.classList.add('list-group');
|
||||
menu.classList.add('ctx-menu');
|
||||
menu.classList.add('ctx-sub-menu');
|
||||
this.itemList.forEach(it=>menu.append(it.render()));
|
||||
}
|
||||
}
|
||||
return this.root;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
show(/**@type {HTMLElement}*/parent) {
|
||||
if (this.isActive) return;
|
||||
this.isActive = true;
|
||||
this.render();
|
||||
parent.append(this.root);
|
||||
requestAnimationFrame(()=>{
|
||||
const rect = this.root.getBoundingClientRect();
|
||||
console.log(window.innerHeight, rect);
|
||||
if (rect.bottom > window.innerHeight - 5) {
|
||||
this.root.style.top = `${window.innerHeight - 5 - rect.bottom}px`;
|
||||
}
|
||||
if (rect.right > window.innerWidth - 5) {
|
||||
this.root.style.left = 'unset';
|
||||
this.root.style.right = '100%';
|
||||
}
|
||||
});
|
||||
}
|
||||
hide() {
|
||||
if (this.root) {
|
||||
this.root.remove();
|
||||
this.root.style.top = '';
|
||||
this.root.style.left = '';
|
||||
}
|
||||
this.isActive = false;
|
||||
}
|
||||
toggle(/**@type {HTMLElement}*/parent) {
|
||||
if (this.isActive) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show(parent);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -44,4 +44,52 @@
|
|||
opacity: 1;
|
||||
filter: brightness(1.2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
.ctx-blocker {
|
||||
backdrop-filter: blur(1px);
|
||||
background-color: rgba(0 0 0 / 10%);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 999;
|
||||
}
|
||||
.ctx-menu {
|
||||
position: fixed;
|
||||
overflow: visible;
|
||||
}
|
||||
.list-group .list-group-item.ctx-header {
|
||||
font-weight: bold;
|
||||
cursor: default;
|
||||
}
|
||||
.ctx-item + .ctx-header {
|
||||
border-top: 1px solid;
|
||||
}
|
||||
.ctx-item {
|
||||
position: relative;
|
||||
}
|
||||
.ctx-expander {
|
||||
border-left: 1px solid;
|
||||
margin-left: 1em;
|
||||
text-align: center;
|
||||
width: 2em;
|
||||
}
|
||||
.ctx-expander:hover {
|
||||
font-weight: bold;
|
||||
}
|
||||
.ctx-sub-menu {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 100%;
|
||||
}
|
||||
@media screen and (max-width: 1000px) {
|
||||
.ctx-blocker {
|
||||
position: absolute;
|
||||
}
|
||||
.list-group .list-group-item.ctx-item {
|
||||
padding: 1em;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue