diff --git a/app/ui/features/wheel/hub.test.js b/app/ui/features/wheel/hub.test.js new file mode 100644 index 00000000..d87a6d61 --- /dev/null +++ b/app/ui/features/wheel/hub.test.js @@ -0,0 +1,55 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createDom } from "../../shell/dom.js"; +import { setupDom, teardownDom } from "../../composer/tests/_dom.js"; + +let hub, readPrefs, createProject; +test.before(async () => { + setupDom(); + ({ createProject } = await import("../../shell/project.js")); + ({ default: hub, readPrefs } = await import("./index.js")); +}); +test.after(() => teardownDom()); + +/** A server holding two projects, as the backend does: whatever was PUT comes back on the next GET. */ +function server() { + const store = { + aaaaaaaaaaaa: { id: "aaaaaaaaaaaa", name: "A", scenes: [], updated_at: 1 }, + bbbbbbbbbbbb: { id: "bbbbbbbbbbbb", name: "B", scenes: [], updated_at: 1 }, + }; + globalThis.fetch = async (path, opts = {}) => { + if (path === "/funpack/api/projects" && !opts.method) return { ok: true, status: 200, json: async () => ({ projects: Object.values(store).map((p) => ({ id: p.id })) }) }; + const id = decodeURIComponent(String(path).split("/").pop()); + if (opts.method === "PUT") { const body = JSON.parse(opts.body); store[id] = { ...body, updated_at: 2 }; return { ok: true, status: 200, json: async () => store[id] }; } + return { ok: true, status: 200, json: async () => JSON.parse(JSON.stringify(store[id])) }; + }; +} + +test("the wheel's settings are saved with the project and come back on a fresh browser, per project", async () => { + server(); + localStorage.clear(); + const project = createProject({}); + await project.start(); // project A + const app = { dom: createDom(), project, actions: [], editorSettings: [] }; + const off = hub.setup({ host: document.createElement("div"), app }); + try { + // change it through the real settings panel + const panel = app.editorSettings[0](); + document.body.append(panel); + const half = [...panel.querySelectorAll("button,[role=radio]")].find((b) => b.textContent.trim() === "Half wheel"); + const left = [...panel.querySelectorAll("button,[role=radio]")].find((b) => b.textContent.trim() === "Left"); + half.click(); left.click(); + await project.flush(); + assert.deepEqual(readPrefs(app), { shape: "half", side: "left", enabled: null }); + + // a rental with an empty browser: same project, no localStorage + localStorage.clear(); + const fresh = createProject({}); + await fresh.start(); + assert.deepEqual(readPrefs({ project: fresh }), { shape: "half", side: "left", enabled: null }, "the choice did not travel with the project"); + + // another project keeps its own choice + await fresh.open("bbbbbbbbbbbb"); + assert.deepEqual(readPrefs({ project: fresh }), { shape: "full", side: "right", enabled: null }); + } finally { off(); } +}); diff --git a/app/ui/features/wheel/index.js b/app/ui/features/wheel/index.js index 1cf60a56..9cd65a6c 100644 --- a/app/ui/features/wheel/index.js +++ b/app/ui/features/wheel/index.js @@ -2,13 +2,18 @@ import { composer as c } from "../../composer/composer.js"; import { offer } from "../../shell/actions.js"; -// Per browser, not per project: the wheel is a habit of the hand. enabled null = everything offered except the opt-in actions. +// Saved with the project (a new rental has an empty browser); the browser copy is only the starting point for a project that has no choice yet. enabled null = everything offered except the opt-in actions. const KEY = "funpack_wheel"; -export function readPrefs() { - try { const p = JSON.parse(localStorage.getItem(KEY)) || {}; return { shape: p.shape === "half" ? "half" : "full", side: p.side === "left" ? "left" : "right", enabled: Array.isArray(p.enabled) ? p.enabled : null }; } - catch { return { shape: "full", side: "right", enabled: null }; } +const clean = (p) => ({ shape: p.shape === "half" ? "half" : "full", side: p.side === "left" ? "left" : "right", enabled: Array.isArray(p.enabled) ? p.enabled : null }); +const fromBrowser = () => { try { return JSON.parse(localStorage.getItem(KEY)) || {}; } catch { return {}; } }; +export function readPrefs(app) { + const saved = app && app.project && app.project.pref ? app.project.pref("wheel", null) : null; + return clean(saved && typeof saved === "object" ? saved : fromBrowser()); +} +function writePrefs(app, p) { + if (app.project && app.project.setPref) app.project.setPref("wheel", p); + try { localStorage.setItem(KEY, JSON.stringify(p)); } catch { /* private window: lasts until reload */ } } -const writePrefs = (p) => { try { localStorage.setItem(KEY, JSON.stringify(p)); } catch { /* private window: lasts until reload */ } }; export default { id: "wheel", @@ -35,7 +40,7 @@ export default { const onDown = (e) => { if (e.button !== 1 || wheel || app.dom.dialogOpen()) return; // nothing behind a dialog e.preventDefault(); - const prefs = readPrefs(), on = (a) => prefs.enabled ? prefs.enabled.includes(a.id) : !a.optIn; + const prefs = readPrefs(app), on = (a) => prefs.enabled ? prefs.enabled.includes(a.id) : !a.optIn; const items = (app.actions || []).filter((a) => on(a) && (!a.when || a.when())).slice(0, 12).map((a) => ({ id: a.id, label: a.label, icon: a.icon })); if (!items.length) return; const shared = { items, onClose: () => { wheel = null; }, @@ -43,7 +48,7 @@ export default { wheel = prefs.shape === "half" ? c.wheel.half({ ...shared, edge: prefs.side, at: e.clientY }) : c.wheel.picker({ ...shared, x: e.clientX, y: e.clientY }); }; app.editorSettings.push(() => { - const prefs = readPrefs(), set = (patch) => writePrefs({ ...readPrefs(), ...patch }); + const prefs = readPrefs(app), set = (patch) => writePrefs(app, { ...readPrefs(app), ...patch }); const offered = (app.actions || []).map((a) => ({ value: a.id, label: `${a.icon || ""} ${a.label}`.trim() })); return c.region.stack({ gap: "sm", children: [c.label.section({ text: "Picker wheel" }), c.hint.default({ text: "Hold the middle mouse button anywhere, flick toward an action and let go." }),