mirror of
https://github.com/litruv/lit.ruv.wtf.git
synced 2026-07-24 02:36:02 +10:00
348 lines
9.9 KiB
JavaScript
348 lines
9.9 KiB
JavaScript
/**
|
|
* @file NodeContextMenu.js
|
|
* Handles right-click context menu for adding nodes to the workspace.
|
|
* Simplified from Picograph's WorkspaceContextMenuManager.
|
|
*/
|
|
|
|
import { NodeRegistry } from './nodes/NodeRegistry.js';
|
|
|
|
/**
|
|
* Manages context menu for adding nodes on right-click.
|
|
*/
|
|
export class NodeContextMenu {
|
|
/** @type {HTMLElement} */
|
|
#canvas;
|
|
|
|
/** @type {HTMLDivElement | null} */
|
|
#container = null;
|
|
|
|
/** @type {HTMLInputElement | null} */
|
|
#searchInput = null;
|
|
|
|
/** @type {HTMLDivElement | null} */
|
|
#list = null;
|
|
|
|
/** @type {boolean} */
|
|
#isVisible = false;
|
|
|
|
/** @type {{ x: number, y: number }} */
|
|
#spawnPosition = { x: 0, y: 0 };
|
|
|
|
/** @type {string[]} */
|
|
#filteredTypes = [];
|
|
|
|
/** @type {number} */
|
|
#selectedIndex = -1;
|
|
|
|
/** @type {HTMLButtonElement[]} */
|
|
#itemElements = [];
|
|
|
|
/** @type {((type: string, worldX: number, worldY: number) => void) | null} */
|
|
#onSpawnNode = null;
|
|
|
|
/**
|
|
* @param {HTMLElement} canvas Workspace canvas element.
|
|
* @param {(type: string, worldX: number, worldY: number) => void} onSpawnNode Callback to spawn node.
|
|
*/
|
|
constructor(canvas, onSpawnNode) {
|
|
this.#canvas = canvas;
|
|
this.#onSpawnNode = onSpawnNode;
|
|
}
|
|
|
|
/**
|
|
* Initializes the context menu DOM.
|
|
*/
|
|
initialize() {
|
|
if (this.#container) return;
|
|
|
|
const container = document.createElement('div');
|
|
container.className = 'node-context-menu';
|
|
container.setAttribute('role', 'dialog');
|
|
container.setAttribute('aria-label', 'Add node');
|
|
|
|
const search = document.createElement('input');
|
|
search.type = 'search';
|
|
search.className = 'context-menu-search';
|
|
search.placeholder = 'Search nodes';
|
|
search.setAttribute('aria-label', 'Search nodes');
|
|
container.appendChild(search);
|
|
|
|
const list = document.createElement('div');
|
|
list.className = 'context-menu-list';
|
|
list.setAttribute('role', 'listbox');
|
|
container.appendChild(list);
|
|
|
|
document.body.appendChild(container);
|
|
|
|
search.addEventListener('input', () => {
|
|
this.#renderResults(search.value);
|
|
});
|
|
|
|
search.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Escape') {
|
|
event.stopPropagation();
|
|
this.hide();
|
|
return;
|
|
}
|
|
if (event.key === 'ArrowDown') {
|
|
event.preventDefault();
|
|
this.#moveSelection(1);
|
|
return;
|
|
}
|
|
if (event.key === 'ArrowUp') {
|
|
event.preventDefault();
|
|
this.#moveSelection(-1);
|
|
return;
|
|
}
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
this.#selectCurrent();
|
|
}
|
|
});
|
|
|
|
document.addEventListener('pointerdown', (event) => {
|
|
if (!this.#isVisible) return;
|
|
if (!(event.target instanceof Node)) {
|
|
this.hide();
|
|
return;
|
|
}
|
|
if (!container.contains(event.target)) {
|
|
this.hide();
|
|
}
|
|
});
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
if (!this.#isVisible) return;
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
this.hide();
|
|
}
|
|
});
|
|
|
|
this.#container = container;
|
|
this.#searchInput = search;
|
|
this.#list = list;
|
|
}
|
|
|
|
/**
|
|
* Shows context menu at viewport coords.
|
|
*
|
|
* @param {number} clientX Viewport X.
|
|
* @param {number} clientY Viewport Y.
|
|
* @param {number} worldX World-space X for spawning.
|
|
* @param {number} worldY World-space Y for spawning.
|
|
*/
|
|
show(clientX, clientY, worldX, worldY) {
|
|
this.initialize();
|
|
|
|
if (!this.#container || !this.#searchInput) return;
|
|
|
|
this.#spawnPosition = { x: worldX, y: worldY };
|
|
this.#selectedIndex = -1;
|
|
this.#searchInput.value = '';
|
|
this.#renderResults('');
|
|
|
|
this.#container.classList.add('is-visible');
|
|
this.#container.style.left = `${clientX}px`;
|
|
this.#container.style.top = `${clientY}px`;
|
|
this.#isVisible = true;
|
|
|
|
// Adjust if menu goes off-screen
|
|
const menuRect = this.#container.getBoundingClientRect();
|
|
let adjustedLeft = clientX;
|
|
let adjustedTop = clientY;
|
|
|
|
if (menuRect.right > window.innerWidth) {
|
|
adjustedLeft -= menuRect.right - window.innerWidth;
|
|
}
|
|
if (menuRect.bottom > window.innerHeight) {
|
|
adjustedTop -= menuRect.bottom - window.innerHeight;
|
|
}
|
|
|
|
adjustedLeft = Math.max(0, adjustedLeft);
|
|
adjustedTop = Math.max(0, adjustedTop);
|
|
|
|
this.#container.style.left = `${adjustedLeft}px`;
|
|
this.#container.style.top = `${adjustedTop}px`;
|
|
|
|
requestAnimationFrame(() => {
|
|
this.#searchInput?.focus();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Hides the context menu.
|
|
*/
|
|
hide() {
|
|
if (!this.#container || !this.#isVisible) return;
|
|
|
|
this.#container.classList.remove('is-visible');
|
|
this.#isVisible = false;
|
|
this.#selectedIndex = -1;
|
|
this.#itemElements = [];
|
|
this.#filteredTypes = [];
|
|
if (this.#searchInput) {
|
|
this.#searchInput.value = '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns true if context menu is visible.
|
|
*
|
|
* @returns {boolean}
|
|
*/
|
|
isVisible() {
|
|
return this.#isVisible;
|
|
}
|
|
|
|
/**
|
|
* Renders filtered node types based on query.
|
|
*
|
|
* @param {string} query Search query.
|
|
*/
|
|
#renderResults(query) {
|
|
if (!this.#list) return;
|
|
|
|
const allTypes = NodeRegistry.BlueprintPure_GetRegisteredTypes();
|
|
const normalizedQuery = query.trim().toLowerCase();
|
|
|
|
this.#filteredTypes = normalizedQuery
|
|
? allTypes.filter(type => type.toLowerCase().includes(normalizedQuery))
|
|
: allTypes.slice();
|
|
|
|
this.#list.innerHTML = '';
|
|
this.#itemElements = [];
|
|
|
|
if (!this.#filteredTypes.length) {
|
|
const empty = document.createElement('p');
|
|
empty.className = 'context-menu-empty';
|
|
empty.textContent = 'No matching nodes';
|
|
this.#list.appendChild(empty);
|
|
this.#selectedIndex = -1;
|
|
return;
|
|
}
|
|
|
|
this.#filteredTypes.forEach((type, index) => {
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'context-menu-item';
|
|
button.setAttribute('role', 'option');
|
|
button.setAttribute('aria-selected', 'false');
|
|
button.textContent = this.#formatNodeTypeName(type);
|
|
|
|
button.addEventListener('click', () => {
|
|
this.#updateSelection(index);
|
|
this.#selectCurrent();
|
|
});
|
|
|
|
button.addEventListener('pointerenter', () => {
|
|
this.#updateSelection(index);
|
|
});
|
|
|
|
button.addEventListener('keydown', (event) => {
|
|
if (event.key === 'ArrowDown') {
|
|
event.preventDefault();
|
|
this.#moveSelection(1);
|
|
} else if (event.key === 'ArrowUp') {
|
|
event.preventDefault();
|
|
this.#moveSelection(-1);
|
|
}
|
|
});
|
|
|
|
this.#list.appendChild(button);
|
|
this.#itemElements.push(button);
|
|
});
|
|
|
|
this.#updateSelection(0);
|
|
}
|
|
|
|
/**
|
|
* Formats node type for display (e.g., "print_node" → "Print Node").
|
|
*
|
|
* @param {string} type Node type string.
|
|
* @returns {string}
|
|
*/
|
|
#formatNodeTypeName(type) {
|
|
return type
|
|
.split('_')
|
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
.join(' ');
|
|
}
|
|
|
|
/**
|
|
* Moves selection by offset.
|
|
*
|
|
* @param {number} offset Delta.
|
|
*/
|
|
#moveSelection(offset) {
|
|
const total = this.#filteredTypes.length;
|
|
if (!total || !Number.isFinite(offset) || offset === 0) return;
|
|
|
|
let index = this.#selectedIndex;
|
|
if (index < 0) {
|
|
index = offset > 0 ? 0 : total - 1;
|
|
} else {
|
|
index = (index + offset + total) % total;
|
|
}
|
|
|
|
this.#updateSelection(index);
|
|
this.#focusSelection();
|
|
}
|
|
|
|
/**
|
|
* Updates visual selection.
|
|
*
|
|
* @param {number} index Target index.
|
|
*/
|
|
#updateSelection(index) {
|
|
const previousIndex = this.#selectedIndex;
|
|
const items = this.#itemElements;
|
|
|
|
if (previousIndex >= 0 && items[previousIndex]) {
|
|
items[previousIndex].classList.remove('is-selected');
|
|
items[previousIndex].setAttribute('aria-selected', 'false');
|
|
}
|
|
|
|
if (index < 0 || index >= this.#filteredTypes.length) {
|
|
this.#selectedIndex = -1;
|
|
return;
|
|
}
|
|
|
|
this.#selectedIndex = index;
|
|
const element = items[index];
|
|
if (element) {
|
|
element.classList.add('is-selected');
|
|
element.setAttribute('aria-selected', 'true');
|
|
element.scrollIntoView({ block: 'nearest' });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Focuses currently selected element.
|
|
*/
|
|
#focusSelection() {
|
|
const element = this.#itemElements[this.#selectedIndex];
|
|
if (!element) return;
|
|
try {
|
|
element.focus({ preventScroll: true });
|
|
} catch {
|
|
element.focus();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Spawns node for current selection.
|
|
*/
|
|
#selectCurrent() {
|
|
if (this.#selectedIndex < 0 || this.#selectedIndex >= this.#filteredTypes.length) {
|
|
return;
|
|
}
|
|
|
|
const type = this.#filteredTypes[this.#selectedIndex];
|
|
if (this.#onSpawnNode) {
|
|
this.#onSpawnNode(type, this.#spawnPosition.x, this.#spawnPosition.y);
|
|
}
|
|
this.hide();
|
|
}
|
|
}
|