dsh-width-tiers 1.0.1 → 1.0.2

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.
Files changed (2) hide show
  1. package/lib/client.js +145 -14
  2. 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";
@@ -174,25 +180,62 @@ window.__ModuleLoader__.load({
174
180
 
175
181
  /* Locate the element that defines --dsh-chat-content-width at
176
182
  * runtime (first match wins; only one element declares it).
177
- * Cached; re-scanned whenever the cached node is gone. */
183
+ * Cached; the app remounts the chat tree on conversation switch,
184
+ * so re-location first walks the recorded child-index path from
185
+ * <body> (React keeps the tree shape — O(depth), one style
186
+ * read) and only falls back to a full scan. */
178
187
  let widthRoot = null;
188
+ let widthPath = null;
179
189
 
180
- function findWidthRoot() {
181
- if (widthRoot !== null && document.contains(widthRoot)) return widthRoot;
182
- widthRoot = null;
190
+ function hasWidthVar(el) {
191
+ return getComputedStyle(el)
192
+ .getPropertyValue("--dsh-chat-content-width")
193
+ .trim() !== "";
194
+ }
195
+
196
+ function recordWidthPath(el) {
197
+ const path = [];
198
+ let node = el;
199
+ while (node && node !== document.body) {
200
+ const parent = node.parentNode;
201
+ if (!parent || !parent.children) break;
202
+ path.unshift(Array.prototype.indexOf.call(parent.children, node));
203
+ node = parent;
204
+ }
205
+ widthPath = path.length > 0 ? path : null;
206
+ }
207
+
208
+ function scanWidthRoot() {
183
209
  const all = document.querySelectorAll("[class]");
184
210
  for (let i = 0; i < all.length; i++) {
185
- const v = getComputedStyle(all[i])
186
- .getPropertyValue("--dsh-chat-content-width")
187
- .trim();
188
- if (v) {
211
+ if (hasWidthVar(all[i])) {
189
212
  widthRoot = all[i];
213
+ recordWidthPath(widthRoot);
190
214
  return widthRoot;
191
215
  }
192
216
  }
217
+ widthRoot = null;
218
+ widthPath = null;
193
219
  return null;
194
220
  }
195
221
 
222
+ function findWidthRoot(allowScan) {
223
+ if (widthRoot !== null && document.contains(widthRoot)) return widthRoot;
224
+ if (widthPath !== null) {
225
+ let el = document.body;
226
+ for (let i = 0; i < widthPath.length; i++) {
227
+ el = el.children[widthPath[i]];
228
+ if (!el) break;
229
+ }
230
+ if (el && el !== widthRoot && document.contains(el) && hasWidthVar(el)) {
231
+ widthRoot = el;
232
+ return widthRoot;
233
+ }
234
+ }
235
+ if (allowScan === false) return null;
236
+ return scanWidthRoot();
237
+ }
238
+
196
239
  function tierWidth(tier) {
197
240
  switch (tier) {
198
241
  case "medium": return "960px";
@@ -367,6 +410,7 @@ window.__ModuleLoader__.load({
367
410
  syncButton();
368
411
  const saved = getSavedTier();
369
412
  if (saved !== "standard") applyTier(saved);
413
+ startHealing();
370
414
  }
371
415
 
372
416
  const readyTimer = setInterval(attach, 300);
@@ -378,20 +422,107 @@ window.__ModuleLoader__.load({
378
422
 
379
423
  /* ---- self-heal: keep the details panel closed for non-standard
380
424
  * tiers. The sidebar is never managed here — the user may expand
381
- * it any time. ---- */
382
- const healTimer = setInterval(() => {
425
+ * it any time. Driven by DOM mutations, scheduled on the next
426
+ * animation frame (before the browser paints) instead of a fixed
427
+ * hot loop: conversation switches remount the chat tree, and the
428
+ * width override must land on the fresh root before its first
429
+ * paint — otherwise the default width flashes for a frame.
430
+ * While the new root has not appeared yet (mid-remount), full
431
+ * scans are owned by a backoff retry; mutation-driven heals only
432
+ * try the cheap path walk so DOM churn cannot storm. ---- */
433
+ let rootMissing = false;
434
+ let rootRetryDelay = ROOT_RETRY_MIN_MS;
435
+ let rootRetryTimer = null;
436
+ let healFrame = null;
437
+ let observer = null;
438
+ let fallbackTimer = null;
439
+
440
+ const scheduleFrame = typeof requestAnimationFrame === "function"
441
+ ? (cb) => { healFrame = requestAnimationFrame(cb); }
442
+ : (cb) => { healFrame = window.setTimeout(cb, 0); };
443
+ const cancelFrame = typeof cancelAnimationFrame === "function"
444
+ ? (id) => cancelAnimationFrame(id)
445
+ : (id) => window.clearTimeout(id);
446
+
447
+ function scheduleRootRetry() {
448
+ if (rootRetryTimer !== null) return;
449
+ rootRetryTimer = window.setTimeout(() => {
450
+ rootRetryTimer = null;
451
+ const tier = currentTier();
452
+ if (tier === "standard") return;
453
+ const prev = widthRoot;
454
+ const root = findWidthRoot();
455
+ if (root === null) {
456
+ rootRetryDelay = Math.min(rootRetryDelay * 2, ROOT_RETRY_MAX_MS);
457
+ scheduleRootRetry();
458
+ } else {
459
+ rootMissing = false;
460
+ rootRetryDelay = ROOT_RETRY_MIN_MS;
461
+ if (root !== prev) applyContentWidth(tier);
462
+ }
463
+ }, rootRetryDelay);
464
+ }
465
+
466
+ function heal(fromObserver) {
383
467
  const tier = currentTier();
384
468
  if (tier === "standard") return;
385
469
  closeDetails();
386
- if (widthRoot !== null && !document.contains(widthRoot)) findWidthRoot();
387
- }, SELF_HEAL_MS);
470
+ const prev = widthRoot;
471
+ let root;
472
+ if (rootMissing) {
473
+ /* Mid-remount: a mutation-driven heal only walks the
474
+ * recorded path (O(depth)) — the backoff retry owns
475
+ * full scans until the new root exists. */
476
+ root = fromObserver ? findWidthRoot(false) : findWidthRoot();
477
+ } else {
478
+ root = findWidthRoot();
479
+ }
480
+ if (root === null) {
481
+ if (!rootMissing) {
482
+ rootMissing = true;
483
+ rootRetryDelay = ROOT_RETRY_MIN_MS;
484
+ scheduleRootRetry();
485
+ }
486
+ return;
487
+ }
488
+ if (rootMissing) {
489
+ rootMissing = false;
490
+ rootRetryDelay = ROOT_RETRY_MIN_MS;
491
+ }
492
+ if (root !== prev) applyContentWidth(tier);
493
+ }
494
+
495
+ function startHealing() {
496
+ if (typeof MutationObserver !== "undefined") {
497
+ observer = new MutationObserver(() => {
498
+ if (healFrame !== null) return;
499
+ scheduleFrame(() => {
500
+ healFrame = null;
501
+ heal(true);
502
+ });
503
+ });
504
+ observer.observe(document.body, {
505
+ subtree: true,
506
+ childList: true,
507
+ attributes: true,
508
+ attributeFilter: ["data-details-collapsed", "data-sidebar-collapsed"]
509
+ });
510
+ }
511
+ /* Low-frequency fallback so self-heal still runs if the
512
+ * observer misses something — cheap: no scan while the
513
+ * cached root is alive. */
514
+ fallbackTimer = setInterval(() => heal(false), FALLBACK_HEAL_MS);
515
+ }
388
516
 
389
517
  /* ---- disposer ---- */
390
518
  return () => {
391
519
  unsubscribeLocale();
392
520
  clearInterval(readyTimer);
393
- clearInterval(healTimer);
521
+ clearInterval(fallbackTimer);
394
522
  clearTimeout(timeout);
523
+ clearTimeout(rootRetryTimer);
524
+ if (healFrame !== null) cancelFrame(healFrame);
525
+ if (observer !== null) observer.disconnect();
395
526
  document.removeEventListener("click", onDocClick);
396
527
  document.removeEventListener("keydown", onDocKey);
397
528
  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.1",
3
+ "version": "1.0.2",
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",