tweakit 0.5.2 → 0.6.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/AGENTS.md +31 -29
- package/README.md +55 -29
- package/THIRD-PARTY-NOTICES.md +0 -1
- package/dist/tweaks/auto.js +2 -0
- package/dist/tweaks/auto.js.map +6 -0
- package/dist/tweaks/bezier-JFEZMCLP.js +2 -0
- package/dist/tweaks/bezier-JFEZMCLP.js.map +6 -0
- package/dist/tweaks/chunk-6UO4DHBN.js +2 -0
- package/dist/tweaks/chunk-6UO4DHBN.js.map +6 -0
- package/dist/tweaks/chunk-DIQMLBAS.js +2 -0
- package/dist/tweaks/chunk-DIQMLBAS.js.map +6 -0
- package/dist/tweaks/chunk-XE6OUJBG.js +2 -0
- package/dist/tweaks/chunk-XE6OUJBG.js.map +6 -0
- package/dist/tweaks/clip-WW7JYO4X.js +2 -0
- package/dist/tweaks/clip-WW7JYO4X.js.map +6 -0
- package/dist/tweaks/color-YMMFURP6.js +2 -0
- package/dist/tweaks/color-YMMFURP6.js.map +6 -0
- package/dist/tweaks/core.js +2 -1
- package/dist/tweaks/core.js.map +6 -0
- package/dist/tweaks/{fold-UGN7TXGX.js → fold-U6YRZLCJ.js} +2 -1
- package/dist/tweaks/fold-U6YRZLCJ.js.map +6 -0
- package/dist/tweaks/gradient-JER7PW4D.js +2 -0
- package/dist/tweaks/gradient-JER7PW4D.js.map +6 -0
- package/dist/tweaks/heavy-CLD7UAVI.js +2 -0
- package/dist/tweaks/heavy-CLD7UAVI.js.map +6 -0
- package/dist/tweaks/image-H6QSLGO3.js +4 -0
- package/dist/tweaks/image-H6QSLGO3.js.map +6 -0
- package/dist/tweaks/image-store-WRKY2NPU.js +2 -0
- package/dist/tweaks/image-store-WRKY2NPU.js.map +6 -0
- package/dist/tweaks/interval-TEUHQGZN.js +2 -0
- package/dist/tweaks/interval-TEUHQGZN.js.map +6 -0
- package/dist/tweaks/markup-NIBJD6E2.js +2 -0
- package/dist/tweaks/markup-NIBJD6E2.js.map +6 -0
- package/dist/tweaks/monitor-3BY7YCGX.js +3 -0
- package/dist/tweaks/monitor-3BY7YCGX.js.map +6 -0
- package/dist/tweaks/motion-FQPUBKKM.js +2 -0
- package/dist/tweaks/motion-FQPUBKKM.js.map +6 -0
- package/dist/tweaks/plot-2HBNBZSG.js +3 -0
- package/dist/tweaks/plot-2HBNBZSG.js.map +6 -0
- package/dist/tweaks/point-VV6XV3I6.js +2 -0
- package/dist/tweaks/point-VV6XV3I6.js.map +6 -0
- package/dist/tweaks/shadow-5E4E55I2.js +2 -0
- package/dist/tweaks/shadow-5E4E55I2.js.map +6 -0
- package/dist/tweaks/sides-P3STEVYY.js +2 -0
- package/dist/tweaks/sides-P3STEVYY.js.map +6 -0
- package/dist/tweaks/spring-HUVSXNVN.js +2 -0
- package/dist/tweaks/spring-HUVSXNVN.js.map +6 -0
- package/dist/tweaks/tabs-76FW7LQ7.js +2 -0
- package/dist/tweaks/tabs-76FW7LQ7.js.map +6 -0
- package/dist/tweaks.css +1 -1
- package/dist/tweaks.js +6 -3
- package/dist/tweaks.js.map +6 -0
- package/dist/types/tweaks/easing.d.ts +8 -11
- package/dist/types/tweaks/panel.d.ts +6 -4
- package/dist/types/tweaks/schema.d.ts +4 -8
- package/dist/types/tweaks/standalone.d.ts +3 -3
- package/dist/types/tweaks/types.d.ts +278 -101
- package/package.json +27 -5
- package/src/tweaks/auto.ts +12 -0
- package/src/tweaks/clip.ts +94 -0
- package/src/tweaks/controls/basic.ts +403 -0
- package/src/tweaks/controls/bezier.ts +99 -0
- package/src/tweaks/controls/color.ts +412 -0
- package/src/tweaks/controls/gradient.ts +240 -0
- package/src/tweaks/controls/image.ts +299 -0
- package/src/tweaks/controls/interval.ts +104 -0
- package/src/tweaks/controls/monitor.ts +105 -0
- package/src/tweaks/controls/motion.ts +141 -0
- package/src/tweaks/controls/plot.ts +220 -0
- package/src/tweaks/controls/point.ts +119 -0
- package/src/tweaks/controls/shadow.ts +83 -0
- package/src/tweaks/controls/sides.ts +86 -0
- package/src/tweaks/controls/spring.ts +148 -0
- package/src/tweaks/controls/tabs.ts +47 -0
- package/src/tweaks/core.ts +9 -0
- package/src/tweaks/easing.ts +93 -0
- package/src/tweaks/enhance.ts +20 -0
- package/src/tweaks/feedback.ts +119 -0
- package/src/tweaks/fold.ts +21 -0
- package/src/tweaks/heavy.ts +302 -0
- package/src/tweaks/icons.ts +19 -0
- package/src/tweaks/image-store.ts +101 -0
- package/src/tweaks/lazy.ts +46 -0
- package/src/tweaks/markup.ts +154 -0
- package/src/tweaks/panel.ts +869 -0
- package/src/tweaks/schema.ts +189 -0
- package/src/tweaks/shared.ts +525 -0
- package/src/tweaks/single.ts +22 -0
- package/src/tweaks/standalone.ts +89 -0
- package/src/tweaks/types.ts +526 -0
- package/src/tweaks.css +959 -0
- package/src/wide-gamut.ts +310 -0
- package/dist/tweaks/bezier-2RABC6LD.js +0 -1
- package/dist/tweaks/chunk-4ATI34PB.js +0 -1
- package/dist/tweaks/chunk-7OSCMZ2J.js +0 -1
- package/dist/tweaks/chunk-W3JNIJ2F.js +0 -1
- package/dist/tweaks/clip-ECGCECEC.js +0 -1
- package/dist/tweaks/colour-2RE3FQ5E.js +0 -1
- package/dist/tweaks/gradient-DB6XTDHO.js +0 -1
- package/dist/tweaks/heavy-PKOMHB3C.js +0 -1
- package/dist/tweaks/image-7MCLBOZ3.js +0 -1
- package/dist/tweaks/interval-L2E4L3AZ.js +0 -1
- package/dist/tweaks/markup-ZJJ6CCWM.js +0 -1
- package/dist/tweaks/monitor-IEK742PG.js +0 -2
- package/dist/tweaks/motion-N5UNVR55.js +0 -1
- package/dist/tweaks/plot-VVTOY3RA.js +0 -2
- package/dist/tweaks/point-YMMHSLO7.js +0 -1
- package/dist/tweaks/shadow-IKYOV3VN.js +0 -1
- package/dist/tweaks/sides-FH227Z54.js +0 -1
- package/dist/tweaks/spring-3PKAFIH7.js +0 -1
- package/dist/tweaks/tabs-BKK5NIIY.js +0 -1
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
/* tweaks() — the panel factory: builds the shell + controls from a schema and returns the live
|
|
2
|
+
* API. Persistence, presets, undo, floating drag and the lazy-window queue live here. */
|
|
3
|
+
import {
|
|
4
|
+
el, btn, txt, clamp, popover, closeActivePopover, stopPointerLeak, foldBody,
|
|
5
|
+
applyThemeVars, resolveTheme, carryScheme, onLive, setDisabled, requestReflow, quietFocus,
|
|
6
|
+
getControl, notice, isObj, hasOwn, isReservedKey, valueChanged, restoreDefault,
|
|
7
|
+
} from "./shared.js";
|
|
8
|
+
import { metaFor, VALUELESS } from "./schema.js";
|
|
9
|
+
import { ensureForMetas, scanTypes } from "./lazy.js";
|
|
10
|
+
import { createFolder, createControl } from "./controls/basic.js";
|
|
11
|
+
import { makeCopyBtn, makeResetBtn, toolbarBtn, copyText, spinReset, announce, hideHintNow, addHintMarker, LABEL_SEL } from "./feedback.js";
|
|
12
|
+
import { ICON_PRESETS, ICON_X } from "./icons.js";
|
|
13
|
+
import type { Schema, HeldSchema, TweaksOptions, Panel, Params, ParamsOf, KnownTypes, PanelState, Control } from "./types.js";
|
|
14
|
+
import type { Meta } from "./schema.js";
|
|
15
|
+
import type { ClipCtx, Scope } from "./clip.js";
|
|
16
|
+
import type { PanelEl } from "./shared.js";
|
|
17
|
+
import type { TabsControl } from "./controls/tabs.js";
|
|
18
|
+
|
|
19
|
+
/** A params object or one of its folder/tabs sub-objects — the holder a control writes into. */
|
|
20
|
+
type Target = Record<string, unknown>;
|
|
21
|
+
/** A valued control's wiring: where its value lives, its set/get, the reset pair (raw, def) and its dotted path. */
|
|
22
|
+
export interface Entry { target: Target; key: string; set: Control["set"]; get: Control["get"]; raw: unknown; def: unknown; path: string[]; persist: boolean; m: Meta; el: HTMLElement; note?: Control["note"]; range?: Control["range"] }
|
|
23
|
+
/** A values snapshot — params as JSON round-trips it. */
|
|
24
|
+
export type Snapshot = Record<string, any>;
|
|
25
|
+
|
|
26
|
+
let topFloating: HTMLElement | null = null; // the floating panel raised last (raise())
|
|
27
|
+
const liveKeys = new Map<string, number>(); // persist keys of the panels alive now, for the shared-key warning
|
|
28
|
+
let picked: [HTMLElement, () => void] | null = null; // the panel holding the page's one pick, and how to drop it
|
|
29
|
+
// The page's one hidden field that a picked ⌘C / ⌘V focuses for the keypress, so the browser's own copy and paste run on
|
|
30
|
+
// it: the system clipboard both ways, with no permission to ask. `onCatch` is that keypress's answer.
|
|
31
|
+
let catchEl: HTMLTextAreaElement | null = null, onCatch: ((e: ClipboardEvent) => void) | null = null;
|
|
32
|
+
|
|
33
|
+
/** Build a panel from `schema`; the shell and API exist synchronously, lazy controls fill in on `ready`.
|
|
34
|
+
* A schema written inline (or `as const`) types `params` from its shape. */
|
|
35
|
+
export function tweaks<const S extends HeldSchema>(name: string, schema: S & KnownTypes<S>, opts?: TweaksOptions): Panel<ParamsOf<S>>;
|
|
36
|
+
export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): Panel {
|
|
37
|
+
const metas = Object.entries(schema).map(([k, v]) => metaFor(k, v)).filter(Boolean);
|
|
38
|
+
const params: Params = {};
|
|
39
|
+
let last: string | undefined; // the dotted path the latest change touched (`card.size`), as set() and changes() name it: on()'s second argument
|
|
40
|
+
const entries: Entry[] = []; // flattened across folders, for reset + persist
|
|
41
|
+
const valueless = new Map<string, string>(); // dotted path → type of each button, monitor, separator: set() says it holds no value
|
|
42
|
+
const listeners = new Set<(p: Params, last?: string) => void>();
|
|
43
|
+
const own = new Set<(p: Params, last?: string) => void>(); // undo + conditionals: all a silent write calls
|
|
44
|
+
const cleanups: Array<() => void> = []; // releases for destroy(): document/window listeners, timers
|
|
45
|
+
let destroyed = false; // set by destroy(): assemble() bails, mutators go silent
|
|
46
|
+
let assembled = false; // set at the end of assemble(); no controls exist before
|
|
47
|
+
// The lazy window: a mutating API call made before the controls exist queues here and replays in order once assemble() has built them.
|
|
48
|
+
const queue: Array<() => void> = [];
|
|
49
|
+
// The last preset op queued per name: a loadPreset() behind a save is accepted, one behind a delete refused.
|
|
50
|
+
let failed = false; // the chunks failed: the panel can never be built
|
|
51
|
+
let built = -1; // cleanups.length as assemble() enters build()
|
|
52
|
+
const later = (fn: () => void) => { if (destroyed) return; if (failed) return; assembled ? fn() : queue.push(fn); }; // the failed build already said why
|
|
53
|
+
let liftSlot: HTMLSpanElement | null = null; // the placeholder a lifted panel leaves in its slot
|
|
54
|
+
const carried: Record<string, string> = {}; // what the lift carried from the slot's context (lift()), under the panel's own theme
|
|
55
|
+
// The values snapshot: undefined written as null (JSON would drop the key).
|
|
56
|
+
const replacer = (_k: string, v: unknown) => v === undefined ? null : v;
|
|
57
|
+
// params as the schema shapes it, folders and tab pages included: a value the host parks on params is its own, so
|
|
58
|
+
// snapshots, restores and undo carry only what a control holds. Null-prototype: a folder named "toString" is its own.
|
|
59
|
+
const valuesOf = (ms: Meta[], from: Target): Snapshot => {
|
|
60
|
+
const o: Snapshot = Object.create(null);
|
|
61
|
+
for (const m of ms) if (hasOwn(from, m.key)) { // skipped and valueless rows hold nothing
|
|
62
|
+
const v: unknown = from[m.key];
|
|
63
|
+
if (m.type === "folder") o[m.key] = valuesOf(m.children, v as Target);
|
|
64
|
+
else if (m.type === "tabs") { const t: Snapshot = o[m.key] = Object.create(null); for (const pg of m.pages) if (hasOwn(v as Target, pg.key)) t[pg.key] = valuesOf(pg.children, (v as Target)[pg.key] as Target); }
|
|
65
|
+
else o[m.key] = v;
|
|
66
|
+
}
|
|
67
|
+
return o;
|
|
68
|
+
};
|
|
69
|
+
const snapshot = (): Snapshot => JSON.parse(JSON.stringify(valuesOf(metas, params), replacer));
|
|
70
|
+
// Storage keys — opt-in via opts.persist (a string key, or `true` to key by name).
|
|
71
|
+
const persistKey = opts.persist ? `tw:${opts.persist === true ? name : opts.persist}` : null;
|
|
72
|
+
const presetsKey = persistKey ? `${persistKey}:presets` : null;
|
|
73
|
+
// Two live panels on one key overwrite each other's saves, the last write winning: say so. A remount after destroy() is fine.
|
|
74
|
+
// The key is held until destroy(), or until a failed build leaves the panel unable to save: a remount then is quiet too.
|
|
75
|
+
let holdsKey = !!persistKey;
|
|
76
|
+
if (persistKey) { const n = liveKeys.get(persistKey) || 0; if (n) notice("sharedKey", persistKey.slice(3)); liveKeys.set(persistKey, n + 1); }
|
|
77
|
+
const releaseKey = () => { if (!holdsKey) return; holdsKey = false; const n = (liveKeys.get(persistKey!) || 1) - 1; if (n) liveKeys.set(persistKey!, n); else liveKeys.delete(persistKey!); };
|
|
78
|
+
const readStore = (k: string): any => { try { return JSON.parse(localStorage.getItem(k) || "null"); } catch { return null; } }; // any: a parsed JSON blob, whatever the store holds
|
|
79
|
+
const writeStore = (k: string, v: unknown) => { try { localStorage.setItem(k, JSON.stringify(v)); return true; } catch { return false; } }; // false: storage off, full or denied
|
|
80
|
+
// Saved values and presets carry a format version, { v: 1, values } and { v: 1, presets }, so a later change to what's
|
|
81
|
+
// stored (a color's precision, a gray's hue) can tell old data from new. Anything else (0.5's bare object) reads as
|
|
82
|
+
// nothing, and the panel opens on its defaults.
|
|
83
|
+
const unwrap = (s: any, k: string) => (s?.v === 1 ? s[k] : null);
|
|
84
|
+
const writeValues = (snap: Snapshot) => writeStore(persistKey!, { v: 1, values: snap });
|
|
85
|
+
const writePresets = (all: unknown) => writeStore(presetsKey!, { v: 1, presets: all });
|
|
86
|
+
const atPath = (obj: Snapshot | null, path: string[]): unknown => path.reduce<any>((o, k) => (o != null && hasOwn(o, k) ? o[k] : undefined), obj); // own keys only: a control keyed toString must not read Object.prototype's
|
|
87
|
+
// Take one entry's value out of a snapshot; true when it was there. Reflect: an undeletable key is left, not thrown.
|
|
88
|
+
const drop = (snap: Snapshot | null, e: Entry) => { const o: any = atPath(snap, e.path.slice(0, -1)); if (!o || typeof o !== "object" || !hasOwn(o, e.key)) return false; Reflect.deleteProperty(o, e.key); return true; };
|
|
89
|
+
// A control with `persist: false` stays out of what is saved and out of what is restored.
|
|
90
|
+
const unsaved = (snap: Snapshot | null) => { for (const e of entries) if (!e.persist) drop(snap, e); return snap; };
|
|
91
|
+
// ── Images in IndexedDB (image-store.ts): what storage holds for an uploaded image is its ref, and `savedImages` maps this
|
|
92
|
+
// panel's refs to their data URLs. The build doesn't wait for them: every other value restores as it builds, and a stored
|
|
93
|
+
// image waits in `waiting` until the store answers, then fills in (restoreImages), by `ready`. With no store to hold an
|
|
94
|
+
// upload (not answered yet, unavailable, or it refused that image), the data URL saves inline, as 0.5 saved it, and moves
|
|
95
|
+
// to the store on a later save; a write storage refuses then leaves the inline images out. With no store, a stored ref
|
|
96
|
+
// stays saved as it was until its row is written. ──
|
|
97
|
+
let imgs: ReturnType<typeof import("./image-store.js")["bind"]> | null = null, savedImages: Map<string, string> | null = null; // both null without IndexedDB
|
|
98
|
+
let imagesReady = false; // the store has answered, or failed to load
|
|
99
|
+
const waiting = new Map<Entry, [string, unknown]>(); // a row's stored ref, and the value it held while the store loaded or the image was fetched
|
|
100
|
+
const isRef = (v: unknown): v is string => typeof v === "string" && v.startsWith("tw-image:"); // image-store.ts's ref, readable before that chunk loads
|
|
101
|
+
const isDataUrl = (v: unknown): v is string => typeof v === "string" && v.startsWith("data:");
|
|
102
|
+
const imageEntries = () => entries.filter((e) => e.m.type === "image");
|
|
103
|
+
const unsavable = new Map<Entry, unknown>();
|
|
104
|
+
const notSaved = (e: Entry, v: unknown) => { if (unsavable.get(e) === v) return; unsavable.set(e, v); e.note?.("Not saved"); };
|
|
105
|
+
const toRefs = (snap: Snapshot) => {
|
|
106
|
+
const stored = imagesReady && imgs ? readStore(persistKey!) : undefined; // undefined: no store to hold an upload
|
|
107
|
+
for (const e of imageEntries()) {
|
|
108
|
+
const o: any = atPath(snap, e.path.slice(0, -1)), v = o?.[e.key], w = waiting.get(e);
|
|
109
|
+
if (w && o && hasOwn(o, e.key)) { if (!valueChanged(w[1], v)) { o[e.key] = w[0]; continue; } waiting.delete(e); } // still loading or no store: keep the stored ref; a write since settles it
|
|
110
|
+
if (!isDataUrl(v) || stored === undefined) continue; // a URL is small, and a data URL with no store saves inline
|
|
111
|
+
const ref = imgs!.save(e, v, stored);
|
|
112
|
+
if (ref) o[e.key] = ref;
|
|
113
|
+
}
|
|
114
|
+
return snap;
|
|
115
|
+
};
|
|
116
|
+
// An image a restore names by ref: from the store's answer, or once it answers; a ref the store has never seen here (a
|
|
117
|
+
// preset another tab saved) is fetched. It lands only on a row the host and the person left alone meanwhile.
|
|
118
|
+
const late = (e: Entry, held: unknown, url: string) => {
|
|
119
|
+
if (destroyed || valueChanged(held, e.get()) || !tryAssign(e, url)) return false;
|
|
120
|
+
undoApi?.patch(e.path, held, e.get()); // not a step of its own: ⌘Z mustn't take a restored image back out
|
|
121
|
+
return true;
|
|
122
|
+
};
|
|
123
|
+
// A stored snapshot back to values, the way applySnapshot takes them: each known ref resolved, an unknown one left out
|
|
124
|
+
// (its control keeps its value) and listed, so `restore` can fill it in once it arrives.
|
|
125
|
+
const fromRefs = (snap: Snapshot | null, pending: [Entry, string][] = []) => {
|
|
126
|
+
if (snap && typeof snap === "object") for (const e of imageEntries()) {
|
|
127
|
+
const v = atPath(snap, e.path); if (!isRef(v)) continue;
|
|
128
|
+
const url = savedImages?.get(v);
|
|
129
|
+
if (url) (atPath(snap, e.path.slice(0, -1)) as any)[e.key] = url;
|
|
130
|
+
else { drop(snap, e); pending.push([e, v]); }
|
|
131
|
+
}
|
|
132
|
+
return snap;
|
|
133
|
+
};
|
|
134
|
+
const restore = (snap: Snapshot | null, silent?: boolean) => {
|
|
135
|
+
const pending: [Entry, string][] = [];
|
|
136
|
+
applySnapshot(fromRefs(snap, pending), silent);
|
|
137
|
+
for (const [e, ref] of pending) {
|
|
138
|
+
const w: [string, unknown] = [ref, e.get()]; waiting.set(e, w); // a write meanwhile settles it, even back to the value held
|
|
139
|
+
if (!imagesReady || !imgs) continue; // no store: the ref stays saved until the row is written
|
|
140
|
+
imgs.fetch(ref).then((url) => { if (waiting.get(e) !== w) return; waiting.delete(e); if (url && late(e, w[1], url)) { last = undefined; notify(); } });
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
// The store answered: each waiting row the host and the person left alone takes its image, in one notify.
|
|
144
|
+
const restoreImages = () => {
|
|
145
|
+
imagesReady = true;
|
|
146
|
+
if (!assembled || destroyed || failed) { waiting.clear(); return; } // not built yet: the build resolves refs itself
|
|
147
|
+
let changed = false;
|
|
148
|
+
for (const [e, [ref, held]] of [...waiting]) { const url = savedImages?.get(ref); if (url && late(e, held, url)) changed = true; }
|
|
149
|
+
if (imgs) waiting.clear(); // no store: each ref stays waiting, so no save writes the default over it and no later session prunes it
|
|
150
|
+
if (changed) { last = undefined; notify(); }
|
|
151
|
+
else if (imgs) persist(); // an upload saved inline while the store loaded moves to it now
|
|
152
|
+
};
|
|
153
|
+
// Delete the records nothing names any more: not the saved values, not a preset. Only records this panel knows, loaded
|
|
154
|
+
// or written: another tab's panel on the same key may have just stored one this panel has never seen.
|
|
155
|
+
const pruneImages = () => imgs?.prune([readStore(persistKey!), listPresets()]);
|
|
156
|
+
// Debounced save; destroy() flushes a pending write rather than dropping it. saveNow writes at once.
|
|
157
|
+
let saveNow = () => {};
|
|
158
|
+
const persist = persistKey ? (() => {
|
|
159
|
+
let saveT = 0;
|
|
160
|
+
// A write storage refuses (off, denied, or full of something else) is dropped silently, unless an image saved inline may
|
|
161
|
+
// be what didn't fit: then those are left out, their rows say so, and the rest is written.
|
|
162
|
+
const save = () => {
|
|
163
|
+
saveT = 0;
|
|
164
|
+
const snap = toRefs(unsaved(snapshot()));
|
|
165
|
+
if (!writeValues(snap)) {
|
|
166
|
+
const inline = imageEntries().filter((e) => isDataUrl(atPath(snap, e.path)));
|
|
167
|
+
if (!inline.length) return;
|
|
168
|
+
for (const e of inline) { notSaved(e, atPath(snap, e.path)); drop(snap, e); }
|
|
169
|
+
if (!writeValues(snap)) return;
|
|
170
|
+
}
|
|
171
|
+
pruneImages();
|
|
172
|
+
};
|
|
173
|
+
saveNow = save; cleanups.push(() => { if (saveT) { clearTimeout(saveT); save(); } });
|
|
174
|
+
return () => { clearTimeout(saveT); saveT = setTimeout(save, 150); };
|
|
175
|
+
})() : () => {};
|
|
176
|
+
// Each listener runs isolated: a throwing callback can't break the others or skip persist().
|
|
177
|
+
// `last` is read once, so a write one listener makes can't change the key the rest are told.
|
|
178
|
+
const notify = (silent?: boolean, changed = last) => { (silent ? own : listeners).forEach((fn) => { try { fn(params, changed); } catch (e) { console.error("[tweaks] listener threw:", e); } }); if (!destroyed) persist(); }; // a listener may destroy() the panel: arm no save behind it
|
|
179
|
+
// The one write path behind restores and set(). A function/symbol/bigint is no control value (JSON can't carry those).
|
|
180
|
+
const notAValue = (v: unknown) => typeof v === "function" || typeof v === "symbol" || typeof v === "bigint";
|
|
181
|
+
// An explicit write also settles a row still waiting for its stored image: the host's or the person's value wins.
|
|
182
|
+
const assign = (e: Entry, v: unknown) => { if (notAValue(v)) throw new TypeError(`a ${typeof v} is not a control value`); waiting.delete(e); e.set(v); e.target[e.key] = e.get(); };
|
|
183
|
+
const tryAssign = (e: Entry, v: unknown) => { try { assign(e, v); return true; } catch (err) { console.error(`[tweaks] restoring "${e.path.join(".")}" failed — value skipped:`, err); return false; } };
|
|
184
|
+
// …and the reset write: the opened form, then the authored value (restoreDefault).
|
|
185
|
+
const assignDefault = (e: Entry) => { waiting.delete(e); restoreDefault(e, e.raw, e.def); e.target[e.key] = e.get(); };
|
|
186
|
+
// opts.onReset replaces the default; a reset() called from INSIDE it performs the default instead of recursing.
|
|
187
|
+
let inOnReset = false;
|
|
188
|
+
const resetAll = () => {
|
|
189
|
+
if (typeof opts.onReset === "function" && !inOnReset) {
|
|
190
|
+
inOnReset = true;
|
|
191
|
+
try { return opts.onReset(); } finally { inOnReset = false; }
|
|
192
|
+
}
|
|
193
|
+
for (const e of entries) {
|
|
194
|
+
try { assignDefault(e); }
|
|
195
|
+
catch (err) { console.error(`[tweaks] resetting "${e.path.join(".")}" failed — control skipped:`, err); }
|
|
196
|
+
}
|
|
197
|
+
last = undefined; notify();
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const panel: PanelEl = el("div", "tw-panel"); panel.dataset.mode = "inline";
|
|
201
|
+
// A landmark, so a screen reader can jump to the panel and hear which one; an unnamed region is no landmark, so a blank name falls back.
|
|
202
|
+
const landmark = (n: string) => panel.setAttribute("aria-label", n.trim() || "Tweaks");
|
|
203
|
+
panel.setAttribute("role", "region"); landmark(name);
|
|
204
|
+
// Stop pointer events leaking past the panel to a stage listening on window.
|
|
205
|
+
stopPointerLeak(panel);
|
|
206
|
+
const header = el("div", "tw-header");
|
|
207
|
+
// The title is the collapse toggle; the toolbar beside it never collapses.
|
|
208
|
+
const titleBtn = btn("tw-header-toggle");
|
|
209
|
+
titleBtn.setAttribute("aria-expanded", "true");
|
|
210
|
+
let title = name; // setName() moves it; the storage key keeps the built name
|
|
211
|
+
const titleEl = txt("span", "tw-title", title); titleBtn.append(titleEl);
|
|
212
|
+
const toolbar = el("div", "tw-toolbar");
|
|
213
|
+
// The values moved off their defaults, keyed by dotted path — setMany()'s shape, for baking tuned values into source.
|
|
214
|
+
const changed = (): Record<string, unknown> => { const out: Record<string, unknown> = {}; for (const e of entries) if (valueChanged(e.get(), e.def)) out[e.path.join(".")] = e.get(); return JSON.parse(JSON.stringify(out, replacer)); };
|
|
215
|
+
// ⇧-click copies the changed values only; reset restores every default or runs opts.onReset.
|
|
216
|
+
const copyBtn = makeCopyBtn((changedOnly) => { if (!changedOnly) return [JSON.stringify(snapshot(), null, 2), -1]; const c = changed(); return [JSON.stringify(c, null, 2), Object.keys(c).length]; });
|
|
217
|
+
// A picked row's paste (pasteScope, in the lazy clip chunk) writes through the panel's own load path; ctx lends it.
|
|
218
|
+
// A paste is its own undo step: what was pending commits first, the paste right after, so neither merges with a drag.
|
|
219
|
+
const ctx: ClipCtx = { panel, entries, fromJSON: (st) => { undoApi?.flush(); api.fromJSON(st); undoApi?.flush(); }, announce, alive: () => !destroyed };
|
|
220
|
+
const resetBtn = makeResetBtn(resetAll);
|
|
221
|
+
// Presets share persistence's storage, so the button needs it on.
|
|
222
|
+
let presetsBtn: HTMLButtonElement | null = null;
|
|
223
|
+
if (persistKey && opts.presets !== false) { // opts.presets:false keeps the saving, without the menu
|
|
224
|
+
presetsBtn = toolbarBtn("", ICON_PRESETS, "Presets");
|
|
225
|
+
presetsBtn.setAttribute("aria-haspopup", "menu"); presetsBtn.setAttribute("aria-expanded", "false");
|
|
226
|
+
toolbar.append(presetsBtn);
|
|
227
|
+
}
|
|
228
|
+
toolbar.append(copyBtn, resetBtn);
|
|
229
|
+
header.append(titleBtn);
|
|
230
|
+
if (opts.toolbar !== false) header.append(toolbar); // toolbar:false → a bare panel, e.g. an embedded demo
|
|
231
|
+
// The buttons act on controls that only exist once assemble() has built them: inert until then.
|
|
232
|
+
const toolbarBtns = [copyBtn, resetBtn, presetsBtn].filter(Boolean);
|
|
233
|
+
for (const b of toolbarBtns) b.disabled = true;
|
|
234
|
+
// swallowClick lives on the header, not the title: once the header holds pointer capture, the click ending a drag is dispatched there.
|
|
235
|
+
let dragMoved = false, swallowClick = false;
|
|
236
|
+
header.addEventListener("click", (e) => { if (swallowClick) { swallowClick = false; e.preventDefault(); e.stopPropagation(); } }, true);
|
|
237
|
+
// A bottom-parked floating panel grows past the viewport when it expands: re-clamp once the collapse has settled.
|
|
238
|
+
const toggleBody = () => foldBody(panel, titleBtn, body, !panel.classList.contains("is-collapsed"), reclamp);
|
|
239
|
+
titleBtn.addEventListener("click", toggleBody);
|
|
240
|
+
// A click anywhere on the header outside the toolbar folds the panel, if the press began on the header or title (a press
|
|
241
|
+
// slid off a toolbar button also clicks here). A drag's closing click arrives defaultPrevented.
|
|
242
|
+
let pressedHeader = false;
|
|
243
|
+
header.addEventListener("pointerdown", (e) => { pressedHeader = e.target === header || titleBtn.contains(e.target as Node); }, true);
|
|
244
|
+
header.addEventListener("click", (e) => { if (e.target === header && pressedHeader && !e.defaultPrevented) toggleBody(); });
|
|
245
|
+
const body = el("div", "tw-body");
|
|
246
|
+
const controls = el("div", "tw-controls");
|
|
247
|
+
body.append(controls);
|
|
248
|
+
panel.append(header, body);
|
|
249
|
+
|
|
250
|
+
// Stash the theme so the portaled popovers (which lose the panel's inherited vars) re-apply it on open.
|
|
251
|
+
let themeVars = resolveTheme(opts.theme);
|
|
252
|
+
applyThemeVars(panel, themeVars); panel._twTheme = themeVars;
|
|
253
|
+
|
|
254
|
+
// ── Floating position — seeded in the shell, so an opts.floating / persisted panel is fixed in place
|
|
255
|
+
// the moment tweaks() returns instead of jumping when the lazy chunks land. ──
|
|
256
|
+
const draggable = opts.draggable !== false;
|
|
257
|
+
const MARGIN = 8, SNAP = 28; // px: viewport inset; snap distance to park against an edge
|
|
258
|
+
// opts.floating's x / y are insets from the viewport's top-left, as px / py below are.
|
|
259
|
+
const place = typeof opts.floating === "object" && opts.floating ? opts.floating : null;
|
|
260
|
+
// The vertical clamp uses the panel's full height, scrollHeight, not its capped one: clamping on the cap would ratchet
|
|
261
|
+
// (every position fits, so a tall panel drags up but never back down). +1: it rounds, so a panel parked at the far edge
|
|
262
|
+
// isn't capped a fraction short of its rows.
|
|
263
|
+
// The viewport from clientWidth / clientHeight: a fixed box's right and bottom edges sit inside the page scrollbars,
|
|
264
|
+
// which innerWidth / innerHeight count. Only in standards mode: with no doctype the root's client box is the page's
|
|
265
|
+
// content, not the viewport (and jsdom has no layout: 0) — the inner size there.
|
|
266
|
+
const root = () => document.compatMode === "CSS1Compat" ? document.documentElement : null;
|
|
267
|
+
const vw = () => root()?.clientWidth || window.innerWidth, vh = () => root()?.clientHeight || window.innerHeight;
|
|
268
|
+
const bounds = () => ({ maxX: Math.max(MARGIN, vw() - panel.offsetWidth - MARGIN), maxY: Math.max(MARGIN, vh() - panel.scrollHeight - 1 - gap) });
|
|
269
|
+
const posKey = persistKey ? `${persistKey}:pos` : null;
|
|
270
|
+
// A pinned panel sits where opts.floating puts it; only a draggable one restores a saved spot.
|
|
271
|
+
const stored = draggable && posKey ? readStore(posKey) : null;
|
|
272
|
+
const saved = stored && typeof stored === "object" ? stored : null;
|
|
273
|
+
const start = saved || place || { x: 16, y: 16 };
|
|
274
|
+
const inset = (v: unknown) => { const n = parseFloat(v as string); return n >= 0 ? n : 16; }; // 0 is an inset, not a missing one
|
|
275
|
+
const x0 = inset(start.x), y0 = inset(start.y);
|
|
276
|
+
let px = x0, py = y0;
|
|
277
|
+
// The gap a capped panel keeps from the far edge: while it sits at its opts.floating placement, the inset that gave the
|
|
278
|
+
// near one (16 for `true`), so a panel placed 16px down stops 16px short of the bottom. Once dragged or restored, it has
|
|
279
|
+
// left that placement: the margin, as the drag clamp keeps. --tw-float-gap overrides either (tweaks.css registers it
|
|
280
|
+
// as a <length>, so a rem or vh value computes to px here).
|
|
281
|
+
let moved = !!saved, gap = MARGIN;
|
|
282
|
+
const readGap = () => { const v = parseFloat(getComputedStyle(panel).getPropertyValue("--tw-float-gap")); gap = v >= 0 ? v : opts.floating && !moved ? inset(place?.y) : MARGIN; };
|
|
283
|
+
// A fixed box ignores page scroll, so a floating panel caps at the viewport past its near edge and scrolls (tweaks.css).
|
|
284
|
+
// Custom properties, not left / top / max-height: the stylesheet maps them, so host CSS can still override each one.
|
|
285
|
+
const apply = () => { const s = panel.style; s.setProperty("--tw-float-x", px + "px"); s.setProperty("--tw-float-y", py + "px"); s.setProperty("--tw-float-max-h", `calc(100dvh - ${py + gap}px)`); };
|
|
286
|
+
const clampPos = () => {
|
|
287
|
+
readGap();
|
|
288
|
+
const { maxX, maxY } = bounds();
|
|
289
|
+
if (draggable) { px = clamp(px, MARGIN, maxX); py = clamp(py, MARGIN, maxY); return; }
|
|
290
|
+
// A pinned panel clamps from its placement each time, so a window that narrows and widens again hands its spot back.
|
|
291
|
+
px = clamp(x0, Math.min(MARGIN, x0), maxX);
|
|
292
|
+
// It keeps its insets while they leave it half the window (or its full height, if less); past that the inset and the
|
|
293
|
+
// far gap shrink together, so a short window never caps it to nothing.
|
|
294
|
+
const room = vh() - Math.min(panel.scrollHeight + 1, vh() / 2), k = Math.min(1, room / (y0 + gap || 1));
|
|
295
|
+
py = Math.round(y0 * k); gap = Math.round(gap * k);
|
|
296
|
+
};
|
|
297
|
+
const reclamp = () => { if (!destroyed && panel.dataset.mode === "floating" && panel.isConnected) { clampPos(); apply(); } };
|
|
298
|
+
if ((draggable || opts.floating) && (opts.floating || saved)) {
|
|
299
|
+
panel.dataset.mode = "floating"; clampPos(); apply();
|
|
300
|
+
// A position saved on a larger monitor restores off-viewport: re-clamp once mounted (offsetWidth is 0 until then).
|
|
301
|
+
requestAnimationFrame(reclamp);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Per-control reset. The target is LABEL_SEL — the hint marker's list, so the two can't drift — or the root.
|
|
305
|
+
const resetEntry = (e: Entry) => { assignDefault(e); last = e.path.join("."); notify(); };
|
|
306
|
+
const wireReset = (root: HTMLElement, entry: Entry) => {
|
|
307
|
+
const t = root.querySelector<HTMLElement>(".tw-slider-value")
|
|
308
|
+
|| root.querySelector<HTMLElement>(LABEL_SEL)
|
|
309
|
+
|| root;
|
|
310
|
+
t.classList.add("tw-resettable"); t.title = "Double-click or hold to reset";
|
|
311
|
+
// Coarse pointers get a press-and-hold reset: the desktop double-click fights double-tap-zoom on touch.
|
|
312
|
+
let holdT = 0, hx = 0, hy = 0, held = false;
|
|
313
|
+
const cancelHold = () => { if (holdT) { clearTimeout(holdT); holdT = 0; t.classList.remove("tw-reset-holding"); } };
|
|
314
|
+
t.addEventListener("pointerdown", (e) => {
|
|
315
|
+
e.stopPropagation(); held = false; // a press on the readout mustn't jump the slider
|
|
316
|
+
if (e.pointerType !== "touch" && e.pointerType !== "pen") return;
|
|
317
|
+
hx = e.clientX; hy = e.clientY; t.classList.add("tw-reset-holding");
|
|
318
|
+
holdT = setTimeout(() => { holdT = 0; t.classList.remove("tw-reset-holding", "is-editable"); held = true; resetEntry(entry); }, 500);
|
|
319
|
+
});
|
|
320
|
+
t.addEventListener("pointermove", (e) => { if (holdT && Math.abs(e.clientX - hx) + Math.abs(e.clientY - hy) > 8) cancelHold(); });
|
|
321
|
+
t.addEventListener("pointerup", cancelHold);
|
|
322
|
+
t.addEventListener("pointercancel", cancelHold);
|
|
323
|
+
t.addEventListener("pointerleave", cancelHold);
|
|
324
|
+
t.addEventListener("contextmenu", (e) => { if (held) e.preventDefault(); }); // a long-press mustn't raise the text callout
|
|
325
|
+
t.addEventListener("click", (e) => { if (held) { e.preventDefault(); e.stopImmediatePropagation(); held = false; } }, true); // a completed hold-reset swallows the trailing tap-to-edit
|
|
326
|
+
t.addEventListener("dblclick", (e) => {
|
|
327
|
+
if (t === root && (e.target as Element).closest?.("input, textarea")) return; // a double-click in a text field is a word-select
|
|
328
|
+
e.preventDefault(); e.stopPropagation(); resetEntry(entry);
|
|
329
|
+
});
|
|
330
|
+
// Click #1 can swap the readout for the inline input, so the dblclick lands there; delegated on the root.
|
|
331
|
+
if (t.classList.contains("tw-slider-value")) root.addEventListener("dblclick", (e) => {
|
|
332
|
+
const inp = e.target as HTMLElement;
|
|
333
|
+
if (!inp.classList?.contains("tw-slider-input")) return;
|
|
334
|
+
e.preventDefault(); e.stopPropagation();
|
|
335
|
+
inp.blur(); // commits + closes the editor; a no-op change, so no notify
|
|
336
|
+
resetEntry(entry);
|
|
337
|
+
});
|
|
338
|
+
// The keyboard reset, Alt-qualified so a bare Backspace in a text field stays a backspace and the gradient editor keeps Delete.
|
|
339
|
+
root.addEventListener("keydown", (e) => {
|
|
340
|
+
if (!e.altKey || e.metaKey || e.ctrlKey || (e.key !== "Backspace" && e.key !== "Delete")) return;
|
|
341
|
+
e.preventDefault(); e.stopPropagation();
|
|
342
|
+
if ((e.target as HTMLElement).classList?.contains("tw-slider-input")) (e.target as HTMLElement).blur(); // an open inline editor commits + closes first
|
|
343
|
+
resetEntry(entry);
|
|
344
|
+
});
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
// Conditional controls — `render` shows/hides, `disabled` grays-out + locks, both re-evaluated on every change; `hint` is a static tooltip.
|
|
348
|
+
const conditionals: Array<{ node: HTMLElement; m: Meta }> = [];
|
|
349
|
+
// fold.ts is a lazy chunk the first pass imports, so that pass just toggles; foldRow returns whether the row was revealed (re-measure).
|
|
350
|
+
let fold: typeof import("./fold.js").fold | undefined;
|
|
351
|
+
const foldRow = (node: HTMLElement & { _hid?: boolean }, hide: boolean) => {
|
|
352
|
+
if (!node._hid === !hide) return false;
|
|
353
|
+
node._hid = hide;
|
|
354
|
+
if (fold) fold(node, hide); else node.classList.toggle("tw-cond-hidden", hide);
|
|
355
|
+
return !hide;
|
|
356
|
+
};
|
|
357
|
+
const registerCond = (node: HTMLElement, m: Meta) => {
|
|
358
|
+
if (m.hint) addHintMarker(node, m.hint);
|
|
359
|
+
if (m.render || m.disabled != null) conditionals.push({ node, m });
|
|
360
|
+
};
|
|
361
|
+
const folderEls: Array<{ path: string[]; el: HTMLElement; setCollapsed: (c: boolean) => void }> = [], tabsCtrls: Array<{ path: string[]; ctrl: TabsControl; pageKeys: string[] }> = []; // UI state for toJSON/fromJSON, keyed by path
|
|
362
|
+
|
|
363
|
+
// A control's own loop (the monitor, the FPS graph) hands back a `destroy`; a panel destroyed before it ever connected would run it forever.
|
|
364
|
+
const adopt = (ctrl: Control) => { if (typeof ctrl.destroy === "function") cleanups.push(ctrl.destroy); };
|
|
365
|
+
|
|
366
|
+
const build = (container: HTMLElement, ms: Meta[], target: Target, basePath: string[] = []) => {
|
|
367
|
+
for (const m of ms) {
|
|
368
|
+
if (m.type === "tabs") {
|
|
369
|
+
const sub: Target = {}; target[m.key] = sub;
|
|
370
|
+
const makeTabs = getControl<TabsControl>("tabs");
|
|
371
|
+
const tabsCtrl = makeTabs && makeTabs(m);
|
|
372
|
+
if (!tabsCtrl) continue; // ensured before assemble; skipped if its chunk failed
|
|
373
|
+
tabsCtrls.push({ path: [...basePath, m.key], ctrl: tabsCtrl, pageKeys: m.pages.map((p) => p.key) });
|
|
374
|
+
m.pages.forEach((page, i) => { const psub: Target = {}; sub[page.key] = psub; build(tabsCtrl.bodies[i], page.children, psub, [...basePath, m.key, page.key]); });
|
|
375
|
+
registerCond(tabsCtrl.el, m); container.append(tabsCtrl.el);
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (m.type === "folder") {
|
|
379
|
+
const sub: Target = {}; target[m.key] = sub;
|
|
380
|
+
const f = createFolder(m);
|
|
381
|
+
folderEls.push({ path: [...basePath, m.key], el: f.el, setCollapsed: f.setCollapsed });
|
|
382
|
+
scopes.set(f.el, { kind: "folder", path: [...basePath, m.key], label: m.label });
|
|
383
|
+
build(f.body, m.children, sub, [...basePath, m.key]); registerCond(f.el, m); container.append(f.el);
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
const valued = !VALUELESS.has(m.type);
|
|
387
|
+
const dotted = [...basePath, m.key].join("."); // a bare leaf couldn't tell size from card.size
|
|
388
|
+
let entry: Entry | undefined; // a change made in the panel settles a row waiting for its stored image, as assign does
|
|
389
|
+
const ctrl = createControl(m, valued ? (v: unknown) => { if (!valueChanged(target[m.key], v)) return; if (entry) waiting.delete(entry); target[m.key] = v; last = dotted; notify(); } : () => {}); // same-value emits (a re-entrant echo) don't notify
|
|
390
|
+
if (!ctrl) continue;
|
|
391
|
+
adopt(ctrl);
|
|
392
|
+
if (!valued) valueless.set(dotted, m.type);
|
|
393
|
+
if (valued) {
|
|
394
|
+
// reset() restores `raw` over the form the control OPENED on (`def`), read before the parked value applies — see restoreDefault.
|
|
395
|
+
const def = ctrl.get();
|
|
396
|
+
// A value a host parked on params before assemble ran wins over the schema default.
|
|
397
|
+
if (hasOwn(target, m.key)) ctrl.set(target[m.key]);
|
|
398
|
+
target[m.key] = ctrl.get();
|
|
399
|
+
entry = { target, key: m.key, set: ctrl.set, get: ctrl.get, raw: m.value, def, path: [...basePath, m.key], persist: m.persist !== false, m, el: ctrl.el, note: ctrl.note, range: ctrl.range };
|
|
400
|
+
entries.push(entry); wireReset(ctrl.el, entry); scopes.set(ctrl.el, { kind: "row", entry, label: m.label });
|
|
401
|
+
}
|
|
402
|
+
registerCond(ctrl.el, m);
|
|
403
|
+
container.append(ctrl.el);
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
// ── Snapshots in: persisted sessions, presets, fromJSON, undo. Per-entry isolation: a control whose
|
|
408
|
+
// set() throws on hostile stored data (a corrupt snapshot, a hand-edited preset) costs only its own value. ──
|
|
409
|
+
const applySnapshot = (snap: Snapshot | null, silent?: boolean) => {
|
|
410
|
+
if (!snap || typeof snap !== "object") return;
|
|
411
|
+
for (const e of entries) {
|
|
412
|
+
const v = atPath(snap, e.path); if (v === undefined) continue;
|
|
413
|
+
tryAssign(e, v);
|
|
414
|
+
}
|
|
415
|
+
last = undefined; notify(silent);
|
|
416
|
+
};
|
|
417
|
+
// ── Named presets (opt-in via opts.persist) — snapshots under "<key>:presets" ──
|
|
418
|
+
const listPresets = (): Record<string, Snapshot> => { const p = presetsKey ? unwrap(readStore(presetsKey), "presets") : null; return Object.assign(Object.create(null), p && typeof p === "object" ? p : {}); }; // null-proto: a preset named "__proto__" is an ordinary key
|
|
419
|
+
// False when storage refused the write, so the caller can say so instead of claiming a save.
|
|
420
|
+
const savePreset = (nm: string) => { const all = listPresets(); all[nm] = toRefs(snapshot()); return writePresets(all); }; // the menu announces a refusal
|
|
421
|
+
const loadPreset = (nm: string) => { const all = listPresets(); if (all[nm]) restore(all[nm]); }; // re-read at replay: a delete queued ahead of it wins
|
|
422
|
+
const deletePreset = (nm: string) => { const all = listPresets(); delete all[nm]; if (writePresets(all)) pruneImages(); };
|
|
423
|
+
|
|
424
|
+
// ── Whole-panel UI state (toJSON / fromJSON) — folder open/closed and the active tab, keyed by dotted path ──
|
|
425
|
+
const pathKey = (p: string[]) => p.map((k) => String(k).replace(/~/g, "~1").replace(/\./g, "~0")).join("."); // JSON-pointer escaping, so a literal "." can't collide
|
|
426
|
+
const collectUI = () => {
|
|
427
|
+
const ui: NonNullable<PanelState["ui"]> = {};
|
|
428
|
+
if (folderEls.length) { const f: Record<string, boolean> = {}; for (const fe of folderEls) f[pathKey(fe.path)] = fe.el.classList.contains("is-collapsed"); ui.folders = f; }
|
|
429
|
+
if (tabsCtrls.length) { const t: Record<string, string | null> = {}; for (const tc of tabsCtrls) t[pathKey(tc.path)] = tc.pageKeys[tc.ctrl.active()] ?? null; ui.tabs = t; }
|
|
430
|
+
return ui;
|
|
431
|
+
};
|
|
432
|
+
const applyUI = (ui: PanelState["ui"]) => {
|
|
433
|
+
if (!ui || typeof ui !== "object") return;
|
|
434
|
+
if (ui.folders) for (const fe of folderEls) { const c = ui.folders[pathKey(fe.path)]; if (typeof c === "boolean") fe.setCollapsed(c); }
|
|
435
|
+
if (ui.tabs) for (const tc of tabsCtrls) { const i = tc.pageKeys.indexOf(ui.tabs[pathKey(tc.path)]); if (i >= 0 && i !== tc.ctrl.active()) tc.ctrl.activate(i); } // a re-activate would fire a spurious tw-reflow
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
// ── Repositioning — drag the header to move the panel ──
|
|
439
|
+
// An inline panel lifts into a floating layer on the first drag, at the spot it sat, then tracks the
|
|
440
|
+
// pointer; on release it eases to a near viewport edge, and the position persists to "<key>:pos".
|
|
441
|
+
if (draggable || opts.floating) {
|
|
442
|
+
if (draggable) panel.dataset.draggable = "true"; // CSS grab cursor on the header — the affordance
|
|
443
|
+
// Lift in place at the current rect; a same-size placeholder stays in the old slot, or the host container reflows mid-drag.
|
|
444
|
+
const lift = () => {
|
|
445
|
+
if (panel.dataset.mode === "floating") return;
|
|
446
|
+
const r = panel.getBoundingClientRect();
|
|
447
|
+
px = r.left; py = r.top;
|
|
448
|
+
if (panel.parentNode) {
|
|
449
|
+
liftSlot = el("span", "tw-lift-slot");
|
|
450
|
+
liftSlot.style.width = r.width + "px"; liftSlot.style.height = r.height + "px";
|
|
451
|
+
liftSlot.setAttribute("aria-hidden", "true");
|
|
452
|
+
panel.before(liftSlot);
|
|
453
|
+
}
|
|
454
|
+
// Portal to <body>: a transformed/filter/contain ancestor would become the fixed panel's containing block. The scheme scope doesn't ride along — carry it.
|
|
455
|
+
// So does the rest of the slot's context: the text properties the kit leaves to inheritance (a host's font-size,
|
|
456
|
+
// letter-spacing, color, direction, lang) and every --tw-* token, inherited or matched through an ancestor selector
|
|
457
|
+
// (`.my-sidebar .tw-panel { --tw-radius: 4px }`, as the theming page shows). Read with the panel's own theme off, so
|
|
458
|
+
// opts.theme / setTheme() still layer on top and setTheme(null) falls back to the host's values; only what the move
|
|
459
|
+
// changes is written, inline. A host rule reaching inside through an ancestor (`#app button { … }`) still stops
|
|
460
|
+
// matching: only a shadow root could keep that.
|
|
461
|
+
applyThemeVars(panel, null);
|
|
462
|
+
const cs = getComputedStyle(panel), lang = panel.closest("[lang]")?.getAttribute("lang"), was: Record<string, string> = {};
|
|
463
|
+
for (const k of ["font-family", "font-size", "font-weight", "font-style", "font-stretch", "font-variant", "font-feature-settings", "font-variation-settings", "font-kerning", "line-height", "letter-spacing", "word-spacing", "text-transform", "text-align", "color", "direction", ...Array.from(cs).filter((k) => k.startsWith("--tw-"))]) was[k] = cs.getPropertyValue(k);
|
|
464
|
+
carryScheme(panel, panel);
|
|
465
|
+
document.body.appendChild(panel);
|
|
466
|
+
for (const k in was) if (cs.getPropertyValue(k) !== was[k]) panel.style.setProperty(k, carried[k] = was[k]);
|
|
467
|
+
if (lang && panel.closest("[lang]")?.getAttribute("lang") !== lang) panel.lang = lang;
|
|
468
|
+
applyThemeVars(panel, themeVars);
|
|
469
|
+
panel.dataset.mode = "floating"; apply();
|
|
470
|
+
// A host that removes its container without destroy() would leave the lifted panel on screen; when the placeholder leaves the document, the panel follows.
|
|
471
|
+
if (liftSlot && typeof MutationObserver === "function") {
|
|
472
|
+
const obs = new MutationObserver(() => { if (liftSlot && !liftSlot.isConnected) { obs.disconnect(); liftSlot = null; panel.remove(); } });
|
|
473
|
+
obs.observe(document.documentElement, { childList: true, subtree: true });
|
|
474
|
+
cleanups.push(() => obs.disconnect());
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
// Two floating panels share one stylesheet z-index; the last touched gets a higher one. A class at plain .tw-panel
|
|
478
|
+
// specificity, not an inline value, so a host's own z-index still wins after a drag.
|
|
479
|
+
const raise = () => { if (topFloating && topFloating !== panel) topFloating.classList.remove("is-raised"); panel.classList.add("is-raised"); topFloating = panel; };
|
|
480
|
+
const grab = () => { moved = true; lift(); raise(); readGap(); };
|
|
481
|
+
const moveTo = (x: number, y: number, dx: number, dy: number) => { const { maxX, maxY } = bounds(); px = clamp(x + dx, MARGIN, maxX); py = clamp(y + dy, MARGIN, maxY); apply(); };
|
|
482
|
+
const savePos = () => { if (posKey) writeStore(posKey, { x: px, y: py }); };
|
|
483
|
+
|
|
484
|
+
let dragId: number | null = null, sx = 0, sy = 0, ox = 0, oy = 0, snapT = 0;
|
|
485
|
+
// Document listeners (capture, so the panel's own pointer-stop can't hide them) for the press's duration.
|
|
486
|
+
// Capture is taken only past the threshold: on the press it would retarget a plain click off the title.
|
|
487
|
+
const listen = (on: boolean) => {
|
|
488
|
+
for (const [t, fn] of [["pointermove", onMove], ["pointerup", endDrag], ["pointercancel", endDrag]] as Array<[string, (e: PointerEvent) => void]>) on ? document.addEventListener(t, fn, true) : document.removeEventListener(t, fn, true);
|
|
489
|
+
on ? document.addEventListener("keydown", onDragKey, true) : document.removeEventListener("keydown", onDragKey, true);
|
|
490
|
+
};
|
|
491
|
+
// Escape mid-press cancels: the panel returns to where the drag began and the release's click is swallowed. Capture
|
|
492
|
+
// phase, so it beats a pane's Escape.
|
|
493
|
+
const onDragKey = (e: KeyboardEvent) => {
|
|
494
|
+
if (e.key !== "Escape" || dragId === null) return;
|
|
495
|
+
e.preventDefault(); e.stopPropagation();
|
|
496
|
+
const id = dragId; dragId = null; listen(false); // before the release, so a lostpointercapture finds no drag to end
|
|
497
|
+
try { header.releasePointerCapture(id); } catch {}
|
|
498
|
+
if (dragMoved) { px = ox; py = oy; apply(); }
|
|
499
|
+
panel.classList.remove("is-dragging");
|
|
500
|
+
swallowClick = true; document.addEventListener("pointerup", () => setTimeout(() => { swallowClick = false; }, 0), { capture: true, once: true });
|
|
501
|
+
};
|
|
502
|
+
if (draggable) header.addEventListener("pointerdown", (e) => { // draggable:false keeps lift/clamp, never the press
|
|
503
|
+
if (e.button !== 0 || dragId !== null || (e.target as Element).closest(".tw-toolbar, input, textarea, select")) return;
|
|
504
|
+
dragId = e.pointerId; sx = e.clientX; sy = e.clientY; dragMoved = false;
|
|
505
|
+
listen(true);
|
|
506
|
+
// Regrab mid–edge-snap: pick the panel up where it visually is, not the parked target.
|
|
507
|
+
if (panel.classList.contains("is-snapping")) {
|
|
508
|
+
clearTimeout(snapT);
|
|
509
|
+
const cur = getComputedStyle(panel);
|
|
510
|
+
px = parseFloat(cur.left) || px; py = parseFloat(cur.top) || py;
|
|
511
|
+
panel.classList.remove("is-snapping"); apply(); // …and drop the snap transition, so the grab is 1:1
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
// The keyboard's drag: with the title focused, Alt+arrows move the panel 10px (Shift: 50px) through the same lift, clamp
|
|
515
|
+
// and saved position as a pointer drag. Alt-qualified, so a bare arrow stays the page's.
|
|
516
|
+
if (draggable) header.addEventListener("keydown", (e) => {
|
|
517
|
+
if (e.target !== titleBtn || !e.altKey || e.metaKey || e.ctrlKey) return;
|
|
518
|
+
const d = e.shiftKey ? 50 : 10, dx = e.key === "ArrowRight" ? d : e.key === "ArrowLeft" ? -d : 0, dy = e.key === "ArrowDown" ? d : e.key === "ArrowUp" ? -d : 0;
|
|
519
|
+
if (!dx && !dy) return;
|
|
520
|
+
e.preventDefault(); e.stopPropagation();
|
|
521
|
+
const lifting = panel.dataset.mode !== "floating";
|
|
522
|
+
grab();
|
|
523
|
+
if (lifting) titleBtn.focus({ preventScroll: true }); // the lift moves the panel to <body>, which drops focus; the next step needs it back
|
|
524
|
+
moveTo(px, py, dx, dy); savePos();
|
|
525
|
+
});
|
|
526
|
+
const onMove = (e: PointerEvent) => {
|
|
527
|
+
if (e.pointerId !== dragId) return;
|
|
528
|
+
// The window lost the pointer: bail as every other drag surface does.
|
|
529
|
+
if (e.buttons === 0) { endDrag(e); return; }
|
|
530
|
+
const dx = e.clientX - sx, dy = e.clientY - sy;
|
|
531
|
+
if (!dragMoved) {
|
|
532
|
+
// Drag slop: quick clicks wander a few px, fingers more. Collapsing is common and moving rare, so favor the click.
|
|
533
|
+
if (Math.hypot(dx, dy) < (e.pointerType === "mouse" ? 6 : 10)) return;
|
|
534
|
+
dragMoved = true; grab(); ox = px; oy = py; // the (possibly just-lifted) origin, once the drag starts
|
|
535
|
+
try { header.setPointerCapture(dragId); } catch {}
|
|
536
|
+
panel.classList.add("is-dragging");
|
|
537
|
+
}
|
|
538
|
+
moveTo(ox, oy, dx, dy);
|
|
539
|
+
};
|
|
540
|
+
const endDrag = (e: PointerEvent) => {
|
|
541
|
+
if (e.pointerId !== dragId) return;
|
|
542
|
+
try { header.releasePointerCapture(dragId); } catch {}
|
|
543
|
+
dragId = null; listen(false);
|
|
544
|
+
// Swallow exactly the click that follows the release; let the flag lapse in case none comes (a pointercancel).
|
|
545
|
+
swallowClick = dragMoved;
|
|
546
|
+
if (swallowClick) setTimeout(() => { swallowClick = false; }, 0);
|
|
547
|
+
if (dragMoved) {
|
|
548
|
+
// Edge magnetism: a drop near a side eases the rest of the way to the margin.
|
|
549
|
+
const { maxX, maxY } = bounds();
|
|
550
|
+
if (px <= MARGIN + SNAP) px = MARGIN; else if (px >= maxX - SNAP) px = maxX;
|
|
551
|
+
if (py <= MARGIN + SNAP) py = MARGIN; else if (py >= maxY - SNAP) py = maxY;
|
|
552
|
+
// The ease is a class (tweaks.css), so the reduced-motion kill-switch covers it.
|
|
553
|
+
// One timer at a time: an earlier drop's would strip the class 340ms after it, partway through this snap.
|
|
554
|
+
clearTimeout(snapT); panel.classList.add("is-snapping"); snapT = setTimeout(() => panel.classList.remove("is-snapping"), 340);
|
|
555
|
+
apply();
|
|
556
|
+
savePos();
|
|
557
|
+
}
|
|
558
|
+
panel.classList.remove("is-dragging");
|
|
559
|
+
};
|
|
560
|
+
header.addEventListener("lostpointercapture", endDrag); // implicit capture loss mid-drag ends it like a release
|
|
561
|
+
cleanups.push(() => listen(false)); // a destroy() mid-press releases the document listeners
|
|
562
|
+
}
|
|
563
|
+
// Registered by assemble(): onLive releases itself once the owner LEAVES the document, which it can only tell if it saw the panel connected first.
|
|
564
|
+
const watchResize = () => {
|
|
565
|
+
if (!(draggable || opts.floating)) return;
|
|
566
|
+
reclamp(); // a resize during the lazy window went unheard; clamp now
|
|
567
|
+
cleanups.push(onLive(panel, [[window, "resize"]], reclamp));
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
if (presetsBtn) {
|
|
571
|
+
const menu = el("div", "tw-presets-menu");
|
|
572
|
+
const saveRow = el("div", "tw-presets-save");
|
|
573
|
+
const input = el("input", "tw-presets-input"); input.type = "text"; input.placeholder = "Preset name…"; input.spellcheck = false; input.setAttribute("aria-label", "New preset name"); quietFocus(input);
|
|
574
|
+
const saveBtn = txt("button", "tw-presets-savebtn", "Save");
|
|
575
|
+
saveRow.append(input, saveBtn);
|
|
576
|
+
const list = el("div", "tw-presets-list");
|
|
577
|
+
menu.append(saveRow, list);
|
|
578
|
+
const renderList = () => {
|
|
579
|
+
const all = listPresets(), names = Object.keys(all);
|
|
580
|
+
list.replaceChildren();
|
|
581
|
+
if (!names.length) return list.append(txt("div", "tw-presets-empty", "No presets yet"));
|
|
582
|
+
for (const nm of names) {
|
|
583
|
+
const row = el("div", "tw-presets-row");
|
|
584
|
+
const load = txt("button", "tw-presets-load", nm);
|
|
585
|
+
load.addEventListener("click", () => { loadPreset(nm); menuPop.close(); announce(`Loaded preset ${nm}`); });
|
|
586
|
+
const del = btn("tw-presets-del", ICON_X); del.setAttribute("aria-label", `Delete preset ${nm}`);
|
|
587
|
+
del.addEventListener("click", (e) => { e.stopPropagation(); deletePreset(nm); renderList(); });
|
|
588
|
+
row.append(load, del); list.append(row);
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
const doSave = () => { const nm = input.value.trim(); if (!nm) { input.focus(); return; } if (!savePreset(nm)) { announce(`Preset ${nm} not saved: storage is full`); return; } input.value = ""; renderList(); announce(`Saved preset ${nm}`); }; // a refused save keeps the typed name
|
|
592
|
+
// The shared popover shell; align:"end" hangs it off the button's right edge.
|
|
593
|
+
const menuPop = popover(presetsBtn, presetsBtn, menu, {
|
|
594
|
+
width: 208, fallbackH: 160, align: "end",
|
|
595
|
+
onOpen: () => { renderList(); input.focus(); },
|
|
596
|
+
});
|
|
597
|
+
saveBtn.addEventListener("click", doSave);
|
|
598
|
+
input.addEventListener("keydown", (e) => { if (e.key === "Enter") doSave(); });
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ── Undo / redo (opts.undo) — a debounced history of snapshots, scoped to a hovered or focused panel so it doesn't hijack the page's own undo ──
|
|
602
|
+
const undoApi = opts.undo ? (() => {
|
|
603
|
+
// Bounded: each step is a deep clone of every value.
|
|
604
|
+
const HIST_MAX = 200;
|
|
605
|
+
let history: Snapshot[] = [], histIdx = 0, applyingHistory = false, histTimer = 0;
|
|
606
|
+
// Walk a new step beside the one before it: a string that didn't change takes the earlier step's copy, so an image's
|
|
607
|
+
// data URL is held once across history, not once a step. Strings only, never objects, so no step can edit another.
|
|
608
|
+
// True when nothing changed, which spares serializing both steps to compare them.
|
|
609
|
+
// (Snapshots are JSON: own keys, no undefined. A key b lacks reads undefined or an inherited function, and never matches.)
|
|
610
|
+
const share = (a: any, b: any): boolean => {
|
|
611
|
+
if (a === b) return true;
|
|
612
|
+
if (!a || !b || typeof a != "object" || typeof b != "object" || Array.isArray(a) != Array.isArray(b)) return false;
|
|
613
|
+
let same = Object.keys(a).length == Object.keys(b).length;
|
|
614
|
+
for (const k in a) if (a[k] === b[k]) a[k] = b[k]; else if (!share(a[k], b[k])) same = false;
|
|
615
|
+
return same;
|
|
616
|
+
};
|
|
617
|
+
const commit = () => {
|
|
618
|
+
histTimer = 0;
|
|
619
|
+
const snap = snapshot();
|
|
620
|
+
if (share(snap, history[histIdx])) return; // unchanged
|
|
621
|
+
history = history.slice(0, histIdx + 1); history.push(snap); // a new edit drops the redo branch
|
|
622
|
+
if (history.length > HIST_MAX) history = history.slice(history.length - HIST_MAX); // oldest steps age out
|
|
623
|
+
histIdx = history.length - 1;
|
|
624
|
+
};
|
|
625
|
+
const record = () => { if (applyingHistory) return; clearTimeout(histTimer); histTimer = setTimeout(commit, 350); };
|
|
626
|
+
const flush = () => { if (histTimer) { clearTimeout(histTimer); commit(); } }; // so ⌘Z right after a change still undoes that change
|
|
627
|
+
listeners.add(record); own.add(record);
|
|
628
|
+
const restore = (idx: number) => { applyingHistory = true; applySnapshot(history[idx]); applyingHistory = false; histIdx = idx; };
|
|
629
|
+
const undo = () => { flush(); if (histIdx > 0) restore(histIdx - 1); };
|
|
630
|
+
const redo = () => { flush(); if (histIdx < history.length - 1) restore(histIdx + 1); };
|
|
631
|
+
const focused = () => panel.matches(":hover") || panel.contains(document.activeElement);
|
|
632
|
+
// Registered by assemble() — see watchResize for why a global listener waits for the build.
|
|
633
|
+
const onKey = (e: KeyboardEvent) => {
|
|
634
|
+
if (!focused() || !(e.metaKey || e.ctrlKey) || inEditable(e.target as Element)) return;
|
|
635
|
+
const k = e.key.toLowerCase();
|
|
636
|
+
if (k === "z") { e.preventDefault(); e.shiftKey ? redo() : undo(); }
|
|
637
|
+
else if (k === "y") { e.preventDefault(); redo(); }
|
|
638
|
+
};
|
|
639
|
+
// patch: a late restore (an image the store just answered for) goes into every step that still held what its row held,
|
|
640
|
+
// so it isn't a step of its own, and ⌘Z neither takes it back out nor folds a pending edit into the base.
|
|
641
|
+
const patch = (path: string[], held: unknown, v: unknown) => {
|
|
642
|
+
const k = path[path.length - 1], h = JSON.parse(JSON.stringify(held ?? null)), nv = JSON.parse(JSON.stringify(v ?? null));
|
|
643
|
+
for (const step of history) { const o: any = atPath(step, path.slice(0, -1)); if (o && typeof o === "object" && hasOwn(o, k) && !valueChanged(o[k], h)) o[k] = nv; }
|
|
644
|
+
};
|
|
645
|
+
return { undo, redo, onKey, flush, arm: () => { history = [snapshot()]; histIdx = 0; }, patch };
|
|
646
|
+
})() : null;
|
|
647
|
+
|
|
648
|
+
// ── Pick and copy / paste — a press beside a row (in the panel's side padding, or a gap between rows) picks it, as
|
|
649
|
+
// a layer is picked in a design tool; beside a folder's header, the folder. ⌘C / ⌘V (ctrl elsewhere) then act on the
|
|
650
|
+
// pick, or with none on the page, on the row holding focus. A row copies its value and pastes one back; a folder copies
|
|
651
|
+
// its values object and pastes one into its own keys, so two folders with the same keys trade values. Escape, or a
|
|
652
|
+
// press anywhere else, drops the pick; a text field or a text selection keeps the browser's own keys. ──
|
|
653
|
+
const scopes = new Map<Element, Scope>(); // a row's or folder's root → its scope, filled by build()
|
|
654
|
+
const scopeAt = (n: Element | null): Scope | undefined => { for (; n && n !== panel && panel.contains(n); n = n.parentElement) { const s = scopes.get(n); if (s) return s; } };
|
|
655
|
+
const rowAt = (n: Element | null): Element | undefined => { for (; n && n !== panel; n = n.parentElement) if (scopes.get(n)?.kind === "row") return n; };
|
|
656
|
+
// Out of sight: hidden by its condition, on a tab page not showing, or inside a collapsed folder or panel.
|
|
657
|
+
const hidden = (el: Element) => !!(el.closest(".tw-cond-hidden, .tw-tabs-page:not([data-active=true])") || el.parentElement?.closest(".is-collapsed"));
|
|
658
|
+
let pick: [Element, Scope] | undefined;
|
|
659
|
+
const away = (e: Event) => { const t = e.target as Element; if (!panel.contains(t) && !t.closest?.(".tw-portal")) unpick(); };
|
|
660
|
+
const unpick = () => { if (!pick) return; pick[0].classList.remove("is-picked"); pick = undefined; document.removeEventListener("pointerdown", away, true); if (picked?.[0] === panel) picked = null; };
|
|
661
|
+
const choose = (el: Element, s: Scope) => {
|
|
662
|
+
if (picked && picked[0] !== panel) picked[1](); // one pick on the page
|
|
663
|
+
unpick(); pick = [el, s]; picked = [panel, unpick]; el.classList.add("is-picked");
|
|
664
|
+
document.addEventListener("pointerdown", away, true); // only while something is picked
|
|
665
|
+
// Focus moves to what was picked (a folder's header, a row's control), so its keys work and a screen reader names
|
|
666
|
+
// it; a press shows no ring. A text field isn't focused, or ⌘V would paste into it as text, and any selection goes.
|
|
667
|
+
const f = [...el.querySelectorAll<HTMLElement>('button:not(:disabled):not([tabindex="-1"]), [tabindex="0"]')].find((n) => !n.closest(".tw-hint, .tw-portal, [inert]")); // its tab stop, not a hint or a closed pop's
|
|
668
|
+
if (f) f.focus({ preventScroll: true, focusVisible: false } as FocusOptions); else (document.activeElement as HTMLElement | null)?.blur?.();
|
|
669
|
+
document.getSelection?.()?.removeAllRanges();
|
|
670
|
+
};
|
|
671
|
+
cleanups.push(unpick);
|
|
672
|
+
panel.addEventListener("pointerdown", (e) => {
|
|
673
|
+
const t = e.target as Element;
|
|
674
|
+
// On a control, or a header: working the picked row keeps the pick, anything else drops it. A touch never picks:
|
|
675
|
+
// a phone has no ⌘C, and a scroll that starts in the padding shouldn't pick what it passes.
|
|
676
|
+
if (rowAt(t) || t.closest(".tw-header, .tw-folder-header, button, input, textarea, select")) { if (!pick?.[0].contains(t)) unpick(); return; }
|
|
677
|
+
if (e.button !== 0 || e.pointerType === "touch") return unpick();
|
|
678
|
+
// Beside a row: the row (or folder header) level with the press, by its own box, so nothing overlapping the panel
|
|
679
|
+
// can stand in the way. The last match is the deepest: a folder's rows come after it.
|
|
680
|
+
let hit: [Element, Scope] | undefined;
|
|
681
|
+
for (const [el, s] of scopes) {
|
|
682
|
+
const r = (s.kind === "folder" ? el.firstElementChild! : el).getBoundingClientRect();
|
|
683
|
+
if (r.height && e.clientY >= r.top && e.clientY < r.bottom && !hidden(el)) hit = [el, s];
|
|
684
|
+
}
|
|
685
|
+
if (!hit) return unpick();
|
|
686
|
+
e.preventDefault(); // no text selection
|
|
687
|
+
choose(...hit);
|
|
688
|
+
}, true); // capture: a label's own pointerdown stops the event before it bubbles here
|
|
689
|
+
const inEditable = (t: Element | null) => !!(t && (t as HTMLElement).matches && ((t as HTMLElement).matches("input, textarea, select") || (t as HTMLElement).isContentEditable));
|
|
690
|
+
// Called from the panel's one keydown listener (registered by assemble(), beside the undo keys). Focus steps to the
|
|
691
|
+
// hidden field for the keypress (the copy selected in it), the browser copies or pastes there, and focus steps back.
|
|
692
|
+
const onClipKey = (e: KeyboardEvent) => {
|
|
693
|
+
if (e.key === "Escape") { if (pick) { e.stopPropagation(); unpick(); } return; }
|
|
694
|
+
if (!(e.metaKey || e.ctrlKey) || e.altKey || e.repeat || inEditable(e.target as Element)) return;
|
|
695
|
+
const k = e.key.toLowerCase(); if (k !== "c" && k !== "v") return;
|
|
696
|
+
if (picked && !picked[0].isConnected) picked[1](); // the picking panel left the page without destroy(): free the slot
|
|
697
|
+
if (pick && hidden(pick[0])) unpick(); // a pick out of sight takes nothing
|
|
698
|
+
const fe = document.activeElement, s = pick ? pick[1] : picked || !fe || hidden(fe) ? undefined : scopeAt(fe); // focus on a hidden row takes nothing either
|
|
699
|
+
if (!s || (k === "c" && String(document.getSelection?.() || ""))) return; // selected text: the browser copies that
|
|
700
|
+
const json = k === "c" ? JSON.stringify(s.kind === "row" ? s.entry.get() : atPath(snapshot(), s.path), replacer, 2) : "";
|
|
701
|
+
const copied = (ok: boolean) => { if (!destroyed) announce(ok ? `${s.label} copied` : "Copy failed"); }; // words for a screen reader; the toolbar's copy button stays as it is
|
|
702
|
+
const band = pick?.[0], back = document.activeElement as HTMLElement | null, ring = !!back?.matches(":focus-visible");
|
|
703
|
+
// The browser's copy or paste can land some milliseconds after this keydown (an app's menu passes it on), so focus
|
|
704
|
+
// steps back only when it lands, or after a second with none; it's caught page-wide, wherever focus is by then.
|
|
705
|
+
if (!catchEl) { catchEl = el("textarea", "tw-sr"); catchEl.tabIndex = -1; catchEl.setAttribute("aria-hidden", "true"); for (const t of ["copy", "paste"]) document.addEventListener(t, (e) => onCatch?.(e as ClipboardEvent), true); }
|
|
706
|
+
const ta = catchEl, done = () => { onCatch = null; if (document.activeElement === ta) { ta.blur(); back?.focus?.({ preventScroll: true, focusVisible: ring } as FocusOptions); } };
|
|
707
|
+
const h = onCatch = (ev) => {
|
|
708
|
+
if (ev.target !== ta && inEditable(ev.target as Element)) return; // a field focused meanwhile keeps its own paste
|
|
709
|
+
ev.preventDefault(); done();
|
|
710
|
+
if (json) return ev.clipboardData?.setData("text/plain", json), copied(!!ev.clipboardData);
|
|
711
|
+
const t = ev.clipboardData?.getData("text/plain") ?? null;
|
|
712
|
+
import("./clip.js").then((m) => m.pasteInto(t, (t) => m.pasteScope(ctx, s, t), ctx, band), () => {});
|
|
713
|
+
};
|
|
714
|
+
if (!ta.isConnected) document.body.appendChild(ta);
|
|
715
|
+
ta.value = json; ta.focus({ preventScroll: true }); ta.select();
|
|
716
|
+
setTimeout(() => { if (onCatch === h) { done(); if (json) copyText(json).then(copied); } }, 1000); // none came: write the copy directly
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
// Build the controls, then everything that needs them: the conditionals' first pass, the persisted restore,
|
|
720
|
+
// the undo seed, the queued API calls. Synchronous when every control type is registered, else behind panel.ready.
|
|
721
|
+
const assemble = () => {
|
|
722
|
+
if (destroyed) return; // destroy() before the chunks landed: nothing to build
|
|
723
|
+
built = cleanups.length; build(controls, metas, params);
|
|
724
|
+
if (conditionals.length) {
|
|
725
|
+
const byKey = new Map<string, Entry>(); for (const e of entries) if (!byKey.has(e.key)) byKey.set(e.key, e); // first wins
|
|
726
|
+
const getVal = (k: string) => { const e = byKey.get(k); return e ? e.target[e.key] : params[k]; };
|
|
727
|
+
const applyConditionals = () => {
|
|
728
|
+
let revealed = false;
|
|
729
|
+
for (const { node, m } of conditionals) {
|
|
730
|
+
// A throwing predicate leaves the node as-is rather than aborting the pass.
|
|
731
|
+
try {
|
|
732
|
+
if (m.render && foldRow(node, !m.render(getVal))) revealed = true;
|
|
733
|
+
if (m.disabled != null) { const d = typeof m.disabled === "function" ? m.disabled(getVal) : m.disabled; setDisabled(node, !!d); }
|
|
734
|
+
} catch {}
|
|
735
|
+
}
|
|
736
|
+
if (revealed) requestReflow(); // built hidden it measured 0×0; shown, let it re-measure
|
|
737
|
+
};
|
|
738
|
+
listeners.add(applyConditionals); own.add(applyConditionals); applyConditionals();
|
|
739
|
+
import("./fold.js").then((m) => { fold = m.fold; }, () => {}); // a fold chunk that fails to load leaves the rows toggling
|
|
740
|
+
}
|
|
741
|
+
// Restore last session, notifying: on the lazy path a host may already have subscribed.
|
|
742
|
+
if (persistKey) restore(unsaved(unwrap(readStore(persistKey), "values")));
|
|
743
|
+
if (destroyed) return; // a listener may have destroyed the panel on that notify
|
|
744
|
+
undoApi?.arm(); watchResize();
|
|
745
|
+
cleanups.push(onLive(panel, [[document, "keydown"]], (e: KeyboardEvent) => { undoApi?.onKey(e); onClipKey(e); })); // one document listener per panel for its keys
|
|
746
|
+
assembled = true;
|
|
747
|
+
// Replay after the restore, so an explicit host set() wins over a stored value; a destroy() mid-replay drops the rest.
|
|
748
|
+
for (const fn of queue.splice(0)) { if (destroyed) break; try { fn(); } catch (e) { console.error("[tweaks] a call queued before ready failed:", e); } }
|
|
749
|
+
for (const b of toolbarBtns) b.disabled = false; // the controls exist now
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
// A set() past a hard range clamps, which is right, but silent it reads as the value not taking: say so, once a key.
|
|
753
|
+
const clamped = new Set<Entry>();
|
|
754
|
+
// Each number asked for is held to the range the control resolved (derived bounds, a point's per component), not the
|
|
755
|
+
// schema's min and max, which a derived range leaves unset; a hair past an end is float noise, not a clamp.
|
|
756
|
+
const clampNote = (e: Entry, key: string, v: unknown) => {
|
|
757
|
+
const r = e.range; if (!r || e.m.soft || clamped.has(e)) return;
|
|
758
|
+
// [component, number] pairs: one number, a list (interval, sides), a sides string, or an object's sides or components.
|
|
759
|
+
const asked: [string, unknown][] = typeof v === "string" ? v.trim().split(/\s+/).map((s) => ["", parseFloat(s)]) : Array.isArray(v) ? v.map((n) => ["", n]) : isObj(v) ? Object.entries(v) : [["", v]];
|
|
760
|
+
for (const [k, n] of asked) {
|
|
761
|
+
const b = Array.isArray(r) ? r : r[k], eps = 1e-9 * Math.max(1, Math.abs(n as number));
|
|
762
|
+
if (b && typeof n === "number" && (n < b[0] - eps || n > b[1] + eps)) { clamped.add(e); notice("clamp", key, v, b[0], b[1], e.m.type); return; }
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
// Resolve a (possibly dotted) key, apply, report whether the leaf changed; never notifies, so setMany fires one notify.
|
|
766
|
+
const applySet = (key: string, v: unknown) => {
|
|
767
|
+
if (String(key).split(".").some(isReservedKey)) return false; // never write through to the prototype
|
|
768
|
+
const parts = String(key).split(".");
|
|
769
|
+
// The path exactly as changes() names it first, so a key holding a dot ("a.b", "f.a.b") is reachable; two controls
|
|
770
|
+
// whose paths read the same (a top-level "a.b" beside folder a's b) are ambiguous, not a guess.
|
|
771
|
+
const exact = entries.filter((x) => x.path.join(".") === key);
|
|
772
|
+
if (exact.length > 1) { notice("ambiguousPath", key, exact.length); return false; }
|
|
773
|
+
let e: Entry | undefined = exact[0];
|
|
774
|
+
if (!e && parts.length > 1) { const t = valueless.get(key); notice(t ? "noValue" : "noPath", key, t); return false; }
|
|
775
|
+
if (!e) {
|
|
776
|
+
const matches = entries.filter((x) => x.key === key);
|
|
777
|
+
if (matches.length > 1) { notice("ambiguousKey", key, matches.length, matches[0].path.join(".")); return false; }
|
|
778
|
+
e = matches[0];
|
|
779
|
+
}
|
|
780
|
+
// A key no control owns is refused: written through, a typo landed on params and in every save after.
|
|
781
|
+
// A button, monitor or separator is there but holds nothing to set, which "no control has that key" would misstate.
|
|
782
|
+
const vt = !e && (valueless.get(key) || [...valueless].find(([p]) => p.split(".").pop() === key)?.[1]);
|
|
783
|
+
if (!e) { if (vt) notice("noValue", key, vt); else notice("noControl", key, metas.some((m) => m.key === key && (m.type === "folder" || m.type === "tabs"))); return false; }
|
|
784
|
+
const prev = e.target[e.key];
|
|
785
|
+
// Same isolation as applySnapshot: a hostile value costs only that key.
|
|
786
|
+
if (notAValue(v)) return false;
|
|
787
|
+
try { assign(e, v); } catch (err) { console.error(`[tweaks] set("${key}") failed — value skipped:`, err); return false; }
|
|
788
|
+
clampNote(e, key, v);
|
|
789
|
+
if (!valueChanged(prev, e.target[e.key])) return false; // keeps a store-sync listener from echoing forever
|
|
790
|
+
last = e.path.join("."); // on((p, last)) sees programmatic sets like control edits
|
|
791
|
+
return true;
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
// Every mutating method goes through later(): live at once, queued in the lazy window, silent after destroy().
|
|
795
|
+
const api = {
|
|
796
|
+
el: panel, params,
|
|
797
|
+
on(fn) { if (destroyed) return () => {}; listeners.add(fn); return () => listeners.delete(fn); },
|
|
798
|
+
set(key, v, o) { const k = String(key), silent = o?.silent; later(() => { if (applySet(k, v)) notify(silent); }); }, // a key that can't stringify throws here, not in the replay
|
|
799
|
+
// Batch write: listeners + persist fire once for the whole batch.
|
|
800
|
+
setMany(values, o) {
|
|
801
|
+
if (values == null || typeof values !== "object") return;
|
|
802
|
+
const batch = Object.entries(values), silent = o?.silent; // read now, so a later mutation can't reach the replay
|
|
803
|
+
later(() => { let changed = 0; for (const [k, v] of batch) { try { if (applySet(k, v)) changed++; } catch (e) { console.error(`[tweaks] setMany("${k}") failed — key skipped:`, e); } } if (changed > 1) last = undefined; if (changed) notify(silent); }); // per-key isolation: a bad value costs only its key; one changed key is named, many aren't
|
|
804
|
+
},
|
|
805
|
+
reset() { if (assembled && !destroyed) spinReset(resetBtn); later(resetAll); },
|
|
806
|
+
// Whole-panel state — values + UI as a JSON-safe object (the standard toJSON hook). Before ready there
|
|
807
|
+
// are no values yet. fromJSON applies one back: values (one notify), then the UI silently, outside undo.
|
|
808
|
+
toJSON() { return destroyed ? { values: {}, ui: {} } : { values: snapshot(), ui: collectUI() }; },
|
|
809
|
+
changes() { return destroyed ? {} : changed(); }, // empty in the lazy window too — no entries yet
|
|
810
|
+
fromJSON(state, o) {
|
|
811
|
+
if (!state || typeof state !== "object") return;
|
|
812
|
+
const silent = o?.silent;
|
|
813
|
+
const values = state.values && typeof state.values === "object" ? { ...state.values } : state.values, ui = state.ui && typeof state.ui === "object" ? { ...state.ui } : state.ui; // read now (one level), like setMany
|
|
814
|
+
later(() => { if (values) applySnapshot(values, silent); applyUI(ui); });
|
|
815
|
+
},
|
|
816
|
+
// Live theming clears the prior theme first, so setTheme(null) reverts to the default look.
|
|
817
|
+
setName(n) { if (destroyed) return; title = String(n); titleEl.textContent = title; landmark(title); }, // a rename never moves saved values or presets
|
|
818
|
+
// A lifted panel's carried context sits under the theme: a key the old theme set and the new one drops falls back to it.
|
|
819
|
+
setTheme(theme) { if (destroyed) return; applyThemeVars(panel, null); for (const k in carried) panel.style.setProperty(k, carried[k]); themeVars = resolveTheme(theme); panel._twTheme = themeVars; applyThemeVars(panel, themeVars); window.dispatchEvent(new Event("tw-retheme")); },
|
|
820
|
+
// Presets API (no-ops without opts.persist); before ready they queue like set() does.
|
|
821
|
+
savePreset: (nm) => { if (destroyed || failed || !presetsKey || !nm) return false; let ok = true; later(() => { ok = savePreset(nm); }); return ok; }, // queued before ready counts as saved; once built, whether storage took it
|
|
822
|
+
loadPreset: (nm) => { if (destroyed || failed || !listPresets()[nm]) return false; later(() => loadPreset(nm)); return true; }, // whether it's stored now; a save still queued before ready isn't yet
|
|
823
|
+
deletePreset: (nm) => { if (presetsKey) later(() => deletePreset(nm)); },
|
|
824
|
+
presets: () => (destroyed ? [] : Object.keys(listPresets())),
|
|
825
|
+
undo: () => { if (undoApi) later(undoApi.undo); }, redo: () => { if (undoApi) later(undoApi.redo); }, // no-ops without opts.undo; queued before ready, in order
|
|
826
|
+
// Teardown. Safe to call before ready resolves — assemble() sees the flag and bails.
|
|
827
|
+
destroy() {
|
|
828
|
+
if (destroyed) return;
|
|
829
|
+
destroyed = true;
|
|
830
|
+
releaseKey();
|
|
831
|
+
closeActivePopover(panel); // page-wide singletons: close only what's anchored here
|
|
832
|
+
hideHintNow(panel);
|
|
833
|
+
for (const fn of cleanups.splice(0)) { try { fn(); } catch {} }
|
|
834
|
+
listeners.clear();
|
|
835
|
+
if (liftSlot) { liftSlot.remove(); liftSlot = null; }
|
|
836
|
+
if (topFloating === panel) topFloating = null;
|
|
837
|
+
panel.dataset.twDestroyed = ""; // onLive listeners release on their next event by this mark
|
|
838
|
+
panel.remove();
|
|
839
|
+
},
|
|
840
|
+
} as Panel; // `ready` is filled in just below
|
|
841
|
+
// Assemble once the missing modules resolve, surfacing that on ready; else assemble now. A panel that persists an image
|
|
842
|
+
// builds without waiting for its stored images; they fill in when the store answers, and `ready` waits for that too.
|
|
843
|
+
// A store that fails to load just saves no uploads.
|
|
844
|
+
const images = persistKey && scanTypes(metas).has("image") ? import("./image-store.js").then(async (m) => {
|
|
845
|
+
savedImages = await m.load(persistKey);
|
|
846
|
+
// A refused upload saves again, inline; after destroy() no save is armed, so it writes now while storage still names that ref.
|
|
847
|
+
if (savedImages) imgs = m.bind(persistKey, savedImages, (ref) => { if (!destroyed) persist(); else if (JSON.stringify(readStore(persistKey)).includes(JSON.stringify(ref))) saveNow(); }, listPresets, (v) => !!presetsKey && writePresets(v));
|
|
848
|
+
}, () => {}) : null;
|
|
849
|
+
if (!images) imagesReady = true;
|
|
850
|
+
const imagesIn = images && images.then(restoreImages);
|
|
851
|
+
const lazy = ensureForMetas(metas);
|
|
852
|
+
if (lazy) {
|
|
853
|
+
// A failed chunk or a throwing build leaves the panel unbuildable: queued calls dropped, later calls refused,
|
|
854
|
+
// ready rejects. A partway build must not leave live controls behind an inert API: release what it wired.
|
|
855
|
+
const fail = (e: unknown) => {
|
|
856
|
+
failed = true; queue.length = 0; releaseKey();
|
|
857
|
+
if (built >= 0) for (const fn of cleanups.splice(built)) { try { fn(); } catch {} } // only what the build wired; the shell's stay for destroy()
|
|
858
|
+
for (const c of controls.children) (c as HTMLElement).dataset.twDestroyed = ""; // detached below, they'd never see the panel's own mark
|
|
859
|
+
listeners.clear(); entries.length = 0; controls.replaceChildren();
|
|
860
|
+
if (!destroyed) console.warn(`[tweaks] "${name}": the panel could not be built — ${built >= 0 ? "the build threw" : "its lazy controls failed to load"}; the API is inert`); // the chunk's own error is logged in lazy.ts
|
|
861
|
+
throw e;
|
|
862
|
+
};
|
|
863
|
+
const building = lazy.then(assemble);
|
|
864
|
+
api.ready = panel.ready = (imagesIn ? Promise.all([building, imagesIn]) : building).then(() => api, fail); // fail covers both the chunk and a build that throws
|
|
865
|
+
api.ready.catch(() => {}); // a handled fork: no unhandled-rejection noise
|
|
866
|
+
}
|
|
867
|
+
else { assemble(); api.ready = panel.ready = imagesIn ? imagesIn.then(() => api) : Promise.resolve(api); }
|
|
868
|
+
return api as Panel;
|
|
869
|
+
}
|