@spicetify/kit 0.1.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.
- package/bin/spicetify-kit.js +4 -0
- package/dist/build.js +335 -0
- package/dist/check.js +284 -0
- package/dist/classmap.js +187 -0
- package/dist/cli.js +53 -0
- package/dist/create.js +429 -0
- package/dist/dev.js +100 -0
- package/dist/from-theme.js +96 -0
- package/dist/install.js +60 -0
- package/dist/launch.js +122 -0
- package/dist/pack.js +31 -0
- package/dist/push.js +211 -0
- package/dist/vault.js +108 -0
- package/package.json +49 -0
- package/vendor/classmaps/1020096/classmap-19f856aefd5.json +109 -0
- package/vendor/shims/chunks.d.ts +10 -0
- package/vendor/shims/remote-modules.d.ts +6 -0
- package/vendor/shims/spicetify.d.ts +2437 -0
- package/vendor/stdlib/deps.ts +24 -0
- package/vendor/stdlib/index.ts +24 -0
- package/vendor/stdlib/lib/modal.tsx +128 -0
- package/vendor/stdlib/lib/popover.ts +84 -0
- package/vendor/stdlib/lib/primitives-classes.ts +66 -0
- package/vendor/stdlib/lib/primitives-vanilla.ts +277 -0
- package/vendor/stdlib/lib/primitives.tsx +314 -0
- package/vendor/stdlib/load.ts +17 -0
- package/vendor/stdlib/metadata.json +18 -0
- package/vendor/stdlib/mixin.ts +32 -0
- package/vendor/stdlib/mod.ts +26 -0
- package/vendor/stdlib/src/chunks.d.ts +10 -0
- package/vendor/stdlib/src/client.ts +114 -0
- package/vendor/stdlib/src/createIconComponent.tsx +29 -0
- package/vendor/stdlib/src/events.mix.ts +55 -0
- package/vendor/stdlib/src/events.ts +58 -0
- package/vendor/stdlib/src/expose/GraphQL.ts +40 -0
- package/vendor/stdlib/src/expose/Platform.ts +48 -0
- package/vendor/stdlib/src/expose/React.ts +95 -0
- package/vendor/stdlib/src/expose/ReactFlipToolkitSpring.ts +27 -0
- package/vendor/stdlib/src/expose/ReduxStore.ts +39 -0
- package/vendor/stdlib/src/expose/SettingsSection.ts +60 -0
- package/vendor/stdlib/src/expose/Snackbar.ts +34 -0
- package/vendor/stdlib/src/expose/Tippy.ts +26 -0
- package/vendor/stdlib/src/expose/enqueueImageSnackbar.ts +25 -0
- package/vendor/stdlib/src/expose/index.ts +14 -0
- package/vendor/stdlib/src/expose/jsx-runtime.ts +46 -0
- package/vendor/stdlib/src/expose/react-dom-shim.ts +50 -0
- package/vendor/stdlib/src/expose/react-shim.ts +116 -0
- package/vendor/stdlib/src/logger.ts +35 -0
- package/vendor/stdlib/src/playbar-compat.tsx +136 -0
- package/vendor/stdlib/src/registers/index.ts +176 -0
- package/vendor/stdlib/src/registers/menu.ts +160 -0
- package/vendor/stdlib/src/registers/mount.ts +307 -0
- package/vendor/stdlib/src/registers/nativeAnchors.ts +49 -0
- package/vendor/stdlib/src/registers/navlink.tsx +165 -0
- package/vendor/stdlib/src/registers/order.ts +22 -0
- package/vendor/stdlib/src/registers/panel.ts +168 -0
- package/vendor/stdlib/src/registers/playbarButton.tsx +93 -0
- package/vendor/stdlib/src/registers/playbarWidget.tsx +71 -0
- package/vendor/stdlib/src/registers/registry.ts +12 -0
- package/vendor/stdlib/src/registers/root.ts +101 -0
- package/vendor/stdlib/src/registers/route.ts +123 -0
- package/vendor/stdlib/src/registers/settingsRow.tsx +56 -0
- package/vendor/stdlib/src/registers/settingsSection.ts +59 -0
- package/vendor/stdlib/src/registers/topbarLeftButton.tsx +86 -0
- package/vendor/stdlib/src/registers/topbarRightButton.tsx +87 -0
- package/vendor/stdlib/src/storage.ts +163 -0
- package/vendor/stdlib/src/util.ts +62 -0
- package/vendor/stdlib/src/utils/index.ts +16 -0
- package/vendor/stdlib/src/webpack/ClassNames.gen.ts +10 -0
- package/vendor/stdlib/src/webpack/ClassNames.ts +6 -0
- package/vendor/stdlib/src/webpack/ClassNames.xpui.ts +16 -0
- package/vendor/stdlib/src/webpack/ComponentLibrary.gen.ts +10 -0
- package/vendor/stdlib/src/webpack/ComponentLibrary.ts +6 -0
- package/vendor/stdlib/src/webpack/ComponentLibrary.xpui.ts +18 -0
- package/vendor/stdlib/src/webpack/FilterContext.gen.ts +10 -0
- package/vendor/stdlib/src/webpack/FilterContext.ts +6 -0
- package/vendor/stdlib/src/webpack/FilterContext.xpui.ts +10 -0
- package/vendor/stdlib/src/webpack/Mousetrap.gen.ts +10 -0
- package/vendor/stdlib/src/webpack/Mousetrap.ts +6 -0
- package/vendor/stdlib/src/webpack/Mousetrap.xpui.ts +12 -0
- package/vendor/stdlib/src/webpack/React.gen.ts +14 -0
- package/vendor/stdlib/src/webpack/React.ts +6 -0
- package/vendor/stdlib/src/webpack/React.xpui.ts +15 -0
- package/vendor/stdlib/src/webpack/ReactComponents.desktop.ts +17 -0
- package/vendor/stdlib/src/webpack/ReactComponents.gen.ts +74 -0
- package/vendor/stdlib/src/webpack/ReactComponents.panel.ts +19 -0
- package/vendor/stdlib/src/webpack/ReactComponents.ts +50 -0
- package/vendor/stdlib/src/webpack/ReactComponents.xpui.ts +164 -0
- package/vendor/stdlib/src/webpack/ReactFlipToolkit.gen.ts +12 -0
- package/vendor/stdlib/src/webpack/ReactFlipToolkit.ts +6 -0
- package/vendor/stdlib/src/webpack/ReactFlipToolkit.xpui.ts +14 -0
- package/vendor/stdlib/src/webpack/ReactHooks.fullscreen.ts +15 -0
- package/vendor/stdlib/src/webpack/ReactHooks.gen.ts +32 -0
- package/vendor/stdlib/src/webpack/ReactHooks.ts +6 -0
- package/vendor/stdlib/src/webpack/ReactHooks.xpui.ts +38 -0
- package/vendor/stdlib/src/webpack/ReactQuery.gen.ts +26 -0
- package/vendor/stdlib/src/webpack/ReactQuery.ts +6 -0
- package/vendor/stdlib/src/webpack/ReactQuery.xpui.ts +46 -0
- package/vendor/stdlib/src/webpack/ReactRouter.gen.ts +12 -0
- package/vendor/stdlib/src/webpack/ReactRouter.ts +6 -0
- package/vendor/stdlib/src/webpack/ReactRouter.xpui.ts +22 -0
- package/vendor/stdlib/src/webpack/Snackbar.gen.ts +12 -0
- package/vendor/stdlib/src/webpack/Snackbar.ts +6 -0
- package/vendor/stdlib/src/webpack/Snackbar.xpui.ts +27 -0
- package/vendor/stdlib/src/webpack/URI.gen.ts +26 -0
- package/vendor/stdlib/src/webpack/URI.ts +82 -0
- package/vendor/stdlib/src/webpack/URI.xpui.ts +111 -0
- package/vendor/stdlib/src/webpack/capture-readiness.ts +49 -0
- package/vendor/stdlib/src/webpack/index.ts +186 -0
- package/vendor/stdlib/src/webpack/misc.gen.ts +16 -0
- package/vendor/stdlib/src/webpack/misc.ts +6 -0
- package/vendor/stdlib/src/webpack/misc.xpui.ts +27 -0
- package/vendor/stdlib/src/wpunpk.mix.ts +104 -0
- package/vendor/stdlib/src/wpunpk.ts +36 -0
- package/vendor/stdlib/vendor/rxjs.d.ts +5 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Afonso Jorge Ramos
|
|
3
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// The client still exposes the classic Spicetify.Playbar.Button/Widget, but
|
|
7
|
+
// they no longer mount in the restructured playbar (they construct a DOM node
|
|
8
|
+
// that is never inserted). Every other v2 helper (Menu, ContextMenu, Topbar,
|
|
9
|
+
// Keyboard, PopupModal, ...) still works, so this is the one gap. We re-back
|
|
10
|
+
// Button/Widget with the v3 playbarButton register so classic extensions and
|
|
11
|
+
// muscle memory keep working, and the typed API never hands you a dead helper.
|
|
12
|
+
|
|
13
|
+
import { React } from "./expose/React.ts";
|
|
14
|
+
import playbarButtons, { PlaybarButton } from "./registers/playbarButton.tsx";
|
|
15
|
+
|
|
16
|
+
// The register feeds `icon` to createIconComponent, which supplies its own
|
|
17
|
+
// <svg> wrapper. Classic icons are full <svg>...</svg> strings, so hand it the
|
|
18
|
+
// inner markup; already-inner markup passes through untouched.
|
|
19
|
+
const innerSvg = (icon: string): string => {
|
|
20
|
+
const match = /<svg[^>]*>([\s\S]*)<\/svg>/i.exec(icon);
|
|
21
|
+
return match ? match[1] : icon;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
class PlaybarCompat {
|
|
25
|
+
onClick: (self: PlaybarCompat) => void;
|
|
26
|
+
private _label: string;
|
|
27
|
+
private _icon: string;
|
|
28
|
+
private _active: boolean;
|
|
29
|
+
private _disabled: boolean;
|
|
30
|
+
private node: React.ReactNode | null = null;
|
|
31
|
+
private readonly refreshers = new Set<() => void>();
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
label: string,
|
|
35
|
+
icon: string,
|
|
36
|
+
onClick: (self: PlaybarCompat) => void = () => {},
|
|
37
|
+
disabled = false,
|
|
38
|
+
active = false,
|
|
39
|
+
registerOnCreate = true,
|
|
40
|
+
) {
|
|
41
|
+
this._label = label;
|
|
42
|
+
this._icon = icon;
|
|
43
|
+
this.onClick = onClick;
|
|
44
|
+
this._disabled = disabled;
|
|
45
|
+
this._active = active;
|
|
46
|
+
if (registerOnCreate) this.register();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private refresh() {
|
|
50
|
+
for (const f of this.refreshers) f();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Mutating any display field re-renders the mounted button, matching the
|
|
54
|
+
// classic API where `widget.active = true` / `widget.label = "..."` update live.
|
|
55
|
+
get label() {
|
|
56
|
+
return this._label;
|
|
57
|
+
}
|
|
58
|
+
set label(v) {
|
|
59
|
+
this._label = v;
|
|
60
|
+
this.refresh();
|
|
61
|
+
}
|
|
62
|
+
get icon() {
|
|
63
|
+
return this._icon;
|
|
64
|
+
}
|
|
65
|
+
set icon(v) {
|
|
66
|
+
this._icon = v;
|
|
67
|
+
this.refresh();
|
|
68
|
+
}
|
|
69
|
+
get active() {
|
|
70
|
+
return this._active;
|
|
71
|
+
}
|
|
72
|
+
set active(v) {
|
|
73
|
+
this._active = v;
|
|
74
|
+
this.refresh();
|
|
75
|
+
}
|
|
76
|
+
get disabled() {
|
|
77
|
+
return this._disabled;
|
|
78
|
+
}
|
|
79
|
+
set disabled(v) {
|
|
80
|
+
this._disabled = v;
|
|
81
|
+
this.refresh();
|
|
82
|
+
}
|
|
83
|
+
// No standalone DOM node in the register model; classic code rarely touches these.
|
|
84
|
+
get element(): HTMLButtonElement | null {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
get tippy(): unknown {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
register() {
|
|
92
|
+
if (this.node) return;
|
|
93
|
+
const self = this;
|
|
94
|
+
const Compat = () => {
|
|
95
|
+
const [, force] = React.useReducer((n: number) => n + 1, 0);
|
|
96
|
+
React.useEffect(() => {
|
|
97
|
+
self.refreshers.add(force);
|
|
98
|
+
return () => {
|
|
99
|
+
self.refreshers.delete(force);
|
|
100
|
+
};
|
|
101
|
+
}, []);
|
|
102
|
+
return (
|
|
103
|
+
<PlaybarButton
|
|
104
|
+
label={self._label}
|
|
105
|
+
icon={innerSvg(self._icon)}
|
|
106
|
+
isActive={self._active}
|
|
107
|
+
disabled={self._disabled}
|
|
108
|
+
onClick={() => self.onClick(self)}
|
|
109
|
+
/>
|
|
110
|
+
);
|
|
111
|
+
};
|
|
112
|
+
this.node = <Compat />;
|
|
113
|
+
playbarButtons.add(this.node);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
deregister() {
|
|
117
|
+
if (!this.node) return;
|
|
118
|
+
playbarButtons.delete(this.node);
|
|
119
|
+
this.node = null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Replace the client's dead Button/Widget with the register-backed shim.
|
|
124
|
+
// Returns a teardown that restores the originals.
|
|
125
|
+
export function installPlaybarCompat(): () => void {
|
|
126
|
+
const S = (globalThis as { Spicetify?: { Playbar?: Record<string, unknown> } }).Spicetify;
|
|
127
|
+
if (!S?.Playbar) return () => {};
|
|
128
|
+
const original = { Button: S.Playbar.Button, Widget: S.Playbar.Widget };
|
|
129
|
+
S.Playbar.Button = PlaybarCompat;
|
|
130
|
+
S.Playbar.Widget = PlaybarCompat;
|
|
131
|
+
return () => {
|
|
132
|
+
if (!S.Playbar) return;
|
|
133
|
+
S.Playbar.Button = original.Button;
|
|
134
|
+
S.Playbar.Widget = original.Widget;
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2024 Delusoire
|
|
3
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ModuleRuntimeContext } from "../../mod.ts";
|
|
7
|
+
|
|
8
|
+
import { React } from "../expose/React.ts";
|
|
9
|
+
import { warn } from "../logger.ts";
|
|
10
|
+
import menu from "./menu.ts";
|
|
11
|
+
import { mountAdjacent } from "./mount.ts";
|
|
12
|
+
import { isNativeAnchor, type NativeAnchor, resolveNativeAnchor } from "./nativeAnchors.ts";
|
|
13
|
+
import navlink from "./navlink.tsx";
|
|
14
|
+
import panel from "./panel.ts";
|
|
15
|
+
import playbarButton, { PlaybarButton } from "./playbarButton.tsx";
|
|
16
|
+
import playbarWidget from "./playbarWidget.tsx";
|
|
17
|
+
import { Registry } from "./registry.ts";
|
|
18
|
+
import root from "./root.ts";
|
|
19
|
+
import route from "./route.ts";
|
|
20
|
+
import settingsRow from "./settingsRow.tsx";
|
|
21
|
+
import settingsSection from "./settingsSection.ts";
|
|
22
|
+
import topbarLeftButton, { TopbarLeftButton } from "./topbarLeftButton.tsx";
|
|
23
|
+
import topbarRightButton, { TopbarRightButton } from "./topbarRightButton.tsx";
|
|
24
|
+
const [rootChild, rootProvider] = root;
|
|
25
|
+
const registers = {
|
|
26
|
+
menu,
|
|
27
|
+
navlink,
|
|
28
|
+
panel,
|
|
29
|
+
playbarButton,
|
|
30
|
+
playbarWidget,
|
|
31
|
+
rootChild,
|
|
32
|
+
rootProvider,
|
|
33
|
+
route,
|
|
34
|
+
settingsRow,
|
|
35
|
+
settingsSection,
|
|
36
|
+
topbarLeftButton,
|
|
37
|
+
topbarRightButton,
|
|
38
|
+
} satisfies Record<string, Registry<any>>;
|
|
39
|
+
type Registers = typeof registers;
|
|
40
|
+
|
|
41
|
+
// ---- placeButton: one ergonomic, position-controllable way to add a button ----
|
|
42
|
+
// Instead of picking the right register key and hand-building the matching
|
|
43
|
+
// component (easy to mismatch, no ordering), a module names a location and
|
|
44
|
+
// passes the button's props in one call. `order` controls position within the
|
|
45
|
+
// group of module buttons in that slot (lower renders earlier).
|
|
46
|
+
|
|
47
|
+
/** Where a module button can live. */
|
|
48
|
+
export type ButtonLocation = "topbar-left" | "topbar-right" | "playbar";
|
|
49
|
+
|
|
50
|
+
export interface PlaceButtonOptions {
|
|
51
|
+
/** Tooltip + accessible label. */
|
|
52
|
+
label: string;
|
|
53
|
+
/** Inner SVG markup drawn on the stdlib 16-grid. */
|
|
54
|
+
icon?: string;
|
|
55
|
+
onClick: () => void;
|
|
56
|
+
disabled?: boolean;
|
|
57
|
+
/** Position within this slot's module buttons; lower renders earlier. Default 0. */
|
|
58
|
+
order?: number;
|
|
59
|
+
/** Playbar only: render the active indicator. Ignored in the top bar. */
|
|
60
|
+
isActive?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Place the button next to one of the client's own buttons instead of in the
|
|
63
|
+
* module-button group. `anchor` is a stable stdlib-owned name (e.g.
|
|
64
|
+
* "playbar:queue"); if it can't be resolved on this client the button falls
|
|
65
|
+
* back to ordinary `order` placement, so it is never hidden. `side` defaults
|
|
66
|
+
* to "after".
|
|
67
|
+
*/
|
|
68
|
+
near?: { anchor: NativeAnchor; side?: "before" | "after" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ButtonHandle {
|
|
72
|
+
/** Remove the button now. It is also removed automatically when the module unloads. */
|
|
73
|
+
remove(): void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const BUTTON_SLOTS = {
|
|
77
|
+
"topbar-left": ["topbarLeftButton", TopbarLeftButton],
|
|
78
|
+
"topbar-right": ["topbarRightButton", TopbarRightButton],
|
|
79
|
+
playbar: ["playbarButton", PlaybarButton],
|
|
80
|
+
} as const satisfies Record<ButtonLocation, [keyof Registers, React.FC<any>]>;
|
|
81
|
+
|
|
82
|
+
export class Registrar {
|
|
83
|
+
constructor(public id: string) {}
|
|
84
|
+
|
|
85
|
+
private ledger = new Map<any, keyof Registers>();
|
|
86
|
+
// Cleanups for things not held in the registry ledger (e.g. near-anchored
|
|
87
|
+
// buttons, which mount their own host). Run on dispose so nothing leaks
|
|
88
|
+
// when the module unloads.
|
|
89
|
+
private disposers = new Set<() => void>();
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Place a button in a known client location without touching register keys
|
|
93
|
+
* or building the component yourself. Returns a handle to remove it early;
|
|
94
|
+
* otherwise it is cleaned up when the module unloads.
|
|
95
|
+
*/
|
|
96
|
+
placeButton(location: ButtonLocation, options: PlaceButtonOptions): ButtonHandle {
|
|
97
|
+
const slot = BUTTON_SLOTS[location];
|
|
98
|
+
if (!slot) throw new Error(`[stdlib] unknown button location: ${location}`);
|
|
99
|
+
const [key, Component] = slot;
|
|
100
|
+
const element = React.createElement(Component as React.FC<PlaceButtonOptions>, { order: 0, ...options });
|
|
101
|
+
|
|
102
|
+
const inGroup = (): ButtonHandle => {
|
|
103
|
+
this.register(key, element);
|
|
104
|
+
return { remove: () => this.unregister(key, element) };
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
if (!options.near) return inGroup();
|
|
108
|
+
const { anchor, side = "after" } = options.near;
|
|
109
|
+
if (!isNativeAnchor(anchor)) {
|
|
110
|
+
warn(`[stdlib] unknown native anchor "${anchor}"; placing "${options.label}" with order instead`);
|
|
111
|
+
return inGroup();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Try to sit next to the named native button; if it never appears, fall
|
|
115
|
+
// back to the module-button group so the button is never lost.
|
|
116
|
+
let fellBackHandle: ButtonHandle | undefined;
|
|
117
|
+
const adjacent = mountAdjacent({
|
|
118
|
+
className: "spicetify-near-button",
|
|
119
|
+
element,
|
|
120
|
+
findTarget: () => resolveNativeAnchor(anchor),
|
|
121
|
+
side,
|
|
122
|
+
giveUpMs: 5000,
|
|
123
|
+
onGiveUp: () => {
|
|
124
|
+
fellBackHandle ??= inGroup();
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
// Tracked so module unload (dispose) tears the near button down too; the
|
|
128
|
+
// near path never touches the registry ledger, so it needs its own hook.
|
|
129
|
+
const cleanup = () => {
|
|
130
|
+
adjacent.remove();
|
|
131
|
+
fellBackHandle?.remove();
|
|
132
|
+
};
|
|
133
|
+
this.disposers.add(cleanup);
|
|
134
|
+
return {
|
|
135
|
+
remove: () => {
|
|
136
|
+
this.disposers.delete(cleanup);
|
|
137
|
+
cleanup();
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
register<R extends keyof Registers>(type: R, ...args: Parameters<Registers[R]["add"]>) {
|
|
143
|
+
this.ledger.set(args[0], type);
|
|
144
|
+
// @ts-ignore
|
|
145
|
+
registers[type].add(...args);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Routes are registered as elements with a string "route" tag; this
|
|
149
|
+
// hides that incantation. Returns the item for manual unregister.
|
|
150
|
+
registerRoute(path: string, element: React.ReactNode): React.ReactNode {
|
|
151
|
+
const item = React.createElement("route", { path, element });
|
|
152
|
+
this.register("route", item);
|
|
153
|
+
return item;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
unregister<R extends keyof Registers>(type: R, ...args: Parameters<Registers[R]["delete"]>) {
|
|
157
|
+
this.ledger.delete(args[0]);
|
|
158
|
+
// @ts-ignore
|
|
159
|
+
registers[type].delete(...args);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
dispose() {
|
|
163
|
+
for (const [item, type] of this.ledger.entries()) this.unregister(type, item);
|
|
164
|
+
this.ledger.clear();
|
|
165
|
+
for (const dispose of this.disposers) dispose();
|
|
166
|
+
this.disposers.clear();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export const createRegistrar = (ctx: ModuleRuntimeContext) => {
|
|
171
|
+
const registrar = new Registrar(ctx.identifier);
|
|
172
|
+
ctx.defer(() => {
|
|
173
|
+
registrar.dispose();
|
|
174
|
+
});
|
|
175
|
+
return registrar;
|
|
176
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2024 Delusoire
|
|
3
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { React } from "../expose/React.ts";
|
|
7
|
+
|
|
8
|
+
import { matchLast } from "../util.ts";
|
|
9
|
+
|
|
10
|
+
import { warn } from "../logger.ts";
|
|
11
|
+
import { transformer } from "../../mixin.ts";
|
|
12
|
+
import { Registry } from "./registry.ts";
|
|
13
|
+
|
|
14
|
+
type __MenuContext = React.Context<MenuContext>;
|
|
15
|
+
|
|
16
|
+
declare global {
|
|
17
|
+
var __MenuContext: __MenuContext;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type MenuContext = {
|
|
21
|
+
props: any;
|
|
22
|
+
trigger: string;
|
|
23
|
+
target: HTMLElement;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const items = new Registry<React.ReactNode>();
|
|
27
|
+
export default items;
|
|
28
|
+
|
|
29
|
+
export const useMenuItem = () => React.useContext(globalThis.__MenuContext);
|
|
30
|
+
|
|
31
|
+
declare global {
|
|
32
|
+
var __renderMenuItems: any;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
globalThis.__renderMenuItems = () => items.all();
|
|
36
|
+
transformer(
|
|
37
|
+
(emit) => (str) => {
|
|
38
|
+
emit();
|
|
39
|
+
|
|
40
|
+
str = str.replace(/("Menu".+?children:)([a-zA-Z_\$][\w\$]*)/, "$1[__renderMenuItems(),$2].flat()");
|
|
41
|
+
|
|
42
|
+
const croppedInput = str.match(/.*value:"contextmenu"/)![0];
|
|
43
|
+
const react = matchLast(croppedInput, /([a-zA-Z_\$][\w\$]*)\.useRef/g)[1];
|
|
44
|
+
|
|
45
|
+
const [, menu, trigger, target] =
|
|
46
|
+
matchLast(
|
|
47
|
+
croppedInput,
|
|
48
|
+
/\(\{[^}]*menu:([a-zA-Z_\$][\w\$]*),[^}]*trigger:([a-zA-Z_\$][\w\$]*),[^}]*triggerRef:([a-zA-Z_\$][\w\$]*)/g,
|
|
49
|
+
) ?? [];
|
|
50
|
+
|
|
51
|
+
let value: string;
|
|
52
|
+
if (menu && trigger && target) {
|
|
53
|
+
value = `{props:${menu}?.props,trigger:${trigger},target:${target}}`;
|
|
54
|
+
} else {
|
|
55
|
+
value = `{props:e.menu?.props,trigger:e.trigger,target:e.triggerRef}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
str = str.replace(
|
|
59
|
+
/render:(.{0,100}?\(0,([a-zA-Z_\$][\w\$]*)\.jsx\)\([a-zA-Z_\$][\w\$]*\.[a-zA-Z_\$][\w\$]*,\{value:"contextmenu",[^\}]+\}[^,]+),/,
|
|
60
|
+
`render:(props)=>{const value=${value};return ($2.jsx)((globalThis.__MenuContext??=${react}.createContext(null)).Provider,{value,children:($1)(props)});},`,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
return str;
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
glob: /^\/xpui\.js/,
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// True when the open menu came from the profile (avatar) button. 1.2.94
|
|
71
|
+
// dropped the user-widget-link testid; the account image inside the
|
|
72
|
+
// trigger button is the durable discriminator, with the old testid kept
|
|
73
|
+
// as a fallback for older clients.
|
|
74
|
+
export const openedFromProfileMenu = (ctx: Pick<MenuContext, "trigger" | "target"> | null): boolean => {
|
|
75
|
+
if (!ctx || ctx.trigger !== "click") return false;
|
|
76
|
+
const button = ctx.target?.closest?.("button");
|
|
77
|
+
if (!button) return false;
|
|
78
|
+
return !!button.querySelector("img, figure") || button.getAttribute("data-testid") === "user-widget-link";
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const createProfileMenuShouldAdd = () => (ctx: MenuContext) => openedFromProfileMenu(ctx);
|
|
82
|
+
|
|
83
|
+
// Foreign menu items have no access to the client's dismissal context;
|
|
84
|
+
// the tippy portal only closes on an outside press. Call after handling
|
|
85
|
+
// a click. Deferred so the item's own handler finishes first.
|
|
86
|
+
export const closeMenu = (): void => {
|
|
87
|
+
setTimeout(() => {
|
|
88
|
+
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
|
89
|
+
}, 0);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Transform-free path: every context menu renders inside the #context-menu
|
|
93
|
+
// tippy portal; registered items are appended to the menu list when one
|
|
94
|
+
// opens. useMenuItem() carries the trigger and target captured from the
|
|
95
|
+
// opening event — the menu component's own props are not reachable from
|
|
96
|
+
// outside the client render pipeline and stay null.
|
|
97
|
+
if (
|
|
98
|
+
!(globalThis as never as Record<string, unknown>).__SPICETIFY_APPLY_TRANSFORMS__ &&
|
|
99
|
+
typeof document !== "undefined"
|
|
100
|
+
) {
|
|
101
|
+
void CHUNKS.xpui.promise.then(async () => {
|
|
102
|
+
const { ReactDOM } = await import("../expose/React.js");
|
|
103
|
+
const { createItemBoundary } = await import("./mount.js");
|
|
104
|
+
const R = React as any;
|
|
105
|
+
const createRoot = (ReactDOM as any).createRoot;
|
|
106
|
+
if (typeof createRoot !== "function") {
|
|
107
|
+
warn("[stdlib] cannot mount menu items (no createRoot)");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let last: Omit<MenuContext, "props"> = { trigger: "", target: document.body } as never;
|
|
112
|
+
document.addEventListener(
|
|
113
|
+
"contextmenu",
|
|
114
|
+
(e) => {
|
|
115
|
+
last = { trigger: "right-click", target: e.target as HTMLElement };
|
|
116
|
+
},
|
|
117
|
+
true,
|
|
118
|
+
);
|
|
119
|
+
document.addEventListener(
|
|
120
|
+
"mousedown",
|
|
121
|
+
(e) => {
|
|
122
|
+
last = { trigger: "click", target: e.target as HTMLElement };
|
|
123
|
+
},
|
|
124
|
+
true,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const ItemBoundary = createItemBoundary(R, "menu");
|
|
128
|
+
const live: Array<{ menu: Element; root: { unmount: () => void } }> = [];
|
|
129
|
+
const processed = new WeakSet<Element>();
|
|
130
|
+
|
|
131
|
+
const sweep = () => {
|
|
132
|
+
for (let i = live.length - 1; i >= 0; i--) {
|
|
133
|
+
if (!live[i].menu.isConnected) {
|
|
134
|
+
live[i].root.unmount();
|
|
135
|
+
live.splice(i, 1);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (items.size === 0) return;
|
|
139
|
+
for (const menu of document.querySelectorAll("#context-menu [role=menu]")) {
|
|
140
|
+
if (processed.has(menu)) continue;
|
|
141
|
+
processed.add(menu);
|
|
142
|
+
const host = document.createElement("li");
|
|
143
|
+
host.setAttribute("role", "presentation");
|
|
144
|
+
host.className = "spicetify-menu-items";
|
|
145
|
+
menu.appendChild(host);
|
|
146
|
+
const root = createRoot(host);
|
|
147
|
+
globalThis.__MenuContext ??= R.createContext(null);
|
|
148
|
+
root.render(
|
|
149
|
+
R.createElement(
|
|
150
|
+
globalThis.__MenuContext.Provider,
|
|
151
|
+
{ value: { props: null, ...last } },
|
|
152
|
+
...items.all().map((item, i) => R.createElement(ItemBoundary, { key: i }, item)),
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
live.push({ menu, root });
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
new MutationObserver(sweep).observe(document.body, { childList: true, subtree: true });
|
|
159
|
+
});
|
|
160
|
+
}
|