components_selection_context_menu.js
/**
* A context menu for word-selection operations.
*
* Shown when the user right-clicks on a word that belongs to an active word
* selection. Provides "Add link" and "Search text" actions.
*
* @example
* const menu = new SelectionContextMenu(event.clientX, event.clientY, {
* onAddLink: () => { handleHyperlink(); },
* onSearchText: () => { handleSearch(); },
* onGenerateLiveQuote: () => { handleEmbed(); }, // null to hide
* onDismiss: () => { menu.close(); },
* });
*
* // Later:
* menu.close();
*/
export class SelectionContextMenu {
/**
* @param {number} x - Preferred left position in viewport pixels.
* @param {number} y - Preferred top position in viewport pixels.
* @param {object} callbacks - Callback functions for menu actions.
* @param {function(): void} [callbacks.onAddLink] - Called when "Add link" is clicked.
* @param {function(): void} [callbacks.onSearchText] - Called when "Search text" is clicked.
* @param {function(): void|null} [callbacks.onGenerateLiveQuote] - Called when "Generate Live Quote" is clicked. Pass null to hide the item.
* @param {function(): void} [callbacks.onDismiss] - Called when the menu is dismissed.
*/
constructor(x, y, { onAddLink, onSearchText, onGenerateLiveQuote, onDismiss } = {}) {
this._onAddLink = onAddLink ?? (() => {});
this._onSearchText = onSearchText ?? (() => {});
this._onGenerateLiveQuote = onGenerateLiveQuote ?? null;
this._onDismiss = onDismiss ?? (() => {});
this.root = document.createElement('div');
this.root.className = 'ctx-menu';
this.#buildControls();
// Prevent mousedown from clearing the native text selection before
// click callbacks have a chance to fire.
this.root.addEventListener('mousedown', (e) => e.preventDefault());
document.body.appendChild(this.root);
this.#position(x, y);
this.#bindOutsideClick();
}
/** Removes the menu from the DOM. */
close() {
this.root.remove();
}
/** Builds and appends the menu items to the root element. */
#buildControls() {
const controls = document.createElement('div');
controls.className = 'ctx-controls';
controls.appendChild(this.#makeItem(
'<span class="icon icon-plus" style="width:14px;height:14px;"></span>',
'Add link',
'⇧K',
() => { this._onDismiss(); this._onAddLink(); },
));
controls.appendChild(this.#makeItem(
'<span class="icon icon-crosshair" style="width:14px;height:14px;"></span>',
'Search text',
'⇧F',
() => { this._onDismiss(); this._onSearchText(); },
));
if (this._onGenerateLiveQuote !== null) {
controls.appendChild(this.#makeItem(
'<span style="font-size:0.85rem;line-height:1;letter-spacing:-0.05em">«»</span>',
'Generate Live Quote',
'⇧Q',
() => { this._onDismiss(); this._onGenerateLiveQuote(); },
));
}
this.root.appendChild(controls);
}
/**
* @param {string} iconHtml - Inner HTML for the icon cell.
* @param {string} label - Display label.
* @param {string} kbd - Keyboard shortcut hint text.
* @param {function} onClick - Called when the item is clicked.
* @returns {HTMLElement} The constructed menu item element.
*/
#makeItem(iconHtml, label, kbd, onClick) {
const item = document.createElement('div');
item.className = 'ctx-item';
const iconSpan = document.createElement('span');
iconSpan.className = 'ctx-icon';
iconSpan.innerHTML = iconHtml;
const inner = document.createElement('span');
inner.className = 'ctx-item-inner';
inner.textContent = label;
const kbdSpan = document.createElement('span');
kbdSpan.className = 'ctx-kbd';
kbdSpan.textContent = kbd;
item.appendChild(iconSpan);
item.appendChild(inner);
item.appendChild(kbdSpan);
item.addEventListener('click', onClick);
return item;
}
/**
* Positions the menu at (x, y), then clamps it to stay within the viewport.
* @param {number} x - Preferred left position in viewport pixels.
* @param {number} y - Preferred top position in viewport pixels.
*/
#position(x, y) {
this.root.style.left = x + 'px';
this.root.style.top = y + 'px';
requestAnimationFrame(() => {
const r = this.root.getBoundingClientRect();
if (x + r.width > window.innerWidth) this.root.style.left = (window.innerWidth - r.width - 8) + 'px';
if (y + r.height > window.innerHeight) this.root.style.top = (window.innerHeight - r.height - 8) + 'px';
});
}
/** Registers a mousedown listener that dismisses the menu on outside clicks. */
#bindOutsideClick() {
setTimeout(() => {
const outside = (e) => {
if (!this.root.contains(e.target)) {
this._onDismiss();
document.removeEventListener('mousedown', outside);
}
};
document.addEventListener('mousedown', outside);
}, 0);
}
}