@spicetify/kit 0.1.0 → 0.3.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.
@@ -11,16 +11,22 @@ import menu from "./menu.ts";
11
11
  import { mountAdjacent } from "./mount.ts";
12
12
  import { isNativeAnchor, type NativeAnchor, resolveNativeAnchor } from "./nativeAnchors.ts";
13
13
  import navlink from "./navlink.tsx";
14
- import panel from "./panel.ts";
14
+ import panel, { type PanelController, type PanelRegistration } from "./panel.ts";
15
15
  import playbarButton, { PlaybarButton } from "./playbarButton.tsx";
16
16
  import playbarWidget from "./playbarWidget.tsx";
17
17
  import { Registry } from "./registry.ts";
18
18
  import root from "./root.ts";
19
19
  import route from "./route.ts";
20
20
  import settingsRow from "./settingsRow.tsx";
21
- import settingsSection from "./settingsSection.ts";
21
+ import settingsSection, { settingsAction } from "./settingsSection.ts";
22
22
  import topbarLeftButton, { TopbarLeftButton } from "./topbarLeftButton.tsx";
23
23
  import topbarRightButton, { TopbarRightButton } from "./topbarRightButton.tsx";
24
+ export type { PanelController, PanelRegistration, PanelWidth } from "./panel.ts";
25
+ export { closeMenu, openedFromProfileMenu, useMenuItem } from "./menu.ts";
26
+ export { NavLink, type NavLinkProps, useHistoryRefresh } from "./navlink.tsx";
27
+ export { PlaybarButton, type PlaybarButtonProps } from "./playbarButton.tsx";
28
+ export { TopbarLeftButton } from "./topbarLeftButton.tsx";
29
+ export { TopbarRightButton } from "./topbarRightButton.tsx";
24
30
  const [rootChild, rootProvider] = root;
