mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-02-20 14:10:39 +01:00
add context menu
This commit is contained in:
parent
bab0c4b0b9
commit
65e16affb7
@ -12,7 +12,6 @@ import { SettingsUi } from './src/ui/SettingsUi.js';
|
||||
|
||||
|
||||
//TODO popout QR button bar (allow separate popouts for each QR set?)
|
||||
//TODO context menus
|
||||
//TODO move advanced QR options into own UI class
|
||||
//TODO slash commands
|
||||
//TODO easy way to CRUD QRs and sets
|
||||
@ -61,7 +60,7 @@ const loadSets = async () => {
|
||||
const setList = (await response.json()).quickReplyPresets ?? [];
|
||||
for (const set of setList) {
|
||||
if (set.version == 2) {
|
||||
QuickReplySet.list.push(QuickReplySet.from(set));
|
||||
QuickReplySet.list.push(QuickReplySet.from(JSON.parse(JSON.stringify(set))));
|
||||
} else {
|
||||
const qrs = new QuickReplySet();
|
||||
qrs.name = set.name;
|
||||
@ -89,6 +88,10 @@ const loadSets = async () => {
|
||||
await qrs.save();
|
||||
}
|
||||
}
|
||||
setList.forEach((set, idx)=>{
|
||||
QuickReplySet.list[idx].qrList = set.qrList.map(it=>QuickReply.from(it));
|
||||
QuickReplySet.list[idx].init();
|
||||
});
|
||||
log('sets: ', QuickReplySet.list);
|
||||
}
|
||||
};
|
||||
|
@ -3,6 +3,7 @@ import { getSortableDelay } from '../../../utils.js';
|
||||
import { log, warn } from '../index.js';
|
||||
import { QuickReplyContextLink } from './QuickReplyContextLink.js';
|
||||
import { QuickReplySet } from './QuickReplySet.js';
|
||||
import { ContextMenu } from './ui/ctx/ContextMenu.js';
|
||||
|
||||
export class QuickReply {
|
||||
/**
|
||||
@ -35,9 +36,15 @@ export class QuickReply {
|
||||
|
||||
|
||||
/**@type {HTMLElement}*/ dom;
|
||||
/**@type {HTMLElement}*/ domLabel;
|
||||
/**@type {HTMLElement}*/ settingsDom;
|
||||
|
||||
|
||||
get hasContext() {
|
||||
return this.contextList && this.contextList.length > 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
unrender() {
|
||||
@ -47,7 +54,8 @@ export class QuickReply {
|
||||
updateRender() {
|
||||
if (!this.dom) return;
|
||||
this.dom.title = this.title || this.message;
|
||||
this.dom.textContent = this.label;
|
||||
this.domLabel.textContent = this.label;
|
||||
this.dom.classList[this.hasContext ? 'add' : 'remove']('qr--hasCtx');
|
||||
}
|
||||
render() {
|
||||
this.unrender();
|
||||
@ -55,8 +63,37 @@ export class QuickReply {
|
||||
const root = document.createElement('div'); {
|
||||
this.dom = root;
|
||||
root.classList.add('qr--button');
|
||||
if (this.hasContext) {
|
||||
root.classList.add('qr--hasCtx');
|
||||
}
|
||||
root.title = this.title || this.message;
|
||||
root.textContent = this.label;
|
||||
root.addEventListener('contextmenu', (evt) => {
|
||||
log('contextmenu', this, this.hasContext);
|
||||
if (this.hasContext) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
const menu = new ContextMenu(this);
|
||||
menu.show(evt);
|
||||
}
|
||||
});
|
||||
const lbl = document.createElement('div'); {
|
||||
this.domLabel = lbl;
|
||||
lbl.classList.add('qr--button-label');
|
||||
lbl.textContent = this.label;
|
||||
root.append(lbl);
|
||||
}
|
||||
const expander = document.createElement('div'); {
|
||||
expander.classList.add('qr--button-expander');
|
||||
expander.textContent = '⋮';
|
||||
expander.title = 'Open context menu';
|
||||
expander.addEventListener('click', (evt) => {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
const menu = new ContextMenu(this);
|
||||
menu.show(evt);
|
||||
});
|
||||
root.append(expander);
|
||||
}
|
||||
root.addEventListener('click', ()=>{
|
||||
if (this.message?.length > 0 && this.onExecute) {
|
||||
this.onExecute(this);
|
||||
|
@ -9,9 +9,9 @@ export class QuickReplySet {
|
||||
|
||||
|
||||
static from(props) {
|
||||
props.qrList = props.qrList?.map(it=>QuickReply.from(it));
|
||||
props.qrList = []; //props.qrList?.map(it=>QuickReply.from(it));
|
||||
const instance = Object.assign(new this(), props);
|
||||
instance.init();
|
||||
// instance.init();
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
108
public/scripts/extensions/quick-reply/src/ui/ctx/ContextMenu.js
Normal file
108
public/scripts/extensions/quick-reply/src/ui/ctx/ContextMenu.js
Normal file
@ -0,0 +1,108 @@
|
||||
import { QuickReply } from '../../QuickReply.js';
|
||||
import { QuickReplyContextLink } from '../../QuickReplyContextLink.js';
|
||||
import { QuickReplySet } from '../../QuickReplySet.js';
|
||||
import { MenuHeader } from './MenuHeader.js';
|
||||
import { MenuItem } from './MenuItem.js';
|
||||
|
||||
export class ContextMenu {
|
||||
/**@type {MenuItem[]}*/ itemList = [];
|
||||
/**@type {Boolean}*/ isActive = false;
|
||||
|
||||
/**@type {HTMLElement}*/ root;
|
||||
/**@type {HTMLElement}*/ menu;
|
||||
|
||||
|
||||
|
||||
|
||||
constructor(/**@type {QuickReply}*/qr) {
|
||||
// this.itemList = items;
|
||||
this.itemList = this.build(qr).children;
|
||||
this.itemList.forEach(item => {
|
||||
item.onExpand = () => {
|
||||
this.itemList.filter(it => it != item)
|
||||
.forEach(it => it.collapse());
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {QuickReply} qr
|
||||
* @param {String} chainedMessage
|
||||
* @param {QuickReplySet[]} hierarchy
|
||||
* @param {String[]} labelHierarchy
|
||||
*/
|
||||
build(qr, chainedMessage = null, hierarchy = [], labelHierarchy = []) {
|
||||
const tree = {
|
||||
label: qr.label,
|
||||
message: (chainedMessage && qr.message ? `${chainedMessage} | ` : '') + qr.message,
|
||||
children: [],
|
||||
};
|
||||
qr.contextList.forEach((cl) => {
|
||||
if (!hierarchy.includes(cl.set)) {
|
||||
const nextHierarchy = [...hierarchy, cl.set];
|
||||
const nextLabelHierarchy = [...labelHierarchy, tree.label];
|
||||
tree.children.push(new MenuHeader(cl.set.name));
|
||||
cl.set.qrList.forEach(subQr => {
|
||||
const subTree = this.build(subQr, cl.isChained ? tree.message : null, nextHierarchy, nextLabelHierarchy);
|
||||
tree.children.push(new MenuItem(
|
||||
subTree.label,
|
||||
subTree.message,
|
||||
(evt) => {
|
||||
evt.stopPropagation();
|
||||
const finalQr = Object.assign(new QuickReply(), subQr);
|
||||
finalQr.message = subTree.message.replace(/%%parent(-\d+)?%%/g, (_, index) => {
|
||||
return nextLabelHierarchy.slice(parseInt(index ?? '-1'))[0];
|
||||
});
|
||||
cl.set.execute(finalQr);
|
||||
},
|
||||
subTree.children,
|
||||
));
|
||||
});
|
||||
}
|
||||
});
|
||||
return tree;
|
||||
}
|
||||
|
||||
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,20 @@
|
||||
import { MenuItem } from './MenuItem.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;
|
||||
}
|
||||
}
|
76
public/scripts/extensions/quick-reply/src/ui/ctx/MenuItem.js
Normal file
76
public/scripts/extensions/quick-reply/src/ui/ctx/MenuItem.js
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
66
public/scripts/extensions/quick-reply/src/ui/ctx/SubMenu.js
Normal file
66
public/scripts/extensions/quick-reply/src/ui/ctx/SubMenu.js
Normal file
@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @typedef {import('./MenuItem.js').MenuItem} MenuItem
|
||||
*/
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
@ -42,6 +42,67 @@
|
||||
opacity: 1;
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
#qr--bar > .qr--buttons .qr--button > .qr--button-expander {
|
||||
display: none;
|
||||
}
|
||||
#qr--bar > .qr--buttons .qr--button.qr--hasCtx > .qr--button-expander {
|
||||
display: block;
|
||||
}
|
||||
.qr--button-expander {
|
||||
border-left: 1px solid;
|
||||
margin-left: 1em;
|
||||
text-align: center;
|
||||
width: 2em;
|
||||
}
|
||||
.qr--button-expander:hover {
|
||||
font-weight: bold;
|
||||
}
|
||||
.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: absolute;
|
||||
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;
|
||||
}
|
||||
}
|
||||
#qr--settings .qr--head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
@ -42,10 +42,84 @@
|
||||
opacity: 1;
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
> .qr--button-expander {
|
||||
display: none;
|
||||
}
|
||||
&.qr--hasCtx {
|
||||
> .qr--button-expander {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qr--button-expander {
|
||||
border-left: 1px solid;
|
||||
margin-left: 1em;
|
||||
text-align: center;
|
||||
width: 2em;
|
||||
&:hover {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.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: absolute;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
#qr--settings {
|
||||
|
Loading…
x
Reference in New Issue
Block a user