palette.ts

examples/fxedit/palette.ts · raw · open the example · all examples · site home

Exports: fuzzyScorePalette

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
// Node search: type to filter, arrows and Enter or a click to add. Opened by
// Tab, a double-click on empty canvas, or a wire dropped on nothing (then it
// lists only nodes that can take the wire).
import type { NodeCategory, NodeDef } from "./fx/index.js";

const CATEGORIES: NodeCategory[] = ["group", "input", "field", "math", "color", "effect", "splat"];

// Subsequence match: higher for consecutive letters and word starts, -1 if
// the letters are not all there in order
export function fuzzyScore(query: string, text: string) {
  if (!query) {
    return 0;
  }
  const q = query.toLowerCase();
  const t = text.toLowerCase();
  let score = 0;
  let last = -2;
  let at = 0;
  for (const c of q) {
    const i = t.indexOf(c, at);
    if (i < 0) {
      return -1;
    }
    score += 1 + (i === last + 1 ? 3 : 0) + (i === 0 || /[\s._/-]/.test(t[i - 1]) ? 2 : 0);
    last = i;
    at = i + 1;
  }
  return score - t.length * 0.01;
}

export class Palette {
  readonly el: HTMLElement;
  private input: HTMLInputElement;
  private list: HTMLElement;
  private title: HTMLElement;
  private defs: () => NodeDef[];
  private shown: NodeDef[] = [];
  private selected = 0;
  private filter?: (def: NodeDef) => boolean;
  private pick?: (def: NodeDef) => void;
  onClose?: () => void;

  // `defs`: what can be added right now (the level decides which groups)
  constructor(parent: HTMLElement, defs: () => NodeDef[]) {
    this.defs = defs;
    this.el = document.createElement("div");
    this.el.className = "fx-palette";
    this.el.hidden = true;
    this.title = document.createElement("div");
    this.title.className = "fx-palette-title";
    this.input = document.createElement("input");
    this.input.placeholder = "search nodes…";
    this.input.spellcheck = false;
    this.list = document.createElement("div");
    this.list.className = "fx-palette-list";
    this.el.append(this.title, this.input, this.list);
    parent.append(this.el);

    this.input.addEventListener("input", () => {
      this.selected = 0;
      this.refresh();
    });
    this.input.addEventListener("keydown", (event) => {
      event.stopPropagation();
      if (event.key === "ArrowDown" || event.key === "ArrowUp") {
        event.preventDefault();
        this.selected = (this.selected + (event.key === "ArrowDown" ? 1 : -1) + this.shown.length) % Math.max(1, this.shown.length);
        this.highlight();
      } else if (event.key === "Enter") {
        event.preventDefault();
        const def = this.shown[this.selected];
        if (def) {
          this.choose(def);
        }
      } else if (event.key === "Escape" || event.key === "Tab") {
        event.preventDefault();
        this.hide();
      }
    });
    this.el.addEventListener("pointerdown", (event) => event.stopPropagation());
  }

  get open() {
    return !this.el.hidden;
  }

  // At (x, y) in the parent's coordinates
  show(x: number, y: number, pick: (def: NodeDef) => void, { filter, title = "add node" }: { filter?: (def: NodeDef) => boolean; title?: string } = {}) {
    this.pick = pick;
    this.filter = filter;
    this.title.textContent = title;
    this.input.value = "";
    this.selected = 0;
    this.el.hidden = false;
    const parent = this.el.parentElement!.getBoundingClientRect();
    this.el.style.left = `${Math.max(8, Math.min(x, parent.width - 280))}px`;
    this.el.style.top = `${Math.max(8, Math.min(y, parent.height - 340))}px`;
    this.el.classList.remove("popping");
    void this.el.offsetWidth;
    this.el.classList.add("popping");
    this.refresh();
    this.input.focus();
  }

  hide() {
    if (this.open) {
      this.el.hidden = true;
      this.pick = undefined;
      this.onClose?.();
    }
  }

  private choose(def: NodeDef) {
    const pick = this.pick;
    this.hide();
    pick?.(def);
  }

  private refresh() {
    const query = this.input.value.trim();
    const defs = this.defs().filter((def) => !this.filter || this.filter(def));
    this.list.replaceChildren();
    if (query) {
      // Label matches rank above id matches, descriptions last
      const score = (def: NodeDef) => Math.max(...[
        [fuzzyScore(query, def.label), 2],
        [fuzzyScore(query, def.id), 0],
        [fuzzyScore(query, def.description ?? ""), -2],
      ].filter(([s]) => s >= 0).map(([s, bonus]) => s + bonus), -1);
      this.shown = defs
        .map((def) => ({ def, score: score(def) }))
        .filter(({ score: s }) => s >= 0)
        .sort((a, b) => b.score - a.score)
        .map(({ def }) => def);
      this.shown.forEach((def, i) => this.list.append(this.item(def, i)));
    } else {
      this.shown = [];
      for (const category of CATEGORIES) {
        const group = defs.filter((def) => def.category === category);
        if (group.length === 0) {
          continue;
        }
        const heading = document.createElement("div");
        heading.className = `fx-palette-group cat-${category}`;
        heading.textContent = category;
        this.list.append(heading);
        for (const def of group) {
          this.list.append(this.item(def, this.shown.length));
          this.shown.push(def);
        }
      }
    }
    if (this.shown.length === 0) {
      const empty = document.createElement("div");
      empty.className = "fx-palette-empty";
      empty.textContent = "nothing fits";
      this.list.append(empty);
    }
    this.highlight();
  }

  private item(def: NodeDef, index: number) {
    const el = document.createElement("div");
    el.className = `fx-palette-item cat-${def.category}`;
    el.dataset.type = def.id;
    el.dataset.index = String(index);
    const label = document.createElement("span");
    label.className = "fx-palette-label";
    label.textContent = def.label;
    const description = document.createElement("span");
    description.className = "fx-palette-desc";
    description.textContent = def.description ?? def.id;
    el.append(label, description);
    el.addEventListener("pointerenter", () => {
      this.selected = index;
      this.highlight();
    });
    el.addEventListener("click", () => this.choose(def));
    return el;
  }

  private highlight() {
    this.list.querySelectorAll(".fx-palette-item").forEach((el) => {
      const on = Number((el as HTMLElement).dataset.index) === this.selected;
      el.classList.toggle("selected", on);
      if (on) {
        el.scrollIntoView({ block: "nearest" });
      }
    });
  }
}