Files
picoGraph/scripts/ui/workspace/WorkspaceContextMenuManager.js
Max Litruv Boonzaayer e64f3e881e Initial commit
moved from private version control
2025-10-19 21:12:41 +11:00

621 lines
17 KiB
JavaScript

/**
* @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace
* @typedef {import('../../nodes/nodeTypes.js').NodeDefinition} NodeDefinition
* @typedef {import('../../nodes/nodeTypes.js').NodeDefinition & {
* shortcut?: (
* { type: 'variable', action: 'get'|'set', variableId: string } |
* { type: 'custom_event_call', eventNodeId: string }
* )
* }} PaletteNodeDefinition
*/
const DEFAULT_PLACEHOLDER = "Search nodes";
/**
* Handles DOM management and interactions for the workspace context menu.
*/
export class WorkspaceContextMenuManager {
#workspace;
/** @type {HTMLDivElement | null} */
#container;
/** @type {HTMLInputElement | null} */
#searchInput;
/** @type {HTMLDivElement | null} */
#list;
/** @type {boolean} */
#isVisible;
/** @type {{ x: number, y: number }} */
#spawnPosition;
/** @type {Array<PaletteNodeDefinition>} */
#results;
/** @type {Array<PaletteNodeDefinition> | null} */
#customResults;
/** @type {string} */
#preSearchNormalized;
/** @type {'default'|'connection'|'variable'} */
#mode;
/** @type {number} */
#selectedIndex;
/** @type {string | null} */
#selectedDefinitionId;
/** @type {Array<HTMLButtonElement>} */
#itemElements;
/** @type {string} */
#lastNormalizedQuery;
/**
* @param {BlueprintWorkspace} workspace Owning workspace instance.
*/
constructor(workspace) {
this.#workspace = workspace;
this.#container = null;
this.#searchInput = null;
this.#list = null;
this.#isVisible = false;
this.#spawnPosition = { x: 0, y: 0 };
this.#results = [];
this.#customResults = null;
this.#preSearchNormalized = "";
this.#mode = "default";
this.#selectedIndex = -1;
this.#selectedDefinitionId = null;
this.#itemElements = [];
this.#lastNormalizedQuery = "";
}
/**
* Builds the context menu DOM if it does not already exist.
*/
initialize() {
if (this.#container) {
return;
}
const container = document.createElement("div");
container.className = "workspace-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 = DEFAULT_PLACEHOLDER;
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);
this.#workspace.workspaceElement.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);
this.#focusSelection();
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
this.#moveSelection(-1);
this.#focusSelection();
return;
}
if (event.key === "Enter") {
event.preventDefault();
this.#selectSelection();
}
});
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;
}
/**
* Determines whether the context menu is currently displayed.
*
* @returns {boolean}
*/
isVisible() {
return this.#isVisible;
}
/**
* Checks whether the provided target node resides inside the context menu.
*
* @param {Node | null} target Target node.
* @returns {boolean}
*/
isTargetInside(target) {
return Boolean(
this.#container && target && this.#container.contains(target)
);
}
/**
* Displays the context menu at the given viewport coordinates.
*
* @param {number} clientX Viewport X coordinate.
* @param {number} clientY Viewport Y coordinate.
* @param {{ definitions?: Array<PaletteNodeDefinition>, placeholder?: string, query?: string, mode?: 'default'|'connection'|'variable' }} [options]
*/
show(clientX, clientY, options = {}) {
this.initialize();
if (!this.#container || !this.#searchInput) {
return;
}
const workspaceRect =
this.#workspace.workspaceElement.getBoundingClientRect();
const relativeX = Math.max(0, clientX - workspaceRect.left);
const relativeY = Math.max(0, clientY - workspaceRect.top);
this.#spawnPosition = { x: relativeX, y: relativeY };
this.#customResults = options.definitions ?? null;
this.#preSearchNormalized = (options.query ?? "").trim().toLowerCase();
this.#mode = options.mode ?? "default";
this.#selectedIndex = -1;
this.#selectedDefinitionId = null;
if (this.#mode !== "connection") {
this.#workspace.pendingConnectionSpawn = null;
}
if (this.#mode !== "variable") {
this.#workspace.pendingVariableSpawn = null;
}
const placeholder = options.placeholder ?? DEFAULT_PLACEHOLDER;
if (this.#searchInput.placeholder !== placeholder) {
this.#searchInput.placeholder = placeholder;
}
this.#searchInput.value = options.query ?? "";
this.#renderResults(this.#searchInput.value);
this.#container.classList.add("is-visible");
this.#container.style.left = `${relativeX}px`;
this.#container.style.top = `${relativeY}px`;
this.#isVisible = true;
const menuRect = this.#container.getBoundingClientRect();
let adjustedLeft = relativeX;
let adjustedTop = relativeY;
if (menuRect.right > workspaceRect.right) {
adjustedLeft -= menuRect.right - workspaceRect.right;
}
if (menuRect.bottom > workspaceRect.bottom) {
adjustedTop -= menuRect.bottom - workspaceRect.bottom;
}
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();
this.#searchInput?.select();
});
}
/**
* Conceals the context menu and clears its state.
*/
hide() {
if (!this.#container || !this.#isVisible) {
return;
}
this.#container.classList.remove("is-visible");
this.#isVisible = false;
this.#customResults = null;
this.#mode = "default";
this.#preSearchNormalized = "";
this.#selectedIndex = -1;
this.#selectedDefinitionId = null;
this.#itemElements = [];
this.#results = [];
this.#spawnPosition = { x: 0, y: 0 };
if (this.#searchInput) {
this.#searchInput.placeholder = DEFAULT_PLACEHOLDER;
this.#searchInput.value = "";
}
this.#workspace.pendingConnectionSpawn = null;
}
/**
* Renders context menu results using the supplied query.
*
* @param {string} query Search query.
*/
#renderResults(query) {
if (!this.#list) {
return;
}
let effectiveQuery = query ?? "";
const normalizedQuery = effectiveQuery.trim().toLowerCase();
const queryChanged = normalizedQuery !== this.#lastNormalizedQuery;
if (
this.#customResults &&
this.#preSearchNormalized &&
normalizedQuery === this.#preSearchNormalized
) {
effectiveQuery = "";
this.#preSearchNormalized = "";
}
const results = this.#computeDefinitions(effectiveQuery);
this.#results = results;
this.#list.innerHTML = "";
this.#list.scrollTop = 0;
this.#itemElements = [];
if (queryChanged) {
this.#selectedDefinitionId = null;
}
this.#lastNormalizedQuery = normalizedQuery;
if (!results.length) {
this.#selectedIndex = -1;
this.#selectedDefinitionId = null;
const empty = document.createElement("p");
empty.className = "context-menu-empty";
empty.textContent = "No matching nodes";
this.#list.appendChild(empty);
return;
}
const section = document.createElement("div");
section.className = "context-menu-section";
const body = document.createElement("div");
body.className = "context-menu-section-body";
section.appendChild(body);
results.forEach((definition) => {
const index = this.#itemElements.length;
const item = this.#createDefinitionButton(definition, () => {
this.#updateSelection(index, { scrollIntoView: false });
this.#selectSelection();
});
item.addEventListener("pointerenter", () => {
this.#updateSelection(index, { scrollIntoView: false });
});
item.addEventListener("pointerdown", () => {
this.#updateSelection(index, { scrollIntoView: false });
});
item.addEventListener("focus", () => {
this.#updateSelection(index, { scrollIntoView: false });
});
item.addEventListener("keydown", (event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
this.#moveSelection(1);
this.#focusSelection();
} else if (event.key === "ArrowUp") {
event.preventDefault();
this.#moveSelection(-1);
this.#focusSelection();
}
});
body.appendChild(item);
this.#itemElements.push(item);
});
this.#list.appendChild(section);
if (this.#selectedDefinitionId) {
const index = results.findIndex(
(definition) => definition.id === this.#selectedDefinitionId
);
if (index >= 0) {
this.#updateSelection(index, { scrollIntoView: false });
return;
}
}
this.#updateSelection(results.length ? 0 : -1, { scrollIntoView: false });
}
/**
* Computes palette definitions for the provided query, including shortcuts.
*
* @param {string} query Search query.
* @returns {Array<PaletteNodeDefinition>}
*/
#computeDefinitions(query) {
const hasQuery = query.trim().length > 0;
if (this.#customResults) {
if (!hasQuery) {
return this.#customResults.slice();
}
return this.#customResults.filter((definition) =>
this.#workspace.registry.matchesDefinition(definition, query)
);
}
const registryMatches = this.#workspace.registry.search(query);
const filteredBase = registryMatches.filter(
(definition) => definition.id !== "get_var" && definition.id !== "set_var"
);
const customEventShortcuts = this.#buildCustomEventShortcuts(query);
const variableShortcuts = this.#buildVariableShortcuts(query);
return filteredBase.concat(customEventShortcuts, variableShortcuts);
}
/**
* Moves the highlighted selection by the provided offset.
*
* @param {number} offset Offset delta.
*/
#moveSelection(offset) {
const total = this.#results.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);
}
/**
* Applies visual selection state for the supplied menu index.
*
* @param {number} index Target index.
* @param {{ scrollIntoView?: boolean }} [options]
*/
#updateSelection(index, options = {}) {
const { scrollIntoView = true } = options;
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 (
typeof index !== "number" ||
index < 0 ||
index >= this.#results.length
) {
this.#selectedIndex = -1;
this.#selectedDefinitionId = null;
return;
}
this.#selectedIndex = index;
this.#selectedDefinitionId = this.#results[index]?.id ?? null;
const element = items[index];
if (element) {
element.classList.add("is-selected");
element.setAttribute("aria-selected", "true");
if (scrollIntoView) {
element.scrollIntoView({ block: "nearest" });
}
}
}
/**
* Attempts to focus the currently highlighted menu element.
*/
#focusSelection() {
const element = this.#itemElements[this.#selectedIndex];
if (!element || typeof element.focus !== "function") {
return;
}
try {
element.focus({ preventScroll: true });
} catch {
element.focus();
}
}
/**
* Spawns a node for the currently highlighted context menu result.
*/
#selectSelection() {
if (
this.#selectedIndex < 0 ||
this.#selectedIndex >= this.#results.length
) {
return;
}
const definition = this.#results[this.#selectedIndex];
const spawn = { ...this.#spawnPosition };
const node = this.#workspace.spawnNodeFromContextMenu(definition, spawn);
if (node) {
this.hide();
}
}
/**
* Builds buttons for context menu rows with an attached selection handler.
*
* @param {PaletteNodeDefinition} definition Palette definition entry.
* @param {() => void} onSelect Selection callback.
* @returns {HTMLButtonElement}
*/
#createDefinitionButton(definition, onSelect) {
const button = document.createElement("button");
button.type = "button";
button.className = "context-menu-item";
button.dataset.definitionId = definition.id;
button.setAttribute("role", "option");
button.setAttribute("aria-selected", "false");
const title = document.createElement("span");
title.className = "context-menu-item-name";
title.textContent = definition.title;
button.appendChild(title);
button.addEventListener("click", () => {
onSelect();
});
return button;
}
/**
* Generates variable shortcut entries matching the provided query.
*
* @param {string} query Search query.
* @returns {Array<PaletteNodeDefinition>}
*/
#buildVariableShortcuts(query) {
const normalizedQuery = (query ?? "").trim().toLowerCase();
const includeAll = normalizedQuery.length === 0;
const entries = [];
this.#workspace.variableOrder.forEach((variableId) => {
const variable = this.#workspace.variables.get(variableId);
if (!variable) {
return;
}
const displayName = (variable.name ?? "").trim() || variable.id;
const normalizedName = displayName.toLowerCase();
const tokens = [
normalizedName,
variableId.toLowerCase(),
`get ${normalizedName}`,
`set ${normalizedName}`,
];
if (
!includeAll &&
!tokens.some((token) => token.includes(normalizedQuery))
) {
return;
}
entries.push({
id: `variable_get_${variableId}`,
title: `Get ${displayName}`,
category: "Variables",
description: `Get ${displayName}`,
shortcut: { type: "variable", action: "get", variableId },
});
entries.push({
id: `variable_set_${variableId}`,
title: `Set ${displayName}`,
category: "Variables",
description: `Set ${displayName}`,
shortcut: { type: "variable", action: "set", variableId },
});
});
return entries;
}
/**
* Generates custom event shortcut entries matching the provided query.
*
* @param {string} query Search query.
* @returns {Array<PaletteNodeDefinition>}
*/
#buildCustomEventShortcuts(query) {
const normalizedQuery = (query ?? "").trim().toLowerCase();
const includeAll = normalizedQuery.length === 0;
const events = [...this.#workspace.graph.nodes.values()]
.filter((node) => node.type === "custom_event")
.sort((a, b) =>
this.#resolveCustomEventLabel(a).localeCompare(
this.#resolveCustomEventLabel(b)
)
);
const entries = [];
events.forEach((eventNode) => {
const label = this.#resolveCustomEventLabel(eventNode);
const normalizedName = label.toLowerCase();
const tokens = [
normalizedName,
eventNode.id.toLowerCase(),
`call ${normalizedName}`,
];
if (
!includeAll &&
!tokens.some((token) => token.includes(normalizedQuery))
) {
return;
}
entries.push({
id: `custom_event_call_${eventNode.id}`,
title: `Call ${label}`,
category: "Custom Events",
description: `Call ${label}`,
shortcut: { type: "custom_event_call", eventNodeId: eventNode.id },
});
});
return entries;
}
/**
* Resolves a display label for the provided custom event node.
*
* @param {import('../../core/BlueprintNode.js').BlueprintNode} node Custom event node.
* @returns {string}
*/
#resolveCustomEventLabel(node) {
const raw =
typeof node.properties.name === "string"
? node.properties.name.trim()
: "";
return raw || "CustomEvent";
}
}