import type { NodeDef } from '../../core/graph'; import { ListView, Popup, ScrollContainer, TextField, Theme, ui } from '../../ui'; import { type PaletteEntry, rankedEntries } from './paletteEntries'; let current: NodePalette | null = null; function setCurrent(palette: NodePalette): void { current = palette; } /** The palette standing open, for the test hook. */ export function currentNodePalette(): NodePalette | null { return current; } /** Below this the list would jump in height with every keystroke while typing a query. */ const MIN_LIST_HEIGHT = 260; /** * Something the palette offers that is not a node: adding a group or a comment. They belong here * because "right-click where it should go" is the one gesture the graph has for putting something * on the canvas – a second gesture for two entries would be a second thing to know. */ export interface PaletteAction { id: string; label: string; hint?: string; /** * What the search matches instead of the label. The label starts with "Add", and a query of * "add" is one somebody types to find the `Add` node – two entries jumping the list for it * would cost more than they help. */ match: string; onSelect: () => void; } /** Searchable list of node types; right-click menu of the graph editor. */ export class NodePalette extends Popup { private readonly search: TextField; private readonly list = new ListView(); private readonly scroll = new ScrollContainer(); private filtered: PaletteEntry[] = []; /** Guards against a second pick per opening (e.g. Enter and a row click racing). */ private picked = false; /** * @param accepts restricts the list, e.g. to the node types an edge dropped into empty space * could actually connect to. */ constructor( private readonly entries: PaletteEntry[], private readonly onPick: (entry: PaletteEntry) => void, private readonly accepts: (def: NodeDef) => boolean = () => true, /** Listed above the nodes. An edge dropped into empty space passes none: it wants a node. */ private readonly actions: readonly PaletteAction[] = [], ) { super({ padding: 4, gap: 4 }); this.minWidth = 240; this.search = new TextField('', { placeholder: 'Search nodes…', onInput: () => this.refresh(), // Enter only. NOT onChange: that also fires when the search field loses focus because // the user clicked a list row, which would add the first match *and* the clicked one. onSubmit: () => { const action = this.matchingActions()[0]; if (action) { this.run(() => action.onSelect()); return; } const first = this.filtered[0]; if (first) this.pick(first); }, }); this.content.add(this.search); this.scroll.minHeight = MIN_LIST_HEIGHT; this.scroll.setContent(this.list); this.content.add(this.scroll, 1); this.list.rowHeight = Theme.rowHeight - 2; this.list.emptyText = 'No matching node'; this.list.onSelect = (id) => { const action = this.matchingActions().find((a) => a.id === id); if (action) { this.run(() => action.onSelect()); return; } const entry = this.filtered.find((e) => e.id === id); if (entry) this.pick(entry); }; this.refresh(); } override open(globalX: number, globalY: number): void { super.open(globalX, globalY); setCurrent(this); this.picked = false; ui().focus.focus(this.search); } override close(): void { if (current === this) current = null; super.close(); } /** What the palette offers, in the order it is listed – actions first, then the best node (test hook). */ rowsDrawn(): { label: string; type: string }[] { return [ ...this.matchingActions().map((a) => ({ label: a.label, type: a.id })), ...this.filtered.map((entry) => ({ label: entry.def.label, type: entry.def.type })), ]; } /** Two entries need no ranking, only a yes or no. */ private matchingActions(): readonly PaletteAction[] { const query = this.search.value.trim().toLowerCase(); return this.actions.filter((a) => a.match.toLowerCase().includes(query)); } private refresh(): void { this.filtered = rankedEntries( this.entries.filter((e) => this.accepts(e.def)), this.search.value, ); this.list.setItems([ ...this.matchingActions().map((a) => ({ id: a.id, label: a.label, hint: a.hint })), ...this.filtered.map((e) => ({ id: e.id, label: e.def.label, hint: e.hint })), ]); this.scroll.minHeight = Math.min( Math.max(this.list.measure().h, MIN_LIST_HEIGHT), this.roomFor(this.scroll), ); this.refreshLayout(); this.scroll.relayoutContent(); } private pick(entry: PaletteEntry): void { this.run(() => this.onPick(entry)); } /** One pick per opening, whatever was picked: Enter and a row click can race. */ private run(chosen: () => void): void { if (this.picked) return; this.picked = true; this.close(); chosen(); } }