From fc31e87890492b0fdf02f5b671da16251e54724e Mon Sep 17 00:00:00 2001 From: pk Date: Thu, 23 Apr 2026 10:22:46 +0200 Subject: [PATCH] Add context menu --- clipboard.js | 13 +++- context-menu.js | 174 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 context-menu.js diff --git a/clipboard.js b/clipboard.js index ecb74d0..c223da2 100644 --- a/clipboard.js +++ b/clipboard.js @@ -6,11 +6,18 @@ export const clipboard = function(superClass) { }; }; -export const copyToClipboard = (content) => { +export const copyToClipboard = async (content) => { const txtEl = document.createElement('input'); txtEl.type = 'hidden'; document.body.appendChild(txtEl); txtEl.value = content; - navigator.clipboard.writeText(content); - document.body.removeChild(txtEl); + + try { + await navigator.clipboard.writeText(content); + return true; + } catch (err) { + return false; + } finally { + document.body.removeChild(txtEl); + } } diff --git a/context-menu.js b/context-menu.js new file mode 100644 index 0000000..eebf2f7 --- /dev/null +++ b/context-menu.js @@ -0,0 +1,174 @@ +/** +@license +Copyright (c) 2026 trading_peter +This program is available under Apache License Version 2.0 +*/ + +import '@tp/tp-popup/tp-popup-menu.js'; +import '@tp/tp-popup/tp-popup-menu-item.js'; +import { html } from 'lit'; + +/** + * General-purpose context menu mixin for LitElement components. + * + * The host must also use the Position mixin (from helpers/position.js) + * so that `_posFixed()` is available. + * + * Usage: + * 1. Mix into your element: ContextMenu(Position(LitElement)) + * 2. Call `this._openContextMenu(e, actions, context)` from a + * `contextmenu` event handler, where: + * - e: the original MouseEvent + * - actions: array of { label, action, icon?, disabled? } + * - context: arbitrary data passed through to the event detail + * 3. Include `${this._renderCtxMenu()}` somewhere in your render(). + * 4. Listen for the `ctx-action` event on the host to handle actions: + * detail: { action, context, originalEvent } + * + * The mixin also dispatches a cancelable `ctx-open` event before showing + * the menu. Calling `e.preventDefault()` on it suppresses the menu. + */ +export const ContextMenu = (superClass) => class ContextMenuHost extends superClass { + + static get properties() { + return { + /** @private */ + _ctxMenu: { type: Object, state: true }, + }; + } + + constructor() { + super(); + this._ctxMenu = null; + this._ctxOutsideHandler = null; + this._ctxKeyHandler = null; + } + + disconnectedCallback() { + super.disconnectedCallback(); + this._removeCtxHandlers(); + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /** + * Open a context menu at the pointer position. + * + * @param {MouseEvent} e - The contextmenu event. + * @param {Array} actions - Menu items: [{ label, action, icon?, disabled? }] + * @param {*} [context] - Arbitrary data forwarded in events. + */ + async _openContextMenu(e, actions, context) { + e.preventDefault(); + e.stopPropagation(); + + const openEvt = new CustomEvent('ctx-open', { + detail: { actions, context, originalEvent: e }, + bubbles: true, + composed: true, + cancelable: true, + }); + this.dispatchEvent(openEvt); + if (openEvt.defaultPrevented) return; + + const x = e.clientX; + const y = e.clientY; + + this._ctxMenu = { actions, context }; + this.requestUpdate(); + await this.updateComplete; + + const menu = this.shadowRoot.querySelector('.ctx-menu'); + if (menu) { + const anchor = { + getBoundingClientRect: () => ({ + top: y, bottom: y, left: x, right: x, width: 0, height: 0, + }), + }; + this._posFixed(anchor, menu, { valign: 'bottom', halign: 'left' }); + } + + this._attachCtxHandlers(); + } + + /** Programmatically close the context menu. */ + _closeContextMenu() { + this._ctxMenu = null; + this.requestUpdate(); + this._removeCtxHandlers(); + } + + // --------------------------------------------------------------------------- + // Rendering — include in your host's render() + // --------------------------------------------------------------------------- + + /** Returns the context-menu template. Place inside your host render(). */ + _renderCtxMenu() { + if (!this._ctxMenu) return null; + const { actions, context } = this._ctxMenu; + + return html` + + ${(actions || []).map((a) => a.separator + ? html`` + : html` + this._onCtxAction(a, context, e)}> + ${a.label} + + ` + )} + + `; + } + + // --------------------------------------------------------------------------- + // Internal + // --------------------------------------------------------------------------- + + /** @private */ + _onCtxAction(action, context, e) { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent('ctx-action', { + detail: { action: action.action, context, originalEvent: e }, + bubbles: true, + composed: true, + })); + this._closeContextMenu(); + } + + /** @private */ + _attachCtxHandlers() { + if (this._ctxOutsideHandler) return; + + this._ctxOutsideHandler = (e) => { + const menu = this.shadowRoot.querySelector('.ctx-menu'); + if (menu && e.composedPath().includes(menu)) return; + this._closeContextMenu(); + }; + + this._ctxKeyHandler = (e) => { + if (e.key === 'Escape') this._closeContextMenu(); + }; + + window.addEventListener('pointerdown', this._ctxOutsideHandler, true); + window.addEventListener('keydown', this._ctxKeyHandler, true); + } + + /** @private */ + _removeCtxHandlers() { + if (this._ctxOutsideHandler) { + window.removeEventListener('pointerdown', this._ctxOutsideHandler, true); + this._ctxOutsideHandler = null; + } + if (this._ctxKeyHandler) { + window.removeEventListener('keydown', this._ctxKeyHandler, true); + this._ctxKeyHandler = null; + } + } +};