tweakit 0.5.2 → 0.7.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 +32 -30
- 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-S2BOXQBM.js +2 -0
- package/dist/tweaks/bezier-S2BOXQBM.js.map +6 -0
- package/dist/tweaks/chunk-3MZAAMC2.js +2 -0
- package/dist/tweaks/chunk-3MZAAMC2.js.map +6 -0
- package/dist/tweaks/chunk-ETB35KFV.js +2 -0
- package/dist/tweaks/chunk-ETB35KFV.js.map +6 -0
- package/dist/tweaks/chunk-YB5GKWZI.js +2 -0
- package/dist/tweaks/chunk-YB5GKWZI.js.map +6 -0
- package/dist/tweaks/clip-A3VI53BK.js +2 -0
- package/dist/tweaks/clip-A3VI53BK.js.map +6 -0
- package/dist/tweaks/color-UGYMAF3W.js +2 -0
- package/dist/tweaks/color-UGYMAF3W.js.map +6 -0
- package/dist/tweaks/core.js +2 -1
- package/dist/tweaks/core.js.map +6 -0
- package/dist/tweaks/fold-GY47HIQA.js +2 -0
- package/dist/tweaks/fold-GY47HIQA.js.map +6 -0
- package/dist/tweaks/gradient-BTX25WSH.js +2 -0
- package/dist/tweaks/gradient-BTX25WSH.js.map +6 -0
- package/dist/tweaks/heavy-NPFIGHRM.js +2 -0
- package/dist/tweaks/heavy-NPFIGHRM.js.map +6 -0
- package/dist/tweaks/image-HTQQ4WX6.js +4 -0
- package/dist/tweaks/image-HTQQ4WX6.js.map +6 -0
- package/dist/tweaks/image-store-QD5SMZXG.js +2 -0
- package/dist/tweaks/image-store-QD5SMZXG.js.map +6 -0
- package/dist/tweaks/interval-BHPI4LRN.js +2 -0
- package/dist/tweaks/interval-BHPI4LRN.js.map +6 -0
- package/dist/tweaks/markup-QM53TQJZ.js +2 -0
- package/dist/tweaks/markup-QM53TQJZ.js.map +6 -0
- package/dist/tweaks/monitor-722WIYGW.js +3 -0
- package/dist/tweaks/monitor-722WIYGW.js.map +6 -0
- package/dist/tweaks/motion-HMCZK6HJ.js +2 -0
- package/dist/tweaks/motion-HMCZK6HJ.js.map +6 -0
- package/dist/tweaks/plot-D3NQ6XNN.js +3 -0
- package/dist/tweaks/plot-D3NQ6XNN.js.map +6 -0
- package/dist/tweaks/point-XDXKMDXH.js +2 -0
- package/dist/tweaks/point-XDXKMDXH.js.map +6 -0
- package/dist/tweaks/presets-EUWNSCUW.js +2 -0
- package/dist/tweaks/presets-EUWNSCUW.js.map +6 -0
- package/dist/tweaks/shadow-AM7VSRNS.js +2 -0
- package/dist/tweaks/shadow-AM7VSRNS.js.map +6 -0
- package/dist/tweaks/shadow-root-RYUV3AGS.js +3 -0
- package/dist/tweaks/shadow-root-RYUV3AGS.js.map +6 -0
- package/dist/tweaks/sides-JWIURDA4.js +2 -0
- package/dist/tweaks/sides-JWIURDA4.js.map +6 -0
- package/dist/tweaks/spring-BF5GBTPM.js +2 -0
- package/dist/tweaks/spring-BF5GBTPM.js.map +6 -0
- package/dist/tweaks/tabs-PRVUUZXZ.js +2 -0
- package/dist/tweaks/tabs-PRVUUZXZ.js.map +6 -0
- package/dist/tweaks.css +1 -1
- package/dist/tweaks.js +7 -3
- package/dist/tweaks.js.map +6 -0
- package/dist/types/tweaks/easing.d.ts +8 -11
- package/dist/types/tweaks/enhance.d.ts +3 -2
- 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 +499 -116
- package/package.json +30 -5
- package/src/tweaks/auto.ts +16 -0
- package/src/tweaks/clip.ts +156 -0
- package/src/tweaks/controls/basic.ts +727 -0
- package/src/tweaks/controls/bezier.ts +204 -0
- package/src/tweaks/controls/color.ts +861 -0
- package/src/tweaks/controls/gradient.ts +419 -0
- package/src/tweaks/controls/image.ts +555 -0
- package/src/tweaks/controls/interval.ts +189 -0
- package/src/tweaks/controls/monitor.ts +211 -0
- package/src/tweaks/controls/motion.ts +316 -0
- package/src/tweaks/controls/plot.ts +440 -0
- package/src/tweaks/controls/point.ts +208 -0
- package/src/tweaks/controls/shadow.ts +156 -0
- package/src/tweaks/controls/sides.ts +196 -0
- package/src/tweaks/controls/spring.ts +308 -0
- package/src/tweaks/controls/tabs.ts +68 -0
- package/src/tweaks/core.ts +9 -0
- package/src/tweaks/easing.ts +118 -0
- package/src/tweaks/enhance.ts +35 -0
- package/src/tweaks/feedback.ts +218 -0
- package/src/tweaks/fold.ts +33 -0
- package/src/tweaks/heavy.ts +626 -0
- package/src/tweaks/icons.ts +26 -0
- package/src/tweaks/image-store.ts +189 -0
- package/src/tweaks/lazy.ts +63 -0
- package/src/tweaks/markup.ts +317 -0
- package/src/tweaks/panel.ts +1693 -0
- package/src/tweaks/presets.ts +85 -0
- package/src/tweaks/schema.ts +351 -0
- package/src/tweaks/shadow-root.ts +178 -0
- package/src/tweaks/shared.ts +1137 -0
- package/src/tweaks/single.ts +22 -0
- package/src/tweaks/standalone.ts +179 -0
- package/src/tweaks/types.ts +1027 -0
- package/src/tweaks/undo.ts +104 -0
- package/src/tweaks.css +965 -0
- package/src/wide-gamut.ts +658 -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/fold-UGN7TXGX.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,1693 @@
|
|
|
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,
|
|
5
|
+
btn,
|
|
6
|
+
txt,
|
|
7
|
+
clamp,
|
|
8
|
+
popover,
|
|
9
|
+
closeActivePopover,
|
|
10
|
+
stopPointerLeak,
|
|
11
|
+
foldBody,
|
|
12
|
+
applyThemeVars,
|
|
13
|
+
resolveTheme,
|
|
14
|
+
carryScheme,
|
|
15
|
+
onLive,
|
|
16
|
+
setDisabled,
|
|
17
|
+
requestReflow,
|
|
18
|
+
quietFocus,
|
|
19
|
+
activeIn,
|
|
20
|
+
isolate,
|
|
21
|
+
using,
|
|
22
|
+
words,
|
|
23
|
+
getControl,
|
|
24
|
+
notice,
|
|
25
|
+
isObj,
|
|
26
|
+
hasOwn,
|
|
27
|
+
isReservedKey,
|
|
28
|
+
valueChanged,
|
|
29
|
+
restoreDefault,
|
|
30
|
+
} from "./shared.js";
|
|
31
|
+
import { metaFor, VALUELESS } from "./schema.js";
|
|
32
|
+
import { ensureForMetas, scanTypes } from "./lazy.js";
|
|
33
|
+
import { createFolder, createControl } from "./controls/basic.js";
|
|
34
|
+
import {
|
|
35
|
+
makeCopyBtn,
|
|
36
|
+
makeResetBtn,
|
|
37
|
+
toolbarBtn,
|
|
38
|
+
copyText,
|
|
39
|
+
spinReset,
|
|
40
|
+
announce,
|
|
41
|
+
hideHintNow,
|
|
42
|
+
addHintMarker,
|
|
43
|
+
LABEL_SEL,
|
|
44
|
+
} from "./feedback.js";
|
|
45
|
+
import { ICON_PRESETS, ICON_X } from "./icons.js";
|
|
46
|
+
import { createUndo } from "./undo.js";
|
|
47
|
+
import type {
|
|
48
|
+
Schema,
|
|
49
|
+
HeldSchema,
|
|
50
|
+
TweaksOptions,
|
|
51
|
+
Panel,
|
|
52
|
+
Params,
|
|
53
|
+
ParamsOf,
|
|
54
|
+
KnownTypes,
|
|
55
|
+
PanelState,
|
|
56
|
+
Control,
|
|
57
|
+
} from "./types.js";
|
|
58
|
+
import type { Meta } from "./schema.js";
|
|
59
|
+
import type { ClipCtx, Scope } from "./clip.js";
|
|
60
|
+
import type { PanelEl } from "./shared.js";
|
|
61
|
+
import type { TabsControl } from "./controls/tabs.js";
|
|
62
|
+
|
|
63
|
+
/** A params object or one of its folder/tabs sub-objects — the holder a control writes into. */
|
|
64
|
+
type Target = Record<string, unknown>;
|
|
65
|
+
/** A valued control's wiring: where its value lives, its set/get, the reset pair (raw, def) and its dotted path. */
|
|
66
|
+
export interface Entry {
|
|
67
|
+
target: Target;
|
|
68
|
+
key: string;
|
|
69
|
+
set: Control["set"];
|
|
70
|
+
get: Control["get"];
|
|
71
|
+
raw: unknown;
|
|
72
|
+
def: unknown;
|
|
73
|
+
path: string[];
|
|
74
|
+
persist: boolean;
|
|
75
|
+
m: Meta;
|
|
76
|
+
el: HTMLElement;
|
|
77
|
+
note?: Control["note"];
|
|
78
|
+
range?: Control["range"];
|
|
79
|
+
}
|
|
80
|
+
/** A values snapshot — params as JSON round-trips it. */
|
|
81
|
+
export type Snapshot = Record<string, any>;
|
|
82
|
+
|
|
83
|
+
let topFloating: HTMLElement | null = null; // the floating panel raised last (raise())
|
|
84
|
+
const liveKeys = new Map<string, number>(); // persist keys of the panels alive now, for the shared-key warning
|
|
85
|
+
let picked: [HTMLElement, () => void] | null = null; // the panel holding the page's one pick, and how to drop it
|
|
86
|
+
// The page's one hidden field that a picked ⌘C / ⌘V focuses for the keypress, so the browser's own copy and paste run
|
|
87
|
+
// on it: the system clipboard both ways, with no permission to ask. `onCatch` is that keypress's answer.
|
|
88
|
+
let catchEl: HTMLTextAreaElement | null = null,
|
|
89
|
+
onCatch: ((e: ClipboardEvent) => void) | null = null;
|
|
90
|
+
|
|
91
|
+
/** Build a panel from `schema`; the shell and API exist synchronously, lazy controls fill in on `ready`.
|
|
92
|
+
* A schema written inline (or `as const`) types `params` from its shape. */
|
|
93
|
+
export function tweaks<const S extends HeldSchema>(
|
|
94
|
+
name: string,
|
|
95
|
+
schema: S & KnownTypes<S>,
|
|
96
|
+
opts?: TweaksOptions,
|
|
97
|
+
): Panel<ParamsOf<S>>;
|
|
98
|
+
export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): Panel {
|
|
99
|
+
// an untitled tab page's name is a word too
|
|
100
|
+
const metas = using(opts.strings, () =>
|
|
101
|
+
Object.entries(schema)
|
|
102
|
+
.map(([k, v]) => metaFor(k, v))
|
|
103
|
+
.filter(Boolean),
|
|
104
|
+
);
|
|
105
|
+
const tr = words(opts.strings); // the panel's own words; its controls take them as assemble() builds them
|
|
106
|
+
const params: Params = {};
|
|
107
|
+
// the dotted path the latest change touched (`card.size`), as set() and changes() name it: on()'s second argument
|
|
108
|
+
let last: string | undefined;
|
|
109
|
+
const entries: Entry[] = []; // flattened across folders, for reset + persist
|
|
110
|
+
// dotted path → type of each button, monitor, separator: set() says it holds no value
|
|
111
|
+
const valueless = new Map<string, string>();
|
|
112
|
+
const listeners = new Set<(p: Params, last?: string) => void>();
|
|
113
|
+
const own = new Set<(p: Params, last?: string) => void>(); // undo + conditionals: all a silent write calls
|
|
114
|
+
const cleanups: Array<() => void> = []; // releases for destroy(): document/window listeners, timers
|
|
115
|
+
let destroyed = false; // set by destroy(): assemble() bails, mutators go silent
|
|
116
|
+
let assembled = false; // set at the end of assemble(); no controls exist before
|
|
117
|
+
// The lazy window: a mutating API call made before the controls exist queues here and replays in order once
|
|
118
|
+
// assemble() has built them.
|
|
119
|
+
const queue: Array<() => void> = [];
|
|
120
|
+
let failed = false; // the chunks failed: the panel can never be built
|
|
121
|
+
let built = -1; // cleanups.length as assemble() enters build()
|
|
122
|
+
// the failed build already said why
|
|
123
|
+
const later = (fn: () => void) => {
|
|
124
|
+
if (destroyed) return;
|
|
125
|
+
if (failed) return;
|
|
126
|
+
assembled ? fn() : queue.push(fn);
|
|
127
|
+
};
|
|
128
|
+
let liftSlot: HTMLSpanElement | null = null; // the placeholder a lifted panel leaves in its slot
|
|
129
|
+
// what the lift carried from the slot's context (lift()), under the panel's own theme
|
|
130
|
+
const carried: Record<string, string> = {};
|
|
131
|
+
// The values snapshot: undefined written as null (JSON would drop the key).
|
|
132
|
+
const replacer = (_k: string, v: unknown) => (v === undefined ? null : v);
|
|
133
|
+
// params as the schema shapes it, folders and tab pages included: a value the host parks on params is its own, so
|
|
134
|
+
// snapshots, restores and undo carry only what a control holds. Null-prototype: a folder named "toString" is its own.
|
|
135
|
+
const valuesOf = (ms: Meta[], from: Target): Snapshot => {
|
|
136
|
+
const o: Snapshot = Object.create(null);
|
|
137
|
+
for (const m of ms)
|
|
138
|
+
if (hasOwn(from, m.key)) {
|
|
139
|
+
// skipped and valueless rows hold nothing
|
|
140
|
+
const v: unknown = from[m.key];
|
|
141
|
+
if (m.type === "folder") o[m.key] = valuesOf(m.children, v as Target);
|
|
142
|
+
else if (m.type === "tabs") {
|
|
143
|
+
const t: Snapshot = (o[m.key] = Object.create(null));
|
|
144
|
+
for (const pg of m.pages)
|
|
145
|
+
if (hasOwn(v as Target, pg.key)) t[pg.key] = valuesOf(pg.children, (v as Target)[pg.key] as Target);
|
|
146
|
+
} else o[m.key] = v;
|
|
147
|
+
}
|
|
148
|
+
return o;
|
|
149
|
+
};
|
|
150
|
+
const snapshot = (): Snapshot => JSON.parse(JSON.stringify(valuesOf(metas, params), replacer));
|
|
151
|
+
// Storage keys — opt-in via opts.persist (a string key, or `true` to key by name).
|
|
152
|
+
const persistKey = opts.persist ? `tw:${opts.persist === true ? name : opts.persist}` : null;
|
|
153
|
+
const presetsKey = persistKey ? `${persistKey}:presets` : null;
|
|
154
|
+
// Two live panels on one key overwrite each other's saves, the last write winning: say so. A remount after destroy()
|
|
155
|
+
// is fine.
|
|
156
|
+
// The key is held until destroy(), or until a failed build leaves the panel unable to save: a remount then is quiet
|
|
157
|
+
// too.
|
|
158
|
+
let holdsKey = !!persistKey;
|
|
159
|
+
if (persistKey) {
|
|
160
|
+
const n = liveKeys.get(persistKey) || 0;
|
|
161
|
+
if (n) notice("sharedKey", persistKey.slice(3));
|
|
162
|
+
liveKeys.set(persistKey, n + 1);
|
|
163
|
+
}
|
|
164
|
+
const releaseKey = () => {
|
|
165
|
+
if (!holdsKey) return;
|
|
166
|
+
holdsKey = false;
|
|
167
|
+
const n = (liveKeys.get(persistKey!) || 1) - 1;
|
|
168
|
+
if (n) liveKeys.set(persistKey!, n);
|
|
169
|
+
else liveKeys.delete(persistKey!);
|
|
170
|
+
};
|
|
171
|
+
// any: a parsed JSON blob, whatever the store holds
|
|
172
|
+
const readStore = (k: string): any => {
|
|
173
|
+
try {
|
|
174
|
+
return JSON.parse(localStorage.getItem(k) || "null");
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
// false: storage off, full or denied
|
|
180
|
+
const writeStore = (k: string, v: unknown) => {
|
|
181
|
+
try {
|
|
182
|
+
localStorage.setItem(k, JSON.stringify(v));
|
|
183
|
+
return true;
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
// Saved values and presets carry a format version, { v: 1, values } and { v: 1, presets }, so a later change to
|
|
189
|
+
// what's stored (a color's precision, a gray's hue) can tell old data from new. Anything else (0.5's bare object)
|
|
190
|
+
// reads as nothing, and the panel opens on its defaults.
|
|
191
|
+
const unwrap = (s: any, k: string) => (s?.v === 1 ? s[k] : null);
|
|
192
|
+
const writeValues = (snap: Snapshot) => writeStore(persistKey!, { v: 1, values: snap });
|
|
193
|
+
const writePresets = (all: unknown) => writeStore(presetsKey!, { v: 1, presets: all });
|
|
194
|
+
// own keys only: a control keyed toString must not read Object.prototype's
|
|
195
|
+
const atPath = (obj: Snapshot | null, path: string[]): unknown =>
|
|
196
|
+
path.reduce<any>((o, k) => (o != null && hasOwn(o, k) ? o[k] : undefined), obj);
|
|
197
|
+
// Take one entry's value out of a snapshot; true when it was there. Reflect: an undeletable key is left, not thrown.
|
|
198
|
+
const drop = (snap: Snapshot | null, e: Entry) => {
|
|
199
|
+
const o: any = atPath(snap, e.path.slice(0, -1));
|
|
200
|
+
if (!o || typeof o !== "object" || !hasOwn(o, e.key)) return false;
|
|
201
|
+
Reflect.deleteProperty(o, e.key);
|
|
202
|
+
return true;
|
|
203
|
+
};
|
|
204
|
+
// A control with `persist: false` stays out of what is saved and out of what is restored.
|
|
205
|
+
const unsaved = (snap: Snapshot | null) => {
|
|
206
|
+
for (const e of entries) if (!e.persist) drop(snap, e);
|
|
207
|
+
return snap;
|
|
208
|
+
};
|
|
209
|
+
// ── Images in IndexedDB (image-store.ts): what storage holds for an uploaded image is its ref, and `savedImages` maps this
|
|
210
|
+
// panel's refs to their data URLs. The build doesn't wait for them: every other value restores as it builds, and a
|
|
211
|
+
// stored image waits in `waiting` until the store answers, then fills in (restoreImages), by `ready`. With no store
|
|
212
|
+
// to hold an upload (not answered yet, unavailable, or it refused that image), the data URL saves inline, as 0.5
|
|
213
|
+
// saved it, and moves to the store on a later save; a write storage refuses then leaves the inline images out. With
|
|
214
|
+
// no store, a stored ref
|
|
215
|
+
// stays saved as it was until its row is written. ──
|
|
216
|
+
// both null without IndexedDB
|
|
217
|
+
let imgs: ReturnType<(typeof import("./image-store.js"))["bind"]> | null = null,
|
|
218
|
+
savedImages: Map<string, string> | null = null;
|
|
219
|
+
let imagesReady = false; // the store has answered, or failed to load
|
|
220
|
+
// a row's stored ref, and the value it held while the store loaded or the image was fetched
|
|
221
|
+
const waiting = new Map<Entry, [string, unknown]>();
|
|
222
|
+
// image-store.ts's ref, readable before that chunk loads
|
|
223
|
+
const isRef = (v: unknown): v is string => typeof v === "string" && v.startsWith("tw-image:");
|
|
224
|
+
const isDataUrl = (v: unknown): v is string => typeof v === "string" && v.startsWith("data:");
|
|
225
|
+
const imageEntries = () => entries.filter((e) => e.m.type === "image");
|
|
226
|
+
const unsavable = new Map<Entry, unknown>();
|
|
227
|
+
const notSaved = (e: Entry, v: unknown) => {
|
|
228
|
+
if (unsavable.get(e) === v) return;
|
|
229
|
+
unsavable.set(e, v);
|
|
230
|
+
e.note?.(tr("notSaved", "Not saved"));
|
|
231
|
+
};
|
|
232
|
+
const toRefs = (snap: Snapshot) => {
|
|
233
|
+
const stored = imagesReady && imgs ? readStore(persistKey!) : undefined; // undefined: no store to hold an upload
|
|
234
|
+
for (const e of imageEntries()) {
|
|
235
|
+
const o: any = atPath(snap, e.path.slice(0, -1)),
|
|
236
|
+
v = o?.[e.key],
|
|
237
|
+
w = waiting.get(e);
|
|
238
|
+
// still loading or no store: keep the stored ref; a write since settles it
|
|
239
|
+
if (w && o && hasOwn(o, e.key)) {
|
|
240
|
+
if (!valueChanged(w[1], v)) {
|
|
241
|
+
o[e.key] = w[0];
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
waiting.delete(e);
|
|
245
|
+
}
|
|
246
|
+
if (!isDataUrl(v) || stored === undefined) continue; // a URL is small, and a data URL with no store saves inline
|
|
247
|
+
const ref = imgs!.save(e, v, stored);
|
|
248
|
+
if (ref) o[e.key] = ref;
|
|
249
|
+
}
|
|
250
|
+
return snap;
|
|
251
|
+
};
|
|
252
|
+
// An image a restore names by ref: from the store's answer, or once it answers; a ref the store has never seen here
|
|
253
|
+
// (a preset another tab saved) is fetched. It lands only on a row the host and the person left alone meanwhile.
|
|
254
|
+
const late = (e: Entry, held: unknown, url: string) => {
|
|
255
|
+
if (destroyed || valueChanged(held, e.get()) || !tryAssign(e, url)) return false;
|
|
256
|
+
undoApi?.patch(e.path, held, e.get()); // not a step of its own: ⌘Z mustn't take a restored image back out
|
|
257
|
+
return true;
|
|
258
|
+
};
|
|
259
|
+
// A stored snapshot back to values, the way applySnapshot takes them: each known ref resolved, an unknown one left
|
|
260
|
+
// out (its control keeps its value) and listed, so `restore` can fill it in once it arrives.
|
|
261
|
+
const fromRefs = (snap: Snapshot | null, pending: [Entry, string][] = []) => {
|
|
262
|
+
if (snap && typeof snap === "object")
|
|
263
|
+
for (const e of imageEntries()) {
|
|
264
|
+
const v = atPath(snap, e.path);
|
|
265
|
+
if (!isRef(v)) continue;
|
|
266
|
+
const url = savedImages?.get(v);
|
|
267
|
+
if (url) (atPath(snap, e.path.slice(0, -1)) as any)[e.key] = url;
|
|
268
|
+
else {
|
|
269
|
+
drop(snap, e);
|
|
270
|
+
pending.push([e, v]);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return snap;
|
|
274
|
+
};
|
|
275
|
+
const restore = (snap: Snapshot | null, silent?: boolean) => {
|
|
276
|
+
const pending: [Entry, string][] = [];
|
|
277
|
+
applySnapshot(fromRefs(snap, pending), silent);
|
|
278
|
+
for (const [e, ref] of pending) {
|
|
279
|
+
// a write meanwhile settles it, even back to the value held
|
|
280
|
+
const w: [string, unknown] = [ref, e.get()];
|
|
281
|
+
waiting.set(e, w);
|
|
282
|
+
if (!imagesReady || !imgs) continue; // no store: the ref stays saved until the row is written
|
|
283
|
+
imgs.fetch(ref).then((url) => {
|
|
284
|
+
if (waiting.get(e) !== w) return;
|
|
285
|
+
waiting.delete(e);
|
|
286
|
+
if (url && late(e, w[1], url)) {
|
|
287
|
+
last = undefined;
|
|
288
|
+
notify();
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
// The store answered: each waiting row the host and the person left alone takes its image, in one notify.
|
|
294
|
+
const restoreImages = () => {
|
|
295
|
+
imagesReady = true;
|
|
296
|
+
// not built yet: the build resolves refs itself
|
|
297
|
+
if (!assembled || destroyed || failed) {
|
|
298
|
+
waiting.clear();
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
let changed = false;
|
|
302
|
+
for (const [e, [ref, held]] of [...waiting]) {
|
|
303
|
+
const url = savedImages?.get(ref);
|
|
304
|
+
if (url && late(e, held, url)) changed = true;
|
|
305
|
+
}
|
|
306
|
+
// no store: each ref stays waiting, so no save writes the default over it and no later session prunes it
|
|
307
|
+
if (imgs) waiting.clear();
|
|
308
|
+
if (changed) {
|
|
309
|
+
last = undefined;
|
|
310
|
+
notify();
|
|
311
|
+
} else if (imgs) persist(); // an upload saved inline while the store loaded moves to it now
|
|
312
|
+
};
|
|
313
|
+
// Delete the records nothing names any more: not the saved values, not a preset. Only records this panel knows,
|
|
314
|
+
// loaded or written: another tab's panel on the same key may have just stored one this panel has never seen.
|
|
315
|
+
const pruneImages = () => imgs?.prune([readStore(persistKey!), listPresets()]);
|
|
316
|
+
// Debounced save; destroy() flushes a pending write rather than dropping it. saveNow writes at once.
|
|
317
|
+
let saveNow = () => {};
|
|
318
|
+
const persist = persistKey
|
|
319
|
+
? (() => {
|
|
320
|
+
let saveT = 0;
|
|
321
|
+
// A write storage refuses (off, denied, or full of something else) is dropped silently, unless an image saved
|
|
322
|
+
// inline may be what didn't fit: then those are left out, their rows say so, and the rest is written.
|
|
323
|
+
const save = () => {
|
|
324
|
+
saveT = 0;
|
|
325
|
+
const snap = toRefs(unsaved(snapshot()));
|
|
326
|
+
if (!writeValues(snap)) {
|
|
327
|
+
const inline = imageEntries().filter((e) => isDataUrl(atPath(snap, e.path)));
|
|
328
|
+
if (!inline.length) return;
|
|
329
|
+
for (const e of inline) {
|
|
330
|
+
notSaved(e, atPath(snap, e.path));
|
|
331
|
+
drop(snap, e);
|
|
332
|
+
}
|
|
333
|
+
if (!writeValues(snap)) return;
|
|
334
|
+
}
|
|
335
|
+
pruneImages();
|
|
336
|
+
};
|
|
337
|
+
saveNow = save;
|
|
338
|
+
cleanups.push(() => {
|
|
339
|
+
if (saveT) {
|
|
340
|
+
clearTimeout(saveT);
|
|
341
|
+
save();
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
return () => {
|
|
345
|
+
clearTimeout(saveT);
|
|
346
|
+
saveT = setTimeout(save, 150);
|
|
347
|
+
};
|
|
348
|
+
})()
|
|
349
|
+
: () => {};
|
|
350
|
+
// Each listener runs isolated: a throwing callback can't break the others or skip persist().
|
|
351
|
+
// `last` is read once, so a write one listener makes can't change the key the rest are told.
|
|
352
|
+
// a listener may destroy() the panel: arm no save behind it
|
|
353
|
+
const notify = (silent?: boolean, changed = last) => {
|
|
354
|
+
(silent ? own : listeners).forEach((fn) => {
|
|
355
|
+
try {
|
|
356
|
+
fn(params, changed);
|
|
357
|
+
} catch (e) {
|
|
358
|
+
console.error("[tweaks] listener threw:", e);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
if (!destroyed) persist();
|
|
362
|
+
};
|
|
363
|
+
// The one write path behind restores and set(). A function/symbol/bigint is no control value (JSON can't carry
|
|
364
|
+
// those).
|
|
365
|
+
const notAValue = (v: unknown) => typeof v === "function" || typeof v === "symbol" || typeof v === "bigint";
|
|
366
|
+
// An explicit write also settles a row still waiting for its stored image: the host's or the person's value wins.
|
|
367
|
+
const assign = (e: Entry, v: unknown) => {
|
|
368
|
+
if (notAValue(v)) throw new TypeError(`a ${typeof v} is not a control value`);
|
|
369
|
+
waiting.delete(e);
|
|
370
|
+
e.set(v);
|
|
371
|
+
e.target[e.key] = e.get();
|
|
372
|
+
};
|
|
373
|
+
const tryAssign = (e: Entry, v: unknown) => {
|
|
374
|
+
try {
|
|
375
|
+
assign(e, v);
|
|
376
|
+
return true;
|
|
377
|
+
} catch (err) {
|
|
378
|
+
console.error(`[tweaks] restoring "${e.path.join(".")}" failed — value skipped:`, err);
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
// …and the reset write: the opened form, then the authored value (restoreDefault).
|
|
383
|
+
const assignDefault = (e: Entry) => {
|
|
384
|
+
waiting.delete(e);
|
|
385
|
+
restoreDefault(e, e.raw, e.def);
|
|
386
|
+
e.target[e.key] = e.get();
|
|
387
|
+
};
|
|
388
|
+
// opts.onReset replaces the default; a reset() called from INSIDE it performs the default instead of recursing.
|
|
389
|
+
let inOnReset = false;
|
|
390
|
+
const resetAll = () => {
|
|
391
|
+
if (typeof opts.onReset === "function" && !inOnReset) {
|
|
392
|
+
inOnReset = true;
|
|
393
|
+
try {
|
|
394
|
+
return opts.onReset();
|
|
395
|
+
} finally {
|
|
396
|
+
inOnReset = false;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
for (const e of entries) {
|
|
400
|
+
try {
|
|
401
|
+
assignDefault(e);
|
|
402
|
+
} catch (err) {
|
|
403
|
+
console.error(`[tweaks] resetting "${e.path.join(".")}" failed — control skipped:`, err);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
last = undefined;
|
|
407
|
+
notify();
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const panel: PanelEl = el("div", "twk-panel");
|
|
411
|
+
panel.dataset.mode = "inline";
|
|
412
|
+
// A landmark, so a screen reader can jump to the panel and hear which one; an unnamed region is no landmark, so a
|
|
413
|
+
// blank name falls back.
|
|
414
|
+
const landmark = (n: string) => panel.setAttribute("aria-label", n.trim() || tr("panel", "Tweaks"));
|
|
415
|
+
panel.setAttribute("role", "region");
|
|
416
|
+
landmark(name);
|
|
417
|
+
// Stop pointer events leaking past the panel to a stage listening on window.
|
|
418
|
+
stopPointerLeak(panel);
|
|
419
|
+
const header = el("div", "twk-header");
|
|
420
|
+
// The title is the collapse toggle; the toolbar beside it never collapses.
|
|
421
|
+
const titleBtn = btn("twk-header-toggle");
|
|
422
|
+
titleBtn.setAttribute("aria-expanded", "true");
|
|
423
|
+
let title = name; // setName() moves it; the storage key keeps the built name
|
|
424
|
+
const titleEl = txt("span", "twk-title", title);
|
|
425
|
+
titleBtn.append(titleEl);
|
|
426
|
+
const toolbar = el("div", "twk-toolbar");
|
|
427
|
+
// The values moved off their defaults, keyed by dotted path — setMany()'s shape, for baking tuned values into source.
|
|
428
|
+
const changed = (): Record<string, unknown> => {
|
|
429
|
+
const out: Record<string, unknown> = {};
|
|
430
|
+
for (const e of entries) if (valueChanged(e.get(), e.def)) out[e.path.join(".")] = e.get();
|
|
431
|
+
return JSON.parse(JSON.stringify(out, replacer));
|
|
432
|
+
};
|
|
433
|
+
// ⇧-click copies the changed values only; reset restores every default or runs opts.onReset.
|
|
434
|
+
const copyBtn = makeCopyBtn((changedOnly) => {
|
|
435
|
+
if (!changedOnly) return [JSON.stringify(snapshot(), null, 2), -1];
|
|
436
|
+
const c = changed();
|
|
437
|
+
return [JSON.stringify(c, null, 2), Object.keys(c).length];
|
|
438
|
+
}, tr);
|
|
439
|
+
// A picked row's paste (pasteScope, in the lazy clip chunk) writes through the panel's own load path; ctx lends it.
|
|
440
|
+
// A paste is its own undo step: what was pending commits first, the paste right after, so neither merges with a drag.
|
|
441
|
+
const ctx: ClipCtx = {
|
|
442
|
+
panel,
|
|
443
|
+
entries,
|
|
444
|
+
fromJSON: (st) => {
|
|
445
|
+
undoApi?.flush();
|
|
446
|
+
api.fromJSON(st);
|
|
447
|
+
undoApi?.flush();
|
|
448
|
+
},
|
|
449
|
+
announce,
|
|
450
|
+
alive: () => !destroyed,
|
|
451
|
+
tr,
|
|
452
|
+
};
|
|
453
|
+
const resetBtn = makeResetBtn(resetAll, tr);
|
|
454
|
+
// Presets share persistence's storage, so the button needs it on.
|
|
455
|
+
let presetsBtn: HTMLButtonElement | null = null;
|
|
456
|
+
if (persistKey && opts.presets !== false) {
|
|
457
|
+
// opts.presets:false keeps the saving, without the menu
|
|
458
|
+
presetsBtn = toolbarBtn("", ICON_PRESETS, tr("presets", "Presets"));
|
|
459
|
+
presetsBtn.setAttribute("aria-haspopup", "menu");
|
|
460
|
+
presetsBtn.setAttribute("aria-expanded", "false");
|
|
461
|
+
toolbar.append(presetsBtn);
|
|
462
|
+
}
|
|
463
|
+
toolbar.append(copyBtn, resetBtn);
|
|
464
|
+
header.append(titleBtn);
|
|
465
|
+
if (opts.toolbar !== false) header.append(toolbar); // toolbar:false → a bare panel, e.g. an embedded demo
|
|
466
|
+
// The buttons act on controls that only exist once assemble() has built them: inert until then.
|
|
467
|
+
const toolbarBtns = [copyBtn, resetBtn, presetsBtn].filter(Boolean);
|
|
468
|
+
for (const b of toolbarBtns) b.disabled = true;
|
|
469
|
+
// swallowClick lives on the header, not the title: once the header holds pointer capture, the click ending a drag is
|
|
470
|
+
// dispatched there.
|
|
471
|
+
let dragMoved = false,
|
|
472
|
+
swallowClick = false;
|
|
473
|
+
header.addEventListener(
|
|
474
|
+
"click",
|
|
475
|
+
(e) => {
|
|
476
|
+
if (swallowClick) {
|
|
477
|
+
swallowClick = false;
|
|
478
|
+
e.preventDefault();
|
|
479
|
+
e.stopPropagation();
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
true,
|
|
483
|
+
);
|
|
484
|
+
// A bottom-parked floating panel grows past the viewport when it expands: re-clamp once the collapse has settled.
|
|
485
|
+
const toggleBody = () => foldBody(panel, titleBtn, body, !panel.classList.contains("is-collapsed"), reclamp);
|
|
486
|
+
titleBtn.addEventListener("click", toggleBody);
|
|
487
|
+
// A click anywhere on the header outside the toolbar folds the panel, if the press began on the header or title (a
|
|
488
|
+
// press slid off a toolbar button also clicks here). A drag's closing click arrives defaultPrevented.
|
|
489
|
+
let pressedHeader = false;
|
|
490
|
+
header.addEventListener(
|
|
491
|
+
"pointerdown",
|
|
492
|
+
(e) => {
|
|
493
|
+
pressedHeader = e.target === header || titleBtn.contains(e.target as Node);
|
|
494
|
+
},
|
|
495
|
+
true,
|
|
496
|
+
);
|
|
497
|
+
header.addEventListener("click", (e) => {
|
|
498
|
+
if (e.target === header && pressedHeader && !e.defaultPrevented) toggleBody();
|
|
499
|
+
});
|
|
500
|
+
const body = el("div", "twk-body");
|
|
501
|
+
const controls = el("div", "twk-controls");
|
|
502
|
+
body.append(controls);
|
|
503
|
+
panel.append(header, body);
|
|
504
|
+
// opts.shadow: the panel lives in a shadow root on its own host, out of page CSS's reach; shadow-root.ts styles it
|
|
505
|
+
// and gives its portals a root of their own. `box` is what sits in the page and moves on a lift.
|
|
506
|
+
const iso = isolate(panel, opts.shadow),
|
|
507
|
+
box = iso.box;
|
|
508
|
+
|
|
509
|
+
// Stash the theme so the portaled popovers (which lose the panel's inherited vars) re-apply it on open.
|
|
510
|
+
let themeVars = resolveTheme(opts.theme);
|
|
511
|
+
applyThemeVars(panel, themeVars);
|
|
512
|
+
panel._twTheme = themeVars;
|
|
513
|
+
|
|
514
|
+
// ── Floating position — seeded in the shell, so an opts.floating / persisted panel is fixed in place
|
|
515
|
+
// the moment tweaks() returns instead of jumping when the lazy chunks land. ──
|
|
516
|
+
const draggable = opts.draggable !== false;
|
|
517
|
+
const MARGIN = 8,
|
|
518
|
+
SNAP = 28; // px: viewport inset; snap distance to park against an edge
|
|
519
|
+
// opts.floating's x / y are insets from the viewport's top-left, as px / py below are.
|
|
520
|
+
const place = typeof opts.floating === "object" && opts.floating ? opts.floating : null;
|
|
521
|
+
// The vertical clamp uses the panel's full height, scrollHeight, not its capped one: clamping on the cap would
|
|
522
|
+
// ratchet (every position fits, so a tall panel drags up but never back down). +1: it rounds, so a panel parked at
|
|
523
|
+
// the far edge isn't capped a fraction short of its rows.
|
|
524
|
+
// The viewport from clientWidth / clientHeight: a fixed box's right and bottom edges sit inside the page scrollbars,
|
|
525
|
+
// which innerWidth / innerHeight count. Only in standards mode: with no doctype the root's client box is the page's
|
|
526
|
+
// content, not the viewport (and jsdom has no layout: 0) — the inner size there.
|
|
527
|
+
const root = () => (document.compatMode === "CSS1Compat" ? document.documentElement : null);
|
|
528
|
+
const vw = () => root()?.clientWidth || window.innerWidth,
|
|
529
|
+
vh = () => root()?.clientHeight || window.innerHeight;
|
|
530
|
+
const bounds = () => ({
|
|
531
|
+
maxX: Math.max(MARGIN, vw() - panel.offsetWidth - MARGIN),
|
|
532
|
+
maxY: Math.max(MARGIN, vh() - panel.scrollHeight - 1 - gap),
|
|
533
|
+
});
|
|
534
|
+
const posKey = persistKey ? `${persistKey}:pos` : null;
|
|
535
|
+
// A pinned panel sits where opts.floating puts it; only a draggable one restores a saved spot.
|
|
536
|
+
const stored = draggable && posKey ? readStore(posKey) : null;
|
|
537
|
+
const saved = stored && typeof stored === "object" ? stored : null;
|
|
538
|
+
const start = saved || place || { x: 16, y: 16 };
|
|
539
|
+
// 0 is an inset, not a missing one
|
|
540
|
+
const inset = (v: unknown) => {
|
|
541
|
+
const n = parseFloat(v as string);
|
|
542
|
+
return n >= 0 ? n : 16;
|
|
543
|
+
};
|
|
544
|
+
const x0 = inset(start.x),
|
|
545
|
+
y0 = inset(start.y);
|
|
546
|
+
let px = x0,
|
|
547
|
+
py = y0;
|
|
548
|
+
// The gap a capped panel keeps from the far edge: while it sits at its opts.floating placement, the inset that gave
|
|
549
|
+
// the near one (16 for `true`), so a panel placed 16px down stops 16px short of the bottom. Once dragged or restored,
|
|
550
|
+
// it has left that placement: the margin, as the drag clamp keeps. --twk-float-gap overrides either (tweaks.css
|
|
551
|
+
// registers it as a <length>, so a rem or vh value computes to px here).
|
|
552
|
+
let moved = !!saved,
|
|
553
|
+
gap = MARGIN;
|
|
554
|
+
const readGap = () => {
|
|
555
|
+
const v = parseFloat(getComputedStyle(panel).getPropertyValue("--twk-float-gap"));
|
|
556
|
+
gap = v >= 0 ? v : opts.floating && !moved ? inset(place?.y) : MARGIN;
|
|
557
|
+
};
|
|
558
|
+
// A fixed box ignores page scroll, so a floating panel caps at the viewport past its near edge and scrolls
|
|
559
|
+
// (tweaks.css).
|
|
560
|
+
// Custom properties, not left / top / max-height: the stylesheet maps them, so host CSS can still override each one.
|
|
561
|
+
const apply = () => {
|
|
562
|
+
const s = panel.style;
|
|
563
|
+
s.setProperty("--twk-float-x", px + "px");
|
|
564
|
+
s.setProperty("--twk-float-y", py + "px");
|
|
565
|
+
s.setProperty("--twk-float-max-h", `calc(100dvh - ${py + gap}px)`);
|
|
566
|
+
};
|
|
567
|
+
const clampPos = () => {
|
|
568
|
+
readGap();
|
|
569
|
+
const { maxX, maxY } = bounds();
|
|
570
|
+
if (draggable) {
|
|
571
|
+
px = clamp(px, MARGIN, maxX);
|
|
572
|
+
py = clamp(py, MARGIN, maxY);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
// A pinned panel clamps from its placement each time, so a window that narrows and widens again hands its spot
|
|
576
|
+
// back.
|
|
577
|
+
px = clamp(x0, Math.min(MARGIN, x0), maxX);
|
|
578
|
+
// It keeps its insets while they leave it half the window (or its full height, if less); past that the inset and
|
|
579
|
+
// the far gap shrink together, so a short window never caps it to nothing.
|
|
580
|
+
const room = vh() - Math.min(panel.scrollHeight + 1, vh() / 2),
|
|
581
|
+
k = Math.min(1, room / (y0 + gap || 1));
|
|
582
|
+
py = Math.round(y0 * k);
|
|
583
|
+
gap = Math.round(gap * k);
|
|
584
|
+
};
|
|
585
|
+
const reclamp = () => {
|
|
586
|
+
if (!destroyed && panel.dataset.mode === "floating" && panel.isConnected) {
|
|
587
|
+
clampPos();
|
|
588
|
+
apply();
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
if ((draggable || opts.floating) && (opts.floating || saved)) {
|
|
592
|
+
panel.dataset.mode = "floating";
|
|
593
|
+
clampPos();
|
|
594
|
+
apply();
|
|
595
|
+
// A position saved on a larger monitor restores off-viewport: re-clamp once mounted (offsetWidth is 0 until then).
|
|
596
|
+
requestAnimationFrame(reclamp);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Per-control reset. The target is LABEL_SEL — the hint marker's list, so the two can't drift — or the root.
|
|
600
|
+
const resetEntry = (e: Entry) => {
|
|
601
|
+
assignDefault(e);
|
|
602
|
+
last = e.path.join(".");
|
|
603
|
+
notify();
|
|
604
|
+
};
|
|
605
|
+
const wireReset = (root: HTMLElement, entry: Entry) => {
|
|
606
|
+
const t =
|
|
607
|
+
root.querySelector<HTMLElement>(".twk-slider-value") || root.querySelector<HTMLElement>(LABEL_SEL) || root;
|
|
608
|
+
t.classList.add("twk-resettable");
|
|
609
|
+
t.title = tr("resetHint", "Double-click or hold to reset");
|
|
610
|
+
// Coarse pointers get a press-and-hold reset: the desktop double-click fights double-tap-zoom on touch.
|
|
611
|
+
let holdT = 0,
|
|
612
|
+
hx = 0,
|
|
613
|
+
hy = 0,
|
|
614
|
+
held = false;
|
|
615
|
+
const cancelHold = () => {
|
|
616
|
+
if (holdT) {
|
|
617
|
+
clearTimeout(holdT);
|
|
618
|
+
holdT = 0;
|
|
619
|
+
t.classList.remove("twk-reset-holding");
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
t.addEventListener("pointerdown", (e) => {
|
|
623
|
+
e.stopPropagation();
|
|
624
|
+
held = false; // a press on the readout mustn't jump the slider
|
|
625
|
+
if (e.pointerType !== "touch" && e.pointerType !== "pen") return;
|
|
626
|
+
hx = e.clientX;
|
|
627
|
+
hy = e.clientY;
|
|
628
|
+
t.classList.add("twk-reset-holding");
|
|
629
|
+
holdT = setTimeout(() => {
|
|
630
|
+
holdT = 0;
|
|
631
|
+
t.classList.remove("twk-reset-holding", "is-editable");
|
|
632
|
+
held = true;
|
|
633
|
+
resetEntry(entry);
|
|
634
|
+
}, 500);
|
|
635
|
+
});
|
|
636
|
+
t.addEventListener("pointermove", (e) => {
|
|
637
|
+
if (holdT && Math.abs(e.clientX - hx) + Math.abs(e.clientY - hy) > 8) cancelHold();
|
|
638
|
+
});
|
|
639
|
+
t.addEventListener("pointerup", cancelHold);
|
|
640
|
+
t.addEventListener("pointercancel", cancelHold);
|
|
641
|
+
t.addEventListener("pointerleave", cancelHold);
|
|
642
|
+
// a long-press mustn't raise the text callout
|
|
643
|
+
t.addEventListener("contextmenu", (e) => {
|
|
644
|
+
if (held) e.preventDefault();
|
|
645
|
+
});
|
|
646
|
+
// a completed hold-reset swallows the trailing tap-to-edit
|
|
647
|
+
t.addEventListener(
|
|
648
|
+
"click",
|
|
649
|
+
(e) => {
|
|
650
|
+
if (held) {
|
|
651
|
+
e.preventDefault();
|
|
652
|
+
e.stopImmediatePropagation();
|
|
653
|
+
held = false;
|
|
654
|
+
}
|
|
655
|
+
},
|
|
656
|
+
true,
|
|
657
|
+
);
|
|
658
|
+
t.addEventListener("dblclick", (e) => {
|
|
659
|
+
// a double-click in a text field is a word-select
|
|
660
|
+
if (t === root && (e.target as Element).closest?.("input, textarea")) return;
|
|
661
|
+
e.preventDefault();
|
|
662
|
+
e.stopPropagation();
|
|
663
|
+
resetEntry(entry);
|
|
664
|
+
});
|
|
665
|
+
// Click #1 can swap the readout for the inline input, so the dblclick lands there; delegated on the root.
|
|
666
|
+
if (t.classList.contains("twk-slider-value"))
|
|
667
|
+
root.addEventListener("dblclick", (e) => {
|
|
668
|
+
const inp = e.target as HTMLElement;
|
|
669
|
+
if (!inp.classList?.contains("twk-slider-input")) return;
|
|
670
|
+
e.preventDefault();
|
|
671
|
+
e.stopPropagation();
|
|
672
|
+
inp.blur(); // commits + closes the editor; a no-op change, so no notify
|
|
673
|
+
resetEntry(entry);
|
|
674
|
+
});
|
|
675
|
+
// The keyboard reset, Alt-qualified so a bare Backspace in a text field stays a backspace and the gradient editor
|
|
676
|
+
// keeps Delete.
|
|
677
|
+
root.addEventListener("keydown", (e) => {
|
|
678
|
+
if (!e.altKey || e.metaKey || e.ctrlKey || (e.key !== "Backspace" && e.key !== "Delete")) return;
|
|
679
|
+
e.preventDefault();
|
|
680
|
+
e.stopPropagation();
|
|
681
|
+
// an open inline editor commits + closes first
|
|
682
|
+
if ((e.target as HTMLElement).classList?.contains("twk-slider-input")) (e.target as HTMLElement).blur();
|
|
683
|
+
resetEntry(entry);
|
|
684
|
+
});
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
// Conditional controls — `render` shows/hides, `disabled` grays-out + locks, both re-evaluated on every change;
|
|
688
|
+
// `hint` is a static tooltip.
|
|
689
|
+
const conditionals: Array<{ node: HTMLElement; m: Meta }> = [];
|
|
690
|
+
// fold.ts is a lazy chunk the first pass imports, so that pass just toggles; foldRow returns whether the row was
|
|
691
|
+
// revealed (re-measure).
|
|
692
|
+
let fold: typeof import("./fold.js").fold | undefined;
|
|
693
|
+
const foldRow = (node: HTMLElement & { _hid?: boolean }, hide: boolean) => {
|
|
694
|
+
if (!node._hid === !hide) return false;
|
|
695
|
+
node._hid = hide;
|
|
696
|
+
if (fold) fold(node, hide);
|
|
697
|
+
else node.classList.toggle("twk-cond-hidden", hide);
|
|
698
|
+
return !hide;
|
|
699
|
+
};
|
|
700
|
+
const registerCond = (node: HTMLElement, m: Meta) => {
|
|
701
|
+
if (m.hint) addHintMarker(node, m.hint);
|
|
702
|
+
if (m.render || m.disabled != null) conditionals.push({ node, m });
|
|
703
|
+
};
|
|
704
|
+
// UI state for toJSON/fromJSON, keyed by path
|
|
705
|
+
const folderEls: Array<{ path: string[]; el: HTMLElement; setCollapsed: (c: boolean) => void }> = [],
|
|
706
|
+
tabsCtrls: Array<{ path: string[]; ctrl: TabsControl; pageKeys: string[] }> = [];
|
|
707
|
+
|
|
708
|
+
// A control's own loop (the monitor, the FPS graph) hands back a `destroy`; a panel destroyed before it ever
|
|
709
|
+
// connected would run it forever.
|
|
710
|
+
const adopt = (ctrl: Control) => {
|
|
711
|
+
if (typeof ctrl.destroy === "function") cleanups.push(ctrl.destroy);
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
const build = (container: HTMLElement, ms: Meta[], target: Target, basePath: string[] = []) => {
|
|
715
|
+
for (const m of ms) {
|
|
716
|
+
if (m.type === "tabs") {
|
|
717
|
+
const sub: Target = {};
|
|
718
|
+
target[m.key] = sub;
|
|
719
|
+
const makeTabs = getControl<TabsControl>("tabs");
|
|
720
|
+
const tabsCtrl = makeTabs && makeTabs(m);
|
|
721
|
+
if (!tabsCtrl) continue; // ensured before assemble; skipped if its chunk failed
|
|
722
|
+
tabsCtrls.push({ path: [...basePath, m.key], ctrl: tabsCtrl, pageKeys: m.pages.map((p) => p.key) });
|
|
723
|
+
m.pages.forEach((page, i) => {
|
|
724
|
+
const psub: Target = {};
|
|
725
|
+
sub[page.key] = psub;
|
|
726
|
+
build(tabsCtrl.bodies[i], page.children, psub, [...basePath, m.key, page.key]);
|
|
727
|
+
});
|
|
728
|
+
registerCond(tabsCtrl.el, m);
|
|
729
|
+
container.append(tabsCtrl.el);
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
if (m.type === "folder") {
|
|
733
|
+
const sub: Target = {};
|
|
734
|
+
target[m.key] = sub;
|
|
735
|
+
const f = createFolder(m);
|
|
736
|
+
folderEls.push({ path: [...basePath, m.key], el: f.el, setCollapsed: f.setCollapsed });
|
|
737
|
+
scopes.set(f.el, { kind: "folder", path: [...basePath, m.key], label: m.label });
|
|
738
|
+
build(f.body, m.children, sub, [...basePath, m.key]);
|
|
739
|
+
registerCond(f.el, m);
|
|
740
|
+
container.append(f.el);
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
const valued = !VALUELESS.has(m.type);
|
|
744
|
+
const dotted = [...basePath, m.key].join("."); // a bare leaf couldn't tell size from card.size
|
|
745
|
+
// a change made in the panel settles a row waiting for its stored image, as assign does
|
|
746
|
+
let entry: Entry | undefined;
|
|
747
|
+
// same-value emits (a re-entrant echo) don't notify
|
|
748
|
+
const ctrl = createControl(
|
|
749
|
+
m,
|
|
750
|
+
valued
|
|
751
|
+
? (v: unknown) => {
|
|
752
|
+
if (!valueChanged(target[m.key], v)) return;
|
|
753
|
+
if (entry) waiting.delete(entry);
|
|
754
|
+
target[m.key] = v;
|
|
755
|
+
last = dotted;
|
|
756
|
+
notify();
|
|
757
|
+
}
|
|
758
|
+
: () => {},
|
|
759
|
+
);
|
|
760
|
+
if (!ctrl) continue;
|
|
761
|
+
adopt(ctrl);
|
|
762
|
+
if (!valued) valueless.set(dotted, m.type);
|
|
763
|
+
if (valued) {
|
|
764
|
+
// reset() restores `raw` over the form the control OPENED on (`def`), read before the parked value applies —
|
|
765
|
+
// see restoreDefault.
|
|
766
|
+
const def = ctrl.get();
|
|
767
|
+
// A value a host parked on params before assemble ran wins over the schema default.
|
|
768
|
+
if (hasOwn(target, m.key)) ctrl.set(target[m.key]);
|
|
769
|
+
target[m.key] = ctrl.get();
|
|
770
|
+
entry = {
|
|
771
|
+
target,
|
|
772
|
+
key: m.key,
|
|
773
|
+
set: ctrl.set,
|
|
774
|
+
get: ctrl.get,
|
|
775
|
+
raw: m.value,
|
|
776
|
+
def,
|
|
777
|
+
path: [...basePath, m.key],
|
|
778
|
+
persist: m.persist !== false,
|
|
779
|
+
m,
|
|
780
|
+
el: ctrl.el,
|
|
781
|
+
note: ctrl.note,
|
|
782
|
+
range: ctrl.range,
|
|
783
|
+
};
|
|
784
|
+
entries.push(entry);
|
|
785
|
+
wireReset(ctrl.el, entry);
|
|
786
|
+
scopes.set(ctrl.el, { kind: "row", entry, label: m.label });
|
|
787
|
+
}
|
|
788
|
+
registerCond(ctrl.el, m);
|
|
789
|
+
container.append(ctrl.el);
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
// ── Snapshots in: persisted sessions, presets, fromJSON, undo. Per-entry isolation: a control whose
|
|
794
|
+
// set() throws on hostile stored data (a corrupt snapshot, a hand-edited preset) costs only its own value. ──
|
|
795
|
+
const applySnapshot = (snap: Snapshot | null, silent?: boolean) => {
|
|
796
|
+
if (!snap || typeof snap !== "object") return;
|
|
797
|
+
for (const e of entries) {
|
|
798
|
+
const v = atPath(snap, e.path);
|
|
799
|
+
if (v === undefined) continue;
|
|
800
|
+
tryAssign(e, v);
|
|
801
|
+
}
|
|
802
|
+
last = undefined;
|
|
803
|
+
notify(silent);
|
|
804
|
+
};
|
|
805
|
+
// ── Named presets (opt-in via opts.persist) — snapshots under "<key>:presets" ──
|
|
806
|
+
// null-proto: a preset named "__proto__" is an ordinary key
|
|
807
|
+
const listPresets = (): Record<string, Snapshot> => {
|
|
808
|
+
const p = presetsKey ? unwrap(readStore(presetsKey), "presets") : null;
|
|
809
|
+
return Object.assign(Object.create(null), p && typeof p === "object" ? p : {});
|
|
810
|
+
};
|
|
811
|
+
// False when storage refused the write, so the caller can say so instead of claiming a save.
|
|
812
|
+
// the menu announces a refusal
|
|
813
|
+
const savePreset = (nm: string) => {
|
|
814
|
+
const all = listPresets();
|
|
815
|
+
all[nm] = toRefs(snapshot());
|
|
816
|
+
return writePresets(all);
|
|
817
|
+
};
|
|
818
|
+
// re-read at replay: a delete queued ahead of it wins
|
|
819
|
+
const loadPreset = (nm: string) => {
|
|
820
|
+
const all = listPresets();
|
|
821
|
+
if (all[nm]) restore(all[nm]);
|
|
822
|
+
};
|
|
823
|
+
const deletePreset = (nm: string) => {
|
|
824
|
+
const all = listPresets();
|
|
825
|
+
delete all[nm];
|
|
826
|
+
if (writePresets(all)) pruneImages();
|
|
827
|
+
};
|
|
828
|
+
|
|
829
|
+
// ── Whole-panel UI state (toJSON / fromJSON) — folder open/closed and the active tab, keyed by dotted path ──
|
|
830
|
+
// JSON-pointer escaping, so a literal "." can't collide
|
|
831
|
+
const pathKey = (p: string[]) => p.map((k) => String(k).replace(/~/g, "~1").replace(/\./g, "~0")).join(".");
|
|
832
|
+
const collectUI = () => {
|
|
833
|
+
const ui: NonNullable<PanelState["ui"]> = {};
|
|
834
|
+
if (folderEls.length) {
|
|
835
|
+
const f: Record<string, boolean> = {};
|
|
836
|
+
for (const fe of folderEls) f[pathKey(fe.path)] = fe.el.classList.contains("is-collapsed");
|
|
837
|
+
ui.folders = f;
|
|
838
|
+
}
|
|
839
|
+
if (tabsCtrls.length) {
|
|
840
|
+
const t: Record<string, string | null> = {};
|
|
841
|
+
for (const tc of tabsCtrls) t[pathKey(tc.path)] = tc.pageKeys[tc.ctrl.active()] ?? null;
|
|
842
|
+
ui.tabs = t;
|
|
843
|
+
}
|
|
844
|
+
return ui;
|
|
845
|
+
};
|
|
846
|
+
const applyUI = (ui: PanelState["ui"]) => {
|
|
847
|
+
if (!ui || typeof ui !== "object") return;
|
|
848
|
+
if (ui.folders)
|
|
849
|
+
for (const fe of folderEls) {
|
|
850
|
+
const c = ui.folders[pathKey(fe.path)];
|
|
851
|
+
if (typeof c === "boolean") fe.setCollapsed(c);
|
|
852
|
+
}
|
|
853
|
+
// a re-activate would fire a spurious twk-reflow
|
|
854
|
+
if (ui.tabs)
|
|
855
|
+
for (const tc of tabsCtrls) {
|
|
856
|
+
const i = tc.pageKeys.indexOf(ui.tabs[pathKey(tc.path)]);
|
|
857
|
+
if (i >= 0 && i !== tc.ctrl.active()) tc.ctrl.activate(i);
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
|
|
861
|
+
// ── Repositioning — drag the header to move the panel ──
|
|
862
|
+
// An inline panel lifts into a floating layer on the first drag, at the spot it sat, then tracks the
|
|
863
|
+
// pointer; on release it eases to a near viewport edge, and the position persists to "<key>:pos".
|
|
864
|
+
if (draggable || opts.floating) {
|
|
865
|
+
if (draggable) panel.dataset.draggable = "true"; // CSS grab cursor on the header — the affordance
|
|
866
|
+
// Lift in place at the current rect; a same-size placeholder stays in the old slot, or the host container reflows
|
|
867
|
+
// mid-drag.
|
|
868
|
+
const lift = () => {
|
|
869
|
+
if (panel.dataset.mode === "floating") return;
|
|
870
|
+
const r = panel.getBoundingClientRect();
|
|
871
|
+
px = r.left;
|
|
872
|
+
py = r.top;
|
|
873
|
+
if (box.parentNode) {
|
|
874
|
+
liftSlot = el("span", "twk-lift-slot");
|
|
875
|
+
liftSlot.style.width = r.width + "px";
|
|
876
|
+
liftSlot.style.height = r.height + "px";
|
|
877
|
+
liftSlot.setAttribute("aria-hidden", "true");
|
|
878
|
+
// a shadow panel's scheme pin is read at its slot from here on
|
|
879
|
+
box.before(liftSlot);
|
|
880
|
+
(box as any)._twSlot = liftSlot;
|
|
881
|
+
}
|
|
882
|
+
// Portal to <body>: a transformed/filter/contain ancestor would become the fixed panel's containing block. The
|
|
883
|
+
// scheme scope doesn't ride along — carry it.
|
|
884
|
+
// So does the rest of the slot's context: the text properties the kit leaves to inheritance (a host's font-size,
|
|
885
|
+
// letter-spacing, color, direction, lang) and every --twk-* token, inherited or matched through an ancestor
|
|
886
|
+
// selector (`.my-sidebar .twk-panel { --twk-radius: 4px }`, as the theming page shows). Read with the panel's own
|
|
887
|
+
// theme off, so opts.theme / setTheme() still layer on top and setTheme(null) falls back to the host's values;
|
|
888
|
+
// only what the move changes is written, inline. A host rule reaching inside through an ancestor
|
|
889
|
+
// (`#app button { … }`) still stops matching: only a shadow root could keep that.
|
|
890
|
+
applyThemeVars(panel, null);
|
|
891
|
+
const cs = getComputedStyle(panel),
|
|
892
|
+
lang = box.closest("[lang]")?.getAttribute("lang"),
|
|
893
|
+
was: Record<string, string> = {};
|
|
894
|
+
for (const k of [
|
|
895
|
+
"font-family",
|
|
896
|
+
"font-size",
|
|
897
|
+
"font-weight",
|
|
898
|
+
"font-style",
|
|
899
|
+
"font-stretch",
|
|
900
|
+
"font-variant",
|
|
901
|
+
"font-feature-settings",
|
|
902
|
+
"font-variation-settings",
|
|
903
|
+
"font-kerning",
|
|
904
|
+
"line-height",
|
|
905
|
+
"letter-spacing",
|
|
906
|
+
"word-spacing",
|
|
907
|
+
"text-transform",
|
|
908
|
+
"text-align",
|
|
909
|
+
"color",
|
|
910
|
+
"direction",
|
|
911
|
+
...Array.from(cs).filter((k) => k.startsWith("--twk-")),
|
|
912
|
+
])
|
|
913
|
+
was[k] = cs.getPropertyValue(k);
|
|
914
|
+
carryScheme(panel, panel);
|
|
915
|
+
document.body.appendChild(box);
|
|
916
|
+
for (const k in was) if (cs.getPropertyValue(k) !== was[k]) panel.style.setProperty(k, (carried[k] = was[k]));
|
|
917
|
+
if (lang && box.closest("[lang]")?.getAttribute("lang") !== lang) panel.lang = lang;
|
|
918
|
+
applyThemeVars(panel, themeVars);
|
|
919
|
+
panel.dataset.mode = "floating";
|
|
920
|
+
apply();
|
|
921
|
+
// A host that removes its container without destroy() would leave the lifted panel on screen; when the
|
|
922
|
+
// placeholder leaves the document, the panel follows.
|
|
923
|
+
if (liftSlot && typeof MutationObserver === "function") {
|
|
924
|
+
const obs = new MutationObserver(() => {
|
|
925
|
+
if (liftSlot && !liftSlot.isConnected) {
|
|
926
|
+
obs.disconnect();
|
|
927
|
+
liftSlot = null;
|
|
928
|
+
box.remove();
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
obs.observe(document.documentElement, { childList: true, subtree: true });
|
|
932
|
+
cleanups.push(() => obs.disconnect());
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
// Two floating panels share one stylesheet z-index; the last touched gets a higher one. A class at plain .twk-panel
|
|
936
|
+
// specificity, not an inline value, so a host's own z-index still wins after a drag.
|
|
937
|
+
const raise = () => {
|
|
938
|
+
if (topFloating && topFloating !== panel) topFloating.classList.remove("is-raised");
|
|
939
|
+
panel.classList.add("is-raised");
|
|
940
|
+
topFloating = panel;
|
|
941
|
+
};
|
|
942
|
+
const grab = () => {
|
|
943
|
+
moved = true;
|
|
944
|
+
lift();
|
|
945
|
+
raise();
|
|
946
|
+
readGap();
|
|
947
|
+
};
|
|
948
|
+
const moveTo = (x: number, y: number, dx: number, dy: number) => {
|
|
949
|
+
const { maxX, maxY } = bounds();
|
|
950
|
+
px = clamp(x + dx, MARGIN, maxX);
|
|
951
|
+
py = clamp(y + dy, MARGIN, maxY);
|
|
952
|
+
apply();
|
|
953
|
+
};
|
|
954
|
+
const savePos = () => {
|
|
955
|
+
if (posKey) writeStore(posKey, { x: px, y: py });
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
let dragId: number | null = null,
|
|
959
|
+
sx = 0,
|
|
960
|
+
sy = 0,
|
|
961
|
+
ox = 0,
|
|
962
|
+
oy = 0,
|
|
963
|
+
snapT = 0;
|
|
964
|
+
// Document listeners (capture, so the panel's own pointer-stop can't hide them) for the press's duration.
|
|
965
|
+
// Capture is taken only past the threshold: on the press it would retarget a plain click off the title.
|
|
966
|
+
const listen = (on: boolean) => {
|
|
967
|
+
for (const [t, fn] of [
|
|
968
|
+
["pointermove", onMove],
|
|
969
|
+
["pointerup", endDrag],
|
|
970
|
+
["pointercancel", endDrag],
|
|
971
|
+
] as Array<[string, (e: PointerEvent) => void]>)
|
|
972
|
+
on ? document.addEventListener(t, fn, true) : document.removeEventListener(t, fn, true);
|
|
973
|
+
on
|
|
974
|
+
? document.addEventListener("keydown", onDragKey, true)
|
|
975
|
+
: document.removeEventListener("keydown", onDragKey, true);
|
|
976
|
+
};
|
|
977
|
+
// Escape mid-press cancels: the panel returns to where the drag began and the release's click is swallowed. Capture
|
|
978
|
+
// phase, so it beats a pane's Escape.
|
|
979
|
+
const onDragKey = (e: KeyboardEvent) => {
|
|
980
|
+
if (e.key !== "Escape" || dragId === null) return;
|
|
981
|
+
e.preventDefault();
|
|
982
|
+
e.stopPropagation();
|
|
983
|
+
// before the release, so a lostpointercapture finds no drag to end
|
|
984
|
+
const id = dragId;
|
|
985
|
+
dragId = null;
|
|
986
|
+
listen(false);
|
|
987
|
+
try {
|
|
988
|
+
header.releasePointerCapture(id);
|
|
989
|
+
} catch {}
|
|
990
|
+
if (dragMoved) {
|
|
991
|
+
px = ox;
|
|
992
|
+
py = oy;
|
|
993
|
+
apply();
|
|
994
|
+
}
|
|
995
|
+
panel.classList.remove("is-dragging");
|
|
996
|
+
swallowClick = true;
|
|
997
|
+
document.addEventListener(
|
|
998
|
+
"pointerup",
|
|
999
|
+
() =>
|
|
1000
|
+
setTimeout(() => {
|
|
1001
|
+
swallowClick = false;
|
|
1002
|
+
}, 0),
|
|
1003
|
+
{ capture: true, once: true },
|
|
1004
|
+
);
|
|
1005
|
+
};
|
|
1006
|
+
if (draggable)
|
|
1007
|
+
header.addEventListener("pointerdown", (e) => {
|
|
1008
|
+
// draggable:false keeps lift/clamp, never the press
|
|
1009
|
+
if (e.button !== 0 || dragId !== null || (e.target as Element).closest(".twk-toolbar, input, textarea, select"))
|
|
1010
|
+
return;
|
|
1011
|
+
dragId = e.pointerId;
|
|
1012
|
+
sx = e.clientX;
|
|
1013
|
+
sy = e.clientY;
|
|
1014
|
+
dragMoved = false;
|
|
1015
|
+
listen(true);
|
|
1016
|
+
// Regrab mid–edge-snap: pick the panel up where it visually is, not the parked target.
|
|
1017
|
+
if (panel.classList.contains("is-snapping")) {
|
|
1018
|
+
clearTimeout(snapT);
|
|
1019
|
+
const cur = getComputedStyle(panel);
|
|
1020
|
+
px = parseFloat(cur.left) || px;
|
|
1021
|
+
py = parseFloat(cur.top) || py;
|
|
1022
|
+
panel.classList.remove("is-snapping");
|
|
1023
|
+
apply(); // …and drop the snap transition, so the grab is 1:1
|
|
1024
|
+
}
|
|
1025
|
+
});
|
|
1026
|
+
// The keyboard's drag: with the title focused, Alt+arrows move the panel 10px (Shift: 50px) through the same lift,
|
|
1027
|
+
// clamp and saved position as a pointer drag. Alt-qualified, so a bare arrow stays the page's.
|
|
1028
|
+
if (draggable)
|
|
1029
|
+
header.addEventListener("keydown", (e) => {
|
|
1030
|
+
if (e.target !== titleBtn || !e.altKey || e.metaKey || e.ctrlKey) return;
|
|
1031
|
+
const d = e.shiftKey ? 50 : 10,
|
|
1032
|
+
dx = e.key === "ArrowRight" ? d : e.key === "ArrowLeft" ? -d : 0,
|
|
1033
|
+
dy = e.key === "ArrowDown" ? d : e.key === "ArrowUp" ? -d : 0;
|
|
1034
|
+
if (!dx && !dy) return;
|
|
1035
|
+
e.preventDefault();
|
|
1036
|
+
e.stopPropagation();
|
|
1037
|
+
const lifting = panel.dataset.mode !== "floating";
|
|
1038
|
+
grab();
|
|
1039
|
+
// the lift moves the panel to <body>, which drops focus; the next step needs it back
|
|
1040
|
+
if (lifting) titleBtn.focus({ preventScroll: true });
|
|
1041
|
+
moveTo(px, py, dx, dy);
|
|
1042
|
+
savePos();
|
|
1043
|
+
});
|
|
1044
|
+
const onMove = (e: PointerEvent) => {
|
|
1045
|
+
if (e.pointerId !== dragId) return;
|
|
1046
|
+
// The window lost the pointer: bail as every other drag surface does.
|
|
1047
|
+
if (e.buttons === 0) {
|
|
1048
|
+
endDrag(e);
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
const dx = e.clientX - sx,
|
|
1052
|
+
dy = e.clientY - sy;
|
|
1053
|
+
if (!dragMoved) {
|
|
1054
|
+
// Drag slop: quick clicks wander a few px, fingers more. Collapsing is common and moving rare, so favor the
|
|
1055
|
+
// click.
|
|
1056
|
+
if (Math.hypot(dx, dy) < (e.pointerType === "mouse" ? 6 : 10)) return;
|
|
1057
|
+
dragMoved = true;
|
|
1058
|
+
grab();
|
|
1059
|
+
ox = px;
|
|
1060
|
+
oy = py; // the (possibly just-lifted) origin, once the drag starts
|
|
1061
|
+
try {
|
|
1062
|
+
header.setPointerCapture(dragId);
|
|
1063
|
+
} catch {}
|
|
1064
|
+
panel.classList.add("is-dragging");
|
|
1065
|
+
}
|
|
1066
|
+
moveTo(ox, oy, dx, dy);
|
|
1067
|
+
};
|
|
1068
|
+
const endDrag = (e: PointerEvent) => {
|
|
1069
|
+
if (e.pointerId !== dragId) return;
|
|
1070
|
+
try {
|
|
1071
|
+
header.releasePointerCapture(dragId);
|
|
1072
|
+
} catch {}
|
|
1073
|
+
dragId = null;
|
|
1074
|
+
listen(false);
|
|
1075
|
+
// Swallow exactly the click that follows the release; let the flag lapse in case none comes (a pointercancel).
|
|
1076
|
+
swallowClick = dragMoved;
|
|
1077
|
+
if (swallowClick)
|
|
1078
|
+
setTimeout(() => {
|
|
1079
|
+
swallowClick = false;
|
|
1080
|
+
}, 0);
|
|
1081
|
+
if (dragMoved) {
|
|
1082
|
+
// Edge magnetism: a drop near a side eases the rest of the way to the margin.
|
|
1083
|
+
const { maxX, maxY } = bounds();
|
|
1084
|
+
if (px <= MARGIN + SNAP) px = MARGIN;
|
|
1085
|
+
else if (px >= maxX - SNAP) px = maxX;
|
|
1086
|
+
if (py <= MARGIN + SNAP) py = MARGIN;
|
|
1087
|
+
else if (py >= maxY - SNAP) py = maxY;
|
|
1088
|
+
// The ease is a class (tweaks.css), so the reduced-motion kill-switch covers it.
|
|
1089
|
+
// One timer at a time: an earlier drop's would strip the class 340ms after it, partway through this snap.
|
|
1090
|
+
clearTimeout(snapT);
|
|
1091
|
+
panel.classList.add("is-snapping");
|
|
1092
|
+
snapT = setTimeout(() => panel.classList.remove("is-snapping"), 340);
|
|
1093
|
+
apply();
|
|
1094
|
+
savePos();
|
|
1095
|
+
}
|
|
1096
|
+
panel.classList.remove("is-dragging");
|
|
1097
|
+
};
|
|
1098
|
+
header.addEventListener("lostpointercapture", endDrag); // implicit capture loss mid-drag ends it like a release
|
|
1099
|
+
cleanups.push(() => listen(false)); // a destroy() mid-press releases the document listeners
|
|
1100
|
+
}
|
|
1101
|
+
// Registered by assemble(): onLive releases itself once the owner LEAVES the document, which it can only tell if it
|
|
1102
|
+
// saw the panel connected first.
|
|
1103
|
+
const watchResize = () => {
|
|
1104
|
+
if (!(draggable || opts.floating)) return;
|
|
1105
|
+
reclamp(); // a resize during the lazy window went unheard; clamp now
|
|
1106
|
+
cleanups.push(onLive(panel, [[window, "resize"]], reclamp));
|
|
1107
|
+
};
|
|
1108
|
+
|
|
1109
|
+
// The menu is presets.ts, downloaded on the button's first hover, focus or press. A press before it lands opens it
|
|
1110
|
+
// once it does, unless Escape, or a press or focus elsewhere, came first, and a second press doesn't queue a second
|
|
1111
|
+
// open; after that the menu's popover owns the clicks. A chunk that fails to load is tried again on the next press.
|
|
1112
|
+
if (presetsBtn) {
|
|
1113
|
+
const b = presetsBtn;
|
|
1114
|
+
let menu: Promise<ReturnType<(typeof import("./presets.js"))["presetsMenu"]>> | undefined,
|
|
1115
|
+
made = false;
|
|
1116
|
+
const load = () =>
|
|
1117
|
+
(menu ||= import("./presets.js").then(
|
|
1118
|
+
(m) => {
|
|
1119
|
+
if (destroyed) throw 0;
|
|
1120
|
+
made = true;
|
|
1121
|
+
return m.presetsMenu(
|
|
1122
|
+
b,
|
|
1123
|
+
() => Object.keys(listPresets()),
|
|
1124
|
+
savePreset,
|
|
1125
|
+
loadPreset,
|
|
1126
|
+
deletePreset,
|
|
1127
|
+
announce,
|
|
1128
|
+
ICON_X,
|
|
1129
|
+
tr,
|
|
1130
|
+
);
|
|
1131
|
+
},
|
|
1132
|
+
(e) => {
|
|
1133
|
+
menu = undefined;
|
|
1134
|
+
if (e !== 0) console.error("[tweaks] presets:", e);
|
|
1135
|
+
throw e;
|
|
1136
|
+
},
|
|
1137
|
+
));
|
|
1138
|
+
for (const t of ["pointerenter", "focus"])
|
|
1139
|
+
b.addEventListener(
|
|
1140
|
+
t,
|
|
1141
|
+
() => {
|
|
1142
|
+
load().catch(() => {});
|
|
1143
|
+
},
|
|
1144
|
+
{ once: true },
|
|
1145
|
+
);
|
|
1146
|
+
let want = false;
|
|
1147
|
+
// Escape, or a press or focus elsewhere
|
|
1148
|
+
const cancel = (e: Event) => {
|
|
1149
|
+
if (e.type === "keydown" ? (e as KeyboardEvent).key === "Escape" : !e.composedPath().includes(b)) want = false;
|
|
1150
|
+
};
|
|
1151
|
+
const watch = (on: boolean) => {
|
|
1152
|
+
for (const t of ["pointerdown", "focusin", "keydown"])
|
|
1153
|
+
(on ? document.addEventListener : document.removeEventListener).call(document, t, cancel, true);
|
|
1154
|
+
};
|
|
1155
|
+
b.addEventListener("click", () => {
|
|
1156
|
+
if (made || b.disabled || want) return;
|
|
1157
|
+
want = true;
|
|
1158
|
+
watch(true);
|
|
1159
|
+
load()
|
|
1160
|
+
.then(
|
|
1161
|
+
(pop) => {
|
|
1162
|
+
if (want) pop.open();
|
|
1163
|
+
},
|
|
1164
|
+
() => {},
|
|
1165
|
+
)
|
|
1166
|
+
.finally(() => {
|
|
1167
|
+
want = false;
|
|
1168
|
+
watch(false);
|
|
1169
|
+
});
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// ── Undo / redo (opts.undo): undo.ts. inEditable is declared below, and only read on a keypress. ──
|
|
1174
|
+
const undoApi = opts.undo
|
|
1175
|
+
? createUndo(
|
|
1176
|
+
panel,
|
|
1177
|
+
snapshot,
|
|
1178
|
+
applySnapshot,
|
|
1179
|
+
atPath,
|
|
1180
|
+
(t) => inEditable(t),
|
|
1181
|
+
(fn) => {
|
|
1182
|
+
listeners.add(fn);
|
|
1183
|
+
own.add(fn);
|
|
1184
|
+
},
|
|
1185
|
+
)
|
|
1186
|
+
: null;
|
|
1187
|
+
|
|
1188
|
+
// ── Pick and copy / paste — a press beside a row (in the panel's side padding, or a gap between rows) picks it, as
|
|
1189
|
+
// a layer is picked in a design tool; beside a folder's header, the folder. ⌘C / ⌘V (ctrl elsewhere) then act on the
|
|
1190
|
+
// pick, or with none on the page, on the row holding focus. A row copies its value and pastes one back; a folder
|
|
1191
|
+
// copies its values object and pastes one into its own keys, so two folders with the same keys trade values. Escape,
|
|
1192
|
+
// or a
|
|
1193
|
+
// press anywhere else, drops the pick; a text field or a text selection keeps the browser's own keys. ──
|
|
1194
|
+
const scopes = new Map<Element, Scope>(); // a row's or folder's root → its scope, filled by build()
|
|
1195
|
+
const scopeAt = (n: Element | null): Scope | undefined => {
|
|
1196
|
+
for (; n && n !== panel && panel.contains(n); n = n.parentElement) {
|
|
1197
|
+
const s = scopes.get(n);
|
|
1198
|
+
if (s) return s;
|
|
1199
|
+
}
|
|
1200
|
+
};
|
|
1201
|
+
const rowAt = (n: Element | null): Element | undefined => {
|
|
1202
|
+
for (; n && n !== panel; n = n.parentElement) if (scopes.get(n)?.kind === "row") return n;
|
|
1203
|
+
};
|
|
1204
|
+
// Out of sight: hidden by its condition, on a tab page not showing, or inside a collapsed folder or panel.
|
|
1205
|
+
const hidden = (el: Element) =>
|
|
1206
|
+
!!(
|
|
1207
|
+
el.closest(".twk-cond-hidden, .twk-tabs-page:not([data-active=true])") ||
|
|
1208
|
+
el.parentElement?.closest(".is-collapsed")
|
|
1209
|
+
);
|
|
1210
|
+
let pick: [Element, Scope] | undefined;
|
|
1211
|
+
// the path: a shadow host retargets e.target
|
|
1212
|
+
const away = (e: Event) => {
|
|
1213
|
+
const t = e.composedPath()[0] as Element;
|
|
1214
|
+
if (!panel.contains(t) && !t.closest?.(".twk-portal")) unpick();
|
|
1215
|
+
};
|
|
1216
|
+
const unpick = () => {
|
|
1217
|
+
if (!pick) return;
|
|
1218
|
+
pick[0].classList.remove("is-picked");
|
|
1219
|
+
pick = undefined;
|
|
1220
|
+
document.removeEventListener("pointerdown", away, true);
|
|
1221
|
+
if (picked?.[0] === panel) picked = null;
|
|
1222
|
+
};
|
|
1223
|
+
const choose = (el: Element, s: Scope) => {
|
|
1224
|
+
if (picked && picked[0] !== panel) picked[1](); // one pick on the page
|
|
1225
|
+
unpick();
|
|
1226
|
+
pick = [el, s];
|
|
1227
|
+
picked = [panel, unpick];
|
|
1228
|
+
el.classList.add("is-picked");
|
|
1229
|
+
document.addEventListener("pointerdown", away, true); // only while something is picked
|
|
1230
|
+
// Focus moves to what was picked (a folder's header, a row's control), so its keys work and a screen reader names
|
|
1231
|
+
// it; a press shows no ring. A text field isn't focused, or ⌘V would paste into it as text, and any selection goes.
|
|
1232
|
+
// its tab stop, not a hint or a closed pop's
|
|
1233
|
+
const f = [...el.querySelectorAll<HTMLElement>('button:not(:disabled):not([tabindex="-1"]), [tabindex="0"]')].find(
|
|
1234
|
+
(n) => !n.closest(".twk-hint, .twk-portal, [inert]"),
|
|
1235
|
+
);
|
|
1236
|
+
if (f) f.focus({ preventScroll: true, focusVisible: false } as FocusOptions);
|
|
1237
|
+
else (activeIn(panel) as HTMLElement | null)?.blur?.();
|
|
1238
|
+
document.getSelection?.()?.removeAllRanges();
|
|
1239
|
+
};
|
|
1240
|
+
cleanups.push(unpick);
|
|
1241
|
+
// capture: a label's own pointerdown stops the event before it bubbles here
|
|
1242
|
+
panel.addEventListener(
|
|
1243
|
+
"pointerdown",
|
|
1244
|
+
(e) => {
|
|
1245
|
+
const t = e.target as Element;
|
|
1246
|
+
// On a control, or a header: working the picked row keeps the pick, anything else drops it. A touch never picks:
|
|
1247
|
+
// a phone has no ⌘C, and a scroll that starts in the padding shouldn't pick what it passes.
|
|
1248
|
+
if (rowAt(t) || t.closest(".twk-header, .twk-folder-header, button, input, textarea, select")) {
|
|
1249
|
+
if (!pick?.[0].contains(t)) unpick();
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
if (e.button !== 0 || e.pointerType === "touch") return unpick();
|
|
1253
|
+
// Beside a row: the row (or folder header) level with the press, by its own box, so nothing overlapping the panel
|
|
1254
|
+
// can stand in the way. The last match is the deepest: a folder's rows come after it.
|
|
1255
|
+
let hit: [Element, Scope] | undefined;
|
|
1256
|
+
for (const [el, s] of scopes) {
|
|
1257
|
+
const r = (s.kind === "folder" ? el.firstElementChild! : el).getBoundingClientRect();
|
|
1258
|
+
if (r.height && e.clientY >= r.top && e.clientY < r.bottom && !hidden(el)) hit = [el, s];
|
|
1259
|
+
}
|
|
1260
|
+
if (!hit) return unpick();
|
|
1261
|
+
e.preventDefault(); // no text selection
|
|
1262
|
+
choose(...hit);
|
|
1263
|
+
},
|
|
1264
|
+
true,
|
|
1265
|
+
);
|
|
1266
|
+
const inEditable = (t: Element | null) =>
|
|
1267
|
+
!!(
|
|
1268
|
+
t &&
|
|
1269
|
+
(t as HTMLElement).matches &&
|
|
1270
|
+
((t as HTMLElement).matches("input, textarea, select") || (t as HTMLElement).isContentEditable)
|
|
1271
|
+
);
|
|
1272
|
+
// Called from the panel's one keydown listener (registered by assemble(), beside the undo keys). Focus steps to the
|
|
1273
|
+
// hidden field for the keypress (the copy selected in it), the browser copies or pastes there, and focus steps back.
|
|
1274
|
+
const onClipKey = (e: KeyboardEvent) => {
|
|
1275
|
+
if (e.key === "Escape") {
|
|
1276
|
+
if (pick) {
|
|
1277
|
+
e.stopPropagation();
|
|
1278
|
+
unpick();
|
|
1279
|
+
}
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
if (!(e.metaKey || e.ctrlKey) || e.altKey || e.repeat || inEditable(e.composedPath()[0] as Element)) return;
|
|
1283
|
+
const k = e.key.toLowerCase();
|
|
1284
|
+
if (k !== "c" && k !== "v") return;
|
|
1285
|
+
// the picking panel left the page without destroy(): free the slot
|
|
1286
|
+
if (picked && !picked[0].isConnected) picked[1]();
|
|
1287
|
+
if (pick && hidden(pick[0])) unpick(); // a pick out of sight takes nothing
|
|
1288
|
+
// focus on a hidden row takes nothing either
|
|
1289
|
+
const fe = activeIn(panel),
|
|
1290
|
+
s = pick ? pick[1] : picked || !fe || hidden(fe) ? undefined : scopeAt(fe);
|
|
1291
|
+
// a shadow root's own selection where it has one (Chrome); else the document's holds it
|
|
1292
|
+
const rn: any = panel.getRootNode();
|
|
1293
|
+
// selected text: the browser copies that
|
|
1294
|
+
if (!s || (k === "c" && String((rn.getSelection ? rn : document).getSelection?.() || ""))) return;
|
|
1295
|
+
const json =
|
|
1296
|
+
k === "c" ? JSON.stringify(s.kind === "row" ? s.entry.get() : atPath(snapshot(), s.path), replacer, 2) : "";
|
|
1297
|
+
// words for a screen reader; the toolbar's copy button stays as it is
|
|
1298
|
+
const copied = (ok: boolean) => {
|
|
1299
|
+
if (!destroyed)
|
|
1300
|
+
announce(ok ? tr("rowCopied", "{label} copied", { label: s.label }) : tr("copyFailed", "Copy failed"));
|
|
1301
|
+
};
|
|
1302
|
+
const band = pick?.[0],
|
|
1303
|
+
back = activeIn(panel) as HTMLElement | null,
|
|
1304
|
+
ring = !!back?.matches(":focus-visible");
|
|
1305
|
+
// The browser's copy or paste can land some milliseconds after this keydown (an app's menu passes it on), so focus
|
|
1306
|
+
// steps back only when it lands, or after a second with none; it's caught page-wide, wherever focus is by then.
|
|
1307
|
+
if (!catchEl) {
|
|
1308
|
+
catchEl = el("textarea", "twk-sr");
|
|
1309
|
+
catchEl.tabIndex = -1;
|
|
1310
|
+
catchEl.setAttribute("aria-hidden", "true");
|
|
1311
|
+
for (const t of ["copy", "paste"]) document.addEventListener(t, (e) => onCatch?.(e as ClipboardEvent), true);
|
|
1312
|
+
}
|
|
1313
|
+
const ta = catchEl,
|
|
1314
|
+
done = () => {
|
|
1315
|
+
onCatch = null;
|
|
1316
|
+
if (document.activeElement === ta) {
|
|
1317
|
+
ta.blur();
|
|
1318
|
+
back?.focus?.({ preventScroll: true, focusVisible: ring } as FocusOptions);
|
|
1319
|
+
}
|
|
1320
|
+
};
|
|
1321
|
+
const h = (onCatch = (ev) => {
|
|
1322
|
+
if (ev.target !== ta && inEditable(ev.target as Element)) return; // a field focused meanwhile keeps its own paste
|
|
1323
|
+
ev.preventDefault();
|
|
1324
|
+
done();
|
|
1325
|
+
if (json) return (ev.clipboardData?.setData("text/plain", json), copied(!!ev.clipboardData));
|
|
1326
|
+
const t = ev.clipboardData?.getData("text/plain") ?? null;
|
|
1327
|
+
import("./clip.js").then(
|
|
1328
|
+
(m) => m.pasteInto(t, (t) => m.pasteScope(ctx, s, t), ctx, band),
|
|
1329
|
+
() => {},
|
|
1330
|
+
);
|
|
1331
|
+
});
|
|
1332
|
+
if (!ta.isConnected) document.body.appendChild(ta);
|
|
1333
|
+
ta.value = json;
|
|
1334
|
+
ta.focus({ preventScroll: true });
|
|
1335
|
+
ta.select();
|
|
1336
|
+
// none came: write the copy directly
|
|
1337
|
+
setTimeout(() => {
|
|
1338
|
+
if (onCatch === h) {
|
|
1339
|
+
done();
|
|
1340
|
+
if (json) copyText(json).then(copied);
|
|
1341
|
+
}
|
|
1342
|
+
}, 1000);
|
|
1343
|
+
};
|
|
1344
|
+
|
|
1345
|
+
// Build the controls, then everything that needs them: the conditionals' first pass, the persisted restore,
|
|
1346
|
+
// the undo seed, the queued API calls. Synchronous when every control type is registered, else behind panel.ready.
|
|
1347
|
+
const assemble = () => {
|
|
1348
|
+
if (destroyed) return; // destroy() before the chunks landed: nothing to build
|
|
1349
|
+
built = cleanups.length;
|
|
1350
|
+
using(opts.strings, () => build(controls, metas, params));
|
|
1351
|
+
if (conditionals.length) {
|
|
1352
|
+
// first wins
|
|
1353
|
+
const byKey = new Map<string, Entry>();
|
|
1354
|
+
for (const e of entries) if (!byKey.has(e.key)) byKey.set(e.key, e);
|
|
1355
|
+
const getVal = (k: string) => {
|
|
1356
|
+
const e = byKey.get(k);
|
|
1357
|
+
return e ? e.target[e.key] : params[k];
|
|
1358
|
+
};
|
|
1359
|
+
const applyConditionals = () => {
|
|
1360
|
+
let revealed = false;
|
|
1361
|
+
for (const { node, m } of conditionals) {
|
|
1362
|
+
// A throwing predicate leaves the node as-is rather than aborting the pass.
|
|
1363
|
+
try {
|
|
1364
|
+
if (m.render && foldRow(node, !m.render(getVal))) revealed = true;
|
|
1365
|
+
if (m.disabled != null) {
|
|
1366
|
+
const d = typeof m.disabled === "function" ? m.disabled(getVal) : m.disabled;
|
|
1367
|
+
setDisabled(node, !!d);
|
|
1368
|
+
}
|
|
1369
|
+
} catch {}
|
|
1370
|
+
}
|
|
1371
|
+
if (revealed) requestReflow(); // built hidden it measured 0×0; shown, let it re-measure
|
|
1372
|
+
};
|
|
1373
|
+
listeners.add(applyConditionals);
|
|
1374
|
+
own.add(applyConditionals);
|
|
1375
|
+
applyConditionals();
|
|
1376
|
+
// a fold chunk that fails to load leaves the rows toggling
|
|
1377
|
+
import("./fold.js").then(
|
|
1378
|
+
(m) => {
|
|
1379
|
+
fold = m.fold;
|
|
1380
|
+
},
|
|
1381
|
+
() => {},
|
|
1382
|
+
);
|
|
1383
|
+
}
|
|
1384
|
+
// Restore last session, notifying: on the lazy path a host may already have subscribed.
|
|
1385
|
+
if (persistKey) restore(unsaved(unwrap(readStore(persistKey), "values")));
|
|
1386
|
+
if (destroyed) return; // a listener may have destroyed the panel on that notify
|
|
1387
|
+
undoApi?.arm();
|
|
1388
|
+
watchResize();
|
|
1389
|
+
// one document listener per panel for its keys
|
|
1390
|
+
cleanups.push(
|
|
1391
|
+
onLive(panel, [[document, "keydown"]], (e: KeyboardEvent) => {
|
|
1392
|
+
undoApi?.onKey(e);
|
|
1393
|
+
onClipKey(e);
|
|
1394
|
+
}),
|
|
1395
|
+
);
|
|
1396
|
+
assembled = true;
|
|
1397
|
+
// Replay after the restore, so an explicit host set() wins over a stored value; a destroy() mid-replay drops the
|
|
1398
|
+
// rest.
|
|
1399
|
+
for (const fn of queue.splice(0)) {
|
|
1400
|
+
if (destroyed) break;
|
|
1401
|
+
try {
|
|
1402
|
+
fn();
|
|
1403
|
+
} catch (e) {
|
|
1404
|
+
console.error("[tweaks] a call queued before ready failed:", e);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
for (const b of toolbarBtns) b.disabled = false; // the controls exist now
|
|
1408
|
+
};
|
|
1409
|
+
|
|
1410
|
+
// A set() past a hard range clamps, which is right, but silent it reads as the value not taking: say so, once a key.
|
|
1411
|
+
const clamped = new Set<Entry>();
|
|
1412
|
+
// Each number asked for is held to the range the control resolved (derived bounds, a point's per component), not the
|
|
1413
|
+
// schema's min and max, which a derived range leaves unset; a hair past an end is float noise, not a clamp.
|
|
1414
|
+
const clampNote = (e: Entry, key: string, v: unknown) => {
|
|
1415
|
+
const r = e.range;
|
|
1416
|
+
if (!r || e.m.soft || clamped.has(e)) return;
|
|
1417
|
+
// [component, number] pairs: one number, a list (interval, sides), a sides string, or an object's sides or
|
|
1418
|
+
// components.
|
|
1419
|
+
const asked: [string, unknown][] =
|
|
1420
|
+
typeof v === "string"
|
|
1421
|
+
? v
|
|
1422
|
+
.trim()
|
|
1423
|
+
.split(/\s+/)
|
|
1424
|
+
.map((s) => ["", parseFloat(s)])
|
|
1425
|
+
: Array.isArray(v)
|
|
1426
|
+
? v.map((n) => ["", n])
|
|
1427
|
+
: isObj(v)
|
|
1428
|
+
? Object.entries(v)
|
|
1429
|
+
: [["", v]];
|
|
1430
|
+
for (const [k, n] of asked) {
|
|
1431
|
+
const b = Array.isArray(r) ? r : r[k],
|
|
1432
|
+
eps = 1e-9 * Math.max(1, Math.abs(n as number));
|
|
1433
|
+
if (b && typeof n === "number" && (n < b[0] - eps || n > b[1] + eps)) {
|
|
1434
|
+
clamped.add(e);
|
|
1435
|
+
notice("clamp", key, v, b[0], b[1], e.m.type);
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
};
|
|
1440
|
+
// Resolve a (possibly dotted) key, apply, report whether the leaf changed; never notifies, so setMany fires one
|
|
1441
|
+
// notify.
|
|
1442
|
+
const applySet = (key: string, v: unknown) => {
|
|
1443
|
+
if (String(key).split(".").some(isReservedKey)) return false; // never write through to the prototype
|
|
1444
|
+
const parts = String(key).split(".");
|
|
1445
|
+
// The path exactly as changes() names it first, so a key holding a dot ("a.b", "f.a.b") is reachable; two controls
|
|
1446
|
+
// whose paths read the same (a top-level "a.b" beside folder a's b) are ambiguous, not a guess.
|
|
1447
|
+
const exact = entries.filter((x) => x.path.join(".") === key);
|
|
1448
|
+
if (exact.length > 1) {
|
|
1449
|
+
notice("ambiguousPath", key, exact.length);
|
|
1450
|
+
return false;
|
|
1451
|
+
}
|
|
1452
|
+
let e: Entry | undefined = exact[0];
|
|
1453
|
+
if (!e && parts.length > 1) {
|
|
1454
|
+
const t = valueless.get(key);
|
|
1455
|
+
notice(t ? "noValue" : "noPath", key, t);
|
|
1456
|
+
return false;
|
|
1457
|
+
}
|
|
1458
|
+
if (!e) {
|
|
1459
|
+
const matches = entries.filter((x) => x.key === key);
|
|
1460
|
+
if (matches.length > 1) {
|
|
1461
|
+
notice("ambiguousKey", key, matches.length, matches[0].path.join("."));
|
|
1462
|
+
return false;
|
|
1463
|
+
}
|
|
1464
|
+
e = matches[0];
|
|
1465
|
+
}
|
|
1466
|
+
// A key no control owns is refused: written through, a typo landed on params and in every save after.
|
|
1467
|
+
// A button, monitor or separator is there but holds nothing to set, which "no control has that key" would misstate.
|
|
1468
|
+
const vt = !e && (valueless.get(key) || [...valueless].find(([p]) => p.split(".").pop() === key)?.[1]);
|
|
1469
|
+
if (!e) {
|
|
1470
|
+
if (vt) notice("noValue", key, vt);
|
|
1471
|
+
else
|
|
1472
|
+
notice(
|
|
1473
|
+
"noControl",
|
|
1474
|
+
key,
|
|
1475
|
+
metas.some((m) => m.key === key && (m.type === "folder" || m.type === "tabs")),
|
|
1476
|
+
);
|
|
1477
|
+
return false;
|
|
1478
|
+
}
|
|
1479
|
+
const prev = e.target[e.key];
|
|
1480
|
+
// Same isolation as applySnapshot: a hostile value costs only that key.
|
|
1481
|
+
if (notAValue(v)) return false;
|
|
1482
|
+
try {
|
|
1483
|
+
assign(e, v);
|
|
1484
|
+
} catch (err) {
|
|
1485
|
+
console.error(`[tweaks] set("${key}") failed — value skipped:`, err);
|
|
1486
|
+
return false;
|
|
1487
|
+
}
|
|
1488
|
+
clampNote(e, key, v);
|
|
1489
|
+
if (!valueChanged(prev, e.target[e.key])) return false; // keeps a store-sync listener from echoing forever
|
|
1490
|
+
last = e.path.join("."); // on((p, last)) sees programmatic sets like control edits
|
|
1491
|
+
return true;
|
|
1492
|
+
};
|
|
1493
|
+
|
|
1494
|
+
// Every mutating method goes through later(): live at once, queued in the lazy window, silent after destroy().
|
|
1495
|
+
const api = {
|
|
1496
|
+
el: box,
|
|
1497
|
+
params,
|
|
1498
|
+
on(fn) {
|
|
1499
|
+
if (destroyed) return () => {};
|
|
1500
|
+
listeners.add(fn);
|
|
1501
|
+
return () => listeners.delete(fn);
|
|
1502
|
+
},
|
|
1503
|
+
// a key that can't stringify throws here, not in the replay
|
|
1504
|
+
set(key, v, o) {
|
|
1505
|
+
const k = String(key),
|
|
1506
|
+
silent = o?.silent;
|
|
1507
|
+
later(() => {
|
|
1508
|
+
if (applySet(k, v)) notify(silent);
|
|
1509
|
+
});
|
|
1510
|
+
},
|
|
1511
|
+
// Batch write: listeners + persist fire once for the whole batch.
|
|
1512
|
+
setMany(values, o) {
|
|
1513
|
+
if (values == null || typeof values !== "object") return;
|
|
1514
|
+
const batch = Object.entries(values),
|
|
1515
|
+
silent = o?.silent; // read now, so a later mutation can't reach the replay
|
|
1516
|
+
// per-key isolation: a bad value costs only its key; one changed key is named, many aren't
|
|
1517
|
+
later(() => {
|
|
1518
|
+
let changed = 0;
|
|
1519
|
+
for (const [k, v] of batch) {
|
|
1520
|
+
try {
|
|
1521
|
+
if (applySet(k, v)) changed++;
|
|
1522
|
+
} catch (e) {
|
|
1523
|
+
console.error(`[tweaks] setMany("${k}") failed — key skipped:`, e);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
if (changed > 1) last = undefined;
|
|
1527
|
+
if (changed) notify(silent);
|
|
1528
|
+
});
|
|
1529
|
+
},
|
|
1530
|
+
reset() {
|
|
1531
|
+
if (assembled && !destroyed) spinReset(resetBtn);
|
|
1532
|
+
later(resetAll);
|
|
1533
|
+
},
|
|
1534
|
+
// Whole-panel state — values + UI as a JSON-safe object (the standard toJSON hook). Before ready there
|
|
1535
|
+
// are no values yet. fromJSON applies one back: values (one notify), then the UI silently, outside undo.
|
|
1536
|
+
toJSON() {
|
|
1537
|
+
return destroyed ? { values: {}, ui: {} } : { values: snapshot(), ui: collectUI() };
|
|
1538
|
+
},
|
|
1539
|
+
// empty in the lazy window too — no entries yet
|
|
1540
|
+
changes() {
|
|
1541
|
+
return destroyed ? {} : changed();
|
|
1542
|
+
},
|
|
1543
|
+
fromJSON(state, o) {
|
|
1544
|
+
if (!state || typeof state !== "object") return;
|
|
1545
|
+
const silent = o?.silent;
|
|
1546
|
+
// read now (one level), like setMany
|
|
1547
|
+
const values = state.values && typeof state.values === "object" ? { ...state.values } : state.values,
|
|
1548
|
+
ui = state.ui && typeof state.ui === "object" ? { ...state.ui } : state.ui;
|
|
1549
|
+
later(() => {
|
|
1550
|
+
if (values) applySnapshot(values, silent);
|
|
1551
|
+
applyUI(ui);
|
|
1552
|
+
});
|
|
1553
|
+
},
|
|
1554
|
+
// Live theming clears the prior theme first, so setTheme(null) reverts to the default look.
|
|
1555
|
+
// a rename never moves saved values or presets
|
|
1556
|
+
setName(n) {
|
|
1557
|
+
if (destroyed) return;
|
|
1558
|
+
title = String(n);
|
|
1559
|
+
titleEl.textContent = title;
|
|
1560
|
+
landmark(title);
|
|
1561
|
+
},
|
|
1562
|
+
// A lifted panel's carried context sits under the theme: a key the old theme set and the new one drops falls back
|
|
1563
|
+
// to it.
|
|
1564
|
+
setTheme(theme) {
|
|
1565
|
+
if (destroyed) return;
|
|
1566
|
+
applyThemeVars(panel, null);
|
|
1567
|
+
for (const k in carried) panel.style.setProperty(k, carried[k]);
|
|
1568
|
+
themeVars = resolveTheme(theme);
|
|
1569
|
+
panel._twTheme = themeVars;
|
|
1570
|
+
applyThemeVars(panel, themeVars);
|
|
1571
|
+
window.dispatchEvent(new Event("twk-retheme"));
|
|
1572
|
+
},
|
|
1573
|
+
// Presets API (no-ops without opts.persist); before ready they queue like set() does.
|
|
1574
|
+
// queued before ready counts as saved; once built, whether storage took it
|
|
1575
|
+
savePreset: (nm) => {
|
|
1576
|
+
if (destroyed || failed || !presetsKey || !nm) return false;
|
|
1577
|
+
let ok = true;
|
|
1578
|
+
later(() => {
|
|
1579
|
+
ok = savePreset(nm);
|
|
1580
|
+
});
|
|
1581
|
+
return ok;
|
|
1582
|
+
},
|
|
1583
|
+
// whether it's stored now; a save still queued before ready isn't yet
|
|
1584
|
+
loadPreset: (nm) => {
|
|
1585
|
+
if (destroyed || failed || !listPresets()[nm]) return false;
|
|
1586
|
+
later(() => loadPreset(nm));
|
|
1587
|
+
return true;
|
|
1588
|
+
},
|
|
1589
|
+
deletePreset: (nm) => {
|
|
1590
|
+
if (presetsKey) later(() => deletePreset(nm));
|
|
1591
|
+
},
|
|
1592
|
+
presets: () => (destroyed ? [] : Object.keys(listPresets())),
|
|
1593
|
+
// no-ops without opts.undo; queued before ready, in order
|
|
1594
|
+
undo: () => {
|
|
1595
|
+
if (undoApi) later(undoApi.undo);
|
|
1596
|
+
},
|
|
1597
|
+
redo: () => {
|
|
1598
|
+
if (undoApi) later(undoApi.redo);
|
|
1599
|
+
},
|
|
1600
|
+
// Teardown. Safe to call before ready resolves — assemble() sees the flag and bails.
|
|
1601
|
+
destroy() {
|
|
1602
|
+
if (destroyed) return;
|
|
1603
|
+
destroyed = true;
|
|
1604
|
+
releaseKey();
|
|
1605
|
+
closeActivePopover(panel); // page-wide singletons: close only what's anchored here
|
|
1606
|
+
hideHintNow(panel);
|
|
1607
|
+
for (const fn of cleanups.splice(0)) {
|
|
1608
|
+
try {
|
|
1609
|
+
fn();
|
|
1610
|
+
} catch {}
|
|
1611
|
+
}
|
|
1612
|
+
listeners.clear();
|
|
1613
|
+
if (liftSlot) {
|
|
1614
|
+
liftSlot.remove();
|
|
1615
|
+
liftSlot = null;
|
|
1616
|
+
}
|
|
1617
|
+
if (topFloating === panel) topFloating = null;
|
|
1618
|
+
panel.dataset.twkDestroyed = ""; // onLive listeners release on their next event by this mark
|
|
1619
|
+
box.remove();
|
|
1620
|
+
},
|
|
1621
|
+
} as Panel; // `ready` is filled in just below
|
|
1622
|
+
// Assemble once the missing modules resolve, surfacing that on ready; else assemble now. A panel that persists an
|
|
1623
|
+
// image builds without waiting for its stored images; they fill in when the store answers, and `ready` waits for that
|
|
1624
|
+
// too.
|
|
1625
|
+
// A store that fails to load just saves no uploads.
|
|
1626
|
+
const images =
|
|
1627
|
+
persistKey && scanTypes(metas).has("image")
|
|
1628
|
+
? import("./image-store.js").then(
|
|
1629
|
+
async (m) => {
|
|
1630
|
+
savedImages = await m.load(persistKey);
|
|
1631
|
+
// A refused upload saves again, inline; after destroy() no save is armed, so it writes now while storage
|
|
1632
|
+
// still names that ref.
|
|
1633
|
+
if (savedImages)
|
|
1634
|
+
imgs = m.bind(
|
|
1635
|
+
persistKey,
|
|
1636
|
+
savedImages,
|
|
1637
|
+
(ref) => {
|
|
1638
|
+
if (!destroyed) persist();
|
|
1639
|
+
else if (JSON.stringify(readStore(persistKey)).includes(JSON.stringify(ref))) saveNow();
|
|
1640
|
+
},
|
|
1641
|
+
listPresets,
|
|
1642
|
+
(v) => !!presetsKey && writePresets(v),
|
|
1643
|
+
);
|
|
1644
|
+
},
|
|
1645
|
+
() => {},
|
|
1646
|
+
)
|
|
1647
|
+
: null;
|
|
1648
|
+
if (!images) imagesReady = true;
|
|
1649
|
+
const imagesIn = images && images.then(restoreImages);
|
|
1650
|
+
// A shadow panel's dressing: `ready` waits for it, not for the stylesheet's download.
|
|
1651
|
+
const shadowIn = iso.dressed;
|
|
1652
|
+
cleanups.push(iso.end);
|
|
1653
|
+
const lazy = ensureForMetas(metas);
|
|
1654
|
+
if (lazy) {
|
|
1655
|
+
// A failed chunk or a throwing build leaves the panel unbuildable: queued calls dropped, later calls refused,
|
|
1656
|
+
// ready rejects. A partway build must not leave live controls behind an inert API: release what it wired.
|
|
1657
|
+
const fail = (e: unknown) => {
|
|
1658
|
+
failed = true;
|
|
1659
|
+
queue.length = 0;
|
|
1660
|
+
releaseKey();
|
|
1661
|
+
// only what the build wired; the shell's stay for destroy()
|
|
1662
|
+
if (built >= 0)
|
|
1663
|
+
for (const fn of cleanups.splice(built)) {
|
|
1664
|
+
try {
|
|
1665
|
+
fn();
|
|
1666
|
+
} catch {}
|
|
1667
|
+
}
|
|
1668
|
+
// detached below, they'd never see the panel's own mark
|
|
1669
|
+
for (const c of controls.children) (c as HTMLElement).dataset.twkDestroyed = "";
|
|
1670
|
+
listeners.clear();
|
|
1671
|
+
entries.length = 0;
|
|
1672
|
+
controls.replaceChildren();
|
|
1673
|
+
// the chunk's own error is logged in lazy.ts
|
|
1674
|
+
if (!destroyed)
|
|
1675
|
+
console.warn(
|
|
1676
|
+
`[tweaks] "${name}": the panel could not be built — ${built >= 0 ? "the build threw" : "its lazy controls failed to load"}; the API is inert`,
|
|
1677
|
+
);
|
|
1678
|
+
throw e;
|
|
1679
|
+
};
|
|
1680
|
+
const building = lazy.then(assemble);
|
|
1681
|
+
// fail covers both the chunk and a build that throws
|
|
1682
|
+
api.ready = panel.ready = (imagesIn || shadowIn ? Promise.all([building, imagesIn, shadowIn]) : building).then(
|
|
1683
|
+
() => api,
|
|
1684
|
+
fail,
|
|
1685
|
+
);
|
|
1686
|
+
api.ready.catch(() => {}); // a handled fork: no unhandled-rejection noise
|
|
1687
|
+
} else {
|
|
1688
|
+
assemble();
|
|
1689
|
+
api.ready = panel.ready =
|
|
1690
|
+
imagesIn || shadowIn ? Promise.all([imagesIn, shadowIn]).then(() => api) : Promise.resolve(api);
|
|
1691
|
+
}
|
|
1692
|
+
return api as Panel;
|
|
1693
|
+
}
|