25
31
  const registers = {
26
32
  menu,
@@ -32,12 +38,15 @@ const registers = {
32
38
  rootProvider,
33
39
  route,
34
40
  settingsRow,
41
+ settingsAction,
35
42
  settingsSection,
36
43
  topbarLeftButton,
37
44
  topbarRightButton,
38
45
  } satisfies Record<string, Registry<any>>;
39
46
  type Registers = typeof registers;
40
47
 
48
+ export { SPICETIFY_SETTINGS_ROUTE } from "./settingsSection.ts";
49
+
41
50
  // ---- placeButton: one ergonomic, position-controllable way to add a button ----
42
51
  // Instead of picking the right register key and hand-building the matching
43
52
  // component (easy to mismatch, no ordering), a module names a location and
@@ -52,7 +61,7 @@ export interface PlaceButtonOptions {
52
61
  label: string;
53
62
  /** Inner SVG markup drawn on the stdlib 16-grid. */
54
63
  icon?: string;
55
- onClick: () => void;
64
+ onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
56
65
  disabled?: boolean;
57
66
  /** Position within this slot's module buttons; lower renders earlier. Default 0. */
58
67
  order?: number;
@@ -73,6 +82,11 @@ export interface ButtonHandle {
73
82
  remove(): void;
74
83
  }
75
84
 
85
+ export interface RegisterPanelOptions extends Omit<PanelRegistration<React.ReactNode>, "id"> {
86
+ /** Stable within this module. Defaults to "default". */
87
+ id?: string;
88
+ }
89
+
76
90
  const BUTTON_SLOTS = {
77
91
  "topbar-left": ["topbarLeftButton", TopbarLeftButton],
78
92
  "topbar-right": ["topbarRightButton", TopbarRightButton],
@@ -139,6 +153,29 @@ export class Registrar {
139
153
  };
140
154
  }
141
155
 
156
+ /**
157
+ * Register a Spicetify-owned right-sidebar panel. The returned controller
158
+ * opens, closes and observes the panel; module unload disposes it.
159
+ */
160
+ registerPanel(options: RegisterPanelOptions): PanelController {
161
+ const registration: PanelRegistration<React.ReactNode> = {
162
+ ...options,
163
+ id: `${this.id}:${options.id ?? "default"}`,
164
+ };
165
+ panel.add(registration);
166
+ const controller = panel.controller(registration);
167
+ if (!controller) throw new Error(`[stdlib] failed to register panel: ${registration.id}`);
168
+ const dispose = () => panel.delete(registration);
169
+ this.disposers.add(dispose);
170
+ return {
171
+ ...controller,
172
+ dispose: () => {
173
+ this.disposers.delete(dispose);
174
+ dispose();
175
+ },
176
+ };
177
+ }
178
+
142
179
  register<R extends keyof Registers>(type: R, ...args: Parameters<Registers[R]["add"]>) {
143
180
  this.ledger.set(args[0], type);
144
181
  // @ts-ignore
@@ -0,0 +1,371 @@
1
+ /*
2
+ * Copyright (C) 2026 Afonso Jorge Ramos
3
+ * SPDX-License-Identifier: GPL-3.0-or-later
4
+ */
5
+
6
+ export interface PanelWidth {
7
+ default?: number;
8
+ min?: number;
9
+ max?: number;
10
+ }
11
+
12
+ export interface PanelRegistration<Content = unknown> {
13
+ id: string;
14
+ label: string;
15
+ render: () => Content;
16
+ width?: PanelWidth;
17
+ onOpen?: () => void;
18
+ onClose?: () => void;
19
+ }
20
+
21
+ export interface PanelController {
22
+ open(): void;
23
+ close(): void;
24
+ toggle(): void;
25
+ isOpen(): boolean;
26
+ subscribe(listener: (open: boolean) => void): () => void;
27
+ dispose(): void;
28
+ }
29
+
30
+ export interface PanelCoordinatorOptions<Content = unknown> {
31
+ document: Document;
32
+ window: Window;
33
+ mount(host: HTMLElement, panel: PanelRegistration<Content>, close: () => void): () => void;
34
+ }
35
+
36
+ export interface PanelCoordinator<Content = unknown> {
37
+ register(panel: PanelRegistration<Content>): PanelController;
38
+ dispose(): void;
39
+ }
40
+
41
+ type RegistrationState<Content> = {
42
+ panel: PanelRegistration<Content>;
43
+ disposed: boolean;
44
+ listeners: Set<(open: boolean) => void>;
45
+ };
46
+
47
+ type NativeSnapshot = { hidden: string | null; inert: string | null };
48
+ type StyleSnapshot = { value: string; priority: string };
49
+
50
+ const WIDTH_DEFAULT = 360;
51
+ const WIDTH_MIN = 280;
52
+ const WIDTH_MAX = 520;
53
+
54
+ const panelWidth = ({ width }: PanelRegistration): number => {
55
+ const min = width?.min ?? WIDTH_MIN;
56
+ const max = Math.max(min, width?.max ?? WIDTH_MAX);
57
+ return Math.min(max, Math.max(min, width?.default ?? WIDTH_DEFAULT));
58
+ };
59
+
60
+ const validatePanelWidth = (width: PanelWidth | undefined) => {
61
+ if (!width) return;
62
+ for (const key of ["default", "min", "max"] as const) {
63
+ const value = width[key];
64
+ if (value !== undefined && (!Number.isFinite(value) || value < 0)) {
65
+ throw new Error(`[stdlib] panel width ${key} must be a finite non-negative number`);
66
+ }
67
+ }
68
+ };
69
+
70
+ const gridTracks = (template: string): string[] => {
71
+ const tracks: string[] = [];
72
+ let token = "";
73
+ let parentheses = 0;
74
+ let brackets = 0;
75
+ for (const character of template.trim()) {
76
+ if (/\s/.test(character) && parentheses === 0 && brackets === 0) {
77
+ if (token) tracks.push(token);
78
+ token = "";
79
+ continue;
80
+ }
81
+ token += character;
82
+ if (character === "(") parentheses += 1;
83
+ else if (character === ")") parentheses -= 1;
84
+ else if (character === "[") brackets += 1;
85
+ else if (character === "]") brackets -= 1;
86
+ }
87
+ if (token) tracks.push(token);
88
+ return tracks.filter((track) => !(track.startsWith("[") && track.endsWith("]")));
89
+ };
90
+
91
+ export function createPanelCoordinator<Content = unknown>(options: PanelCoordinatorOptions<Content>) {
92
+ const registrations = new Map<string, RegistrationState<Content>>();
93
+ const suspended = new Map<HTMLElement, NativeSnapshot>();
94
+ const ownedStyles = new Map<HTMLElement, Map<string, StyleSnapshot>>();
95
+ let active: RegistrationState<Content> | undefined;
96
+ let activeHost: HTMLElement | undefined;
97
+ let activeSidebar: HTMLElement | undefined;
98
+ let activeTopContainer: HTMLElement | undefined;
99
+ let unmount: (() => void) | undefined;
100
+ let shellObserver: MutationObserver | undefined;
101
+ let sidebarObserver: MutationObserver | undefined;
102
+ let returnFocus: HTMLElement | undefined;
103
+ let disposed = false;
104
+ let transitioning = false;
105
+ const transitions: Array<() => void> = [];
106
+ const Observer = (options.window as unknown as { MutationObserver: typeof MutationObserver }).MutationObserver;
107
+
108
+ const safely = (label: string, callback: (() => void) | undefined) => {
109
+ if (!callback) return;
110
+ try {
111
+ callback();
112
+ } catch (error) {
113
+ console.error(`[stdlib] panel ${label} failed:`, error);
114
+ }
115
+ };
116
+
117
+ const notify = (state: RegistrationState<Content>, open: boolean) => {
118
+ for (const listener of state.listeners) safely("subscriber", () => listener(open));
119
+ };
120
+
121
+ const transition = (action: () => void) => {
122
+ transitions.push(action);
123
+ if (transitioning) return;
124
+ transitioning = true;
125
+ let firstError: unknown;
126
+ try {
127
+ let next: (() => void) | undefined;
128
+ while ((next = transitions.shift())) {
129
+ try {
130
+ next();
131
+ } catch (error) {
132
+ firstError ??= error;
133
+ }
134
+ }
135
+ } finally {
136
+ transitioning = false;
137
+ }
138
+ if (firstError) throw firstError;
139
+ };
140
+
141
+ const restoreNative = () => {
142
+ for (const [element, snapshot] of suspended) {
143
+ for (const attribute of ["hidden", "inert"] as const) {
144
+ const value = snapshot[attribute];
145
+ if (value === null) element.removeAttribute(attribute);
146
+ else element.setAttribute(attribute, value);
147
+ }
148
+ }
149
+ suspended.clear();
150
+ };
151
+
152
+ const setOwnedStyle = (element: HTMLElement, property: string, value: string) => {
153
+ let snapshots = ownedStyles.get(element);
154
+ if (!snapshots) {
155
+ snapshots = new Map();
156
+ ownedStyles.set(element, snapshots);
157
+ }
158
+ if (!snapshots.has(property)) {
159
+ snapshots.set(property, {
160
+ value: element.style.getPropertyValue(property),
161
+ priority: element.style.getPropertyPriority(property),
162
+ });
163
+ }
164
+ element.style.setProperty(property, value, "important");
165
+ };
166
+
167
+ const restoreOwnedStyles = (element: HTMLElement | undefined) => {
168
+ if (!element) return;
169
+ const snapshots = ownedStyles.get(element);
170
+ if (!snapshots) return;
171
+ for (const [property, snapshot] of snapshots) {
172
+ if (snapshot.value) element.style.setProperty(property, snapshot.value, snapshot.priority);
173
+ else element.style.removeProperty(property);
174
+ }
175
+ ownedStyles.delete(element);
176
+ };
177
+
178
+ const suspendNative = (sidebar: HTMLElement) => {
179
+ for (const child of sidebar.children) {
180
+ if (!(child instanceof HTMLElement) || child === activeHost) continue;
181
+ if (!suspended.has(child)) {
182
+ suspended.set(child, { hidden: child.getAttribute("hidden"), inert: child.getAttribute("inert") });
183
+ }
184
+ child.setAttribute("hidden", "");
185
+ child.setAttribute("inert", "");
186
+ }
187
+ };
188
+
189
+ const observeSidebar = (sidebar: HTMLElement) => {
190
+ sidebarObserver?.disconnect();
191
+ sidebarObserver = new Observer(() => suspendNative(sidebar));
192
+ sidebarObserver.observe(sidebar, { childList: true });
193
+ };
194
+
195
+ const clearLayout = () => {
196
+ activeTopContainer?.removeAttribute("data-spicetify-panel-active");
197
+ restoreOwnedStyles(activeTopContainer);
198
+ restoreOwnedStyles(activeSidebar);
199
+ activeTopContainer = undefined;
200
+ activeSidebar = undefined;
201
+ };
202
+
203
+ const placeActiveHost = (): boolean => {
204
+ if (!active || !activeHost) return false;
205
+ const sidebar = options.document.querySelector<HTMLElement>(".Root__right-sidebar");
206
+ const topContainer = options.document.querySelector<HTMLElement>(".Root__top-container");
207
+ if (!sidebar || !topContainer) return false;
208
+
209
+ if (activeSidebar !== sidebar) {
210
+ restoreNative();
211
+ restoreOwnedStyles(activeSidebar);
212
+ activeSidebar = sidebar;
213
+ observeSidebar(sidebar);
214
+ }
215
+ if (activeHost.parentElement !== sidebar) sidebar.prepend(activeHost);
216
+ if (activeTopContainer !== topContainer) {
217
+ activeTopContainer?.removeAttribute("data-spicetify-panel-active");
218
+ restoreOwnedStyles(activeTopContainer);
219
+ activeTopContainer = topContainer;
220
+ }
221
+ topContainer.dataset.spicetifyPanelActive = "";
222
+ const trailingTracks = gridTracks(options.window.getComputedStyle(topContainer).gridTemplateColumns).slice(3);
223
+ const trailingWidth = trailingTracks.reduce(
224
+ (total, track) => total + (track.endsWith("px") ? Number.parseFloat(track) || 0 : 0),
225
+ 0,
226
+ );
227
+ setOwnedStyle(topContainer, "--spicetify-panel-requested-width", `${panelWidth(active.panel)}px`);
228
+ setOwnedStyle(topContainer, "--spicetify-panel-trailing-width", `${trailingWidth}px`);
229
+ setOwnedStyle(
230
+ topContainer,
231
+ "grid-template-columns",
232
+ ["auto", "minmax(0, 1fr)", "var(--spicetify-panel-width)", ...trailingTracks].join(" "),
233
+ );
234
+ setOwnedStyle(sidebar, "width", "var(--spicetify-panel-width)");
235
+ setOwnedStyle(sidebar, "min-width", "var(--spicetify-panel-width)");
236
+ suspendNative(sidebar);
237
+ return true;
238
+ };
239
+
240
+ const closeActive = (restoreFocus: boolean) => {
241
+ const closing = active;
242
+ if (!closing) return;
243
+ active = undefined;
244
+ shellObserver?.disconnect();
245
+ shellObserver = undefined;
246
+ sidebarObserver?.disconnect();
247
+ sidebarObserver = undefined;
248
+ safely("unmount", unmount);
249
+ unmount = undefined;
250
+ activeHost?.remove();
251
+ activeHost = undefined;
252
+ restoreNative();
253
+ clearLayout();
254
+ safely("onClose", closing.panel.onClose);
255
+ notify(closing, false);
256
+ if (restoreFocus) {
257
+ if (returnFocus?.isConnected) returnFocus.focus();
258
+ returnFocus = undefined;
259
+ }
260
+ };
261
+
262
+ const open = (state: RegistrationState<Content>) => {
263
+ if (disposed || state.disposed) return;
264
+ if (active === state) return;
265
+ const replacing = !!active;
266
+ if (replacing) closeActive(false);
267
+ const focused = options.document.activeElement;
268
+ if (!replacing) returnFocus = focused instanceof HTMLElement ? focused : undefined;
269
+ active = state;
270
+ activeHost = options.document.createElement("section");
271
+ activeHost.className = "spicetify-panel-host";
272
+ activeHost.dataset.panelId = state.panel.id;
273
+ activeHost.setAttribute("role", "complementary");
274
+ activeHost.setAttribute("aria-label", state.panel.label);
275
+ activeHost.tabIndex = -1;
276
+ try {
277
+ unmount = options.mount(activeHost, state.panel, () => transition(() => closeActive(true)));
278
+ } catch (error) {
279
+ active = undefined;
280
+ activeHost.remove();
281
+ activeHost = undefined;
282
+ if (returnFocus?.isConnected) returnFocus.focus();
283
+ returnFocus = undefined;
284
+ throw error;
285
+ }
286
+ placeActiveHost();
287
+ shellObserver = new Observer(() => {
288
+ if (activeHost?.isConnected && activeSidebar?.isConnected && activeTopContainer?.isConnected) return;
289
+ placeActiveHost();
290
+ });
291
+ if (options.document.body) shellObserver.observe(options.document.body, { childList: true, subtree: true });
292
+ safely("onOpen", state.panel.onOpen);
293
+ notify(state, true);
294
+ activeHost.focus();
295
+ };
296
+
297
+ const onKeyDown = (event: KeyboardEvent) => {
298
+ if (event.key !== "Escape" || event.defaultPrevented || !active) return;
299
+ if (
300
+ options.document.querySelector(
301
+ ".spicetify-popover, .spicetify-scrim, .spicetify-root-children [role='dialog']",
302
+ )
303
+ )
304
+ return;
305
+ event.preventDefault();
306
+ transition(() => closeActive(true));
307
+ };
308
+ options.document.addEventListener("keydown", onKeyDown);
309
+
310
+ const register = (panel: PanelRegistration<Content>): PanelController => {
311
+ if (disposed) throw new Error("[stdlib] panel coordinator is disposed");
312
+ if (!panel.id.trim()) throw new Error("[stdlib] panel id must not be empty");
313
+ if (!panel.label.trim()) throw new Error("[stdlib] panel label must not be empty");
314
+ validatePanelWidth(panel.width);
315
+ if (registrations.has(panel.id)) throw new Error(`[stdlib] duplicate panel id: ${panel.id}`);
316
+ const state: RegistrationState<Content> = { panel, disposed: false, listeners: new Set() };
317
+ registrations.set(panel.id, state);
318
+
319
+ return {
320
+ open: () => {
321
+ if (disposed || state.disposed) throw new Error(`[stdlib] panel "${state.panel.id}" is disposed`);
322
+ transition(() => open(state));
323
+ },
324
+ close: () => {
325
+ transition(() => {
326
+ if (active === state) closeActive(true);
327
+ });
328
+ },
329
+ toggle: () => {
330
+ if (disposed || state.disposed) throw new Error(`[stdlib] panel "${state.panel.id}" is disposed`);
331
+ transition(() => {
332
+ if (active === state) closeActive(true);
333
+ else open(state);
334
+ });
335
+ },
336
+ isOpen: () => active === state,
337
+ subscribe(listener) {
338
+ if (state.disposed) throw new Error(`[stdlib] panel "${state.panel.id}" is disposed`);
339
+ state.listeners.add(listener);
340
+ safely("subscriber", () => listener(active === state));
341
+ return () => state.listeners.delete(listener);
342
+ },
343
+ dispose() {
344
+ if (state.disposed) return;
345
+ state.disposed = true;
346
+ transition(() => {
347
+ if (active === state) closeActive(true);
348
+ state.listeners.clear();
349
+ registrations.delete(panel.id);
350
+ });
351
+ },
352
+ };
353
+ };
354
+
355
+ const dispose = () => {
356
+ if (disposed) return;
357
+ disposed = true;
358
+ transition(() => {
359
+ closeActive(false);
360
+ returnFocus = undefined;
361
+ for (const state of registrations.values()) {
362
+ state.disposed = true;
363
+ state.listeners.clear();
364
+ }
365
+ registrations.clear();
366
+ options.document.removeEventListener("keydown", onKeyDown);
367
+ });
368
+ };
369
+
370
+ return { register, dispose } satisfies PanelCoordinator<Content>;
371
+ }
@@ -1,168 +1,80 @@
1
1
  /*
2
2
  * Copyright (C) 2024 Delusoire
3
+ * Copyright (C) 2026 Afonso Jorge Ramos
3
4
  * SPDX-License-Identifier: GPL-3.0-or-later
4
5
  */
5
6
 
6
- import { transformer } from "../../mixin.ts";
7
-
8
- import type { createMachine as createMachineT } from "npm:xstate";
7
+ import { React, ReactDOM } from "../expose/React.ts";
8
+ import { createItemBoundary } from "./mount.ts";
9
9
  import { Registry } from "./registry.ts";
10
-
11
- export type StateMachine = ReturnType<typeof createMachineT>;
12
- export let Machine: StateMachine;
13
-
14
- const registry = new (class extends Registry<React.ReactNode> {
15
- private static NodeNash = Symbol.for("NodeHash");
16
-
17
- getHash(value: React.ReactNode) {
18
- if (!this.has(value)) {
19
- return null;
20
- }
21
- // @ts-ignore
22
- const hash = value[this.constructor.NodeNash];
23
- const state = `bespoke_${hash}`;
24
- const event = `bespoke_${hash}_button_click`;
25
- return { state, event };
26
- }
27
-
28
- override add(value: React.ReactNode, onEntry?: any, onExit?: any): this {
29
- const hash = crypto.randomUUID();
30
- // @ts-ignore
31
- value[this.constructor.NodeNash] = hash;
32
- const state = `bespoke_${hash}`;
33
- const event = `bespoke_${hash}_button_click`;
34
-
35
- stateToNode.set(state, value);
36
-
37
- ON[event] = {
38
- target: state,
39
- };
40
-
41
- STATES[state] = {
42
- on: Object.setPrototypeOf(
43
- {
44
- [event]: {
45
- target: "disabled",
10
+ import {
11
+ createPanelCoordinator,
12
+ type PanelController,
13
+ type PanelCoordinator,
14
+ type PanelRegistration,
15
+ } from "./panel-logic.ts";
16
+
17
+ export type { PanelController, PanelRegistration, PanelWidth } from "./panel-logic.ts";
18
+
19
+ const mountReactPanel = (host: HTMLElement, panel: PanelRegistration<React.ReactNode>, close: () => void) => {
20
+ const root = ReactDOM.createRoot(host);
21
+ const PanelBoundary = createItemBoundary(React, "panel") as unknown as React.ComponentType<{
22
+ children?: React.ReactNode;
23
+ }>;
24
+ const PanelContent = () => React.createElement("div", { className: "spicetify-panel-content" }, panel.render());
25
+ const PanelSurface = () =>
26
+ React.createElement(
27
+ React.Fragment,
28
+ null,
29
+ React.createElement(
30
+ "header",
31
+ { className: "spicetify-panel-header" },
32
+ React.createElement("h2", { className: "spicetify-panel-title" }, panel.label),
33
+ React.createElement(
34
+ "button",
35
+ {
36
+ className: "spicetify-panel-close spicetify-button-circle",
37
+ "aria-label": `Close ${panel.label}`,
38
+ onClick: close,
46
39
  },
47
- },
48
- ON,
40
+ "×",
41
+ ),
49
42
  ),
50
- };
51
-
52
- if (onEntry) {
53
- const entry = `bespoke_${hash}_entry`;
54
- STATES[state].entry = [entry];
55
- ACTIONS[entry] = onEntry;
56
- }
57
- if (onExit) {
58
- const exit = `bespoke_${hash}_exit`;
59
- Machine.config.states![state].exit = [exit];
60
- ACTIONS[exit] = onExit;
61
- }
43
+ React.createElement(PanelBoundary, null, React.createElement(PanelContent)),
44
+ );
45
+ root.render(React.createElement(PanelSurface));
46
+ return () => root.unmount();
47
+ };
62
48
 
63
- return super.add(value);
64
- }
49
+ const panelCoordinatorKey = Symbol.for("spicetify.stdlib.panel-coordinator");
50
+ const shared = globalThis as typeof globalThis & Record<symbol, unknown>;
51
+ const previousCoordinator = shared[panelCoordinatorKey] as PanelCoordinator<React.ReactNode> | undefined;
52
+ previousCoordinator?.dispose();
65
53
 
66
- override delete(item: React.ReactNode): boolean {
67
- // @ts-ignore
68
- const hash = item[this.constructor.NodeNash];
69
- const state = `bespoke_${hash}`;
70
- const event = `bespoke_${hash}_button_click`;
54
+ export const panelCoordinator = createPanelCoordinator<React.ReactNode>({
55
+ document,
56
+ window,
57
+ mount: mountReactPanel,
58
+ });
59
+ shared[panelCoordinatorKey] = panelCoordinator;
71
60
 
72
- stateToNode.delete(state);
61
+ class PanelRegistry extends Registry<PanelRegistration<React.ReactNode>> {
62
+ private controllers = new Map<PanelRegistration<React.ReactNode>, PanelController>();
73
63
 
74
- delete ON[event];
75
- return super.delete(item);
64
+ override add(panel: PanelRegistration<React.ReactNode>): this {
65
+ this.controllers.set(panel, panelCoordinator.register(panel));
66
+ return super.add(panel);
76
67
  }
77
- })();
78
- export default registry;
79
-
80
- const stateToNode = new Map<string, React.ReactNode>();
81
-
82
- declare global {
83
- var __renderPanel: any;
84
- }
85
68
 
86
- globalThis.__renderPanel = (state: string) => {
87
- if (!state.startsWith("bespoke_")) {
88
- return null;
69
+ override delete(panel: PanelRegistration<React.ReactNode>): boolean {
70
+ this.controllers.get(panel)?.dispose();
71
+ this.controllers.delete(panel);
72
+ return super.delete(panel);
89
73
  }
90
74
 
91
- return stateToNode.get(state);
92
- };
93
-
94
- let ON: Record<string, any> = {};
95
- let STATES: Record<string, any> = {};
96
- let ACTIONS: Record<string, any> = {};
97
-
98
- transformer(
99
- (emit) => (str) => {
100
- emit();
101
-
102
- str = str.replace(/(=\(0,[a-zA-Z_\$][\w\$]*\.[a-zA-Z_\$][\w\$]*\)\(\{id:"RightPanelState)/, "=__Machine$1");
103
- Object.defineProperty(globalThis, "__Machine", {
104
- set: ($: StateMachine) => {
105
- Machine = $;
106
-
107
- queueMicrotask(() => {
108
- ON = {
109
- ...ON,
110
- ...Machine.config.states!.disabled.on,
111
- panel_close_click: [
112
- {
113
- target: "disabled",
114
- },
115
- ],
116
- };
117
- delete ON.playback_autoplay_context_changed;
118
-
119
- for (const [k, v] of Object.entries(Machine.config.states! as Record<string, any>)) {
120
- if (k === "puffin_activation") {
121
- continue;
122
- }
123
- v.on = new Proxy(v.on!, {
124
- get(target, p, _receiver) {
125
- // @ts-ignore
126
- if (p.startsWith("bespoke_")) {
127
- // @ts-ignore
128
- return ON[p];
129
- }
130
- // @ts-ignore
131
- return target[p];
132
- },
133
- });
134
- }
135
-
136
- Object.setPrototypeOf(Machine.config.states!, STATES);
137
-
138
- Machine._options.actions = ACTIONS;
139
- });
140
- },
141
- get: () => Machine,
142
- });
143
-
144
- // ! HACKY ALERT
145
- str = str.replace(
146
- /(case [a-zA-Z_\$][\w\$]*\.[a-zA-Z_\$][\w\$]*\.Disabled:return!0;default:)/,
147
- "$1return true;",
148
- );
149
-
150
- return str;
151
- },
152
- {
153
- glob: /^\/xpui\.js/,
154
- },
155
- );
156
-
157
- transformer(
158
- (emit) => (str) => {
159
- emit();
160
-
161
- str = str.replace(/(\(([a-zA-Z_\$][\w\$]*),"PanelSection".+?children:\[?)/, "$1__renderPanel($2),");
75
+ controller(panel: PanelRegistration<React.ReactNode>): PanelController | undefined {
76
+ return this.controllers.get(panel);
77
+ }
78
+ }
162
79
 
163
- return str;
164
- },
165
- {
166
- glob: /^\/dwp\-panel\-section\.js/,
167
- },
168
- );
80
+ export default new PanelRegistry();