@nano133/nanoterra 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +206 -10
  3. package/ai/SPEC.md +51 -2
  4. package/dist/admin/adapters/d1.d.ts +1 -0
  5. package/dist/admin/adapters/d1.js +6 -4
  6. package/dist/admin/adapters/firestore.d.ts +1 -0
  7. package/dist/admin/adapters/firestore.js +7 -5
  8. package/dist/admin/conformance.d.ts +1 -1
  9. package/dist/admin/conformance.js +12 -4
  10. package/dist/admin/handler.d.ts +1 -1
  11. package/dist/admin/handler.js +2 -2
  12. package/dist/admin/index.d.ts +2 -2
  13. package/dist/admin/index.js +1 -1
  14. package/dist/admin/memory.js +3 -1
  15. package/dist/admin/panel/gameCard.js +13 -10
  16. package/dist/admin/panel/icons.d.ts +17 -0
  17. package/dist/admin/panel/icons.js +3 -0
  18. package/dist/admin/panel/overview.js +1 -1
  19. package/dist/admin/store.d.ts +2 -1
  20. package/dist/admin/switches.d.ts +13 -3
  21. package/dist/admin/switches.js +25 -6
  22. package/dist/character/create.js +6 -2
  23. package/dist/character/layer.d.ts +14 -0
  24. package/dist/character/layer.js +10 -0
  25. package/dist/emotes/clip.d.ts +79 -0
  26. package/dist/emotes/clip.js +165 -0
  27. package/dist/emotes/clips.d.ts +3 -0
  28. package/dist/emotes/clips.js +2 -0
  29. package/dist/emotes/events.d.ts +22 -0
  30. package/dist/emotes/events.js +19 -0
  31. package/dist/emotes/icons.d.ts +1 -0
  32. package/dist/emotes/icons.js +6 -0
  33. package/dist/emotes/index.d.ts +8 -0
  34. package/dist/emotes/index.js +11 -0
  35. package/dist/emotes/list.d.ts +123 -0
  36. package/dist/emotes/list.js +23 -0
  37. package/dist/emotes/options.d.ts +16 -0
  38. package/dist/emotes/options.js +34 -0
  39. package/dist/emotes/panel/index.d.ts +3 -0
  40. package/dist/emotes/panel/index.js +9 -0
  41. package/dist/emotes/panel/mount.d.ts +56 -0
  42. package/dist/emotes/panel/mount.js +360 -0
  43. package/dist/emotes/player.d.ts +37 -0
  44. package/dist/emotes/player.js +101 -0
  45. package/dist/emotes/wheel.d.ts +42 -0
  46. package/dist/emotes/wheel.js +110 -0
  47. package/dist/friends/panel/mount.d.ts +3 -3
  48. package/dist/friends/panel/mount.js +46 -12
  49. package/dist/options/common.d.ts +88 -0
  50. package/dist/options/common.js +59 -0
  51. package/dist/options/core.d.ts +130 -0
  52. package/dist/options/core.js +420 -0
  53. package/dist/options/index.d.ts +7 -0
  54. package/dist/options/index.js +24 -0
  55. package/dist/options/panel/index.d.ts +2 -0
  56. package/dist/options/panel/index.js +10 -0
  57. package/dist/options/panel/mount.d.ts +30 -0
  58. package/dist/options/panel/mount.js +378 -0
  59. package/dist/options/reflect.d.ts +5 -0
  60. package/dist/options/reflect.js +27 -0
  61. package/package.json +12 -4
  62. package/src/admin/adapters/d1.ts +5 -4
  63. package/src/admin/adapters/firestore.ts +6 -5
  64. package/src/admin/conformance.ts +14 -5
  65. package/src/admin/handler.ts +2 -2
  66. package/src/admin/index.ts +2 -2
  67. package/src/admin/memory.ts +2 -1
  68. package/src/admin/panel/gameCard.ts +13 -9
  69. package/src/admin/panel/icons.ts +3 -0
  70. package/src/admin/panel/overview.ts +1 -1
  71. package/src/admin/store.ts +2 -2
  72. package/src/admin/switches.ts +29 -12
  73. package/src/character/create.ts +6 -2
  74. package/src/character/layer.ts +21 -0
  75. package/src/emotes/clip.ts +204 -0
  76. package/src/emotes/clips.ts +7 -0
  77. package/src/emotes/events.ts +28 -0
  78. package/src/emotes/icons.ts +7 -0
  79. package/src/emotes/icons.webp +0 -0
  80. package/src/emotes/index.ts +16 -0
  81. package/src/emotes/list.ts +33 -0
  82. package/src/emotes/options.ts +43 -0
  83. package/src/emotes/panel/index.ts +11 -0
  84. package/src/emotes/panel/mount.ts +390 -0
  85. package/src/emotes/panel/style.css +415 -0
  86. package/src/emotes/player.ts +131 -0
  87. package/src/emotes/wheel.ts +131 -0
  88. package/src/friends/panel/mount.ts +47 -13
  89. package/src/friends/panel/style.css +26 -0
  90. package/src/options/common.ts +65 -0
  91. package/src/options/core.ts +465 -0
  92. package/src/options/index.ts +41 -0
  93. package/src/options/panel/index.ts +12 -0
  94. package/src/options/panel/mount.ts +418 -0
  95. package/src/options/panel/style.css +528 -0
  96. package/src/options/reflect.ts +28 -0
