From 65e16affb7e9bf72437c72ad8fd94eb8682caef9 Mon Sep 17 00:00:00 2001 From: LenAnderson Date: Wed, 20 Dec 2023 21:44:55 +0000 Subject: [PATCH] add context menu --- .../scripts/extensions/quick-reply/index.js | 7 +- .../extensions/quick-reply/src/QuickReply.js | 41 ++++++- .../quick-reply/src/QuickReplySet.js | 4 +- .../quick-reply/src/ui/ctx/ContextMenu.js | 108 ++++++++++++++++++ .../quick-reply/src/ui/ctx/MenuHeader.js | 20 ++++ .../quick-reply/src/ui/ctx/MenuItem.js | 76 ++++++++++++ .../quick-reply/src/ui/ctx/SubMenu.js | 66 +++++++++++ .../scripts/extensions/quick-reply/style.css | 61 ++++++++++ .../scripts/extensions/quick-reply/style.less | 74 ++++++++++++ 9 files changed, 451 insertions(+), 6 deletions(-) create mode 100644 public/scripts/extensions/quick-reply/src/ui/ctx/ContextMenu.js create mode 100644 public/scripts/extensions/quick-reply/src/ui/ctx/MenuHeader.js create mode 100644 public/scripts/extensions/quick-reply/src/ui/ctx/MenuItem.js create mode 100644 public/scripts/extensions/quick-reply/src/ui/ctx/SubMenu.js diff --git a/public/scripts/extensions/quick-reply/index.js b/public/scripts/extensions/quick-reply/index.js index ba694edab..ee1a71836 100644 --- a/public/scripts/extensions/quick-reply/index.js +++ b/public/scripts/extensions/quick-reply/index.js @@ -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); } }; diff --git a/public/scripts/extensions/quick-reply/src/QuickReply.js b/public/scripts/extensions/quick-reply/src/QuickReply.js index 4e27d63aa..be30e7ad9 100644 --- a/public/scripts/extensions/quick-reply/src/QuickReply.js +++ b/public/scripts/extensions/quick-reply/src/QuickReply.js @@ -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); diff --git a/public/scripts/extensions/quick-reply/src/QuickReplySet.js b/public/scripts/extensions/quick-reply/src/QuickReplySet.js index 61156750e..0d0aff585 100644 --- a/public/scripts/extensions/quick-reply/src/QuickReplySet.js +++ b/public/scripts/extensions/quick-reply/src/QuickReplySet.js @@ -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; } diff --git a/public/scripts/extensions/quick-reply/src/ui/ctx/ContextMenu.js b/public/scripts/extensions/quick-reply/src/ui/ctx/ContextMenu.js new file mode 100644 index 000000000..f651ec7e9 --- /dev/null +++ b/public/scripts/extensions/quick-reply/src/ui/ctx/ContextMenu.js @@ -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); + } + } +} diff --git a/public/scripts/extensions/quick-reply/src/ui/ctx/MenuHeader.js b/public/scripts/extensions/quick-reply/src/ui/ctx/MenuHeader.js new file mode 100644 index 000000000..f1f38c83c --- /dev/null +++ b/public/scripts/extensions/quick-reply/src/ui/ctx/MenuHeader.js @@ -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; + } +} diff --git a/public/scripts/extensions/quick-reply/src/ui/ctx/MenuItem.js b/public/scripts/extensions/quick-reply/src/ui/ctx/MenuItem.js new file mode 100644 index 000000000..d72fad310 --- /dev/null +++ b/public/scripts/extensions/quick-reply/src/ui/ctx/MenuItem.js @@ -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(); + } + } +} diff --git a/public/scripts/extensions/quick-reply/src/ui/ctx/SubMenu.js b/public/scripts/extensions/quick-reply/src/ui/ctx/SubMenu.js new file mode 100644 index 000000000..a018c60af --- /dev/null +++ b/public/scripts/extensions/quick-reply/src/ui/ctx/SubMenu.js @@ -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); + } + } +} diff --git a/public/scripts/extensions/quick-reply/style.css b/public/scripts/extensions/quick-reply/style.css index 970b2162a..c0f169655 100644 --- a/public/scripts/extensions/quick-reply/style.css +++ b/public/scripts/extensions/quick-reply/style.css @@ -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; diff --git a/public/scripts/extensions/quick-reply/style.less b/public/scripts/extensions/quick-reply/style.less index 9a7f14916..a119e31fb 100644 --- a/public/scripts/extensions/quick-reply/style.less +++ b/public/scripts/extensions/quick-reply/style.less @@ -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 {