dsh-width-tiers 1.0.7 → 1.0.9
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/README.md +69 -82
- package/README.zh.md +54 -62
- package/lib/client.js +599 -138
- package/package.json +5 -2
package/lib/client.js
CHANGED
|
@@ -29,11 +29,26 @@ window.__ModuleLoader__.load({
|
|
|
29
29
|
* 全宽 full column max (sidebar collapsed to the icon rail)
|
|
30
30
|
* 自定义 custom user-dragged width (plugin hands width over to
|
|
31
31
|
* the app's native handles; the dragged value stays
|
|
32
|
-
* re-selectable from the menu)
|
|
32
|
+
* re-selectable from the menu). Before the first
|
|
33
|
+
* drag, picking it opens a guide at the right edge
|
|
34
|
+
* and the live column width is shown while dragging.
|
|
33
35
|
*
|
|
34
|
-
* The menu and title
|
|
35
|
-
*
|
|
36
|
-
*
|
|
36
|
+
* The menu and title show fixed px for medium/wide/ultra
|
|
37
|
+
* (1120/1280/1400), the live app default px for standard, the live
|
|
38
|
+
* maximum (column − 176) for full, and the recorded drag width or
|
|
39
|
+
* "未设置" for custom. The app still clamps every applied px to
|
|
40
|
+
* column − 176. Every handle press (not just the first custom one)
|
|
41
|
+
* opens the live width readout, so repeat drags keep the highlight
|
|
42
|
+
* and the px bubble.
|
|
43
|
+
*
|
|
44
|
+
* The app preference stores the tier's configured target (nominal px,
|
|
45
|
+
* the recorded custom width, or the live cap for full); only the
|
|
46
|
+
* inline value is clamped. This lets the app restore the target when
|
|
47
|
+
* the window widens instead of staying pinned to a narrow clamp.
|
|
48
|
+
* The picker icon has one bar per tier (full lights five, custom all
|
|
49
|
+
* six), tier changes sync across tabs, the menu supports
|
|
50
|
+
* ArrowUp/Down/Home/End/Escape, and the sidebar's embedded
|
|
51
|
+
* Conversation is excluded from the width-root lookup.
|
|
37
52
|
*
|
|
38
53
|
* - Panels are driven through the layout service (ctx.layout), never
|
|
39
54
|
* by simulating clicks.
|
|
@@ -59,9 +74,10 @@ window.__ModuleLoader__.load({
|
|
|
59
74
|
* the horizontal budget both width handles + safe edges reserve. */
|
|
60
75
|
const CONTENT_MIN_PX = 640;
|
|
61
76
|
const CONTENT_EDGE_BUDGET_PX = 176;
|
|
62
|
-
/*
|
|
63
|
-
|
|
64
|
-
|
|
77
|
+
/* Nominal content widths of the fixed tiers (px). */
|
|
78
|
+
const NOMINAL_WIDTH_PX = { medium: 1120, wide: 1280, ultra: 1400 };
|
|
79
|
+
/* The app's no-preference formula: clamp(680px, column × 0.64, 920px).
|
|
80
|
+
* Used as the last-resort live readout before the first drag. */
|
|
65
81
|
const DEFAULT_MIN_PX = 680;
|
|
66
82
|
const DEFAULT_MAX_PX = 920;
|
|
67
83
|
const DEFAULT_COLUMN_RATIO = 0.64;
|
|
@@ -87,6 +103,8 @@ window.__ModuleLoader__.load({
|
|
|
87
103
|
"tier.ultra": "超宽",
|
|
88
104
|
"tier.full": "全宽",
|
|
89
105
|
"tier.custom": "自定义",
|
|
106
|
+
"value.custom.unset": "未设置",
|
|
107
|
+
"guide.hint": "拖拽右侧边缘调整对话宽度",
|
|
90
108
|
"button.aria": "对话区宽度档位:{label}({value}),点击选择档位",
|
|
91
109
|
"button.title.summary": "对话区宽度档位:{label}({value})",
|
|
92
110
|
"button.title.hint": "点击选择:{list}"
|
|
@@ -99,22 +117,25 @@ window.__ModuleLoader__.load({
|
|
|
99
117
|
"tier.ultra": "Ultra",
|
|
100
118
|
"tier.full": "Full",
|
|
101
119
|
"tier.custom": "Custom",
|
|
120
|
+
"value.custom.unset": "Not set",
|
|
121
|
+
"guide.hint": "Drag the right edge to resize",
|
|
102
122
|
"button.aria": "Chat width tier: {label} ({value}). Click to choose a tier.",
|
|
103
123
|
"button.title.summary": "Chat width tier: {label} ({value})",
|
|
104
124
|
"button.title.hint": "Click to choose: {list}"
|
|
105
125
|
};
|
|
106
126
|
//#endregion
|
|
107
127
|
|
|
108
|
-
/* Tier ids and label keys only —
|
|
109
|
-
*
|
|
110
|
-
*
|
|
128
|
+
/* Tier ids and label keys only — the picker resolves displayed values
|
|
129
|
+
* in tierValueText (fixed px for medium/wide/ultra, live px for
|
|
130
|
+
* standard/full, recorded px for custom). */
|
|
111
131
|
const TIERS = [
|
|
112
132
|
{ id: "standard", key: "tier.standard" },
|
|
113
133
|
{ id: "medium", key: "tier.medium" },
|
|
114
134
|
{ id: "wide", key: "tier.wide" },
|
|
115
135
|
{ id: "ultra", key: "tier.ultra" },
|
|
116
136
|
{ id: "full", key: "tier.full" },
|
|
117
|
-
/* Reached
|
|
137
|
+
/* Reached by dragging a native width handle, or by picking it in
|
|
138
|
+
* the menu and following the right-edge drag guide. */
|
|
118
139
|
{ id: "custom", key: "tier.custom" }
|
|
119
140
|
];
|
|
120
141
|
|
|
@@ -136,15 +157,22 @@ window.__ModuleLoader__.load({
|
|
|
136
157
|
"#dsh-wide-toggle:not(:hover):not(:focus-visible) svg rect{width:6px;x:5px}",
|
|
137
158
|
"#dsh-wide-toggle.active{background:var(--dsw-alias-state-business-primary);border-color:transparent;color:var(--dsw-static-neutral-00);opacity:1}",
|
|
138
159
|
"#dsh-wide-toggle.pulse{animation:dsh-wide-pulse .65s ease-out}",
|
|
160
|
+
"@media (prefers-reduced-motion:reduce){#dsh-wide-toggle{transition:none}#dsh-wide-toggle.pulse{animation:none}}",
|
|
139
161
|
"@keyframes dsh-wide-pulse{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--dsw-alias-state-business-primary) 60%,transparent)}100%{box-shadow:0 0 0 14px transparent}}",
|
|
140
162
|
"#dsh-width-menu{position:fixed;right:20px;bottom:264px;z-index:1000;min-width:196px;padding:6px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-specific-menu);box-shadow:0 8px 28px rgba(0,0,0,.24);display:flex;flex-direction:column;gap:2px}",
|
|
141
163
|
"#dsh-width-menu[hidden]{display:none}",
|
|
142
164
|
"#dsh-width-menu button{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;border:none;border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:18px;text-align:left;cursor:pointer}",
|
|
143
165
|
"#dsh-width-menu button:hover{background:var(--dsw-alias-interactive-bg-hover)}",
|
|
144
|
-
"#dsh-width-menu button
|
|
166
|
+
"#dsh-width-menu button:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:-2px}",
|
|
145
167
|
"#dsh-width-menu button.selected{background:var(--dsw-alias-interactive-bg-hover-solid);color:var(--dsw-alias-state-business-primary)}",
|
|
146
168
|
"#dsh-width-menu .dshwm-value{color:var(--dsw-alias-label-tertiary);font-size:12px}",
|
|
147
|
-
"#dsh-width-menu button.selected .dshwm-value{color:var(--dsw-alias-state-business-primary)}"
|
|
169
|
+
"#dsh-width-menu button.selected .dshwm-value{color:var(--dsw-alias-state-business-primary)}",
|
|
170
|
+
"#dsh-width-guide{position:fixed;z-index:1001;display:flex;align-items:center;gap:8px;padding:8px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-specific-menu);color:var(--dsw-alias-label-primary);font-size:12px;line-height:16px;box-shadow:0 8px 28px rgba(0,0,0,.24);pointer-events:none;white-space:nowrap;transform:translateY(-50%)}",
|
|
171
|
+
"#dsh-width-guide[hidden]{display:none}",
|
|
172
|
+
"#dsh-width-guide svg{flex:none;color:var(--dsw-alias-state-business-primary)}",
|
|
173
|
+
"#dsh-width-guide[data-mode=live]{font-size:13px;font-weight:600;font-variant-numeric:tabular-nums}",
|
|
174
|
+
"body[data-dsh-width-guide] [data-width-handle]{background:linear-gradient(to bottom,transparent calc(50% - 64px),color-mix(in srgb,var(--dsw-alias-state-business-primary) 18%,transparent) calc(50% - 64px),color-mix(in srgb,var(--dsw-alias-state-business-primary) 18%,transparent) calc(50% + 64px),transparent calc(50% + 64px));border-radius:3px}",
|
|
175
|
+
"body[data-dsh-width-guide] [data-width-handle]::after{opacity:1}"
|
|
148
176
|
].join("");
|
|
149
177
|
//#endregion
|
|
150
178
|
|
|
@@ -224,6 +252,13 @@ window.__ModuleLoader__.load({
|
|
|
224
252
|
let widthRoot = null;
|
|
225
253
|
let widthPath = null;
|
|
226
254
|
|
|
255
|
+
/* Whether this plugin wrote the app's width preference. Native
|
|
256
|
+
* drags also write it, so unload only removes a value this
|
|
257
|
+
* plugin owns. */
|
|
258
|
+
let preferenceOwned = false;
|
|
259
|
+
let preferenceWritten = null;
|
|
260
|
+
let pulseTimer = null;
|
|
261
|
+
|
|
227
262
|
function hasWidthVar(el) {
|
|
228
263
|
return getComputedStyle(el)
|
|
229
264
|
.getPropertyValue("--dsh-chat-content-width")
|
|
@@ -242,14 +277,35 @@ window.__ModuleLoader__.load({
|
|
|
242
277
|
widthPath = path.length > 0 ? path : null;
|
|
243
278
|
}
|
|
244
279
|
|
|
280
|
+
/* Sidebar chat tabs render an embedded Conversation whose
|
|
281
|
+
* `--dsh-chat-content-width` is a local min() and never reads
|
|
282
|
+
* `--dsh-chat-user-width`. Writing there would be a no-op on
|
|
283
|
+
* the visible column and could starve the real transcript. */
|
|
284
|
+
function isSidebarConversation(el) {
|
|
285
|
+
return typeof el.closest === "function"
|
|
286
|
+
&& el.closest("[data-sidebar-chat]") !== null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function firstWidthRoot(list) {
|
|
290
|
+
for (let i = 0; i < list.length; i++) {
|
|
291
|
+
const el = list[i];
|
|
292
|
+
if (isSidebarConversation(el)) continue;
|
|
293
|
+
if (hasWidthVar(el)) return el;
|
|
294
|
+
}
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
|
|
245
298
|
function scanWidthRoot() {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
299
|
+
/* Prefer the stable Conversation data attribute (one or two
|
|
300
|
+
* elements) over every classed node in the document. The
|
|
301
|
+
* [class] walk remains the compatibility fallback for older
|
|
302
|
+
* clients that do not publish data-conversation-content. */
|
|
303
|
+
let found = firstWidthRoot(document.querySelectorAll("[data-conversation-content]"));
|
|
304
|
+
if (found === null) found = firstWidthRoot(document.querySelectorAll("[class]"));
|
|
305
|
+
if (found !== null) {
|
|
306
|
+
widthRoot = found;
|
|
307
|
+
recordWidthPath(widthRoot);
|
|
308
|
+
return widthRoot;
|
|
253
309
|
}
|
|
254
310
|
widthRoot = null;
|
|
255
311
|
widthPath = null;
|
|
@@ -257,14 +313,16 @@ window.__ModuleLoader__.load({
|
|
|
257
313
|
}
|
|
258
314
|
|
|
259
315
|
function findWidthRoot(allowScan) {
|
|
260
|
-
if (widthRoot !== null && document.contains(widthRoot)
|
|
316
|
+
if (widthRoot !== null && document.contains(widthRoot)
|
|
317
|
+
&& !isSidebarConversation(widthRoot)) return widthRoot;
|
|
261
318
|
if (widthPath !== null) {
|
|
262
319
|
let el = document.body;
|
|
263
320
|
for (let i = 0; i < widthPath.length; i++) {
|
|
264
321
|
el = el.children[widthPath[i]];
|
|
265
322
|
if (!el) break;
|
|
266
323
|
}
|
|
267
|
-
if (el && el !== widthRoot && document.contains(el)
|
|
324
|
+
if (el && el !== widthRoot && document.contains(el)
|
|
325
|
+
&& !isSidebarConversation(el) && hasWidthVar(el)) {
|
|
268
326
|
widthRoot = el;
|
|
269
327
|
return widthRoot;
|
|
270
328
|
}
|
|
@@ -293,33 +351,51 @@ window.__ModuleLoader__.load({
|
|
|
293
351
|
}
|
|
294
352
|
}
|
|
295
353
|
|
|
296
|
-
/*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
|
|
300
|
-
|
|
354
|
+
/* The tier's configured target, before the app's column clamp.
|
|
355
|
+
* This is the value persisted to the app preference: on a
|
|
356
|
+
* narrow column the app clamps it for display, and when the
|
|
357
|
+
* window widens it restores the configured target instead of
|
|
358
|
+
* staying pinned to the old clamp. `full` is inherently the
|
|
359
|
+
* live maximum. */
|
|
360
|
+
function tierTargetPx(tier, root) {
|
|
301
361
|
switch (tier) {
|
|
302
|
-
case "medium":
|
|
303
|
-
case "wide":
|
|
304
|
-
case "ultra":
|
|
305
|
-
|
|
306
|
-
case "
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
}
|
|
362
|
+
case "medium":
|
|
363
|
+
case "wide":
|
|
364
|
+
case "ultra":
|
|
365
|
+
return NOMINAL_WIDTH_PX[tier];
|
|
366
|
+
case "full":
|
|
367
|
+
return Math.max(CONTENT_MIN_PX, root.offsetWidth - CONTENT_EDGE_BUDGET_PX);
|
|
368
|
+
case "custom": return readStoredNumber(CUSTOM_KEY);
|
|
310
369
|
default: return null; /* standard: app default */
|
|
311
370
|
}
|
|
312
371
|
}
|
|
313
372
|
|
|
314
|
-
/* The px
|
|
315
|
-
*
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
if (
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
373
|
+
/* The px actually applied to the column (same floor/max as the
|
|
374
|
+
* app's resolveContentWidth). */
|
|
375
|
+
function appliedTierPx(tier, root) {
|
|
376
|
+
const target = tierTargetPx(tier, root);
|
|
377
|
+
if (target === null) return null;
|
|
378
|
+
const cap = Math.max(CONTENT_MIN_PX, root.offsetWidth - CONTENT_EDGE_BUDGET_PX);
|
|
379
|
+
return Math.min(Math.max(target, CONTENT_MIN_PX), cap);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/* The transcript width the app currently resolves for the live
|
|
383
|
+
* column (after its own clamp), used by the custom-drag guide.
|
|
384
|
+
* During a drag the app writes the clamped px to the target's
|
|
385
|
+
* inline `--dsh-chat-user-width`, so reading that is exact and
|
|
386
|
+
* avoids relying on how a browser serializes a computed custom
|
|
387
|
+
* property (which may stay as a var()/clamp() token stream). */
|
|
388
|
+
function liveWidthPx(root) {
|
|
389
|
+
const target = widthTarget(root);
|
|
390
|
+
const inline = Number.parseFloat(target.style.getPropertyValue("--dsh-chat-user-width"));
|
|
391
|
+
if (Number.isFinite(inline) && inline > 0) return Math.round(inline);
|
|
392
|
+
const raw = getComputedStyle(root).getPropertyValue("--dsh-chat-content-width");
|
|
393
|
+
const computed = Number.parseFloat(raw);
|
|
394
|
+
if (Number.isFinite(computed) && computed > 0) return Math.round(computed);
|
|
395
|
+
return Math.round(Math.max(
|
|
396
|
+
DEFAULT_MIN_PX,
|
|
397
|
+
Math.min(root.offsetWidth * DEFAULT_COLUMN_RATIO, DEFAULT_MAX_PX)
|
|
398
|
+
));
|
|
323
399
|
}
|
|
324
400
|
|
|
325
401
|
function applyContentWidth(tier) {
|
|
@@ -329,28 +405,47 @@ window.__ModuleLoader__.load({
|
|
|
329
405
|
/* custom before the first drag has no width of its own —
|
|
330
406
|
* leave whatever the app last published in place. */
|
|
331
407
|
if (tier === "custom" && readStoredNumber(CUSTOM_KEY) === null) return;
|
|
332
|
-
const
|
|
333
|
-
if (
|
|
334
|
-
|
|
408
|
+
const targetPx = tierTargetPx(tier, root);
|
|
409
|
+
if (targetPx === null) {
|
|
410
|
+
/* standard: hand the width back to the app default. The
|
|
411
|
+
* app's width preference is cleared as well; standard is
|
|
412
|
+
* deliberately the reset to the app's default width. */
|
|
413
|
+
if (target.style.getPropertyValue("--dsh-chat-user-width") !== "" || preferenceOwned) {
|
|
335
414
|
target.style.removeProperty("--dsh-chat-user-width");
|
|
336
415
|
try {
|
|
337
416
|
window.localStorage.removeItem(APP_PREF_KEY);
|
|
338
417
|
} catch (e) {
|
|
339
418
|
/* ignore */
|
|
340
419
|
}
|
|
420
|
+
preferenceOwned = false;
|
|
421
|
+
preferenceWritten = null;
|
|
341
422
|
}
|
|
342
423
|
return;
|
|
343
424
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
*
|
|
347
|
-
|
|
348
|
-
if (
|
|
349
|
-
|
|
425
|
+
const laidOut = root.offsetWidth > 0;
|
|
426
|
+
/* `full` derives its target from the live column; without a
|
|
427
|
+
* layout box it would collapse to the 640px floor and pin a
|
|
428
|
+
* narrow width. Wait for the fallback/resize pass. */
|
|
429
|
+
if (!laidOut && tier === "full") return;
|
|
430
|
+
if (laidOut) {
|
|
431
|
+
const px = appliedTierPx(tier, root);
|
|
432
|
+
if (px !== null) {
|
|
433
|
+
const next = String(Math.round(px));
|
|
434
|
+
if (target.style.getPropertyValue("--dsh-chat-user-width") !== next + "px") {
|
|
435
|
+
target.style.setProperty("--dsh-chat-user-width", next + "px");
|
|
436
|
+
}
|
|
437
|
+
}
|
|
350
438
|
}
|
|
439
|
+
/* Persist the unclamped target even while the root is not
|
|
440
|
+
* laid out (fixed tiers / recorded custom width do not
|
|
441
|
+
* depend on the column). The app clamps it for display and
|
|
442
|
+
* restores it after a resize / remount. */
|
|
443
|
+
const preference = String(Math.round(targetPx));
|
|
351
444
|
try {
|
|
352
|
-
if (window.localStorage.getItem(APP_PREF_KEY) !==
|
|
353
|
-
window.localStorage.setItem(APP_PREF_KEY,
|
|
445
|
+
if (window.localStorage.getItem(APP_PREF_KEY) !== preference) {
|
|
446
|
+
window.localStorage.setItem(APP_PREF_KEY, preference);
|
|
447
|
+
preferenceOwned = true;
|
|
448
|
+
preferenceWritten = preference;
|
|
354
449
|
}
|
|
355
450
|
} catch (e) {
|
|
356
451
|
/* ignore */
|
|
@@ -360,25 +455,30 @@ window.__ModuleLoader__.load({
|
|
|
360
455
|
/* true while the CURRENT tier is the one that collapsed the sidebar */
|
|
361
456
|
let collapsedByTier = false;
|
|
362
457
|
|
|
363
|
-
|
|
458
|
+
/* `persist` is false when another tab already wrote the tier:
|
|
459
|
+
* adopting it must not write localStorage again. */
|
|
460
|
+
function applyTier(tier, persist = true) {
|
|
364
461
|
body().setAttribute("data-dsh-width", tier);
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
462
|
+
if (persist) {
|
|
463
|
+
try {
|
|
464
|
+
window.localStorage.setItem(KEY, tier);
|
|
465
|
+
} catch (e) {
|
|
466
|
+
/* storage unavailable — state still applies this session */
|
|
467
|
+
}
|
|
369
468
|
}
|
|
370
469
|
applyContentWidth(tier);
|
|
371
470
|
if (tier === "full") {
|
|
372
471
|
if (!isSidebarCollapsed()) {
|
|
373
472
|
collapseSidebar();
|
|
374
473
|
collapsedByTier = true;
|
|
375
|
-
} else {
|
|
376
|
-
collapsedByTier = false;
|
|
377
474
|
}
|
|
475
|
+
/* already collapsed: don't claim ownership we may not have */
|
|
378
476
|
} else if (tier === "standard") {
|
|
379
477
|
if (collapsedByTier && isSidebarCollapsed()) expandSidebar();
|
|
380
478
|
collapsedByTier = false;
|
|
381
|
-
} else {
|
|
479
|
+
} else if (!isSidebarCollapsed()) {
|
|
480
|
+
/* the user (or another panel) expanded it — the plugin no
|
|
481
|
+
* longer owns the collapsed state */
|
|
382
482
|
collapsedByTier = false;
|
|
383
483
|
}
|
|
384
484
|
syncButton();
|
|
@@ -398,27 +498,49 @@ window.__ModuleLoader__.load({
|
|
|
398
498
|
for (let i = 0; i < TIERS.length; i++) {
|
|
399
499
|
if (TIERS[i].id === tier) idx = i;
|
|
400
500
|
}
|
|
401
|
-
|
|
501
|
+
/* One bar per tier so full (5 bars) and custom (6 bars,
|
|
502
|
+
* all lit) remain distinguishable. */
|
|
503
|
+
const widths = [3.5, 5.3, 7.1, 8.9, 10.7, 12.5];
|
|
402
504
|
let bars = "";
|
|
403
|
-
for (let b = 0; b <
|
|
404
|
-
const y = 0.
|
|
405
|
-
bars += `<rect x="1.5" y="${y}" width="${widths[b]}" height="
|
|
505
|
+
for (let b = 0; b < TIERS.length; b++) {
|
|
506
|
+
const y = 0.75 + b * 2.55;
|
|
507
|
+
bars += `<rect x="1.5" y="${y}" width="${widths[b]}" height="1.6" rx="0.8" fill="currentColor" opacity="${b <= idx ? 1 : 0.3}"/>`;
|
|
406
508
|
}
|
|
407
509
|
return `<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">${bars}</svg>`;
|
|
408
510
|
}
|
|
409
511
|
|
|
410
|
-
/*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
512
|
+
/* Value shown in the picker. Fixed tiers advertise their
|
|
513
|
+
* nominal px; standard and full are inherently relative to the
|
|
514
|
+
* current column, so they show the px the app resolves right
|
|
515
|
+
* now (full is the column's maximum, not a word). */
|
|
414
516
|
function tierValueText(tierId) {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
517
|
+
switch (tierId) {
|
|
518
|
+
case "standard": {
|
|
519
|
+
const root = findWidthRoot();
|
|
520
|
+
if (root === null) return "—";
|
|
521
|
+
return `${Math.round(Math.max(
|
|
522
|
+
DEFAULT_MIN_PX,
|
|
523
|
+
Math.min(root.offsetWidth * DEFAULT_COLUMN_RATIO, DEFAULT_MAX_PX)
|
|
524
|
+
))}px`;
|
|
525
|
+
}
|
|
526
|
+
case "medium":
|
|
527
|
+
case "wide":
|
|
528
|
+
case "ultra":
|
|
529
|
+
return `${NOMINAL_WIDTH_PX[tierId]}px`;
|
|
530
|
+
case "full": {
|
|
531
|
+
const root = findWidthRoot();
|
|
532
|
+
if (root === null) return "—";
|
|
533
|
+
return `${Math.round(Math.max(
|
|
534
|
+
CONTENT_MIN_PX,
|
|
535
|
+
root.offsetWidth - CONTENT_EDGE_BUDGET_PX
|
|
536
|
+
))}px`;
|
|
537
|
+
}
|
|
538
|
+
case "custom": {
|
|
539
|
+
const dragged = readStoredNumber(CUSTOM_KEY);
|
|
540
|
+
return dragged === null ? t("value.custom.unset") : `${Math.round(dragged)}px`;
|
|
541
|
+
}
|
|
542
|
+
default: return "—";
|
|
419
543
|
}
|
|
420
|
-
const dragged = readStoredNumber(CUSTOM_KEY);
|
|
421
|
-
return dragged === null ? "—" : `${Math.round(dragged)}px`;
|
|
422
544
|
}
|
|
423
545
|
|
|
424
546
|
function syncButton() {
|
|
@@ -447,12 +569,22 @@ window.__ModuleLoader__.load({
|
|
|
447
569
|
item.type = "button";
|
|
448
570
|
item.setAttribute("role", "menuitemradio");
|
|
449
571
|
item.setAttribute("data-tier", def.id);
|
|
572
|
+
/* Roving focus: the menu is opened by focusing one item, and
|
|
573
|
+
* ArrowUp/Down/Home/End move between items. */
|
|
574
|
+
item.tabIndex = -1;
|
|
450
575
|
item.innerHTML = `<span class="dshwm-label">${t(def.key)}</span><span class="dshwm-value">${tierValueText(def.id)}</span>`;
|
|
451
576
|
item.addEventListener("click", (e) => {
|
|
452
577
|
if (e && e.stopPropagation) e.stopPropagation();
|
|
453
|
-
|
|
578
|
+
/* Custom before the first drag has no width of its own:
|
|
579
|
+
* open the right-edge guide instead of applying a value. */
|
|
580
|
+
if (def.id === "custom" && readStoredNumber(CUSTOM_KEY) === null) {
|
|
581
|
+
beginCustomGuide();
|
|
582
|
+
hideMenu(true);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
endGuide(false);
|
|
454
586
|
applyTier(def.id);
|
|
455
|
-
hideMenu();
|
|
587
|
+
hideMenu(true);
|
|
456
588
|
});
|
|
457
589
|
menu.appendChild(item);
|
|
458
590
|
}
|
|
@@ -476,40 +608,210 @@ window.__ModuleLoader__.load({
|
|
|
476
608
|
if (labelEl) setText(labelEl, t(tierOf(id).key));
|
|
477
609
|
const valueEl = items[i].querySelector(".dshwm-value");
|
|
478
610
|
if (valueEl) setText(valueEl, tierValueText(id));
|
|
479
|
-
/* custom is inert until the user has dragged once */
|
|
480
|
-
const disabled = id === "custom" && readStoredNumber(CUSTOM_KEY) === null;
|
|
481
|
-
items[i].classList.toggle("disabled", disabled);
|
|
482
|
-
items[i].setAttribute("aria-disabled", String(disabled));
|
|
483
611
|
}
|
|
484
612
|
}
|
|
485
613
|
|
|
614
|
+
function menuItems() {
|
|
615
|
+
return Array.from(menu.querySelectorAll("button[data-tier]"));
|
|
616
|
+
}
|
|
617
|
+
|
|
486
618
|
function showMenu() {
|
|
487
619
|
menu.hidden = false;
|
|
488
620
|
btn.setAttribute("aria-expanded", "true");
|
|
489
621
|
syncMenu();
|
|
622
|
+
const items = menuItems();
|
|
623
|
+
const selected = items.find((item) => item.getAttribute("data-tier") === currentTier()) || items[0];
|
|
624
|
+
if (selected && typeof selected.focus === "function") selected.focus();
|
|
490
625
|
}
|
|
491
626
|
|
|
492
|
-
|
|
627
|
+
/* restoreFocus is true for Escape / item selection, false for a
|
|
628
|
+
* click-away so the click target keeps focus. */
|
|
629
|
+
function hideMenu(restoreFocus) {
|
|
630
|
+
const hadFocus = menu.contains(document.activeElement);
|
|
493
631
|
menu.hidden = true;
|
|
494
632
|
btn.setAttribute("aria-expanded", "false");
|
|
633
|
+
if (restoreFocus && hadFocus && typeof btn.focus === "function") btn.focus();
|
|
495
634
|
}
|
|
496
635
|
|
|
497
636
|
btn.addEventListener("click", (e) => {
|
|
498
637
|
if (e && e.stopPropagation) e.stopPropagation();
|
|
499
638
|
if (menu.hidden) showMenu();
|
|
500
|
-
else hideMenu();
|
|
639
|
+
else hideMenu(true);
|
|
501
640
|
});
|
|
502
641
|
|
|
503
642
|
const onDocClick = (e) => {
|
|
504
|
-
if (menu.hidden)
|
|
505
|
-
|
|
643
|
+
if (!menu.hidden && e.target !== btn && !menu.contains(e.target)) hideMenu(false);
|
|
644
|
+
/* Clicking anywhere but the guide's handle cancels a
|
|
645
|
+
* pending custom guide and restores the previous tier. */
|
|
646
|
+
if (guideActive && !guideDragging && e.target !== btn && !menu.contains(e.target) && !isHandleEvent(e)) {
|
|
647
|
+
endGuide(true);
|
|
648
|
+
}
|
|
506
649
|
};
|
|
507
650
|
const onDocKey = (e) => {
|
|
508
|
-
if (e.key === "Escape"
|
|
651
|
+
if (e.key === "Escape") {
|
|
652
|
+
if (!menu.hidden) hideMenu(true);
|
|
653
|
+
if (guideActive) endGuide(true);
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (menu.hidden) return;
|
|
657
|
+
if (e.key === "Tab") {
|
|
658
|
+
/* Let Tab move on, but do not leave an orphaned menu. */
|
|
659
|
+
hideMenu(false);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const items = menuItems();
|
|
663
|
+
if (items.length === 0) return;
|
|
664
|
+
const current = items.indexOf(document.activeElement);
|
|
665
|
+
let next = -1;
|
|
666
|
+
if (e.key === "ArrowDown") next = current < 0 ? 0 : (current + 1) % items.length;
|
|
667
|
+
else if (e.key === "ArrowUp") next = current < 0 ? items.length - 1 : (current - 1 + items.length) % items.length;
|
|
668
|
+
else if (e.key === "Home") next = 0;
|
|
669
|
+
else if (e.key === "End") next = items.length - 1;
|
|
670
|
+
if (next < 0) return;
|
|
671
|
+
e.preventDefault();
|
|
672
|
+
const item = items[next];
|
|
673
|
+
if (typeof item.focus === "function") item.focus();
|
|
509
674
|
};
|
|
510
675
|
document.addEventListener("click", onDocClick);
|
|
511
676
|
document.addEventListener("keydown", onDocKey);
|
|
512
677
|
|
|
678
|
+
/* ---- custom-drag guide ----
|
|
679
|
+
* A first-time `custom` pick has no recorded width. Instead of
|
|
680
|
+
* silently keeping the current width, point at the app's
|
|
681
|
+
* right-edge width handle and show the live transcript width
|
|
682
|
+
* while the user drags. The guide never writes the width
|
|
683
|
+
* itself: the app's native drag does (and wins the cascade),
|
|
684
|
+
* and the plugin only records the settled value on release. */
|
|
685
|
+
const guide = document.createElement("div");
|
|
686
|
+
guide.id = "dsh-width-guide";
|
|
687
|
+
guide.setAttribute("aria-hidden", "true");
|
|
688
|
+
guide.hidden = true;
|
|
689
|
+
guide.innerHTML =
|
|
690
|
+
'<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false">' +
|
|
691
|
+
'<path d="M6 3 2 8l4 5M10 3l4 5-4 5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>' +
|
|
692
|
+
'<span class="dshwg-text"></span>';
|
|
693
|
+
const guideTextEl = guide.querySelector(".dshwg-text");
|
|
694
|
+
|
|
695
|
+
let guideActive = false;
|
|
696
|
+
let guideDragging = false;
|
|
697
|
+
/* true while the bubble came from picking custom in the menu (its
|
|
698
|
+
* cancel path restores the previous tier). Direct handle drags
|
|
699
|
+
* open the same bubble but never "cancel" a tier. */
|
|
700
|
+
let guideFromMenu = false;
|
|
701
|
+
let guideHideTimer = null;
|
|
702
|
+
let guideHandle = null;
|
|
703
|
+
let tierBeforeGuide = "standard";
|
|
704
|
+
|
|
705
|
+
/* The px the app currently resolves for the transcript, after
|
|
706
|
+
* its own clamp — what the guide's live readout shows. */
|
|
707
|
+
function guideValueText() {
|
|
708
|
+
const root = findWidthRoot();
|
|
709
|
+
const px = root === null ? null : liveWidthPx(root);
|
|
710
|
+
return px === null ? "—" : `${px}px`;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/* Keep the bubble beside the handle the user actually grabbed
|
|
714
|
+
* (it moves with the dragged edge); fall back to the right
|
|
715
|
+
* handle / frame edge when the handle is not mounted yet
|
|
716
|
+
* (menu-opened guide, hero or settling phase). */
|
|
717
|
+
function positionGuide() {
|
|
718
|
+
if (!guideActive) return;
|
|
719
|
+
const handle = guideHandle !== null && document.contains(guideHandle)
|
|
720
|
+
? guideHandle
|
|
721
|
+
: document.querySelector('[data-width-handle="right"]');
|
|
722
|
+
const viewport = window.innerWidth || 0;
|
|
723
|
+
if (handle === null || typeof handle.getBoundingClientRect !== "function" || viewport === 0) {
|
|
724
|
+
guide.style.left = "auto";
|
|
725
|
+
guide.style.right = "24px";
|
|
726
|
+
guide.style.top = "50%";
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
const rect = handle.getBoundingClientRect();
|
|
730
|
+
const gap = 10;
|
|
731
|
+
const width = guide.offsetWidth || 180;
|
|
732
|
+
guide.style.top = `${rect.top + rect.height / 2}px`;
|
|
733
|
+
guide.style.right = "auto";
|
|
734
|
+
if (viewport - rect.right >= width + gap) {
|
|
735
|
+
guide.style.left = `${rect.right + gap}px`;
|
|
736
|
+
} else {
|
|
737
|
+
guide.style.left = `${Math.max(8, rect.left - gap - width)}px`;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function setGuideText(mode, text) {
|
|
742
|
+
guide.dataset.mode = mode;
|
|
743
|
+
setText(guideTextEl, text);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function startGuide() {
|
|
747
|
+
guideFromMenu = true;
|
|
748
|
+
guideActive = true;
|
|
749
|
+
guideDragging = false;
|
|
750
|
+
guideHandle = null;
|
|
751
|
+
guide.hidden = false;
|
|
752
|
+
body().setAttribute("data-dsh-width-guide", "custom");
|
|
753
|
+
setGuideText("hint", t("guide.hint"));
|
|
754
|
+
positionGuide();
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/* Every handle press shows the live readout, not just the first
|
|
758
|
+
* guided drag — a repeat custom drag must keep the handle
|
|
759
|
+
* highlight and the width bubble. */
|
|
760
|
+
function startLiveGuide(handle) {
|
|
761
|
+
window.clearTimeout(guideHideTimer);
|
|
762
|
+
guideActive = true;
|
|
763
|
+
guideDragging = true;
|
|
764
|
+
guideHandle = handle || null;
|
|
765
|
+
guide.hidden = false;
|
|
766
|
+
body().setAttribute("data-dsh-width-guide", "custom");
|
|
767
|
+
setGuideText("live", guideValueText());
|
|
768
|
+
positionGuide();
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function beginCustomGuide() {
|
|
772
|
+
/* Remember the tier to restore on cancel only on the first
|
|
773
|
+
* entry; re-picking custom while the guide is open must not
|
|
774
|
+
* make "custom" the restore target. */
|
|
775
|
+
if (!guideActive) {
|
|
776
|
+
tierBeforeGuide = currentTier();
|
|
777
|
+
applyTier("custom");
|
|
778
|
+
}
|
|
779
|
+
startGuide();
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/* Show the settled width briefly, then remove the guide. */
|
|
783
|
+
function finishGuide() {
|
|
784
|
+
if (!guideActive) return;
|
|
785
|
+
guideDragging = false;
|
|
786
|
+
setGuideText("live", guideValueText());
|
|
787
|
+
window.clearTimeout(guideHideTimer);
|
|
788
|
+
guideHideTimer = window.setTimeout(() => endGuide(false), 800);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/* `restoreTier` is for cancelling a menu-opened guide (click-away /
|
|
792
|
+
* Escape): if it never recorded a width, go back to the tier the
|
|
793
|
+
* user had before opening it. Drag-opened bubbles keep custom. */
|
|
794
|
+
function endGuide(restoreTier) {
|
|
795
|
+
if (!guideActive) return;
|
|
796
|
+
guideActive = false;
|
|
797
|
+
guideDragging = false;
|
|
798
|
+
window.clearTimeout(guideHideTimer);
|
|
799
|
+
guide.hidden = true;
|
|
800
|
+
guide.dataset.mode = "hint";
|
|
801
|
+
guideHandle = null;
|
|
802
|
+
body().removeAttribute("data-dsh-width-guide");
|
|
803
|
+
const restore = restoreTier && guideFromMenu && readStoredNumber(CUSTOM_KEY) === null;
|
|
804
|
+
guideFromMenu = false;
|
|
805
|
+
if (restore) applyTier(tierBeforeGuide);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const onGuidePointerMove = () => {
|
|
809
|
+
if (!guideActive || !guideDragging) return;
|
|
810
|
+
setGuideText("live", guideValueText());
|
|
811
|
+
positionGuide();
|
|
812
|
+
};
|
|
813
|
+
document.addEventListener("pointermove", onGuidePointerMove, true);
|
|
814
|
+
|
|
513
815
|
/* ---- hand the width over to the app's native drag handles ----
|
|
514
816
|
* Pressing a `[data-width-handle]` means the user is taking the
|
|
515
817
|
* width over: switch to the `custom` tier so healing stops
|
|
@@ -521,14 +823,15 @@ window.__ModuleLoader__.load({
|
|
|
521
823
|
e.target instanceof Element && e.target.closest("[data-width-handle]") !== null;
|
|
522
824
|
|
|
523
825
|
const onHandlePointerDown = (e) => {
|
|
524
|
-
if (!isHandleEvent(e)
|
|
826
|
+
if (!isHandleEvent(e)) return;
|
|
827
|
+
startLiveGuide(e.target.closest("[data-width-handle]"));
|
|
828
|
+
if (currentTier() === "custom") return;
|
|
525
829
|
body().setAttribute("data-dsh-width", "custom");
|
|
526
830
|
try {
|
|
527
831
|
window.localStorage.setItem(KEY, "custom");
|
|
528
832
|
} catch (err) {
|
|
529
833
|
/* ignore */
|
|
530
834
|
}
|
|
531
|
-
collapsedByTier = false;
|
|
532
835
|
syncButton();
|
|
533
836
|
syncMenu();
|
|
534
837
|
};
|
|
@@ -536,39 +839,80 @@ window.__ModuleLoader__.load({
|
|
|
536
839
|
const onHandlePointerUp = (e) => {
|
|
537
840
|
if (!isHandleEvent(e)) return;
|
|
538
841
|
const root = findWidthRoot();
|
|
539
|
-
if (root
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
842
|
+
if (root !== null) {
|
|
843
|
+
const px = Number.parseFloat(
|
|
844
|
+
widthTarget(root).style.getPropertyValue("--dsh-chat-user-width")
|
|
845
|
+
);
|
|
846
|
+
if (Number.isFinite(px) && px > 0) {
|
|
847
|
+
try {
|
|
848
|
+
window.localStorage.setItem(CUSTOM_KEY, String(Math.round(px)));
|
|
849
|
+
} catch (err) {
|
|
850
|
+
/* ignore */
|
|
851
|
+
}
|
|
852
|
+
}
|
|
548
853
|
}
|
|
854
|
+
if (guideActive) finishGuide();
|
|
855
|
+
syncButton();
|
|
856
|
+
syncMenu();
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
/* A cancelled drag (touch gesture, window switch) ends without
|
|
860
|
+
* pointerup; without this handler the bubble would stay stuck
|
|
861
|
+
* in dragging mode and click-away would no longer dismiss it. */
|
|
862
|
+
const onHandlePointerCancel = () => {
|
|
863
|
+
/* The cancel target can be the capture element or the
|
|
864
|
+
* document; while a drag is active, end it regardless. */
|
|
865
|
+
if (!guideActive || !guideDragging) return;
|
|
866
|
+
endGuide(false);
|
|
549
867
|
syncButton();
|
|
550
868
|
syncMenu();
|
|
551
869
|
};
|
|
552
870
|
document.addEventListener("pointerdown", onHandlePointerDown, true);
|
|
553
871
|
document.addEventListener("pointerup", onHandlePointerUp, true);
|
|
872
|
+
document.addEventListener("pointercancel", onHandlePointerCancel, true);
|
|
554
873
|
|
|
555
874
|
/* ---- feedback ring so every selection is visibly answered ---- */
|
|
556
875
|
function pulse() {
|
|
557
876
|
btn.classList.remove("pulse");
|
|
558
877
|
void btn.offsetWidth;
|
|
559
878
|
btn.classList.add("pulse");
|
|
560
|
-
window.
|
|
879
|
+
window.clearTimeout(pulseTimer);
|
|
880
|
+
pulseTimer = window.setTimeout(() => {
|
|
881
|
+
btn.classList.remove("pulse");
|
|
882
|
+
pulseTimer = null;
|
|
883
|
+
}, 700);
|
|
561
884
|
}
|
|
562
885
|
|
|
563
886
|
/* ---- re-render localized copy when the app language changes ---- */
|
|
564
887
|
const renderTexts = () => {
|
|
565
888
|
syncButton();
|
|
566
889
|
syncMenu();
|
|
890
|
+
if (guideActive && !guideDragging && guide.dataset.mode === "hint") {
|
|
891
|
+
setText(guideTextEl, t("guide.hint"));
|
|
892
|
+
}
|
|
567
893
|
};
|
|
568
894
|
const unsubscribeLocale = ctx.locale.subscribe(renderTexts);
|
|
569
895
|
|
|
570
896
|
/* ---- attach once the app shell has mounted ---- */
|
|
571
897
|
let attached = false;
|
|
898
|
+
let readyTimer = null;
|
|
899
|
+
let slowTimer = null;
|
|
900
|
+
let readyTimeout = null;
|
|
901
|
+
|
|
902
|
+
function stopWaiting() {
|
|
903
|
+
if (readyTimer !== null) {
|
|
904
|
+
clearInterval(readyTimer);
|
|
905
|
+
readyTimer = null;
|
|
906
|
+
}
|
|
907
|
+
if (slowTimer !== null) {
|
|
908
|
+
clearInterval(slowTimer);
|
|
909
|
+
slowTimer = null;
|
|
910
|
+
}
|
|
911
|
+
if (readyTimeout !== null) {
|
|
912
|
+
window.clearTimeout(readyTimeout);
|
|
913
|
+
readyTimeout = null;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
572
916
|
|
|
573
917
|
/* The old gate matched [data-sidebar-collapsed] /
|
|
574
918
|
* [data-details-collapsed]. 0.1.5-rc.2 renders collapsed
|
|
@@ -581,6 +925,7 @@ window.__ModuleLoader__.load({
|
|
|
581
925
|
if (attached || !body()) return;
|
|
582
926
|
if (document.querySelector("[data-shell-overlay]") === null) return;
|
|
583
927
|
attached = true;
|
|
928
|
+
stopWaiting();
|
|
584
929
|
try {
|
|
585
930
|
window.localStorage.removeItem(OLD_KEY);
|
|
586
931
|
} catch (e) {
|
|
@@ -588,18 +933,39 @@ window.__ModuleLoader__.load({
|
|
|
588
933
|
}
|
|
589
934
|
body().appendChild(btn);
|
|
590
935
|
body().appendChild(menu);
|
|
936
|
+
body().appendChild(guide);
|
|
591
937
|
syncButton();
|
|
592
938
|
const saved = getSavedTier();
|
|
593
939
|
if (saved !== "standard") applyTier(saved);
|
|
594
940
|
startHealing();
|
|
941
|
+
if (widthRoot !== null) observeWidthTarget(widthRoot);
|
|
595
942
|
}
|
|
596
943
|
|
|
597
|
-
|
|
598
|
-
window.
|
|
599
|
-
|
|
944
|
+
readyTimer = setInterval(attach, 300);
|
|
945
|
+
readyTimeout = window.setTimeout(() => {
|
|
946
|
+
if (attached) return;
|
|
600
947
|
clearInterval(readyTimer);
|
|
601
|
-
|
|
948
|
+
readyTimer = null;
|
|
949
|
+
/* A very slow boot should not leave the picker permanently
|
|
950
|
+
* unmounted: keep a low-frequency retry instead of giving up. */
|
|
951
|
+
slowTimer = setInterval(attach, 2000);
|
|
602
952
|
}, READY_TIMEOUT_MS);
|
|
953
|
+
window.addEventListener("load", attach);
|
|
954
|
+
|
|
955
|
+
/* Tier changes from another tab: adopt the stored tier without
|
|
956
|
+
* writing localStorage again (that would loop). */
|
|
957
|
+
const onStorage = (e) => {
|
|
958
|
+
if (!e || e.key !== KEY) return;
|
|
959
|
+
/* No storageArea identity check: spec does not require the
|
|
960
|
+
* event object to be the same Storage instance as
|
|
961
|
+
* window.localStorage, and dropping the event would break
|
|
962
|
+
* cross-tab sync. */
|
|
963
|
+
const next = e.newValue;
|
|
964
|
+
if (typeof next !== "string" || !TIERS.some((tier) => tier.id === next)) return;
|
|
965
|
+
if (currentTier() === next) return;
|
|
966
|
+
applyTier(next, false);
|
|
967
|
+
};
|
|
968
|
+
window.addEventListener("storage", onStorage);
|
|
603
969
|
|
|
604
970
|
/* ---- self-heal: re-apply the width when the chat tree
|
|
605
971
|
* remounts (conversation switch). Panels are never managed
|
|
@@ -618,15 +984,51 @@ window.__ModuleLoader__.load({
|
|
|
618
984
|
let rootRetryDelay = ROOT_RETRY_MIN_MS;
|
|
619
985
|
let rootRetryTimer = null;
|
|
620
986
|
let healFrame = null;
|
|
987
|
+
let resizeFrame = null;
|
|
621
988
|
let observer = null;
|
|
622
989
|
let fallbackTimer = null;
|
|
990
|
+
let widthObserver = null;
|
|
991
|
+
let observedTarget = null;
|
|
992
|
+
|
|
993
|
+
/* Return the frame id so heal and resize can be debounced and
|
|
994
|
+
* cancelled independently. The old helper always wrote the
|
|
995
|
+
* heal slot, so resizeFrame never became non-null and every
|
|
996
|
+
* resize event scheduled another frame. */
|
|
997
|
+
function scheduleFrame(cb) {
|
|
998
|
+
return typeof requestAnimationFrame === "function"
|
|
999
|
+
? requestAnimationFrame(cb)
|
|
1000
|
+
: window.setTimeout(cb, 0);
|
|
1001
|
+
}
|
|
1002
|
+
function cancelFrame(id) {
|
|
1003
|
+
if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(id);
|
|
1004
|
+
else window.clearTimeout(id);
|
|
1005
|
+
}
|
|
623
1006
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
1007
|
+
/* Watch the column that sizes the transcript. Sidebar/rightbar
|
|
1008
|
+
* changes and frame resizes alter it; `full` (and a clamped
|
|
1009
|
+
* fixed/custom width) must follow without polling every
|
|
1010
|
+
* mutation. Guarded for browsers/tests without ResizeObserver. */
|
|
1011
|
+
function observeWidthTarget(root) {
|
|
1012
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
1013
|
+
const target = widthTarget(root);
|
|
1014
|
+
if (target === observedTarget && widthObserver !== null) return;
|
|
1015
|
+
if (widthObserver !== null) {
|
|
1016
|
+
widthObserver.disconnect();
|
|
1017
|
+
widthObserver = null;
|
|
1018
|
+
}
|
|
1019
|
+
observedTarget = target;
|
|
1020
|
+
widthObserver = new ResizeObserver(() => {
|
|
1021
|
+
/* Never fight the app's native drag: it owns the width
|
|
1022
|
+
* while the user is moving a handle. */
|
|
1023
|
+
if (guideActive && guideDragging) return;
|
|
1024
|
+
const tier = currentTier();
|
|
1025
|
+
if (tier === "standard") return;
|
|
1026
|
+
applyContentWidth(tier);
|
|
1027
|
+
syncButton();
|
|
1028
|
+
syncMenu();
|
|
1029
|
+
});
|
|
1030
|
+
widthObserver.observe(target);
|
|
1031
|
+
}
|
|
630
1032
|
|
|
631
1033
|
function scheduleRootRetry() {
|
|
632
1034
|
if (rootRetryTimer !== null) return;
|
|
@@ -642,21 +1044,32 @@ window.__ModuleLoader__.load({
|
|
|
642
1044
|
} else {
|
|
643
1045
|
rootMissing = false;
|
|
644
1046
|
rootRetryDelay = ROOT_RETRY_MIN_MS;
|
|
645
|
-
if (root !== prev)
|
|
1047
|
+
if (root !== prev) {
|
|
1048
|
+
applyContentWidth(tier);
|
|
1049
|
+
syncButton();
|
|
1050
|
+
syncMenu();
|
|
1051
|
+
}
|
|
1052
|
+
observeWidthTarget(root);
|
|
646
1053
|
}
|
|
647
1054
|
}, rootRetryDelay);
|
|
648
1055
|
}
|
|
649
1056
|
|
|
650
1057
|
function heal(fromObserver) {
|
|
1058
|
+
/* The right handle only mounts in the active phase; keep the
|
|
1059
|
+
* guide pinned to it once the chat tree settles. */
|
|
1060
|
+
if (guideActive) positionGuide();
|
|
1061
|
+
/* The app's native drag owns the inline width until release;
|
|
1062
|
+
* a heal must not restore the previous custom value on top. */
|
|
1063
|
+
if (guideActive && guideDragging) return;
|
|
651
1064
|
const tier = currentTier();
|
|
652
1065
|
if (tier === "standard") return;
|
|
653
1066
|
const prev = widthRoot;
|
|
654
1067
|
let root;
|
|
655
|
-
if (rootMissing) {
|
|
656
|
-
/* Mid-remount
|
|
657
|
-
*
|
|
658
|
-
*
|
|
659
|
-
root =
|
|
1068
|
+
if (rootMissing || fromObserver) {
|
|
1069
|
+
/* Mid-remount / mutation churn: walk the recorded path
|
|
1070
|
+
* only (O(depth)); the backoff retry owns full scans
|
|
1071
|
+
* until the new root exists. */
|
|
1072
|
+
root = findWidthRoot(false);
|
|
660
1073
|
} else {
|
|
661
1074
|
root = findWidthRoot();
|
|
662
1075
|
}
|
|
@@ -664,27 +1077,33 @@ window.__ModuleLoader__.load({
|
|
|
664
1077
|
if (!rootMissing) {
|
|
665
1078
|
rootMissing = true;
|
|
666
1079
|
rootRetryDelay = ROOT_RETRY_MIN_MS;
|
|
667
|
-
scheduleRootRetry();
|
|
668
1080
|
}
|
|
1081
|
+
/* Idempotent: keep a backoff retry alive even if the
|
|
1082
|
+
* previous one ended while this tier was standard. */
|
|
1083
|
+
scheduleRootRetry();
|
|
669
1084
|
return;
|
|
670
1085
|
}
|
|
671
1086
|
if (rootMissing) {
|
|
672
1087
|
rootMissing = false;
|
|
673
1088
|
rootRetryDelay = ROOT_RETRY_MIN_MS;
|
|
674
1089
|
}
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
if (
|
|
1090
|
+
observeWidthTarget(root);
|
|
1091
|
+
if (root !== prev) {
|
|
1092
|
+
applyContentWidth(tier);
|
|
1093
|
+
syncButton();
|
|
1094
|
+
syncMenu();
|
|
1095
|
+
} else if (!fromObserver) {
|
|
1096
|
+
/* Fallback tick: retry an apply that was skipped while
|
|
1097
|
+
* the root had no layout box. */
|
|
1098
|
+
applyContentWidth(tier);
|
|
1099
|
+
}
|
|
681
1100
|
}
|
|
682
1101
|
|
|
683
1102
|
function startHealing() {
|
|
684
1103
|
if (typeof MutationObserver !== "undefined") {
|
|
685
1104
|
observer = new MutationObserver(() => {
|
|
686
1105
|
if (healFrame !== null) return;
|
|
687
|
-
scheduleFrame(() => {
|
|
1106
|
+
healFrame = scheduleFrame(() => {
|
|
688
1107
|
healFrame = null;
|
|
689
1108
|
heal(true);
|
|
690
1109
|
});
|
|
@@ -697,53 +1116,95 @@ window.__ModuleLoader__.load({
|
|
|
697
1116
|
});
|
|
698
1117
|
}
|
|
699
1118
|
/* Low-frequency fallback so self-heal still runs if the
|
|
700
|
-
* observer misses something — cheap: no scan while the
|
|
1119
|
+
* observer misses something — cheap: no full scan while the
|
|
701
1120
|
* cached root is alive. */
|
|
702
1121
|
fallbackTimer = setInterval(() => heal(false), FALLBACK_HEAL_MS);
|
|
703
1122
|
}
|
|
704
1123
|
|
|
705
|
-
/* ---- keep the
|
|
706
|
-
*
|
|
707
|
-
*
|
|
708
|
-
* The menu is also re-synced whenever it opens. */
|
|
709
|
-
let resizeFrame = null;
|
|
710
|
-
|
|
1124
|
+
/* ---- keep the picker and the guide aligned with the live
|
|
1125
|
+
* column. Standard/full re-resolve on resize, and the guide
|
|
1126
|
+
* must stay beside the handle when the frame resizes. ---- */
|
|
711
1127
|
const onWindowResize = () => {
|
|
712
1128
|
if (resizeFrame !== null) return;
|
|
713
|
-
scheduleFrame(() => {
|
|
1129
|
+
resizeFrame = scheduleFrame(() => {
|
|
714
1130
|
resizeFrame = null;
|
|
1131
|
+
if (!(guideActive && guideDragging)) {
|
|
1132
|
+
const tier = currentTier();
|
|
1133
|
+
if (tier !== "standard") applyContentWidth(tier);
|
|
1134
|
+
}
|
|
715
1135
|
syncButton();
|
|
716
1136
|
syncMenu();
|
|
1137
|
+
positionGuide();
|
|
717
1138
|
});
|
|
718
1139
|
};
|
|
719
1140
|
window.addEventListener("resize", onWindowResize);
|
|
720
1141
|
|
|
1142
|
+
/* All healing/observer state is initialized by now; try an
|
|
1143
|
+
* immediate mount instead of waiting for the first interval. */
|
|
1144
|
+
attach();
|
|
1145
|
+
|
|
721
1146
|
/* ---- disposer ---- */
|
|
722
1147
|
return () => {
|
|
723
1148
|
unsubscribeLocale();
|
|
724
|
-
|
|
1149
|
+
stopWaiting();
|
|
725
1150
|
clearInterval(fallbackTimer);
|
|
726
|
-
clearTimeout(timeout);
|
|
727
1151
|
clearTimeout(rootRetryTimer);
|
|
1152
|
+
clearTimeout(guideHideTimer);
|
|
1153
|
+
clearTimeout(pulseTimer);
|
|
728
1154
|
if (healFrame !== null) cancelFrame(healFrame);
|
|
729
1155
|
if (resizeFrame !== null) cancelFrame(resizeFrame);
|
|
1156
|
+
if (widthObserver !== null) {
|
|
1157
|
+
widthObserver.disconnect();
|
|
1158
|
+
widthObserver = null;
|
|
1159
|
+
observedTarget = null;
|
|
1160
|
+
}
|
|
730
1161
|
window.removeEventListener("resize", onWindowResize);
|
|
1162
|
+
window.removeEventListener("load", attach);
|
|
1163
|
+
window.removeEventListener("storage", onStorage);
|
|
731
1164
|
if (observer !== null) observer.disconnect();
|
|
732
1165
|
document.removeEventListener("click", onDocClick);
|
|
733
1166
|
document.removeEventListener("keydown", onDocKey);
|
|
734
1167
|
document.removeEventListener("pointerdown", onHandlePointerDown, true);
|
|
735
1168
|
document.removeEventListener("pointerup", onHandlePointerUp, true);
|
|
1169
|
+
document.removeEventListener("pointercancel", onHandlePointerCancel, true);
|
|
1170
|
+
document.removeEventListener("pointermove", onGuidePointerMove, true);
|
|
736
1171
|
if (btn.parentNode) btn.parentNode.removeChild(btn);
|
|
737
1172
|
if (menu.parentNode) menu.parentNode.removeChild(menu);
|
|
1173
|
+
if (guide.parentNode) guide.parentNode.removeChild(guide);
|
|
738
1174
|
if (styleTag.parentNode) styleTag.parentNode.removeChild(styleTag);
|
|
739
|
-
/* Hand the width back to the app default
|
|
740
|
-
*
|
|
741
|
-
*
|
|
742
|
-
* semantics. */
|
|
1175
|
+
/* Hand the width back to the app default. Only a value this
|
|
1176
|
+
* plugin wrote is removed, so a native drag preference that
|
|
1177
|
+
* predates the plugin survives unload. */
|
|
743
1178
|
if (widthRoot !== null) {
|
|
744
1179
|
widthTarget(widthRoot).style.removeProperty("--dsh-chat-user-width");
|
|
745
1180
|
}
|
|
1181
|
+
if (preferenceOwned) {
|
|
1182
|
+
try {
|
|
1183
|
+
/* Only remove the exact value this plugin wrote; a
|
|
1184
|
+
* native drag since then owns the preference. */
|
|
1185
|
+
if (preferenceWritten !== null
|
|
1186
|
+
&& window.localStorage.getItem(APP_PREF_KEY) === preferenceWritten) {
|
|
1187
|
+
window.localStorage.removeItem(APP_PREF_KEY);
|
|
1188
|
+
}
|
|
1189
|
+
} catch (e) {
|
|
1190
|
+
/* ignore */
|
|
1191
|
+
}
|
|
1192
|
+
preferenceOwned = false;
|
|
1193
|
+
preferenceWritten = null;
|
|
1194
|
+
}
|
|
1195
|
+
/* Undo a collapse this plugin owned; a manual collapse is
|
|
1196
|
+
* left alone. */
|
|
1197
|
+
if (collapsedByTier) {
|
|
1198
|
+
try {
|
|
1199
|
+
if (isSidebarCollapsed()) layout.toggleSidebar();
|
|
1200
|
+
} catch (e) {
|
|
1201
|
+
/* ignore */
|
|
1202
|
+
}
|
|
1203
|
+
collapsedByTier = false;
|
|
1204
|
+
}
|
|
1205
|
+
guideHandle = null;
|
|
746
1206
|
body().removeAttribute("data-dsh-width");
|
|
1207
|
+
body().removeAttribute("data-dsh-width-guide");
|
|
747
1208
|
};
|
|
748
1209
|
}, "dsh-width-tiers: ui");
|
|
749
1210
|
}
|