@@ -0,0 +1,390 @@
1
+ // The Emotes key and the emote wheel of a game: `mountEmotes(element, options)`. Plain DOM, no framework, no Three.js: nano133.com's EmoteKeys.tsx and
2
+ // EmoteWheel.tsx (Nano Island), for any game. The key goes into `element`; it opens the wheel in the middle of the screen over a dim page: eight round
3
+ // places on a ring, slot 1 at the top and on clockwise, each the emote's picture and name, and a middle disc with the name of the place under the
4
+ // finger and Edit. Two ways with a finger or a mouse: a tap on the key opens the wheel and a tap on a place plays it; or a hold on the key opens it at
5
+ // once, and sliding onto a place and letting go plays that one (a slide under 14 px is a tap, a let-go in the middle leaves the wheel open). A press
6
+ // beside the wheel, Escape or the key closes it. On a keyboard B opens and closes it (not while a field has the keys, or when the game's `keyAllowed`
7
+ // says no) and 1 to 8 play its places while it is open. Edit shows every emote: a tap puts one on the wheel in its first empty place or takes it off,
8
+ // and a ninth is refused with WHEEL_FULL; in Edit a tap on a place takes its emote off.
9
+ //
10
+ // The list is Nanoterra's (each id names one of EMOTES: its name and picture come from the kit), the game's own ({ id, label, icon }), or both. The
11
+ // wheel only calls `onPlay(id)`: the game plays it (Nanoterra's on the kit's character: createEmotes(...).play, from "@nano133/nanoterra/emotes/player").
12
+
13
+ import { h } from "../../admin/panel/dom.js";
14
+ import { icon } from "../../admin/panel/icons.js";
15
+ import { EMOTE_ICONS_URL } from "../icons.js";
16
+ import { EMOTES, emoteOf, isEmote, type EmoteId } from "../list.js";
17
+ import { readEmoteOptions, type EmoteOptionsStore } from "../options.js";
18
+ import { WHEEL_FULL, WHEEL_SLOTS, createWheel, type Slots } from "../wheel.js";
19
+
20
+ /** One of the game's own emotes: its id (what `onPlay` gets), the name on the wheel, and a picture's address (square; left out: the name alone). */
21
+ export type OwnEmote = { id: string; label: string; icon?: string };
22
+ /** A Nanoterra emote by its id, or one of the game's own. */
23
+ export type WheelEmote = EmoteId | OwnEmote;
24
+
25
+ export type EmotesMountOptions = {
26
+ /** The game's id: the wheel's eight places are kept per game, in this browser (`n133-emotes:<game>`). */
27
+ game: string;
28
+ /** The player picked an emote. Play it: Nanoterra's with `createEmotes(...).play(id)`, the game's own with its own animation. */
29
+ onPlay: (id: string) => void;
30
+ /** The emotes the player may put on the wheel, in the order Edit shows them. Default: Nanoterra's eleven. */
31
+ emotes?: readonly WheelEmote[];
32
+ /** The admin's `emotes` setting: "off" leaves Nanoterra's emotes out (the game's own stay). With nothing left, nothing is drawn. Default "on". */
33
+ nanoterraEmotes?: "on" | "off";
34
+ /** The eight places before the player changes them (ids, or null for an empty place). Default: the island's wheel, or the list's first eight. */
35
+ defaults?: readonly (string | null)[];
36
+ /** The options menu's store: `emotes.show` off hides the key (B still opens the wheel); `keys.emotes` is the wheel's key. Left out: the key shows, and B. */
37
+ options?: EmoteOptionsStore | null;
38
+ /** The address of Nanoterra's picture strip. Default: the package's icons.webp (see the README for bundlers). */
39
+ icons?: string;
40
+ /** B (or the player's own key: the options' "keys.emotes") opens and closes the wheel, and 1 to 8 play its places. Default true. */
41
+ keys?: boolean;
42
+ /** Asked before B opens or closes the wheel: false when the game uses the key now (a dialog of its own, B bound to an action). */
43
+ keyAllowed?: (e: KeyboardEvent) => boolean;
44
+ /** The wheel opened. Load the clips here the first time: `loadEmotes` of "@nano133/nanoterra/emotes/player". */
45
+ onOpen?: () => void;
46
+ /** "light", "dark", or "device" (the default): the page's `data-n133-theme` when it has one, else the device's setting. */
47
+ theme?: "light" | "dark" | "device";
48
+ /** Where the wheel is kept (default: the page's localStorage). null keeps it only for the life of the page. */
49
+ storage?: Pick<Storage, "getItem" | "setItem"> | null;
50
+ };
51
+
52
+ export type MountedEmotes = {
53
+ open(): void;
54
+ close(): void;
55
+ readonly isOpen: boolean;
56
+ /** The eight places now. */
57
+ readonly slots: Slots;
58
+ /** Removes the key and the wheel, and stops listening to the keyboard and the options. */
59
+ destroy(): void;
60
+ };
61
+
62
+ /** How far a finger moves on the key before it is a slide, not a tap (CSS pixels). */
63
+ export const SLIDE_PX = 14;
64
+
65
+ const NOTHING: MountedEmotes = { open() {}, close() {}, isOpen: false, slots: [], destroy() {} };
66
+ /** Each place's direction from the middle: slot 1 at the top, then clockwise. */
67
+ const AT = Array.from({ length: WHEEL_SLOTS }, (_, i) => {
68
+ const a = ((-90 + i * (360 / WHEEL_SLOTS)) * Math.PI) / 180;
69
+ return { x: Math.cos(a), y: Math.sin(a) };
70
+ });
71
+ const DIGITS: Record<string, number> = Object.fromEntries(
72
+ Array.from({ length: WHEEL_SLOTS }, (_, i) => [
73
+ [`Digit${i + 1}`, i],
74
+ [`Numpad${i + 1}`, i],
75
+ ]).flat(),
76
+ );
77
+
78
+ /** A key that a field, or a page's own editable text, is holding. */
79
+ const inField = (t: EventTarget | null) => {
80
+ const el = t as HTMLElement | null;
81
+ return !!el && (["INPUT", "SELECT", "TEXTAREA"].includes(el.tagName) || el.isContentEditable === true);
82
+ };
83
+
84
+ /** The place under a point of the screen, by its angle from the wheel's middle, outside the middle disc; null in the middle or with no wheel. */
85
+ export function placeAt(rect: { left: number; top: number; width: number; height: number } | null, x: number, y: number): number | null {
86
+ if (!rect || !rect.width) return null;
87
+ const dx = x - (rect.left + rect.width / 2);
88
+ const dy = y - (rect.top + rect.height / 2);
89
+ if (Math.hypot(dx, dy) < rect.width * 0.2) return null;
90
+ const turn = (Math.atan2(dy, dx) * 180) / Math.PI + 90;
91
+ return ((Math.round(turn / (360 / WHEEL_SLOTS)) % WHEEL_SLOTS) + WHEEL_SLOTS) % WHEEL_SLOTS;
92
+ }
93
+
94
+ let mounts = 0;
95
+
96
+ export function mountEmotes(root: HTMLElement, options: EmotesMountOptions): MountedEmotes {
97
+ const nanoOn = options.nanoterraEmotes !== "off";
98
+ const list: { id: string; label: string; sheet: number; icon: string | null }[] = [];
99
+ for (const e of options.emotes ?? EMOTES.map((x) => x.id)) {
100
+ if (typeof e === "string") {
101
+ if (nanoOn && isEmote(e)) list.push({ id: e, label: emoteOf(e)!.label, sheet: EMOTES.findIndex((x) => x.id === e), icon: null });
102
+ } else if (e && typeof e.id === "string" && typeof e.label === "string") list.push({ id: e.id, label: e.label, sheet: -1, icon: typeof e.icon === "string" ? e.icon : null });
103
+ }
104
+ // Each id once: the first wins.
105
+ const items = list.filter((x, i) => list.findIndex((y) => y.id === x.id) === i);
106
+ if (!items.length) return NOTHING;
107
+ const byId = new Map(items.map((x) => [x.id, x]));
108
+ const n = ++mounts;
109
+ const sheet = options.icons ?? EMOTE_ICONS_URL;
110
+ const wheel = createWheel({ game: options.game, ids: items.map((x) => x.id), defaults: options.defaults, storage: options.storage });
111
+ const keys = options.keys !== false;
112
+ let isOpen = false;
113
+ let edit = false;
114
+ let full = false;
115
+ let hover: number | null = null;
116
+ let mouse: number | null = null;
117
+ let destroyed = false;
118
+ const cleanup: (() => void)[] = [];
119
+
120
+ /** An emote's picture: its cell of the strip, or the game's own picture; nothing when it has none. */
121
+ const picture = (id: string, cls: string): HTMLElement | null => {
122
+ const it = byId.get(id);
123
+ if (!it) return null;
124
+ if (it.sheet >= 0) {
125
+ const count = EMOTES.length;
126
+ const el = h("span", { class: cls, "aria-hidden": "true" });
127
+ el.style.backgroundImage = `url("${sheet}")`;
128
+ el.style.backgroundSize = `${count * 100}% 100%`;
129
+ el.style.backgroundPosition = `${(it.sheet / (count - 1)) * 100}% 0`;
130
+ return el;
131
+ }
132
+ if (!it.icon) return null;
133
+ const el = h("span", { class: `${cls} is-own`, "aria-hidden": "true" });
134
+ el.style.backgroundImage = `url("${it.icon.replace(/["\\\n\r]/g, "")}")`;
135
+ return el;
136
+ };
137
+ const labelOf = (id: string) => byId.get(id)?.label ?? id;
138
+
139
+ const key = h("button", { class: "n133e-key", type: "button", "aria-expanded": "false", "aria-haspopup": "dialog", "aria-controls": `n133e-wheel-${n}` }, icon("smile", 20), h("span", {}, "Emotes"));
140
+ const hubName = h("span", { class: "n133e-hub-name", "aria-live": "polite" });
141
+ const editKey = h("button", { class: "n133e-edit", type: "button", "aria-pressed": "false" }, "Edit");
142
+ const slotKeys = AT.map((p, i) => {
143
+ const b = h("button", { class: "n133e-slot", type: "button", "data-slot": i });
144
+ b.style.setProperty("--x", String(p.x));
145
+ b.style.setProperty("--y", String(p.y));
146
+ b.addEventListener("pointerenter", () => {
147
+ mouse = i;
148
+ drawLit();
149
+ });
150
+ b.addEventListener("pointerleave", () => {
151
+ if (mouse === i) mouse = null;
152
+ drawLit();
153
+ });
154
+ b.addEventListener("click", () => pick(i));
155
+ return b;
156
+ });
157
+ const ring = h("div", { class: "n133e-wheel", id: `n133e-wheel-${n}`, role: "dialog", "aria-label": "Emotes" }, h("div", { class: "n133e-ring", "aria-hidden": "true" }), ...slotKeys, h("div", { class: "n133e-hub" }, hubName, editKey));
158
+ const fullLine = h("p", { class: "n133e-list-line", "aria-live": "polite" });
159
+ const grid = h("div", { class: "n133e-grid" });
160
+ const editList = h("div", { class: "n133e-list", role: "group", "aria-label": "All emotes" }, fullLine, grid);
161
+ const layer = h("div", { class: "n133e-layer", "data-open": "false" }, ring, editList);
162
+ layer.hidden = true;
163
+ const wrap = h("div", { class: "n133-emotes" }, key, layer);
164
+ root.append(wrap);
165
+
166
+ // The theme: the game's word, or the page's (data-n133-theme, which the admin panel sets too), or the device's setting.
167
+ let media: MediaQueryList | null = null;
168
+ try {
169
+ media = typeof globalThis.matchMedia === "function" ? globalThis.matchMedia("(prefers-color-scheme: dark)") : null;
170
+ } catch {
171
+ media = null;
172
+ }
173
+ const drawTheme = () => {
174
+ const want = options.theme ?? "device";
175
+ const page = globalThis.document?.documentElement?.getAttribute("data-n133-theme");
176
+ const dark = want === "device" ? (page === "dark" || page === "light" ? page === "dark" : media?.matches === true) : want === "dark";
177
+ wrap.setAttribute("data-theme", dark ? "dark" : "light");
178
+ };
179
+ drawTheme();
180
+ media?.addEventListener?.("change", drawTheme);
181
+ cleanup.push(() => media?.removeEventListener?.("change", drawTheme));
182
+
183
+ // The options: `emotes.show` off hides the key; `keys.emotes` is the key that opens the wheel (B unless the player changed it).
184
+ let openCode = "KeyB";
185
+ const drawShow = () => {
186
+ const o = readEmoteOptions(options.options);
187
+ key.hidden = !o.show;
188
+ openCode = o.key;
189
+ if (keys) key.setAttribute("aria-keyshortcuts", openCode.replace(/^(Key|Digit)/, ""));
190
+ };
191
+ drawShow();
192
+ const unOptions = options.options?.subscribe?.((k) => {
193
+ if (k === "emotes.show" || k === "keys.emotes") drawShow();
194
+ });
195
+ if (unOptions) cleanup.push(unOptions);
196
+
197
+ function drawLit() {
198
+ const lit = hover ?? mouse;
199
+ const slots = wheel.slots;
200
+ slotKeys.forEach((b, i) => b.classList.toggle("is-lit", lit === i && slots[i] !== null));
201
+ const id = lit !== null ? slots[lit] : null;
202
+ hubName.textContent = id ? labelOf(id) : edit ? "Your wheel" : "Emotes";
203
+ }
204
+ function drawSlots() {
205
+ const slots = wheel.slots;
206
+ slotKeys.forEach((b, i) => {
207
+ const id = slots[i];
208
+ b.classList.toggle("is-empty", !id);
209
+ b.setAttribute("aria-label", id ? (edit ? `Take ${labelOf(id)} off the wheel` : labelOf(id)) : edit ? "Empty place" : "Empty place: edit the wheel");
210
+ if (id && !edit && keys) b.setAttribute("aria-keyshortcuts", String(i + 1));
211
+ else b.removeAttribute("aria-keyshortcuts");
212
+ b.replaceChildren(
213
+ ...(id
214
+ ? [picture(id, "n133e-pic") ?? "", h("span", { class: "n133e-name" }, labelOf(id)), !edit && keys ? h("span", { class: "n133e-num", "aria-hidden": "true" }, String(i + 1)) : "", edit ? h("span", { class: "n133e-off", "aria-hidden": "true" }, "×") : ""]
215
+ : [h("span", { class: "n133e-plus", "aria-hidden": "true" }, "+")]),
216
+ );
217
+ });
218
+ layer.classList.toggle("is-edit", edit);
219
+ editKey.textContent = edit ? "Done" : "Edit";
220
+ editKey.setAttribute("aria-pressed", String(edit));
221
+ editList.hidden = !edit;
222
+ fullLine.textContent = full ? WHEEL_FULL : `Tap to put on the wheel or take off. Up to ${WHEEL_SLOTS}.`;
223
+ fullLine.classList.toggle("is-full", full);
224
+ if (edit) {
225
+ grid.replaceChildren(
226
+ ...items.map((it) => {
227
+ const at = slots.indexOf(it.id);
228
+ return h(
229
+ "button",
230
+ { class: `n133e-tile${at >= 0 ? " is-on" : ""}`, type: "button", "aria-pressed": String(at >= 0), "data-emote": it.id, onclick: () => tap(it.id) },
231
+ picture(it.id, "n133e-tile-pic") ?? h("span", { class: "n133e-tile-pic is-blank", "aria-hidden": "true" }),
232
+ h("span", { class: "n133e-tile-name" }, it.label),
233
+ at >= 0 ? h("span", { class: "n133e-badge", "aria-label": `place ${at + 1}` }, String(at + 1)) : null,
234
+ );
235
+ }),
236
+ );
237
+ }
238
+ drawLit();
239
+ }
240
+ const unWheel = wheel.subscribe(drawSlots);
241
+ cleanup.push(unWheel);
242
+
243
+ function tap(id: string) {
244
+ full = wheel.toggle(id) === "full";
245
+ drawSlots();
246
+ // The tile that was pressed keeps the focus after the grid is drawn again.
247
+ for (const el of Array.from(grid.children)) if ((el as HTMLElement).dataset.emote === id) (el as HTMLElement).focus();
248
+ }
249
+ function play(id: string) {
250
+ close(false);
251
+ options.onPlay(id);
252
+ }
253
+ function pick(i: number) {
254
+ const id = wheel.slots[i];
255
+ if (edit) {
256
+ if (id) wheel.clear(i);
257
+ full = false;
258
+ drawSlots();
259
+ return;
260
+ }
261
+ if (!id) {
262
+ edit = true;
263
+ drawSlots();
264
+ return;
265
+ }
266
+ play(id);
267
+ }
268
+ function open(focusFirst = false) {
269
+ if (destroyed || isOpen) return;
270
+ isOpen = true;
271
+ edit = false;
272
+ full = false;
273
+ hover = null;
274
+ mouse = null;
275
+ drawTheme();
276
+ drawSlots();
277
+ layer.hidden = false;
278
+ layer.dataset.open = "true";
279
+ key.setAttribute("aria-expanded", "true");
280
+ options.onOpen?.();
281
+ if (focusFirst) slotKeys[Math.max(0, wheel.slots.findIndex((x) => x !== null))].focus();
282
+ }
283
+ function close(focusKey: boolean) {
284
+ if (destroyed || !isOpen) return;
285
+ isOpen = false;
286
+ hover = null;
287
+ press = null;
288
+ layer.hidden = true;
289
+ layer.dataset.open = "false";
290
+ key.setAttribute("aria-expanded", "false");
291
+ if (focusKey && !key.hidden) key.focus();
292
+ }
293
+
294
+ editKey.addEventListener("click", () => {
295
+ edit = !edit;
296
+ full = false;
297
+ drawSlots();
298
+ });
299
+ // A press on the dim page beside the wheel closes it.
300
+ layer.addEventListener("pointerdown", (e) => {
301
+ if (e.target === layer) close(false);
302
+ });
303
+
304
+ // The key: a press opens the wheel at once; a slide from it onto a place and a let-go plays that place.
305
+ let press: { id: number; x: number; y: number; slid: boolean } | null = null;
306
+ key.addEventListener("pointerdown", (e) => {
307
+ if (e.button !== 0 || isOpen) return;
308
+ open();
309
+ press = { id: e.pointerId, x: e.clientX, y: e.clientY, slid: false };
310
+ try {
311
+ key.setPointerCapture(e.pointerId);
312
+ } catch {
313
+ // A pointer that is already gone: the wheel stays open for a tap.
314
+ }
315
+ });
316
+ key.addEventListener("pointermove", (e) => {
317
+ const p = press;
318
+ if (!p || p.id !== e.pointerId) return;
319
+ if (!p.slid && Math.hypot(e.clientX - p.x, e.clientY - p.y) > SLIDE_PX) p.slid = true;
320
+ if (p.slid) {
321
+ hover = placeAt(ring.getBoundingClientRect(), e.clientX, e.clientY);
322
+ drawLit();
323
+ }
324
+ });
325
+ key.addEventListener("pointerup", (e) => {
326
+ const p = press;
327
+ press = null;
328
+ if (!p || !p.slid || p.id !== e.pointerId) return;
329
+ const i = placeAt(ring.getBoundingClientRect(), e.clientX, e.clientY);
330
+ const id = i === null ? null : wheel.slots[i];
331
+ if (id) play(id);
332
+ else {
333
+ hover = null;
334
+ drawLit();
335
+ }
336
+ });
337
+ key.addEventListener("pointercancel", () => {
338
+ press = null;
339
+ hover = null;
340
+ drawLit();
341
+ });
342
+ // Enter or Space is a click with no pointer: it opens and closes the wheel. A pointer's click is already handled.
343
+ key.addEventListener("click", (e) => {
344
+ if ((e as MouseEvent).detail !== 0) return;
345
+ if (isOpen) close(true);
346
+ else open(true);
347
+ });
348
+
349
+ const onKey = (e: KeyboardEvent) => {
350
+ if (destroyed) return;
351
+ if (e.key === "Escape" && isOpen) {
352
+ e.preventDefault();
353
+ close(true);
354
+ return;
355
+ }
356
+ if (!keys || e.repeat || e.ctrlKey || e.metaKey || e.altKey || inField(e.target)) return;
357
+ if (e.code === openCode) {
358
+ if (options.keyAllowed && !options.keyAllowed(e)) return;
359
+ e.preventDefault();
360
+ if (isOpen) close(true);
361
+ else open(true);
362
+ return;
363
+ }
364
+ const i = DIGITS[e.code];
365
+ if (i === undefined || !isOpen || edit) return;
366
+ const id = wheel.slots[i];
367
+ if (!id) return;
368
+ e.preventDefault();
369
+ play(id);
370
+ };
371
+ document.addEventListener("keydown", onKey);
372
+ cleanup.push(() => document.removeEventListener("keydown", onKey));
373
+
374
+ return {
375
+ open: () => open(false),
376
+ close: () => close(false),
377
+ get isOpen() {
378
+ return isOpen;
379
+ },
380
+ get slots() {
381
+ return wheel.slots;
382
+ },
383
+ destroy() {
384
+ if (destroyed) return;
385
+ destroyed = true;
386
+ for (const f of cleanup) f();
387
+ wrap.remove();
388
+ },
389
+ };
390
+ }