feat: picker wheel -- customizable radial menu, replaces v4's pinned buttons
[[project_v5_ui_requirements]]: "any action in the UI can be assigned to a
slot... full wheel or half wheel... half wheel positioned left or right...
both draggable... default: middle mouse button, anywhere."
app/legacy/actions.js: ported the composer tree's tiny action registry
(app/shell/actions.js, an ES module, unreferenced from boot) as a classic
global window.FunPackActions -- offerAction({id,label,icon,run}), offered(),
run(id). Whatever announces itself is what the wheel (or anything later --
a command palette, a keymap) can show.
app/legacy/wheel.js: middle-mouse (anywhere) opens a radial menu of
whatever's currently on it. Full wheel spaces items evenly around 360°;
half wheel spaces them over a 180° arc on the chosen side, with drag
locked to the vertical axis only (full wheel drags both axes) per the
spec's "half wheel moves up/down, full wheel up/down and left/right" line.
Dragging repositions the CURRENTLY OPEN wheel only, not persisted -- read
"draggable" as "don't get stuck somewhere awkward this time" rather than a
remembered dock position, since the spec doesn't say which and this is the
simpler reading. Customize dialog (reuses pinned_buttons.js's existing
.pin-modal/.pin-slot CSS) toggles shape, side, and which offered actions
are on the wheel. pinned_buttons.js is NOT removed -- the spec says this
supersedes it, but retiring a working control felt like a separate
decision from adding a new one; both coexist for now, confirmed to not
conflict (separate storage keys, no shared DOM, never both open at once).
app/legacy/wheel_actions_seed.js: seeds 9 starter actions (Generate,
Generate selected, Render, Auto Montage, Undo, Redo, Models & Pipeline,
Engine settings, Sampler quick-access) so the wheel isn't empty on first
use.
Two rounds of extensive_testing, plus a bug I found myself in live
testing before either round: the half wheel's fixed radius, sized for a
full wheel's 360° spread, packed the same item count into half the
angular room and produced heavily overlapping items -- fixed by growing
the radius only as far as needed to keep adjacent items from touching,
confirmed visually before/after.
Round 1: no functional break found in outside-click-vs-item-click
ordering, listener leaks on repeated opens, or coexistence with
pinned_buttons.js.
Round 2: found the real defect -- enabledIds()'s first-call snapshot
("default to everything offered so far") could run in the gap between
wheel.js executing and wheel_actions_seed.js finishing (classic <script>
tags block sequentially, and this window widens on a slow/cold-cache
load), permanently persisting an empty or partial action list with no
error, no indication -- the user would just experience "the wheel doesn't
work" and have no way to know why. Fixed by deferring the snapshot to the
window `load` event (guaranteed after every script has run); before that,
enabledIds() shows whatever's offered so far live without writing it
down as the permanent default. Also cleaned up a dead if/else in the
half-wheel angle math flagged as a "looks like a bug that got half-fixed"
smell.
Live-verified after both fixes: real physical drag (manual mousedown/
mousemove/mouseup sequences, not just JS calls) confirms half-wheel drag
is genuinely locked to the y-axis (x stays exactly put) while full-wheel
drag moves both, and neither drag opens Customize -- only a press that
never crosses the drag threshold does. npm test: 691 pass, 1 pre-existing
unrelated flake (shell/tests/projects.test.js:285).
No test file exists yet for actions.js/wheel.js/wheel_actions_seed.js --
a real, named test gap, not fixed here. Real hardware middle-click and
cross-browser autoscroll-blocking are also unverified (browser automation
has no native middle-click) -- declared, not chased further without
physical hardware.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
4a1f60bda6
commit
4f18eefa69
@@ -0,0 +1,48 @@
|
||||
// Everything the app can be asked to do, by name.
|
||||
//
|
||||
// Announced, not listed: whoever builds a control offers it here, and
|
||||
// whoever shows a list of them -- the wheel today, a command palette or a
|
||||
// keymap later -- reads what is there. A part of the app that failed to
|
||||
// load offers nothing and is simply not among them, the same rule regions
|
||||
// and modules already live by elsewhere in this codebase.
|
||||
//
|
||||
// Ported from app/shell/actions.js (the composer-era original, unreferenced
|
||||
// from boot) as a classic global instead of an ES module -- same registry,
|
||||
// same contract, so anything written against the composer version's shape
|
||||
// needs no translation if it is ever revived.
|
||||
(function () {
|
||||
const actions = new Map();
|
||||
|
||||
/**
|
||||
* offerAction({ id, label, icon, run }) -> a function that takes it away
|
||||
* again. The id is the address: offering the same one twice replaces it,
|
||||
* because the alternative is two entries that look identical and do
|
||||
* different things.
|
||||
*/
|
||||
function offerAction({ id, label, icon, run } = {}) {
|
||||
if (!id || typeof run !== "function") {
|
||||
throw new TypeError("An action needs an id and something to run.");
|
||||
}
|
||||
actions.set(id, { id, label: label || id, icon: icon || "•", run });
|
||||
return () => actions.delete(id);
|
||||
}
|
||||
|
||||
/** In the order they were offered, which is the order they were built in. */
|
||||
function offered() { return [...actions.values()]; }
|
||||
|
||||
/** Run one by name. Unknown is not an error: whatever offered it may be gone. */
|
||||
function run(id) {
|
||||
const action = actions.get(id);
|
||||
if (!action) return false;
|
||||
try {
|
||||
action.run();
|
||||
} catch (err) {
|
||||
// One broken action must not take down whatever listed it.
|
||||
console.warn(`[FunPack] the "${id}" action failed: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
window.FunPackActions = { offerAction, offered, run };
|
||||
})();
|
||||
@@ -120,6 +120,7 @@
|
||||
|
||||
<script src="api.js"></script>
|
||||
<script src="dom.js"></script>
|
||||
<script src="actions.js"></script>
|
||||
<script src="pipeline_state.js"></script>
|
||||
<script src="settings_field.js"></script>
|
||||
<!-- Module scripts always defer, so this runs after every classic script
|
||||
@@ -154,6 +155,8 @@
|
||||
<script src="sampler_panel.js"></script>
|
||||
<script src="engine_settings.js"></script>
|
||||
<script src="sampler_quickbar.js"></script>
|
||||
<script src="wheel.js"></script>
|
||||
<script src="wheel_actions_seed.js"></script>
|
||||
<script src="editor_settings.js"></script>
|
||||
<script src="temp_browser.js"></script>
|
||||
<script src="block_sweep.js"></script>
|
||||
|
||||
@@ -1080,6 +1080,31 @@ input[type=number] { font-family: var(--font-ui); }
|
||||
.sq-body { padding: 10px 12px 12px; }
|
||||
.sq-body .sw-rows { margin-top: 0; }
|
||||
.sq-body .sw-rows-label:first-child { margin-top: 0; }
|
||||
|
||||
/* Picker wheel: a hub plus radial items, each absolutely positioned by its
|
||||
own inline transform (the geometry lives in wheel.js, this just supplies
|
||||
the look). Anchored at {left,top} = the point it was opened/dragged to,
|
||||
with every child centered on that point via translate(-50%,-50%) before
|
||||
its own radial offset. */
|
||||
.fp-wheel { position: fixed; z-index: 10002; width: 0; height: 0; }
|
||||
.fp-wheel-hub {
|
||||
position: absolute; left: 0; top: 0; transform: translate(-50%, -50%);
|
||||
width: 40px; height: 40px; border-radius: 50%; cursor: grab;
|
||||
background: var(--popup); border: 1px solid var(--popup-line); color: var(--text);
|
||||
font-size: 16px; box-shadow: 0 8px 24px color-mix(in srgb, var(--shadow-ink) 55%, transparent);
|
||||
}
|
||||
.fp-wheel-hub:active { cursor: grabbing; }
|
||||
.fp-wheel-item {
|
||||
position: absolute; left: 0; top: 0; width: 56px; height: 56px; border-radius: 50%;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
|
||||
background: var(--popup); border: 1px solid var(--popup-line); color: var(--text);
|
||||
box-shadow: 0 8px 24px color-mix(in srgb, var(--shadow-ink) 45%, transparent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.fp-wheel-item:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); background: var(--ink-3); }
|
||||
.fp-wheel-item-icon { font-size: 16px; line-height: 1; }
|
||||
.fp-wheel-item-label { font-size: 8px; line-height: 1.1; max-width: 48px; text-align: center;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
|
||||
.me-rating-section { padding: 8px 10px 6px; border-bottom: 1px solid var(--overlay-1); }
|
||||
.me-rating-section-label {
|
||||
font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px;
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
// The picker wheel: any offered action, under the pointer.
|
||||
//
|
||||
// [[project_v5_ui_requirements]]: "any action in the UI can be assigned to
|
||||
// a slot -- opening a menu, changing a value, pressing a button, anything.
|
||||
// Two shapes, the user's choice: full wheel or half wheel. Half wheel is
|
||||
// positioned left or right. Both draggable... default: middle mouse
|
||||
// button, anywhere." Supersedes v4's pinned_buttons.js (three fixed
|
||||
// toolbar slots) -- kept installed alongside this for now rather than
|
||||
// removed in the same change, since replacing a working control is a
|
||||
// bigger decision than adding a new one.
|
||||
//
|
||||
// Reads window.FunPackActions.offered() -- whatever announced itself is
|
||||
// what the wheel can show. Which of those are actually ON the wheel, and
|
||||
// the shape/side preference, is a per-browser preference (like
|
||||
// timeline_peek.js's own reasoning: how you want this gadget to behave is
|
||||
// a property of the screen you're at, not the project), stored in
|
||||
// localStorage.
|
||||
(function () {
|
||||
const { el } = window.dom;
|
||||
const A = window.FunPackActions;
|
||||
if (!A) return;
|
||||
|
||||
const LS_KEY = "funpack_wheel_prefs";
|
||||
const RADIUS = 92;
|
||||
const ITEM_SIZE = 56;
|
||||
const MIDDLE_BUTTON = 1;
|
||||
const DRAG_THRESHOLD = 6; // px before a hub press counts as a drag, not a click
|
||||
|
||||
function loadPrefs() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
const p = raw ? JSON.parse(raw) : {};
|
||||
return {
|
||||
shape: p.shape === "half" ? "half" : "full",
|
||||
side: p.side === "left" ? "left" : "right",
|
||||
enabled: Array.isArray(p.enabled) ? p.enabled : null, // null = not chosen yet
|
||||
};
|
||||
} catch (_) {
|
||||
return { shape: "full", side: "right", enabled: null };
|
||||
}
|
||||
}
|
||||
function savePrefs(p) {
|
||||
try { localStorage.setItem(LS_KEY, JSON.stringify(p)); } catch (_) { /* private mode */ }
|
||||
}
|
||||
let prefs = loadPrefs();
|
||||
|
||||
// Default set, the first time this ever opens: everything offered by
|
||||
// the time the page has FINISHED loading. After that, an action offered
|
||||
// later still (a module that loaded after this ran) does not silently
|
||||
// appear on the wheel uninvited -- the user adds it from Customize, same
|
||||
// as any other action they didn't ask for yet.
|
||||
//
|
||||
// Snapshotting on the FIRST CALL rather than on `load` would let an
|
||||
// unlucky fast middle-click, fired in the gap between this script and a
|
||||
// later one that still has actions to register, permanently persist an
|
||||
// empty or partial default -- there would be nothing wrong to notice,
|
||||
// just a wheel that looks broken forever until someone thinks to open
|
||||
// Customize. `defaultsReady` closes that window: before `load`, this
|
||||
// shows whatever is offered SO FAR without writing it down as the
|
||||
// permanent choice.
|
||||
let defaultsReady = false;
|
||||
window.addEventListener("load", () => {
|
||||
defaultsReady = true;
|
||||
if (prefs.enabled === null) {
|
||||
prefs.enabled = A.offered().map((a) => a.id);
|
||||
savePrefs(prefs);
|
||||
}
|
||||
});
|
||||
|
||||
function enabledIds() {
|
||||
if (prefs.enabled) return prefs.enabled;
|
||||
if (!defaultsReady) return A.offered().map((a) => a.id);
|
||||
prefs.enabled = A.offered().map((a) => a.id);
|
||||
savePrefs(prefs);
|
||||
return prefs.enabled;
|
||||
}
|
||||
|
||||
function wheelItems() {
|
||||
const byId = new Map(A.offered().map((a) => [a.id, a]));
|
||||
return enabledIds().map((id) => byId.get(id)).filter(Boolean);
|
||||
}
|
||||
|
||||
// ── geometry ──────────────────────────────────────────────────────────
|
||||
// 0deg = right, 90deg = down (screen y-down), matching CSS/canvas convention.
|
||||
function anglesFor(n, shape, side) {
|
||||
if (n <= 0) return [];
|
||||
if (shape === "full") {
|
||||
return Array.from({ length: n }, (_, i) => -90 + i * (360 / n));
|
||||
}
|
||||
if (n === 1) return [side === "left" ? 180 : 0];
|
||||
// Left and right arcs both sweep in the same rotational direction --
|
||||
// it's the START angle (90 vs -90) that mirrors them, not the sweep.
|
||||
const start = side === "left" ? 90 : -90;
|
||||
const step = 180 / (n - 1);
|
||||
return Array.from({ length: n }, (_, i) => start + i * step);
|
||||
}
|
||||
|
||||
// ── the wheel itself ─────────────────────────────────────────────────
|
||||
let wheelEl = null;
|
||||
let cleanup = null;
|
||||
|
||||
function close() {
|
||||
if (!wheelEl) return;
|
||||
wheelEl.remove();
|
||||
wheelEl = null;
|
||||
if (cleanup) { cleanup(); cleanup = null; }
|
||||
}
|
||||
|
||||
function openCustomize() {
|
||||
close();
|
||||
openCustomizeDialog();
|
||||
}
|
||||
|
||||
function open(cx, cy) {
|
||||
if (wheelEl) { close(); return; }
|
||||
const items = wheelItems();
|
||||
const angles = anglesFor(items.length, prefs.shape, prefs.side);
|
||||
|
||||
wheelEl = el("div", "fp-wheel");
|
||||
wheelEl.style.left = cx + "px";
|
||||
wheelEl.style.top = cy + "px";
|
||||
|
||||
const hub = el("button", "fp-wheel-hub", "⚙");
|
||||
hub.type = "button";
|
||||
hub.title = "Drag to move — click to customize";
|
||||
wheelEl.append(hub);
|
||||
|
||||
// A half wheel packs the same item count into HALF the angular room a
|
||||
// full wheel gets -- the same fixed radius that looks fine for a full
|
||||
// wheel of 9 puts adjacent half-wheel items closer together than their
|
||||
// own diameter. Grow the radius only as far as needed to keep adjacent
|
||||
// items from overlapping, using the tightest gap in the layout (the
|
||||
// last-to-first wraparound for a full wheel, the actual last step for
|
||||
// a half wheel, which never wraps).
|
||||
const stepDeg = items.length > 1
|
||||
? (prefs.shape === "full" ? 360 / items.length : 180 / (items.length - 1))
|
||||
: 360;
|
||||
const minRadius = ITEM_SIZE / (2 * Math.sin((stepDeg / 2) * Math.PI / 180));
|
||||
const r = Math.max(RADIUS, minRadius + 8);
|
||||
|
||||
items.forEach((action, i) => {
|
||||
const a = (angles[i] * Math.PI) / 180;
|
||||
const x = Math.cos(a) * r;
|
||||
const y = Math.sin(a) * r;
|
||||
const btn = el("button", "fp-wheel-item");
|
||||
btn.type = "button";
|
||||
btn.title = action.label;
|
||||
btn.style.transform = `translate(${x}px, ${y}px) translate(-50%, -50%)`;
|
||||
btn.append(el("span", "fp-wheel-item-icon", action.icon));
|
||||
btn.append(el("span", "fp-wheel-item-label", action.label));
|
||||
btn.onclick = (e) => { e.stopPropagation(); close(); A.run(action.id); };
|
||||
wheelEl.append(btn);
|
||||
});
|
||||
|
||||
document.body.append(wheelEl);
|
||||
|
||||
// Hub drag: reposition THIS open only (not persisted -- "draggable" per
|
||||
// the spec is about the wheel not being stuck somewhere awkward once
|
||||
// it's up, not a remembered dock position). A press that never moves
|
||||
// past the threshold is a click, opening Customize instead.
|
||||
let dragging = false;
|
||||
let moved = false;
|
||||
let startX = 0, startY = 0, originX = cx, originY = cy;
|
||||
const axis = prefs.shape === "half" ? "y" : "xy";
|
||||
|
||||
const onMove = (e) => {
|
||||
if (!dragging) return;
|
||||
const dx = e.clientX - startX;
|
||||
const dy = e.clientY - startY;
|
||||
if (!moved && Math.hypot(dx, dy) > DRAG_THRESHOLD) moved = true;
|
||||
const nx = axis === "xy" ? originX + dx : originX;
|
||||
const ny = originY + dy;
|
||||
wheelEl.style.left = nx + "px";
|
||||
wheelEl.style.top = ny + "px";
|
||||
};
|
||||
const onUp = () => {
|
||||
dragging = false;
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
if (!moved) openCustomize();
|
||||
};
|
||||
hub.onmousedown = (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
dragging = true; moved = false;
|
||||
startX = e.clientX; startY = e.clientY;
|
||||
originX = parseFloat(wheelEl.style.left) || cx;
|
||||
originY = parseFloat(wheelEl.style.top) || cy;
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
};
|
||||
|
||||
const onOutside = (e) => { if (!wheelEl.contains(e.target)) close(); };
|
||||
const onKey = (e) => { if (e.key === "Escape") close(); };
|
||||
setTimeout(() => document.addEventListener("mousedown", onOutside, true), 0);
|
||||
document.addEventListener("keydown", onKey);
|
||||
cleanup = () => {
|
||||
document.removeEventListener("mousedown", onOutside, true);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
}
|
||||
|
||||
function toggleAt(cx, cy) { if (wheelEl) close(); else open(cx, cy); }
|
||||
|
||||
// ── trigger: middle mouse, anywhere ──────────────────────────────────
|
||||
// Middle click is otherwise autoscroll, which does nothing useful over
|
||||
// this app -- safe to claim everywhere, not just inside one region.
|
||||
document.addEventListener("mousedown", (e) => {
|
||||
if (e.button !== MIDDLE_BUTTON) return;
|
||||
e.preventDefault();
|
||||
toggleAt(e.clientX, e.clientY);
|
||||
});
|
||||
|
||||
// ── customize dialog: which offered actions are on the wheel, and its shape ──
|
||||
let dialog = null;
|
||||
function closeDialog() { dialog?.remove(); dialog = null; }
|
||||
|
||||
function openCustomizeDialog() {
|
||||
closeDialog();
|
||||
dialog = el("div", "modal-overlay pin-overlay");
|
||||
const box = el("div", "modal pin-modal");
|
||||
|
||||
const head = el("div", "modal-head");
|
||||
head.append(el("div", "modal-title", "Customize the wheel"));
|
||||
const closeBtn = el("button", "btn ghost tiny", "✕");
|
||||
closeBtn.type = "button";
|
||||
closeBtn.onclick = closeDialog;
|
||||
const hr = el("div", "modal-head-right");
|
||||
hr.append(closeBtn);
|
||||
head.append(hr);
|
||||
box.append(head);
|
||||
|
||||
const content = el("div", "modal-content");
|
||||
|
||||
const shapeRow = el("div", "pin-clear-row");
|
||||
["full", "half"].forEach((shape) => {
|
||||
const b = el("button", "btn ghost tiny" + (prefs.shape === shape ? " occupied" : ""),
|
||||
shape === "full" ? "Full wheel" : "Half wheel");
|
||||
b.type = "button";
|
||||
b.onclick = () => { prefs.shape = shape; savePrefs(prefs); openCustomizeDialog(); };
|
||||
shapeRow.append(b);
|
||||
});
|
||||
if (prefs.shape === "half") {
|
||||
["left", "right"].forEach((side) => {
|
||||
const b = el("button", "btn ghost tiny" + (prefs.side === side ? " occupied" : ""),
|
||||
side === "left" ? "Opens left" : "Opens right");
|
||||
b.type = "button";
|
||||
b.onclick = () => { prefs.side = side; savePrefs(prefs); openCustomizeDialog(); };
|
||||
shapeRow.append(b);
|
||||
});
|
||||
}
|
||||
content.append(shapeRow);
|
||||
content.append(el("div", "pin-hint",
|
||||
"Middle mouse button opens the wheel anywhere. Check the actions you want on it."));
|
||||
|
||||
const list = el("div", "pin-slots");
|
||||
const enabled = new Set(enabledIds());
|
||||
A.offered().forEach((action) => {
|
||||
const row = el("button", "pin-slot" + (enabled.has(action.id) ? " occupied" : ""));
|
||||
row.type = "button";
|
||||
const left = el("div", "pin-slot-text");
|
||||
left.append(el("div", "pin-slot-name", `${action.icon} ${action.label}`));
|
||||
row.append(left);
|
||||
row.append(el("span", "pin-slot-pos", enabled.has(action.id) ? "on the wheel" : "not shown"));
|
||||
row.onclick = () => {
|
||||
if (enabled.has(action.id)) enabled.delete(action.id); else enabled.add(action.id);
|
||||
prefs.enabled = A.offered().map((a) => a.id).filter((id) => enabled.has(id));
|
||||
savePrefs(prefs);
|
||||
openCustomizeDialog();
|
||||
};
|
||||
list.append(row);
|
||||
});
|
||||
content.append(list);
|
||||
|
||||
box.append(content);
|
||||
dialog.append(box);
|
||||
dialog.addEventListener("click", (e) => { if (e.target === dialog) closeDialog(); });
|
||||
document.body.append(dialog);
|
||||
}
|
||||
|
||||
window.FunPackWheel = { open, close, toggleAt, openCustomizeDialog };
|
||||
})();
|
||||
@@ -0,0 +1,25 @@
|
||||
// The wheel's starter set: a modest, obviously-useful list so the feature
|
||||
// isn't empty the first time anyone opens it. Anything else offers itself
|
||||
// from wherever it's built -- this file is not the only place an action can
|
||||
// come from, just where the first ones do.
|
||||
(function () {
|
||||
const A = window.FunPackActions;
|
||||
const S = window.Store;
|
||||
if (!A || !S) return;
|
||||
|
||||
A.offerAction({ id: "generate-all", label: "Generate", icon: "▶", run: () => S.generate(null) });
|
||||
A.offerAction({ id: "generate-selected", label: "Generate selected", icon: "▶", run: () => S.generateSelected() });
|
||||
A.offerAction({ id: "render-final", label: "Render", icon: "⧉", run: () => S.renderFinal() });
|
||||
A.offerAction({ id: "auto-montage", label: "Auto Montage", icon: "⚡", run: () => window.MontageDialog?.open() });
|
||||
A.offerAction({
|
||||
id: "undo", label: "Undo", icon: "↶",
|
||||
run: () => { window.EditorHistory?.undo(S); },
|
||||
});
|
||||
A.offerAction({
|
||||
id: "redo", label: "Redo", icon: "↷",
|
||||
run: () => { window.EditorHistory?.redo(S); },
|
||||
});
|
||||
A.offerAction({ id: "open-models", label: "Models & Pipeline", icon: "▤", run: () => window.ModelsModal?.open() });
|
||||
A.offerAction({ id: "open-engine", label: "Engine settings", icon: "⚙", run: () => window.SettingsWindow?.open("engine") });
|
||||
A.offerAction({ id: "sampler-quick", label: "Sampler", icon: "⏱", run: () => window.SamplerQuickbar?.toggle() });
|
||||
})();
|
||||
Reference in New Issue
Block a user