add context menu

This commit is contained in:
LenAnderson 2023-12-20 21:44:55 +00:00
parent bab0c4b0b9
commit 65e16affb7
9 changed files with 451 additions and 6 deletions

View File

@ -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);
}
};

View File

@ -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);

View File

@ -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;
}

View 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);
}
}
}

View File

@ -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;
}
}

View 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();
}
}
}

View 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);
}
}
}

View File

@ -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;

View File

@ -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 {