dsh-width-tiers 1.0.1 → 1.0.3
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/lib/client.js +152 -18
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -41,8 +41,14 @@ window.__ModuleLoader__.load({
|
|
|
41
41
|
*/
|
|
42
42
|
const KEY = "dsh.widthTier";
|
|
43
43
|
const OLD_KEY = "dsh.wide";
|
|
44
|
-
const SELF_HEAL_MS = 1000;
|
|
45
44
|
const READY_TIMEOUT_MS = 30000;
|
|
45
|
+
/* Self-heal cadence: DOM-driven (MutationObserver scheduling on the
|
|
46
|
+
* next animation frame — before paint, so the width never flashes
|
|
47
|
+
* default) with a low-frequency fallback and backoff retries while
|
|
48
|
+
* the width root is missing (conversation-switch remount). */
|
|
49
|
+
const ROOT_RETRY_MIN_MS = 200;
|
|
50
|
+
const ROOT_RETRY_MAX_MS = 2000;
|
|
51
|
+
const FALLBACK_HEAL_MS = 2000;
|
|
46
52
|
|
|
47
53
|
/** Dictionary namespace owned by this plugin. */
|
|
48
54
|
const NS = "width-tiers";
|
|
@@ -91,13 +97,16 @@ window.__ModuleLoader__.load({
|
|
|
91
97
|
//#region lib/types/client/styles.js
|
|
92
98
|
/** Button + menu styles (theme variables, light/dark aware). */
|
|
93
99
|
const css = [
|
|
94
|
-
"#dsh-wide-toggle{position:fixed;right:
|
|
95
|
-
"#dsh-wide-toggle
|
|
96
|
-
"#dsh-wide-toggle:
|
|
100
|
+
"#dsh-wide-toggle{position:fixed;right:10px;bottom:216px;z-index:999;display:flex;align-items:center;justify-content:center;width:36px;height:36px;padding:0;border:1px solid var(--dsw-alias-border-l2);border-radius:50%;overflow:hidden;background:var(--dsw-alias-button-floating-fill);color:var(--dsw-alias-label-secondary);box-shadow:0 2px 8px rgba(0,0,0,.18);cursor:pointer;opacity:.72;transition:opacity .2s ease,width .2s ease,border-radius .2s ease,background .2s ease,color .2s ease}",
|
|
101
|
+
"#dsh-wide-toggle::after{content:'';position:fixed;right:10px;bottom:216px;width:36px;height:36px;pointer-events:auto}",
|
|
102
|
+
"#dsh-wide-toggle:hover{opacity:1}",
|
|
103
|
+
"#dsh-wide-toggle:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px;width:36px;border-radius:50%}",
|
|
104
|
+
"#dsh-wide-toggle:not(:hover):not(:focus-visible){width:10px;border-radius:6px}",
|
|
105
|
+
"#dsh-wide-toggle:not(:hover):not(:focus-visible) svg rect{width:6px;x:5px}",
|
|
97
106
|
"#dsh-wide-toggle.active{background:var(--dsw-alias-state-business-primary);border-color:transparent;color:var(--dsw-static-neutral-00);opacity:1}",
|
|
98
107
|
"#dsh-wide-toggle.pulse{animation:dsh-wide-pulse .65s ease-out}",
|
|
99
108
|
"@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}}",
|
|
100
|
-
"#dsh-width-menu{position:fixed;right:20px;bottom:
|
|
109
|
+
"#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}",
|
|
101
110
|
"#dsh-width-menu[hidden]{display:none}",
|
|
102
111
|
"#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}",
|
|
103
112
|
"#dsh-width-menu button:hover{background:var(--dsw-alias-interactive-bg-hover)}",
|
|
@@ -174,25 +183,62 @@ window.__ModuleLoader__.load({
|
|
|
174
183
|
|
|
175
184
|
/* Locate the element that defines --dsh-chat-content-width at
|
|
176
185
|
* runtime (first match wins; only one element declares it).
|
|
177
|
-
* Cached;
|
|
186
|
+
* Cached; the app remounts the chat tree on conversation switch,
|
|
187
|
+
* so re-location first walks the recorded child-index path from
|
|
188
|
+
* <body> (React keeps the tree shape — O(depth), one style
|
|
189
|
+
* read) and only falls back to a full scan. */
|
|
178
190
|
let widthRoot = null;
|
|
191
|
+
let widthPath = null;
|
|
179
192
|
|
|
180
|
-
function
|
|
181
|
-
|
|
182
|
-
|
|
193
|
+
function hasWidthVar(el) {
|
|
194
|
+
return getComputedStyle(el)
|
|
195
|
+
.getPropertyValue("--dsh-chat-content-width")
|
|
196
|
+
.trim() !== "";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function recordWidthPath(el) {
|
|
200
|
+
const path = [];
|
|
201
|
+
let node = el;
|
|
202
|
+
while (node && node !== document.body) {
|
|
203
|
+
const parent = node.parentNode;
|
|
204
|
+
if (!parent || !parent.children) break;
|
|
205
|
+
path.unshift(Array.prototype.indexOf.call(parent.children, node));
|
|
206
|
+
node = parent;
|
|
207
|
+
}
|
|
208
|
+
widthPath = path.length > 0 ? path : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function scanWidthRoot() {
|
|
183
212
|
const all = document.querySelectorAll("[class]");
|
|
184
213
|
for (let i = 0; i < all.length; i++) {
|
|
185
|
-
|
|
186
|
-
.getPropertyValue("--dsh-chat-content-width")
|
|
187
|
-
.trim();
|
|
188
|
-
if (v) {
|
|
214
|
+
if (hasWidthVar(all[i])) {
|
|
189
215
|
widthRoot = all[i];
|
|
216
|
+
recordWidthPath(widthRoot);
|
|
190
217
|
return widthRoot;
|
|
191
218
|
}
|
|
192
219
|
}
|
|
220
|
+
widthRoot = null;
|
|
221
|
+
widthPath = null;
|
|
193
222
|
return null;
|
|
194
223
|
}
|
|
195
224
|
|
|
225
|
+
function findWidthRoot(allowScan) {
|
|
226
|
+
if (widthRoot !== null && document.contains(widthRoot)) return widthRoot;
|
|
227
|
+
if (widthPath !== null) {
|
|
228
|
+
let el = document.body;
|
|
229
|
+
for (let i = 0; i < widthPath.length; i++) {
|
|
230
|
+
el = el.children[widthPath[i]];
|
|
231
|
+
if (!el) break;
|
|
232
|
+
}
|
|
233
|
+
if (el && el !== widthRoot && document.contains(el) && hasWidthVar(el)) {
|
|
234
|
+
widthRoot = el;
|
|
235
|
+
return widthRoot;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (allowScan === false) return null;
|
|
239
|
+
return scanWidthRoot();
|
|
240
|
+
}
|
|
241
|
+
|
|
196
242
|
function tierWidth(tier) {
|
|
197
243
|
switch (tier) {
|
|
198
244
|
case "medium": return "960px";
|
|
@@ -367,6 +413,7 @@ window.__ModuleLoader__.load({
|
|
|
367
413
|
syncButton();
|
|
368
414
|
const saved = getSavedTier();
|
|
369
415
|
if (saved !== "standard") applyTier(saved);
|
|
416
|
+
startHealing();
|
|
370
417
|
}
|
|
371
418
|
|
|
372
419
|
const readyTimer = setInterval(attach, 300);
|
|
@@ -378,20 +425,107 @@ window.__ModuleLoader__.load({
|
|
|
378
425
|
|
|
379
426
|
/* ---- self-heal: keep the details panel closed for non-standard
|
|
380
427
|
* tiers. The sidebar is never managed here — the user may expand
|
|
381
|
-
* it any time.
|
|
382
|
-
|
|
428
|
+
* it any time. Driven by DOM mutations, scheduled on the next
|
|
429
|
+
* animation frame (before the browser paints) instead of a fixed
|
|
430
|
+
* hot loop: conversation switches remount the chat tree, and the
|
|
431
|
+
* width override must land on the fresh root before its first
|
|
432
|
+
* paint — otherwise the default width flashes for a frame.
|
|
433
|
+
* While the new root has not appeared yet (mid-remount), full
|
|
434
|
+
* scans are owned by a backoff retry; mutation-driven heals only
|
|
435
|
+
* try the cheap path walk so DOM churn cannot storm. ---- */
|
|
436
|
+
let rootMissing = false;
|
|
437
|
+
let rootRetryDelay = ROOT_RETRY_MIN_MS;
|
|
438
|
+
let rootRetryTimer = null;
|
|
439
|
+
let healFrame = null;
|
|
440
|
+
let observer = null;
|
|
441
|
+
let fallbackTimer = null;
|
|
442
|
+
|
|
443
|
+
const scheduleFrame = typeof requestAnimationFrame === "function"
|
|
444
|
+
? (cb) => { healFrame = requestAnimationFrame(cb); }
|
|
445
|
+
: (cb) => { healFrame = window.setTimeout(cb, 0); };
|
|
446
|
+
const cancelFrame = typeof cancelAnimationFrame === "function"
|
|
447
|
+
? (id) => cancelAnimationFrame(id)
|
|
448
|
+
: (id) => window.clearTimeout(id);
|
|
449
|
+
|
|
450
|
+
function scheduleRootRetry() {
|
|
451
|
+
if (rootRetryTimer !== null) return;
|
|
452
|
+
rootRetryTimer = window.setTimeout(() => {
|
|
453
|
+
rootRetryTimer = null;
|
|
454
|
+
const tier = currentTier();
|
|
455
|
+
if (tier === "standard") return;
|
|
456
|
+
const prev = widthRoot;
|
|
457
|
+
const root = findWidthRoot();
|
|
458
|
+
if (root === null) {
|
|
459
|
+
rootRetryDelay = Math.min(rootRetryDelay * 2, ROOT_RETRY_MAX_MS);
|
|
460
|
+
scheduleRootRetry();
|
|
461
|
+
} else {
|
|
462
|
+
rootMissing = false;
|
|
463
|
+
rootRetryDelay = ROOT_RETRY_MIN_MS;
|
|
464
|
+
if (root !== prev) applyContentWidth(tier);
|
|
465
|
+
}
|
|
466
|
+
}, rootRetryDelay);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function heal(fromObserver) {
|
|
383
470
|
const tier = currentTier();
|
|
384
471
|
if (tier === "standard") return;
|
|
385
472
|
closeDetails();
|
|
386
|
-
|
|
387
|
-
|
|
473
|
+
const prev = widthRoot;
|
|
474
|
+
let root;
|
|
475
|
+
if (rootMissing) {
|
|
476
|
+
/* Mid-remount: a mutation-driven heal only walks the
|
|
477
|
+
* recorded path (O(depth)) — the backoff retry owns
|
|
478
|
+
* full scans until the new root exists. */
|
|
479
|
+
root = fromObserver ? findWidthRoot(false) : findWidthRoot();
|
|
480
|
+
} else {
|
|
481
|
+
root = findWidthRoot();
|
|
482
|
+
}
|
|
483
|
+
if (root === null) {
|
|
484
|
+
if (!rootMissing) {
|
|
485
|
+
rootMissing = true;
|
|
486
|
+
rootRetryDelay = ROOT_RETRY_MIN_MS;
|
|
487
|
+
scheduleRootRetry();
|
|
488
|
+
}
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (rootMissing) {
|
|
492
|
+
rootMissing = false;
|
|
493
|
+
rootRetryDelay = ROOT_RETRY_MIN_MS;
|
|
494
|
+
}
|
|
495
|
+
if (root !== prev) applyContentWidth(tier);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function startHealing() {
|
|
499
|
+
if (typeof MutationObserver !== "undefined") {
|
|
500
|
+
observer = new MutationObserver(() => {
|
|
501
|
+
if (healFrame !== null) return;
|
|
502
|
+
scheduleFrame(() => {
|
|
503
|
+
healFrame = null;
|
|
504
|
+
heal(true);
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
observer.observe(document.body, {
|
|
508
|
+
subtree: true,
|
|
509
|
+
childList: true,
|
|
510
|
+
attributes: true,
|
|
511
|
+
attributeFilter: ["data-details-collapsed", "data-sidebar-collapsed"]
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
/* Low-frequency fallback so self-heal still runs if the
|
|
515
|
+
* observer misses something — cheap: no scan while the
|
|
516
|
+
* cached root is alive. */
|
|
517
|
+
fallbackTimer = setInterval(() => heal(false), FALLBACK_HEAL_MS);
|
|
518
|
+
}
|
|
388
519
|
|
|
389
520
|
/* ---- disposer ---- */
|
|
390
521
|
return () => {
|
|
391
522
|
unsubscribeLocale();
|
|
392
523
|
clearInterval(readyTimer);
|
|
393
|
-
clearInterval(
|
|
524
|
+
clearInterval(fallbackTimer);
|
|
394
525
|
clearTimeout(timeout);
|
|
526
|
+
clearTimeout(rootRetryTimer);
|
|
527
|
+
if (healFrame !== null) cancelFrame(healFrame);
|
|
528
|
+
if (observer !== null) observer.disconnect();
|
|
395
529
|
document.removeEventListener("click", onDocClick);
|
|
396
530
|
document.removeEventListener("keydown", onDocKey);
|
|
397
531
|
if (btn.parentNode) btn.parentNode.removeChild(btn);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-width-tiers",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "DSH Web client plugin: chat content width tiers (standard / medium / wide / ultra / full) with a floating picker button. Frees wide tables from the app's hard-coded 748px content cap. UI copy follows the app language (zh / en).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "aaronlei",
|