tweakit 0.6.0 → 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 +5 -5
- package/README.md +3 -3
- package/dist/tweaks/auto.js +1 -1
- package/dist/tweaks/auto.js.map +1 -1
- 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/core.js +1 -1
- package/dist/tweaks/core.js.map +3 -3
- 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-CLD7UAVI.js → heavy-NPFIGHRM.js} +2 -2
- package/dist/tweaks/image-HTQQ4WX6.js +4 -0
- package/dist/tweaks/image-HTQQ4WX6.js.map +6 -0
- package/dist/tweaks/{image-store-WRKY2NPU.js → image-store-QD5SMZXG.js} +1 -1
- 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 +5 -4
- package/dist/tweaks.js.map +3 -3
- package/dist/types/tweaks/enhance.d.ts +3 -2
- package/dist/types/tweaks/types.d.ts +278 -72
- package/package.json +5 -2
- package/src/tweaks/auto.ts +9 -5
- package/src/tweaks/clip.ts +84 -22
- package/src/tweaks/controls/basic.ts +463 -139
- package/src/tweaks/controls/bezier.ts +152 -47
- package/src/tweaks/controls/color.ts +610 -161
- package/src/tweaks/controls/gradient.ts +256 -77
- package/src/tweaks/controls/image.ts +388 -132
- package/src/tweaks/controls/interval.ts +126 -41
- package/src/tweaks/controls/monitor.ts +142 -36
- package/src/tweaks/controls/motion.ts +231 -56
- package/src/tweaks/controls/plot.ts +321 -101
- package/src/tweaks/controls/point.ts +134 -45
- package/src/tweaks/controls/shadow.ts +103 -30
- package/src/tweaks/controls/sides.ts +150 -40
- package/src/tweaks/controls/spring.ts +220 -60
- package/src/tweaks/controls/tabs.ts +39 -18
- package/src/tweaks/core.ts +1 -1
- package/src/tweaks/easing.ts +45 -20
- package/src/tweaks/enhance.ts +21 -6
- package/src/tweaks/feedback.ts +133 -34
- package/src/tweaks/fold.ts +22 -10
- package/src/tweaks/heavy.ts +461 -137
- package/src/tweaks/icons.ts +12 -5
- package/src/tweaks/image-store.ts +131 -43
- package/src/tweaks/lazy.ts +36 -19
- package/src/tweaks/markup.ts +255 -92
- package/src/tweaks/panel.ts +1262 -438
- package/src/tweaks/presets.ts +85 -0
- package/src/tweaks/schema.ts +213 -51
- package/src/tweaks/shadow-root.ts +178 -0
- package/src/tweaks/shared.ts +802 -190
- package/src/tweaks/standalone.ts +120 -30
- package/src/tweaks/types.ts +644 -143
- package/src/tweaks/undo.ts +104 -0
- package/src/tweaks.css +556 -550
- package/src/wide-gamut.ts +473 -125
- package/dist/tweaks/bezier-JFEZMCLP.js +0 -2
- package/dist/tweaks/bezier-JFEZMCLP.js.map +0 -6
- package/dist/tweaks/chunk-6UO4DHBN.js +0 -2
- package/dist/tweaks/chunk-6UO4DHBN.js.map +0 -6
- package/dist/tweaks/chunk-DIQMLBAS.js +0 -2
- package/dist/tweaks/chunk-DIQMLBAS.js.map +0 -6
- package/dist/tweaks/chunk-XE6OUJBG.js +0 -2
- package/dist/tweaks/chunk-XE6OUJBG.js.map +0 -6
- package/dist/tweaks/clip-WW7JYO4X.js +0 -2
- package/dist/tweaks/clip-WW7JYO4X.js.map +0 -6
- package/dist/tweaks/color-YMMFURP6.js +0 -2
- package/dist/tweaks/fold-U6YRZLCJ.js +0 -2
- package/dist/tweaks/fold-U6YRZLCJ.js.map +0 -6
- package/dist/tweaks/gradient-JER7PW4D.js +0 -2
- package/dist/tweaks/gradient-JER7PW4D.js.map +0 -6
- package/dist/tweaks/image-H6QSLGO3.js +0 -4
- package/dist/tweaks/image-H6QSLGO3.js.map +0 -6
- package/dist/tweaks/image-store-WRKY2NPU.js.map +0 -6
- package/dist/tweaks/interval-TEUHQGZN.js +0 -2
- package/dist/tweaks/interval-TEUHQGZN.js.map +0 -6
- package/dist/tweaks/markup-NIBJD6E2.js +0 -2
- package/dist/tweaks/markup-NIBJD6E2.js.map +0 -6
- package/dist/tweaks/monitor-3BY7YCGX.js +0 -3
- package/dist/tweaks/monitor-3BY7YCGX.js.map +0 -6
- package/dist/tweaks/motion-FQPUBKKM.js +0 -2
- package/dist/tweaks/motion-FQPUBKKM.js.map +0 -6
- package/dist/tweaks/plot-2HBNBZSG.js +0 -3
- package/dist/tweaks/plot-2HBNBZSG.js.map +0 -6
- package/dist/tweaks/point-VV6XV3I6.js +0 -2
- package/dist/tweaks/point-VV6XV3I6.js.map +0 -6
- package/dist/tweaks/shadow-5E4E55I2.js +0 -2
- package/dist/tweaks/shadow-5E4E55I2.js.map +0 -6
- package/dist/tweaks/sides-P3STEVYY.js +0 -2
- package/dist/tweaks/sides-P3STEVYY.js.map +0 -6
- package/dist/tweaks/spring-HUVSXNVN.js +0 -2
- package/dist/tweaks/spring-HUVSXNVN.js.map +0 -6
- package/dist/tweaks/tabs-76FW7LQ7.js +0 -2
- package/dist/tweaks/tabs-76FW7LQ7.js.map +0 -6
- /package/dist/tweaks/{color-YMMFURP6.js.map → color-UGYMAF3W.js.map} +0 -0
- /package/dist/tweaks/{heavy-CLD7UAVI.js.map → heavy-NPFIGHRM.js.map} +0 -0
package/src/tweaks/panel.ts
CHANGED
|
@@ -1,16 +1,60 @@
|
|
|
1
1
|
/* tweaks() — the panel factory: builds the shell + controls from a schema and returns the live
|
|
2
2
|
* API. Persistence, presets, undo, floating drag and the lazy-window queue live here. */
|
|
3
3
|
import {
|
|
4
|
-
el,
|
|
5
|
-
|
|
6
|
-
|
|
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,
|
|
7
30
|
} from "./shared.js";
|
|
8
31
|
import { metaFor, VALUELESS } from "./schema.js";
|
|
9
32
|
import { ensureForMetas, scanTypes } from "./lazy.js";
|
|
10
33
|
import { createFolder, createControl } from "./controls/basic.js";
|
|
11
|
-
import {
|
|
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";
|
|
12
45
|
import { ICON_PRESETS, ICON_X } from "./icons.js";
|
|
13
|
-
import
|
|
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";
|
|
14
58
|
import type { Meta } from "./schema.js";
|
|
15
59
|
import type { ClipCtx, Scope } from "./clip.js";
|
|
16
60
|
import type { PanelEl } from "./shared.js";
|
|
@@ -19,210 +63,401 @@ import type { TabsControl } from "./controls/tabs.js";
|
|
|
19
63
|
/** A params object or one of its folder/tabs sub-objects — the holder a control writes into. */
|
|
20
64
|
type Target = Record<string, unknown>;
|
|
21
65
|
/** 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 {
|
|
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
|
+
}
|
|
23
80
|
/** A values snapshot — params as JSON round-trips it. */
|
|
24
81
|
export type Snapshot = Record<string, any>;
|
|
25
82
|
|
|
26
83
|
let topFloating: HTMLElement | null = null; // the floating panel raised last (raise())
|
|
27
84
|
const liveKeys = new Map<string, number>(); // persist keys of the panels alive now, for the shared-key warning
|
|
28
85
|
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
|
|
30
|
-
// it: the system clipboard both ways, with no permission to ask. `onCatch` is that keypress's answer.
|
|
31
|
-
let catchEl: HTMLTextAreaElement | null = null,
|
|
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;
|
|
32
90
|
|
|
33
91
|
/** Build a panel from `schema`; the shell and API exist synchronously, lazy controls fill in on `ready`.
|
|
34
92
|
* A schema written inline (or `as const`) types `params` from its shape. */
|
|
35
|
-
export function tweaks<const S extends HeldSchema>(
|
|
93
|
+
export function tweaks<const S extends HeldSchema>(
|
|
94
|
+
name: string,
|
|
95
|
+
schema: S & KnownTypes<S>,
|
|
96
|
+
opts?: TweaksOptions,
|
|
97
|
+
): Panel<ParamsOf<S>>;
|
|
36
98
|
export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}): Panel {
|
|
37
|
-
|
|
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
|
|
38
106
|
const params: Params = {};
|
|
39
|
-
|
|
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;
|
|
40
109
|
const entries: Entry[] = []; // flattened across folders, for reset + persist
|
|
41
|
-
|
|
110
|
+
// dotted path → type of each button, monitor, separator: set() says it holds no value
|
|
111
|
+
const valueless = new Map<string, string>();
|
|
42
112
|
const listeners = new Set<(p: Params, last?: string) => void>();
|
|
43
113
|
const own = new Set<(p: Params, last?: string) => void>(); // undo + conditionals: all a silent write calls
|
|
44
114
|
const cleanups: Array<() => void> = []; // releases for destroy(): document/window listeners, timers
|
|
45
115
|
let destroyed = false; // set by destroy(): assemble() bails, mutators go silent
|
|
46
116
|
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
|
|
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.
|
|
48
119
|
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
120
|
let failed = false; // the chunks failed: the panel can never be built
|
|
51
121
|
let built = -1; // cleanups.length as assemble() enters build()
|
|
52
|
-
|
|
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
|
+
};
|
|
53
128
|
let liftSlot: HTMLSpanElement | null = null; // the placeholder a lifted panel leaves in its slot
|
|
54
|
-
|
|
129
|
+
// what the lift carried from the slot's context (lift()), under the panel's own theme
|
|
130
|
+
const carried: Record<string, string> = {};
|
|
55
131
|
// The values snapshot: undefined written as null (JSON would drop the key).
|
|
56
|
-
const replacer = (_k: string, v: unknown) => v === undefined ? null : v;
|
|
132
|
+
const replacer = (_k: string, v: unknown) => (v === undefined ? null : v);
|
|
57
133
|
// params as the schema shapes it, folders and tab pages included: a value the host parks on params is its own, so
|
|
58
134
|
// snapshots, restores and undo carry only what a control holds. Null-prototype: a folder named "toString" is its own.
|
|
59
135
|
const valuesOf = (ms: Meta[], from: Target): Snapshot => {
|
|
60
136
|
const o: Snapshot = Object.create(null);
|
|
61
|
-
for (const m of ms)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
+
}
|
|
67
148
|
return o;
|
|
68
149
|
};
|
|
69
150
|
const snapshot = (): Snapshot => JSON.parse(JSON.stringify(valuesOf(metas, params), replacer));
|
|
70
151
|
// Storage keys — opt-in via opts.persist (a string key, or `true` to key by name).
|
|
71
152
|
const persistKey = opts.persist ? `tw:${opts.persist === true ? name : opts.persist}` : null;
|
|
72
153
|
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()
|
|
74
|
-
//
|
|
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.
|
|
75
158
|
let holdsKey = !!persistKey;
|
|
76
|
-
if (persistKey) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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.
|
|
83
191
|
const unwrap = (s: any, k: string) => (s?.v === 1 ? s[k] : null);
|
|
84
192
|
const writeValues = (snap: Snapshot) => writeStore(persistKey!, { v: 1, values: snap });
|
|
85
193
|
const writePresets = (all: unknown) => writeStore(presetsKey!, { v: 1, presets: all });
|
|
86
|
-
|
|
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);
|
|
87
197
|
// 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) => {
|
|
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
|
+
};
|
|
89
204
|
// A control with `persist: false` stays out of what is saved and out of what is restored.
|
|
90
|
-
const unsaved = (snap: Snapshot | null) => {
|
|
205
|
+
const unsaved = (snap: Snapshot | null) => {
|
|
206
|
+
for (const e of entries) if (!e.persist) drop(snap, e);
|
|
207
|
+
return snap;
|
|
208
|
+
};
|
|
91
209
|
// ── 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
|
|
93
|
-
// image waits in `waiting` until the store answers, then fills in (restoreImages), by `ready`. With no store
|
|
94
|
-
// upload (not answered yet, unavailable, or it refused that image), the data URL saves inline, as 0.5
|
|
95
|
-
// to the store on a later save; a write storage refuses then leaves the inline images out. With
|
|
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
|
|
96
215
|
// stays saved as it was until its row is written. ──
|
|
97
|
-
|
|
216
|
+
// both null without IndexedDB
|
|
217
|
+
let imgs: ReturnType<(typeof import("./image-store.js"))["bind"]> | null = null,
|
|
218
|
+
savedImages: Map<string, string> | null = null;
|
|
98
219
|
let imagesReady = false; // the store has answered, or failed to load
|
|
99
|
-
|
|
100
|
-
const
|
|
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:");
|
|
101
224
|
const isDataUrl = (v: unknown): v is string => typeof v === "string" && v.startsWith("data:");
|
|
102
225
|
const imageEntries = () => entries.filter((e) => e.m.type === "image");
|
|
103
226
|
const unsavable = new Map<Entry, unknown>();
|
|
104
|
-
const notSaved = (e: Entry, v: 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
|
+
};
|
|
105
232
|
const toRefs = (snap: Snapshot) => {
|
|
106
233
|
const stored = imagesReady && imgs ? readStore(persistKey!) : undefined; // undefined: no store to hold an upload
|
|
107
234
|
for (const e of imageEntries()) {
|
|
108
|
-
const o: any = atPath(snap, e.path.slice(0, -1)),
|
|
109
|
-
|
|
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
|
+
}
|
|
110
246
|
if (!isDataUrl(v) || stored === undefined) continue; // a URL is small, and a data URL with no store saves inline
|
|
111
247
|
const ref = imgs!.save(e, v, stored);
|
|
112
248
|
if (ref) o[e.key] = ref;
|
|
113
249
|
}
|
|
114
250
|
return snap;
|
|
115
251
|
};
|
|
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
|
|
117
|
-
// preset another tab saved) is fetched. It lands only on a row the host and the person left alone meanwhile.
|
|
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.
|
|
118
254
|
const late = (e: Entry, held: unknown, url: string) => {
|
|
119
255
|
if (destroyed || valueChanged(held, e.get()) || !tryAssign(e, url)) return false;
|
|
120
256
|
undoApi?.patch(e.path, held, e.get()); // not a step of its own: ⌘Z mustn't take a restored image back out
|
|
121
257
|
return true;
|
|
122
258
|
};
|
|
123
|
-
// A stored snapshot back to values, the way applySnapshot takes them: each known ref resolved, an unknown one left
|
|
124
|
-
// (its control keeps its value) and listed, so `restore` can fill it in once it arrives.
|
|
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.
|
|
125
261
|
const fromRefs = (snap: Snapshot | null, pending: [Entry, string][] = []) => {
|
|
126
|
-
if (snap && typeof snap === "object")
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
+
}
|
|
132
273
|
return snap;
|
|
133
274
|
};
|
|
134
275
|
const restore = (snap: Snapshot | null, silent?: boolean) => {
|
|
135
276
|
const pending: [Entry, string][] = [];
|
|
136
277
|
applySnapshot(fromRefs(snap, pending), silent);
|
|
137
278
|
for (const [e, ref] of pending) {
|
|
138
|
-
|
|
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);
|
|
139
282
|
if (!imagesReady || !imgs) continue; // no store: the ref stays saved until the row is written
|
|
140
|
-
imgs.fetch(ref).then((url) => {
|
|
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
|
+
});
|
|
141
291
|
}
|
|
142
292
|
};
|
|
143
293
|
// The store answered: each waiting row the host and the person left alone takes its image, in one notify.
|
|
144
294
|
const restoreImages = () => {
|
|
145
295
|
imagesReady = true;
|
|
146
|
-
|
|
296
|
+
// not built yet: the build resolves refs itself
|
|
297
|
+
if (!assembled || destroyed || failed) {
|
|
298
|
+
waiting.clear();
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
147
301
|
let changed = false;
|
|
148
|
-
for (const [e, [ref, held]] of [...waiting]) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
152
312
|
};
|
|
153
|
-
// Delete the records nothing names any more: not the saved values, not a preset. Only records this panel knows,
|
|
154
|
-
// or written: another tab's panel on the same key may have just stored one this panel has never seen.
|
|
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.
|
|
155
315
|
const pruneImages = () => imgs?.prune([readStore(persistKey!), listPresets()]);
|
|
156
316
|
// Debounced save; destroy() flushes a pending write rather than dropping it. saveNow writes at once.
|
|
157
317
|
let saveNow = () => {};
|
|
158
|
-
const persist = persistKey
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
+
: () => {};
|
|
176
350
|
// Each listener runs isolated: a throwing callback can't break the others or skip persist().
|
|
177
351
|
// `last` is read once, so a write one listener makes can't change the key the rest are told.
|
|
178
|
-
|
|
179
|
-
|
|
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).
|
|
180
365
|
const notAValue = (v: unknown) => typeof v === "function" || typeof v === "symbol" || typeof v === "bigint";
|
|
181
366
|
// 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) => {
|
|
183
|
-
|
|
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
|
+
};
|
|
184
382
|
// …and the reset write: the opened form, then the authored value (restoreDefault).
|
|
185
|
-
const assignDefault = (e: Entry) => {
|
|
383
|
+
const assignDefault = (e: Entry) => {
|
|
384
|
+
waiting.delete(e);
|
|
385
|
+
restoreDefault(e, e.raw, e.def);
|
|
386
|
+
e.target[e.key] = e.get();
|
|
387
|
+
};
|
|
186
388
|
// opts.onReset replaces the default; a reset() called from INSIDE it performs the default instead of recursing.
|
|
187
389
|
let inOnReset = false;
|
|
188
390
|
const resetAll = () => {
|
|
189
391
|
if (typeof opts.onReset === "function" && !inOnReset) {
|
|
190
392
|
inOnReset = true;
|
|
191
|
-
try {
|
|
393
|
+
try {
|
|
394
|
+
return opts.onReset();
|
|
395
|
+
} finally {
|
|
396
|
+
inOnReset = false;
|
|
397
|
+
}
|
|
192
398
|
}
|
|
193
399
|
for (const e of entries) {
|
|
194
|
-
try {
|
|
195
|
-
|
|
400
|
+
try {
|
|
401
|
+
assignDefault(e);
|
|
402
|
+
} catch (err) {
|
|
403
|
+
console.error(`[tweaks] resetting "${e.path.join(".")}" failed — control skipped:`, err);
|
|
404
|
+
}
|
|
196
405
|
}
|
|
197
|
-
last = undefined;
|
|
406
|
+
last = undefined;
|
|
407
|
+
notify();
|
|
198
408
|
};
|
|
199
409
|
|
|
200
|
-
const panel: PanelEl = el("div", "
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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);
|
|
204
417
|
// Stop pointer events leaking past the panel to a stage listening on window.
|
|
205
418
|
stopPointerLeak(panel);
|
|
206
|
-
const header = el("div", "
|
|
419
|
+
const header = el("div", "twk-header");
|
|
207
420
|
// The title is the collapse toggle; the toolbar beside it never collapses.
|
|
208
|
-
const titleBtn = btn("
|
|
421
|
+
const titleBtn = btn("twk-header-toggle");
|
|
209
422
|
titleBtn.setAttribute("aria-expanded", "true");
|
|
210
423
|
let title = name; // setName() moves it; the storage key keeps the built name
|
|
211
|
-
const titleEl = txt("span", "
|
|
212
|
-
|
|
424
|
+
const titleEl = txt("span", "twk-title", title);
|
|
425
|
+
titleBtn.append(titleEl);
|
|
426
|
+
const toolbar = el("div", "twk-toolbar");
|
|
213
427
|
// 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> => {
|
|
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
|
+
};
|
|
215
433
|
// ⇧-click copies the changed values only; reset restores every default or runs opts.onReset.
|
|
216
|
-
const copyBtn = makeCopyBtn((changedOnly) => {
|
|
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);
|
|
217
439
|
// A picked row's paste (pasteScope, in the lazy clip chunk) writes through the panel's own load path; ctx lends it.
|
|
218
440
|
// 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 = {
|
|
220
|
-
|
|
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);
|
|
221
454
|
// Presets share persistence's storage, so the button needs it on.
|
|
222
455
|
let presetsBtn: HTMLButtonElement | null = null;
|
|
223
|
-
if (persistKey && opts.presets !== false) {
|
|
224
|
-
|
|
225
|
-
presetsBtn
|
|
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");
|
|
226
461
|
toolbar.append(presetsBtn);
|
|
227
462
|
}
|
|
228
463
|
toolbar.append(copyBtn, resetBtn);
|
|
@@ -231,173 +466,324 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}):
|
|
|
231
466
|
// The buttons act on controls that only exist once assemble() has built them: inert until then.
|
|
232
467
|
const toolbarBtns = [copyBtn, resetBtn, presetsBtn].filter(Boolean);
|
|
233
468
|
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
|
|
235
|
-
|
|
236
|
-
|
|
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
|
+
);
|
|
237
484
|
// A bottom-parked floating panel grows past the viewport when it expands: re-clamp once the collapse has settled.
|
|
238
485
|
const toggleBody = () => foldBody(panel, titleBtn, body, !panel.classList.contains("is-collapsed"), reclamp);
|
|
239
486
|
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
|
|
241
|
-
// slid off a toolbar button also clicks here). A drag's closing click arrives defaultPrevented.
|
|
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.
|
|
242
489
|
let pressedHeader = false;
|
|
243
|
-
header.addEventListener(
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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");
|
|
247
502
|
body.append(controls);
|
|
248
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;
|
|
249
508
|
|
|
250
509
|
// Stash the theme so the portaled popovers (which lose the panel's inherited vars) re-apply it on open.
|
|
251
510
|
let themeVars = resolveTheme(opts.theme);
|
|
252
|
-
applyThemeVars(panel, themeVars);
|
|
511
|
+
applyThemeVars(panel, themeVars);
|
|
512
|
+
panel._twTheme = themeVars;
|
|
253
513
|
|
|
254
514
|
// ── Floating position — seeded in the shell, so an opts.floating / persisted panel is fixed in place
|
|
255
515
|
// the moment tweaks() returns instead of jumping when the lazy chunks land. ──
|
|
256
516
|
const draggable = opts.draggable !== false;
|
|
257
|
-
const MARGIN = 8,
|
|
517
|
+
const MARGIN = 8,
|
|
518
|
+
SNAP = 28; // px: viewport inset; snap distance to park against an edge
|
|
258
519
|
// opts.floating's x / y are insets from the viewport's top-left, as px / py below are.
|
|
259
520
|
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
|
|
261
|
-
// (every position fits, so a tall panel drags up but never back down). +1: it rounds, so a panel parked at
|
|
262
|
-
// isn't capped a fraction short of its rows.
|
|
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.
|
|
263
524
|
// The viewport from clientWidth / clientHeight: a fixed box's right and bottom edges sit inside the page scrollbars,
|
|
264
525
|
// which innerWidth / innerHeight count. Only in standards mode: with no doctype the root's client box is the page's
|
|
265
526
|
// 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,
|
|
268
|
-
|
|
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
|
+
});
|
|
269
534
|
const posKey = persistKey ? `${persistKey}:pos` : null;
|
|
270
535
|
// A pinned panel sits where opts.floating puts it; only a draggable one restores a saved spot.
|
|
271
536
|
const stored = draggable && posKey ? readStore(posKey) : null;
|
|
272
537
|
const saved = stored && typeof stored === "object" ? stored : null;
|
|
273
538
|
const start = saved || place || { x: 16, y: 16 };
|
|
274
|
-
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
let
|
|
282
|
-
|
|
283
|
-
//
|
|
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).
|
|
284
560
|
// Custom properties, not left / top / max-height: the stylesheet maps them, so host CSS can still override each one.
|
|
285
|
-
const apply = () => {
|
|
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
|
+
};
|
|
286
567
|
const clampPos = () => {
|
|
287
568
|
readGap();
|
|
288
569
|
const { maxX, maxY } = bounds();
|
|
289
|
-
if (draggable) {
|
|
290
|
-
|
|
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.
|
|
291
577
|
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
|
|
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),
|
|
295
|
-
|
|
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
|
+
}
|
|
296
590
|
};
|
|
297
|
-
const reclamp = () => { if (!destroyed && panel.dataset.mode === "floating" && panel.isConnected) { clampPos(); apply(); } };
|
|
298
591
|
if ((draggable || opts.floating) && (opts.floating || saved)) {
|
|
299
|
-
panel.dataset.mode = "floating";
|
|
592
|
+
panel.dataset.mode = "floating";
|
|
593
|
+
clampPos();
|
|
594
|
+
apply();
|
|
300
595
|
// A position saved on a larger monitor restores off-viewport: re-clamp once mounted (offsetWidth is 0 until then).
|
|
301
596
|
requestAnimationFrame(reclamp);
|
|
302
597
|
}
|
|
303
598
|
|
|
304
599
|
// 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) => {
|
|
600
|
+
const resetEntry = (e: Entry) => {
|
|
601
|
+
assignDefault(e);
|
|
602
|
+
last = e.path.join(".");
|
|
603
|
+
notify();
|
|
604
|
+
};
|
|
306
605
|
const wireReset = (root: HTMLElement, entry: Entry) => {
|
|
307
|
-
const t =
|
|
308
|
-
|| root.querySelector<HTMLElement>(LABEL_SEL)
|
|
309
|
-
|
|
310
|
-
t.
|
|
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");
|
|
311
610
|
// Coarse pointers get a press-and-hold reset: the desktop double-click fights double-tap-zoom on touch.
|
|
312
|
-
let holdT = 0,
|
|
313
|
-
|
|
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
|
+
};
|
|
314
622
|
t.addEventListener("pointerdown", (e) => {
|
|
315
|
-
e.stopPropagation();
|
|
623
|
+
e.stopPropagation();
|
|
624
|
+
held = false; // a press on the readout mustn't jump the slider
|
|
316
625
|
if (e.pointerType !== "touch" && e.pointerType !== "pen") return;
|
|
317
|
-
hx = e.clientX;
|
|
318
|
-
|
|
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();
|
|
319
638
|
});
|
|
320
|
-
t.addEventListener("pointermove", (e) => { if (holdT && Math.abs(e.clientX - hx) + Math.abs(e.clientY - hy) > 8) cancelHold(); });
|
|
321
639
|
t.addEventListener("pointerup", cancelHold);
|
|
322
640
|
t.addEventListener("pointercancel", cancelHold);
|
|
323
641
|
t.addEventListener("pointerleave", cancelHold);
|
|
324
|
-
|
|
325
|
-
t.addEventListener("
|
|
326
|
-
|
|
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);
|
|
642
|
+
// a long-press mustn't raise the text callout
|
|
643
|
+
t.addEventListener("contextmenu", (e) => {
|
|
644
|
+
if (held) e.preventDefault();
|
|
329
645
|
});
|
|
330
|
-
//
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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();
|
|
336
663
|
resetEntry(entry);
|
|
337
664
|
});
|
|
338
|
-
//
|
|
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.
|
|
339
677
|
root.addEventListener("keydown", (e) => {
|
|
340
678
|
if (!e.altKey || e.metaKey || e.ctrlKey || (e.key !== "Backspace" && e.key !== "Delete")) return;
|
|
341
|
-
e.preventDefault();
|
|
342
|
-
|
|
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();
|
|
343
683
|
resetEntry(entry);
|
|
344
684
|
});
|
|
345
685
|
};
|
|
346
686
|
|
|
347
|
-
// Conditional controls — `render` shows/hides, `disabled` grays-out + locks, both re-evaluated on every change;
|
|
687
|
+
// Conditional controls — `render` shows/hides, `disabled` grays-out + locks, both re-evaluated on every change;
|
|
688
|
+
// `hint` is a static tooltip.
|
|
348
689
|
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
|
|
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).
|
|
350
692
|
let fold: typeof import("./fold.js").fold | undefined;
|
|
351
693
|
const foldRow = (node: HTMLElement & { _hid?: boolean }, hide: boolean) => {
|
|
352
694
|
if (!node._hid === !hide) return false;
|
|
353
695
|
node._hid = hide;
|
|
354
|
-
if (fold) fold(node, hide);
|
|
696
|
+
if (fold) fold(node, hide);
|
|
697
|
+
else node.classList.toggle("twk-cond-hidden", hide);
|
|
355
698
|
return !hide;
|
|
356
699
|
};
|
|
357
700
|
const registerCond = (node: HTMLElement, m: Meta) => {
|
|
358
701
|
if (m.hint) addHintMarker(node, m.hint);
|
|
359
702
|
if (m.render || m.disabled != null) conditionals.push({ node, m });
|
|
360
703
|
};
|
|
361
|
-
|
|
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[] }> = [];
|
|
362
707
|
|
|
363
|
-
// A control's own loop (the monitor, the FPS graph) hands back a `destroy`; a panel destroyed before it ever
|
|
364
|
-
|
|
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
|
+
};
|
|
365
713
|
|
|
366
714
|
const build = (container: HTMLElement, ms: Meta[], target: Target, basePath: string[] = []) => {
|
|
367
715
|
for (const m of ms) {
|
|
368
716
|
if (m.type === "tabs") {
|
|
369
|
-
const sub: Target = {};
|
|
717
|
+
const sub: Target = {};
|
|
718
|
+
target[m.key] = sub;
|
|
370
719
|
const makeTabs = getControl<TabsControl>("tabs");
|
|
371
720
|
const tabsCtrl = makeTabs && makeTabs(m);
|
|
372
721
|
if (!tabsCtrl) continue; // ensured before assemble; skipped if its chunk failed
|
|
373
722
|
tabsCtrls.push({ path: [...basePath, m.key], ctrl: tabsCtrl, pageKeys: m.pages.map((p) => p.key) });
|
|
374
|
-
m.pages.forEach((page, i) => {
|
|
375
|
-
|
|
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);
|
|
376
730
|
continue;
|
|
377
731
|
}
|
|
378
732
|
if (m.type === "folder") {
|
|
379
|
-
const sub: Target = {};
|
|
733
|
+
const sub: Target = {};
|
|
734
|
+
target[m.key] = sub;
|
|
380
735
|
const f = createFolder(m);
|
|
381
736
|
folderEls.push({ path: [...basePath, m.key], el: f.el, setCollapsed: f.setCollapsed });
|
|
382
737
|
scopes.set(f.el, { kind: "folder", path: [...basePath, m.key], label: m.label });
|
|
383
|
-
build(f.body, m.children, sub, [...basePath, m.key]);
|
|
738
|
+
build(f.body, m.children, sub, [...basePath, m.key]);
|
|
739
|
+
registerCond(f.el, m);
|
|
740
|
+
container.append(f.el);
|
|
384
741
|
continue;
|
|
385
742
|
}
|
|
386
743
|
const valued = !VALUELESS.has(m.type);
|
|
387
744
|
const dotted = [...basePath, m.key].join("."); // a bare leaf couldn't tell size from card.size
|
|
388
|
-
|
|
389
|
-
|
|
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
|
+
);
|
|
390
760
|
if (!ctrl) continue;
|
|
391
761
|
adopt(ctrl);
|
|
392
762
|
if (!valued) valueless.set(dotted, m.type);
|
|
393
763
|
if (valued) {
|
|
394
|
-
// reset() restores `raw` over the form the control OPENED on (`def`), read before the parked value applies —
|
|
764
|
+
// reset() restores `raw` over the form the control OPENED on (`def`), read before the parked value applies —
|
|
765
|
+
// see restoreDefault.
|
|
395
766
|
const def = ctrl.get();
|
|
396
767
|
// A value a host parked on params before assemble ran wins over the schema default.
|
|
397
768
|
if (hasOwn(target, m.key)) ctrl.set(target[m.key]);
|
|
398
769
|
target[m.key] = ctrl.get();
|
|
399
|
-
entry = {
|
|
400
|
-
|
|
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 });
|
|
401
787
|
}
|
|
402
788
|
registerCond(ctrl.el, m);
|
|
403
789
|
container.append(ctrl.el);
|
|
@@ -409,30 +795,67 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}):
|
|
|
409
795
|
const applySnapshot = (snap: Snapshot | null, silent?: boolean) => {
|
|
410
796
|
if (!snap || typeof snap !== "object") return;
|
|
411
797
|
for (const e of entries) {
|
|
412
|
-
const v = atPath(snap, e.path);
|
|
798
|
+
const v = atPath(snap, e.path);
|
|
799
|
+
if (v === undefined) continue;
|
|
413
800
|
tryAssign(e, v);
|
|
414
801
|
}
|
|
415
|
-
last = undefined;
|
|
802
|
+
last = undefined;
|
|
803
|
+
notify(silent);
|
|
416
804
|
};
|
|
417
805
|
// ── Named presets (opt-in via opts.persist) — snapshots under "<key>:presets" ──
|
|
418
|
-
|
|
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
|
+
};
|
|
419
811
|
// False when storage refused the write, so the caller can say so instead of claiming a save.
|
|
420
|
-
|
|
421
|
-
const
|
|
422
|
-
|
|
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
|
+
};
|
|
423
828
|
|
|
424
829
|
// ── Whole-panel UI state (toJSON / fromJSON) — folder open/closed and the active tab, keyed by dotted path ──
|
|
425
|
-
|
|
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(".");
|
|
426
832
|
const collectUI = () => {
|
|
427
833
|
const ui: NonNullable<PanelState["ui"]> = {};
|
|
428
|
-
if (folderEls.length) {
|
|
429
|
-
|
|
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
|
+
}
|
|
430
844
|
return ui;
|
|
431
845
|
};
|
|
432
846
|
const applyUI = (ui: PanelState["ui"]) => {
|
|
433
847
|
if (!ui || typeof ui !== "object") return;
|
|
434
|
-
if (ui.folders)
|
|
435
|
-
|
|
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
|
+
}
|
|
436
859
|
};
|
|
437
860
|
|
|
438
861
|
// ── Repositioning — drag the header to move the panel ──
|
|
@@ -440,118 +863,233 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}):
|
|
|
440
863
|
// pointer; on release it eases to a near viewport edge, and the position persists to "<key>:pos".
|
|
441
864
|
if (draggable || opts.floating) {
|
|
442
865
|
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
|
|
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.
|
|
444
868
|
const lift = () => {
|
|
445
869
|
if (panel.dataset.mode === "floating") return;
|
|
446
870
|
const r = panel.getBoundingClientRect();
|
|
447
|
-
px = r.left;
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
liftSlot
|
|
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";
|
|
451
877
|
liftSlot.setAttribute("aria-hidden", "true");
|
|
452
|
-
panel
|
|
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;
|
|
453
881
|
}
|
|
454
|
-
// Portal to <body>: a transformed/filter/contain ancestor would become the fixed panel's containing block. The
|
|
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.
|
|
455
884
|
// 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 --
|
|
457
|
-
// (`.my-sidebar .
|
|
458
|
-
// opts.theme / setTheme() still layer on top and setTheme(null) falls back to the host's values;
|
|
459
|
-
// changes is written, inline. A host rule reaching inside through an ancestor
|
|
460
|
-
// matching: only a shadow root could keep that.
|
|
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.
|
|
461
890
|
applyThemeVars(panel, null);
|
|
462
|
-
const cs = getComputedStyle(panel),
|
|
463
|
-
|
|
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);
|
|
464
914
|
carryScheme(panel, panel);
|
|
465
|
-
document.body.appendChild(
|
|
466
|
-
for (const k in was) if (cs.getPropertyValue(k) !== was[k]) panel.style.setProperty(k, carried[k] = was[k]);
|
|
467
|
-
if (lang &&
|
|
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;
|
|
468
918
|
applyThemeVars(panel, themeVars);
|
|
469
|
-
panel.dataset.mode = "floating";
|
|
470
|
-
|
|
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.
|
|
471
923
|
if (liftSlot && typeof MutationObserver === "function") {
|
|
472
|
-
const obs = new MutationObserver(() => {
|
|
924
|
+
const obs = new MutationObserver(() => {
|
|
925
|
+
if (liftSlot && !liftSlot.isConnected) {
|
|
926
|
+
obs.disconnect();
|
|
927
|
+
liftSlot = null;
|
|
928
|
+
box.remove();
|
|
929
|
+
}
|
|
930
|
+
});
|
|
473
931
|
obs.observe(document.documentElement, { childList: true, subtree: true });
|
|
474
932
|
cleanups.push(() => obs.disconnect());
|
|
475
933
|
}
|
|
476
934
|
};
|
|
477
|
-
// Two floating panels share one stylesheet z-index; the last touched gets a higher one. A class at plain .
|
|
935
|
+
// Two floating panels share one stylesheet z-index; the last touched gets a higher one. A class at plain .twk-panel
|
|
478
936
|
// specificity, not an inline value, so a host's own z-index still wins after a drag.
|
|
479
|
-
const raise = () => {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
+
};
|
|
483
957
|
|
|
484
|
-
let dragId: number | null = null,
|
|
958
|
+
let dragId: number | null = null,
|
|
959
|
+
sx = 0,
|
|
960
|
+
sy = 0,
|
|
961
|
+
ox = 0,
|
|
962
|
+
oy = 0,
|
|
963
|
+
snapT = 0;
|
|
485
964
|
// Document listeners (capture, so the panel's own pointer-stop can't hide them) for the press's duration.
|
|
486
965
|
// Capture is taken only past the threshold: on the press it would retarget a plain click off the title.
|
|
487
966
|
const listen = (on: boolean) => {
|
|
488
|
-
for (const [t, fn] of [
|
|
489
|
-
|
|
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);
|
|
490
976
|
};
|
|
491
977
|
// Escape mid-press cancels: the panel returns to where the drag began and the release's click is swallowed. Capture
|
|
492
978
|
// phase, so it beats a pane's Escape.
|
|
493
979
|
const onDragKey = (e: KeyboardEvent) => {
|
|
494
980
|
if (e.key !== "Escape" || dragId === null) return;
|
|
495
|
-
e.preventDefault();
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
+
}
|
|
499
995
|
panel.classList.remove("is-dragging");
|
|
500
|
-
swallowClick = true;
|
|
996
|
+
swallowClick = true;
|
|
997
|
+
document.addEventListener(
|
|
998
|
+
"pointerup",
|
|
999
|
+
() =>
|
|
1000
|
+
setTimeout(() => {
|
|
1001
|
+
swallowClick = false;
|
|
1002
|
+
}, 0),
|
|
1003
|
+
{ capture: true, once: true },
|
|
1004
|
+
);
|
|
501
1005
|
};
|
|
502
|
-
if (draggable)
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
+
});
|
|
526
1044
|
const onMove = (e: PointerEvent) => {
|
|
527
1045
|
if (e.pointerId !== dragId) return;
|
|
528
1046
|
// The window lost the pointer: bail as every other drag surface does.
|
|
529
|
-
if (e.buttons === 0) {
|
|
530
|
-
|
|
1047
|
+
if (e.buttons === 0) {
|
|
1048
|
+
endDrag(e);
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
const dx = e.clientX - sx,
|
|
1052
|
+
dy = e.clientY - sy;
|
|
531
1053
|
if (!dragMoved) {
|
|
532
|
-
// Drag slop: quick clicks wander a few px, fingers more. Collapsing is common and moving rare, so favor the
|
|
1054
|
+
// Drag slop: quick clicks wander a few px, fingers more. Collapsing is common and moving rare, so favor the
|
|
1055
|
+
// click.
|
|
533
1056
|
if (Math.hypot(dx, dy) < (e.pointerType === "mouse" ? 6 : 10)) return;
|
|
534
|
-
dragMoved = true;
|
|
535
|
-
|
|
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 {}
|
|
536
1064
|
panel.classList.add("is-dragging");
|
|
537
1065
|
}
|
|
538
1066
|
moveTo(ox, oy, dx, dy);
|
|
539
1067
|
};
|
|
540
1068
|
const endDrag = (e: PointerEvent) => {
|
|
541
1069
|
if (e.pointerId !== dragId) return;
|
|
542
|
-
try {
|
|
543
|
-
|
|
1070
|
+
try {
|
|
1071
|
+
header.releasePointerCapture(dragId);
|
|
1072
|
+
} catch {}
|
|
1073
|
+
dragId = null;
|
|
1074
|
+
listen(false);
|
|
544
1075
|
// Swallow exactly the click that follows the release; let the flag lapse in case none comes (a pointercancel).
|
|
545
1076
|
swallowClick = dragMoved;
|
|
546
|
-
if (swallowClick)
|
|
1077
|
+
if (swallowClick)
|
|
1078
|
+
setTimeout(() => {
|
|
1079
|
+
swallowClick = false;
|
|
1080
|
+
}, 0);
|
|
547
1081
|
if (dragMoved) {
|
|
548
1082
|
// Edge magnetism: a drop near a side eases the rest of the way to the margin.
|
|
549
1083
|
const { maxX, maxY } = bounds();
|
|
550
|
-
if (px <= MARGIN + SNAP) px = MARGIN;
|
|
551
|
-
|
|
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;
|
|
552
1088
|
// The ease is a class (tweaks.css), so the reduced-motion kill-switch covers it.
|
|
553
1089
|
// One timer at a time: an earlier drop's would strip the class 340ms after it, partway through this snap.
|
|
554
|
-
clearTimeout(snapT);
|
|
1090
|
+
clearTimeout(snapT);
|
|
1091
|
+
panel.classList.add("is-snapping");
|
|
1092
|
+
snapT = setTimeout(() => panel.classList.remove("is-snapping"), 340);
|
|
555
1093
|
apply();
|
|
556
1094
|
savePos();
|
|
557
1095
|
}
|
|
@@ -560,192 +1098,312 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}):
|
|
|
560
1098
|
header.addEventListener("lostpointercapture", endDrag); // implicit capture loss mid-drag ends it like a release
|
|
561
1099
|
cleanups.push(() => listen(false)); // a destroy() mid-press releases the document listeners
|
|
562
1100
|
}
|
|
563
|
-
// Registered by assemble(): onLive releases itself once the owner LEAVES the document, which it can only tell if it
|
|
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.
|
|
564
1103
|
const watchResize = () => {
|
|
565
1104
|
if (!(draggable || opts.floating)) return;
|
|
566
1105
|
reclamp(); // a resize during the lazy window went unheard; clamp now
|
|
567
1106
|
cleanups.push(onLive(panel, [[window, "resize"]], reclamp));
|
|
568
1107
|
};
|
|
569
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.
|
|
570
1112
|
if (presetsBtn) {
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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);
|
|
590
1154
|
};
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
+
});
|
|
596
1170
|
});
|
|
597
|
-
saveBtn.addEventListener("click", doSave);
|
|
598
|
-
input.addEventListener("keydown", (e) => { if (e.key === "Enter") doSave(); });
|
|
599
1171
|
}
|
|
600
1172
|
|
|
601
|
-
// ── Undo / redo (opts.undo)
|
|
602
|
-
const undoApi = opts.undo
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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;
|
|
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;
|
|
647
1187
|
|
|
648
1188
|
// ── Pick and copy / paste — a press beside a row (in the panel's side padding, or a gap between rows) picks it, as
|
|
649
1189
|
// 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
|
|
651
|
-
// its values object and pastes one into its own keys, so two folders with the same keys trade values. Escape,
|
|
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
|
|
652
1193
|
// press anywhere else, drops the pick; a text field or a text selection keeps the browser's own keys. ──
|
|
653
1194
|
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 => {
|
|
655
|
-
|
|
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
|
+
};
|
|
656
1204
|
// 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) =>
|
|
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
|
+
);
|
|
658
1210
|
let pick: [Element, Scope] | undefined;
|
|
659
|
-
|
|
660
|
-
const
|
|
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
|
+
};
|
|
661
1223
|
const choose = (el: Element, s: Scope) => {
|
|
662
1224
|
if (picked && picked[0] !== panel) picked[1](); // one pick on the page
|
|
663
|
-
unpick();
|
|
1225
|
+
unpick();
|
|
1226
|
+
pick = [el, s];
|
|
1227
|
+
picked = [panel, unpick];
|
|
1228
|
+
el.classList.add("is-picked");
|
|
664
1229
|
document.addEventListener("pointerdown", away, true); // only while something is picked
|
|
665
1230
|
// Focus moves to what was picked (a folder's header, a row's control), so its keys work and a screen reader names
|
|
666
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.
|
|
667
|
-
|
|
668
|
-
|
|
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?.();
|
|
669
1238
|
document.getSelection?.()?.removeAllRanges();
|
|
670
1239
|
};
|
|
671
1240
|
cleanups.push(unpick);
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
if (
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
+
);
|
|
690
1272
|
// Called from the panel's one keydown listener (registered by assemble(), beside the undo keys). Focus steps to the
|
|
691
1273
|
// hidden field for the keypress (the copy selected in it), the browser copies or pastes there, and focus steps back.
|
|
692
1274
|
const onClipKey = (e: KeyboardEvent) => {
|
|
693
|
-
if (e.key === "Escape") {
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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]();
|
|
697
1287
|
if (pick && hidden(pick[0])) unpick(); // a pick out of sight takes nothing
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
const
|
|
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");
|
|
703
1305
|
// The browser's copy or paste can land some milliseconds after this keydown (an app's menu passes it on), so focus
|
|
704
1306
|
// 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) {
|
|
706
|
-
|
|
707
|
-
|
|
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) => {
|
|
708
1322
|
if (ev.target !== ta && inEditable(ev.target as Element)) return; // a field focused meanwhile keeps its own paste
|
|
709
|
-
ev.preventDefault();
|
|
710
|
-
|
|
1323
|
+
ev.preventDefault();
|
|
1324
|
+
done();
|
|
1325
|
+
if (json) return (ev.clipboardData?.setData("text/plain", json), copied(!!ev.clipboardData));
|
|
711
1326
|
const t = ev.clipboardData?.getData("text/plain") ?? null;
|
|
712
|
-
import("./clip.js").then(
|
|
713
|
-
|
|
1327
|
+
import("./clip.js").then(
|
|
1328
|
+
(m) => m.pasteInto(t, (t) => m.pasteScope(ctx, s, t), ctx, band),
|
|
1329
|
+
() => {},
|
|
1330
|
+
);
|
|
1331
|
+
});
|
|
714
1332
|
if (!ta.isConnected) document.body.appendChild(ta);
|
|
715
|
-
ta.value = json;
|
|
716
|
-
|
|
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);
|
|
717
1343
|
};
|
|
718
1344
|
|
|
719
1345
|
// Build the controls, then everything that needs them: the conditionals' first pass, the persisted restore,
|
|
720
1346
|
// the undo seed, the queued API calls. Synchronous when every control type is registered, else behind panel.ready.
|
|
721
1347
|
const assemble = () => {
|
|
722
1348
|
if (destroyed) return; // destroy() before the chunks landed: nothing to build
|
|
723
|
-
built = cleanups.length;
|
|
1349
|
+
built = cleanups.length;
|
|
1350
|
+
using(opts.strings, () => build(controls, metas, params));
|
|
724
1351
|
if (conditionals.length) {
|
|
725
|
-
|
|
726
|
-
const
|
|
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
|
+
};
|
|
727
1359
|
const applyConditionals = () => {
|
|
728
1360
|
let revealed = false;
|
|
729
1361
|
for (const { node, m } of conditionals) {
|
|
730
1362
|
// A throwing predicate leaves the node as-is rather than aborting the pass.
|
|
731
1363
|
try {
|
|
732
1364
|
if (m.render && foldRow(node, !m.render(getVal))) revealed = true;
|
|
733
|
-
if (m.disabled != null) {
|
|
1365
|
+
if (m.disabled != null) {
|
|
1366
|
+
const d = typeof m.disabled === "function" ? m.disabled(getVal) : m.disabled;
|
|
1367
|
+
setDisabled(node, !!d);
|
|
1368
|
+
}
|
|
734
1369
|
} catch {}
|
|
735
1370
|
}
|
|
736
1371
|
if (revealed) requestReflow(); // built hidden it measured 0×0; shown, let it re-measure
|
|
737
1372
|
};
|
|
738
|
-
listeners.add(applyConditionals);
|
|
739
|
-
|
|
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
|
+
);
|
|
740
1383
|
}
|
|
741
1384
|
// Restore last session, notifying: on the lazy path a host may already have subscribed.
|
|
742
1385
|
if (persistKey) restore(unsaved(unwrap(readStore(persistKey), "values")));
|
|
743
1386
|
if (destroyed) return; // a listener may have destroyed the panel on that notify
|
|
744
|
-
undoApi?.arm();
|
|
745
|
-
|
|
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
|
+
);
|
|
746
1396
|
assembled = true;
|
|
747
|
-
// Replay after the restore, so an explicit host set() wins over a stored value; a destroy() mid-replay drops the
|
|
748
|
-
|
|
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
|
+
}
|
|
749
1407
|
for (const b of toolbarBtns) b.disabled = false; // the controls exist now
|
|
750
1408
|
};
|
|
751
1409
|
|
|
@@ -754,37 +1412,79 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}):
|
|
|
754
1412
|
// Each number asked for is held to the range the control resolved (derived bounds, a point's per component), not the
|
|
755
1413
|
// schema's min and max, which a derived range leaves unset; a hair past an end is float noise, not a clamp.
|
|
756
1414
|
const clampNote = (e: Entry, key: string, v: unknown) => {
|
|
757
|
-
const r = e.range;
|
|
758
|
-
|
|
759
|
-
|
|
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]];
|
|
760
1430
|
for (const [k, n] of asked) {
|
|
761
|
-
const b = Array.isArray(r) ? r : r[k],
|
|
762
|
-
|
|
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
|
+
}
|
|
763
1438
|
}
|
|
764
1439
|
};
|
|
765
|
-
// Resolve a (possibly dotted) key, apply, report whether the leaf changed; never notifies, so setMany fires one
|
|
1440
|
+
// Resolve a (possibly dotted) key, apply, report whether the leaf changed; never notifies, so setMany fires one
|
|
1441
|
+
// notify.
|
|
766
1442
|
const applySet = (key: string, v: unknown) => {
|
|
767
1443
|
if (String(key).split(".").some(isReservedKey)) return false; // never write through to the prototype
|
|
768
1444
|
const parts = String(key).split(".");
|
|
769
1445
|
// The path exactly as changes() names it first, so a key holding a dot ("a.b", "f.a.b") is reachable; two controls
|
|
770
1446
|
// whose paths read the same (a top-level "a.b" beside folder a's b) are ambiguous, not a guess.
|
|
771
1447
|
const exact = entries.filter((x) => x.path.join(".") === key);
|
|
772
|
-
if (exact.length > 1) {
|
|
1448
|
+
if (exact.length > 1) {
|
|
1449
|
+
notice("ambiguousPath", key, exact.length);
|
|
1450
|
+
return false;
|
|
1451
|
+
}
|
|
773
1452
|
let e: Entry | undefined = exact[0];
|
|
774
|
-
if (!e && parts.length > 1) {
|
|
1453
|
+
if (!e && parts.length > 1) {
|
|
1454
|
+
const t = valueless.get(key);
|
|
1455
|
+
notice(t ? "noValue" : "noPath", key, t);
|
|
1456
|
+
return false;
|
|
1457
|
+
}
|
|
775
1458
|
if (!e) {
|
|
776
1459
|
const matches = entries.filter((x) => x.key === key);
|
|
777
|
-
if (matches.length > 1) {
|
|
1460
|
+
if (matches.length > 1) {
|
|
1461
|
+
notice("ambiguousKey", key, matches.length, matches[0].path.join("."));
|
|
1462
|
+
return false;
|
|
1463
|
+
}
|
|
778
1464
|
e = matches[0];
|
|
779
1465
|
}
|
|
780
1466
|
// A key no control owns is refused: written through, a typo landed on params and in every save after.
|
|
781
1467
|
// A button, monitor or separator is there but holds nothing to set, which "no control has that key" would misstate.
|
|
782
1468
|
const vt = !e && (valueless.get(key) || [...valueless].find(([p]) => p.split(".").pop() === key)?.[1]);
|
|
783
|
-
if (!e) {
|
|
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
|
+
}
|
|
784
1479
|
const prev = e.target[e.key];
|
|
785
1480
|
// Same isolation as applySnapshot: a hostile value costs only that key.
|
|
786
1481
|
if (notAValue(v)) return false;
|
|
787
|
-
try {
|
|
1482
|
+
try {
|
|
1483
|
+
assign(e, v);
|
|
1484
|
+
} catch (err) {
|
|
1485
|
+
console.error(`[tweaks] set("${key}") failed — value skipped:`, err);
|
|
1486
|
+
return false;
|
|
1487
|
+
}
|
|
788
1488
|
clampNote(e, key, v);
|
|
789
1489
|
if (!valueChanged(prev, e.target[e.key])) return false; // keeps a store-sync listener from echoing forever
|
|
790
1490
|
last = e.path.join("."); // on((p, last)) sees programmatic sets like control edits
|
|
@@ -793,36 +1493,110 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}):
|
|
|
793
1493
|
|
|
794
1494
|
// Every mutating method goes through later(): live at once, queued in the lazy window, silent after destroy().
|
|
795
1495
|
const api = {
|
|
796
|
-
el:
|
|
797
|
-
|
|
798
|
-
|
|
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
|
+
},
|
|
799
1511
|
// Batch write: listeners + persist fire once for the whole batch.
|
|
800
1512
|
setMany(values, o) {
|
|
801
1513
|
if (values == null || typeof values !== "object") return;
|
|
802
|
-
const batch = Object.entries(values),
|
|
803
|
-
|
|
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);
|
|
804
1533
|
},
|
|
805
|
-
reset() { if (assembled && !destroyed) spinReset(resetBtn); later(resetAll); },
|
|
806
1534
|
// Whole-panel state — values + UI as a JSON-safe object (the standard toJSON hook). Before ready there
|
|
807
1535
|
// are no values yet. fromJSON applies one back: values (one notify), then the UI silently, outside undo.
|
|
808
|
-
toJSON() {
|
|
809
|
-
|
|
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
|
+
},
|
|
810
1543
|
fromJSON(state, o) {
|
|
811
1544
|
if (!state || typeof state !== "object") return;
|
|
812
1545
|
const silent = o?.silent;
|
|
813
|
-
|
|
814
|
-
|
|
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
|
+
});
|
|
815
1553
|
},
|
|
816
1554
|
// Live theming clears the prior theme first, so setTheme(null) reverts to the default look.
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
+
},
|
|
820
1573
|
// Presets API (no-ops without opts.persist); before ready they queue like set() does.
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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
|
+
},
|
|
824
1592
|
presets: () => (destroyed ? [] : Object.keys(listPresets())),
|
|
825
|
-
|
|
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
|
+
},
|
|
826
1600
|
// Teardown. Safe to call before ready resolves — assemble() sees the flag and bails.
|
|
827
1601
|
destroy() {
|
|
828
1602
|
if (destroyed) return;
|
|
@@ -830,40 +1604,90 @@ export function tweaks(name: string, schema: Schema, opts: TweaksOptions = {}):
|
|
|
830
1604
|
releaseKey();
|
|
831
1605
|
closeActivePopover(panel); // page-wide singletons: close only what's anchored here
|
|
832
1606
|
hideHintNow(panel);
|
|
833
|
-
for (const fn of cleanups.splice(0)) {
|
|
1607
|
+
for (const fn of cleanups.splice(0)) {
|
|
1608
|
+
try {
|
|
1609
|
+
fn();
|
|
1610
|
+
} catch {}
|
|
1611
|
+
}
|
|
834
1612
|
listeners.clear();
|
|
835
|
-
if (liftSlot) {
|
|
1613
|
+
if (liftSlot) {
|
|
1614
|
+
liftSlot.remove();
|
|
1615
|
+
liftSlot = null;
|
|
1616
|
+
}
|
|
836
1617
|
if (topFloating === panel) topFloating = null;
|
|
837
|
-
panel.dataset.
|
|
838
|
-
|
|
1618
|
+
panel.dataset.twkDestroyed = ""; // onLive listeners release on their next event by this mark
|
|
1619
|
+
box.remove();
|
|
839
1620
|
},
|
|
840
1621
|
} 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
|
|
842
|
-
// builds without waiting for its stored images; they fill in when the store answers, and `ready` waits for that
|
|
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.
|
|
843
1625
|
// A store that fails to load just saves no uploads.
|
|
844
|
-
const images =
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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;
|
|
849
1648
|
if (!images) imagesReady = true;
|
|
850
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);
|
|
851
1653
|
const lazy = ensureForMetas(metas);
|
|
852
1654
|
if (lazy) {
|
|
853
1655
|
// A failed chunk or a throwing build leaves the panel unbuildable: queued calls dropped, later calls refused,
|
|
854
1656
|
// ready rejects. A partway build must not leave live controls behind an inert API: release what it wired.
|
|
855
1657
|
const fail = (e: unknown) => {
|
|
856
|
-
failed = true;
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
if (
|
|
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
|
+
);
|
|
861
1678
|
throw e;
|
|
862
1679
|
};
|
|
863
1680
|
const building = lazy.then(assemble);
|
|
864
|
-
|
|
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
|
+
);
|
|
865
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);
|
|
866
1691
|
}
|
|
867
|
-
else { assemble(); api.ready = panel.ready = imagesIn ? imagesIn.then(() => api) : Promise.resolve(api); }
|
|
868
1692
|
return api as Panel;
|
|
869
1693
|
}
|