@lightworkai.official/debug-capture 0.19.5 → 0.19.7

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.
@@ -10,6 +10,7 @@
10
10
  * sign-in the panel asks (`hello`); the host's token goes to it by postMessage, to the support host's origin only
11
11
  * แจ้งปัญหา… in the panel: the panel folds and this library's report opens — its screen, its page — linked to the chat
12
12
  * the corner the call's card and frame sit above the bubble (`--lw-chat-lift`)
13
+ * its place out of the page's own floating controls, and where the desk says for a page (0.19.6, ui/bubblePlace)
13
14
  *
14
15
  * Shown only for a signed-in person (`identity` answers a token): a chat is someone's, and is read back.
15
16
  */
@@ -17,6 +18,7 @@ import { chatPlace, getConfig } from "../config";
17
18
  import { hostToken, onLiveFrame } from "../live";
18
19
  import { openSupportPortal } from "../portal";
19
20
  import { launch } from "../reporter";
21
+ import { blockedRanges, nearestFreeY, placeFor, scanColumns, settleSpot, type PlaceRule, type Side } from "./bubblePlace";
20
22
  import { followHostFont, OWN_FONTS } from "./hostFont";
21
23
  import { strings } from "./strings";
22
24
 
@@ -214,7 +216,7 @@ function setLabel(): void {
214
216
  bubble.classList.toggle("unlabelled", !l);
215
217
  bubble.classList.toggle("labelled", !!l?.shown);
216
218
  bubble.classList.toggle("new", !!l?.isNew);
217
- bubble.classList.toggle("at-left", spot.side === "left");
219
+ bubble.classList.toggle("at-left", (shown ?? spot).side === "left");
218
220
  label.querySelector(".text")!.textContent = l?.text ?? "";
219
221
  }
220
222
 
@@ -227,6 +229,10 @@ export interface DeskLook {
227
229
  color?: string;
228
230
  /** A path on the support host; null: the chat glyph on the colour. */
229
231
  faceUrl?: string | null;
232
+ /** Step aside from the page's own floating controls (0.19.6) — the desk's switch; on unless false. */
233
+ avoid?: boolean;
234
+ /** A page's own place (0.19.6): hidden there, or started in a corner at a height — the desk's rules. */
235
+ places?: PlaceRule[];
230
236
  }
231
237
  let deskLook: DeskLook | null = null;
232
238
  /** The face's address for a desk's look: its own path on the support host, the shipped one, or none (the glyph). Pure. */
@@ -247,6 +253,8 @@ function wearLook(look: DeskLook | null): void {
247
253
  else root.style.removeProperty("--lw-chat-color");
248
254
  const face = shadow?.querySelector<HTMLImageElement>(".face");
249
255
  const url = lookFaceUrl(getConfig().host, look);
256
+ // Its rules and its switch may have changed where it stands.
257
+ queueRescan("now");
250
258
  if (face) {
251
259
  if (!url) {
252
260
  bubble.classList.remove("has-face");
@@ -303,6 +311,7 @@ function show(on: boolean): void {
303
311
  // Its words in the host page's own font — read again here, after the host has drawn itself.
304
312
  if (on) followHostFont(root);
305
313
  layout();
314
+ if (on) queueRescan("now");
306
315
  }
307
316
 
308
317
  /* ── Where it is: the config's place, or where the person put it ───────── */
@@ -339,26 +348,186 @@ function savePlace(): void {
339
348
  }
340
349
  }
341
350
 
351
+ /* ── Out of the page's way, and a page's own place (0.19.6, ui/bubblePlace) ─────── */
352
+
353
+ /** The host page's path — through `window`, as every other read here is. */
354
+ const pagePath = () => window.location?.pathname ?? "/";
355
+ /** Where it stands now — `spot` (or the page's rule), moved clear of the page's floating controls. */
356
+ let shown: BubbleSpot | null = null;
357
+ /** What `shown` was chosen for — the page and the wish; while they hold, a free `shown` is kept (settleSpot). */
358
+ let shownFor = "";
359
+ /**
360
+ * The heights each side's column rules out, as last read off the page — null: not read yet, or not stepping aside. A
361
+ * side is null when it was not read: its own place was free, so the other corner could not be better.
362
+ */
363
+ let blocked: Record<Side, [number, number][] | null> | null = null;
364
+ /** Hidden on this page by the desk's rule (ซ่อนปุ่มแชทในหน้านี้). */
365
+ let ruleHidden = false;
366
+
367
+ function avoidSetting(): { on: boolean; extra: string | null } {
368
+ const c = getConfig().chat;
369
+ const own = c && c !== true ? c.avoid : undefined;
370
+ if (own === false || deskLook?.avoid === false) return { on: false, extra: null };
371
+ return { on: true, extra: typeof own === "string" && own.trim() ? own.trim() : null };
372
+ }
373
+ /** Where it wants to be on this page: put by hand — kept; else the desk's rule for the page; else the config's place. */
374
+ function wanted(): BubbleSpot {
375
+ const rule = placeFor(pagePath(), deskLook?.places);
376
+ ruleHidden = rule?.show === "hide";
377
+ if (movedByHand || !rule || rule.show === "hide") return spot;
378
+ return { side: rule.show, x: spot.x, y: rule.y };
379
+ }
380
+ /** Read the page's floating controls across both corners' columns — then place again. */
381
+ function rescan(): void {
382
+ if (!bubble) return;
383
+ const { on, extra } = avoidSetting();
384
+ if (!on) {
385
+ blocked = null;
386
+ return layout();
387
+ }
388
+ const v = view();
389
+ const x = spot.x;
390
+ const cols: Record<Side, { left: number; right: number }> = { right: { left: v.w - x - SIZE, right: v.w - x }, left: { left: x, right: x + SIZE } };
391
+ const read = (side: Side) => blockedRanges(scanColumns([cols[side]], v, { extra })[0] ?? [], cols[side], v.h, SIZE);
392
+ // Its own side first; the other corner is read only when the place it wants there is taken (half the hit tests).
393
+ const want = wanted();
394
+ const wantY = clampBubbleY(want.y, v.h);
395
+ const other: Side = want.side === "right" ? "left" : "right";
396
+ const here = read(want.side);
397
+ const there = nearestFreeY(wantY, here, MARGIN, Math.max(MARGIN, v.h - SIZE - MARGIN)) === wantY ? null : read(other);
398
+ const next = { [want.side]: here, [other]: there } as Record<Side, [number, number][] | null>;
399
+ // Nothing new where it could stand: the page is looked at less often while that holds (queueRescan).
400
+ calm = JSON.stringify([next.right, next.left]) === JSON.stringify(blocked && [blocked.right, blocked.left]) ? Math.min(calm + 1, CALM_STEPS) : 0;
401
+ blocked = next;
402
+ layout();
403
+ }
404
+ /*
405
+ * How often the page is read (a read is a column or two of hit tests). A page that keeps changing — a clock, a
406
+ * live list — said "look again" ten times a second, and a read every 400 ms cost it half a second of script in ten
407
+ * (0.19.5: 20 ms). So: a read that finds nothing new doubles the wait for the next one a change asks for (400 ms up to
408
+ * 3.2 s); a person's own press or key, a new page, a resize or the tab coming back is looked at at once (URGENT_MS),
409
+ * and resets it; a transition or animation that ends in a corner's column — something there just finished appearing
410
+ * — resets it too and is looked at within RESCAN_MS ("soon": a read during the ERP tray's 300 ms fade-in saw it nearly
411
+ * clear, and the next waited out the doubling). Never while the panel is open (it does not move then), while it is
412
+ * dragged, or in a hidden tab.
413
+ */
414
+ const RESCAN_MS = 400;
415
+ const CALM_STEPS = 3;
416
+ const URGENT_MS = 60;
417
+ let rescanTimer: ReturnType<typeof setTimeout> | undefined;
418
+ let rescanDue = 0;
419
+ let lastRescan = 0;
420
+ let calm = 0;
421
+ function queueRescan(when: "now" | "soon" | "later" = "later"): void {
422
+ if (!root) return;
423
+ const now = Date.now();
424
+ if (when !== "later") calm = 0;
425
+ const wait = when === "now" ? URGENT_MS : Math.max(0, RESCAN_MS * 2 ** calm - (now - lastRescan));
426
+ // One read waiting at a time — moved sooner when this asks sooner.
427
+ if (rescanTimer && rescanDue <= now + wait) return;
428
+ clearTimeout(rescanTimer);
429
+ rescanDue = now + wait;
430
+ rescanTimer = setTimeout(() => {
431
+ rescanTimer = undefined;
432
+ if (!root || isOpen || pressed?.moved || (typeof document !== "undefined" && document.hidden)) return;
433
+ lastRescan = Date.now();
434
+ rescan();
435
+ }, wait);
436
+ }
437
+ /** The page's own changes worth a look: a toolbar opening, a page arriving — never this library's own boxes. */
438
+ let watcher: MutationObserver | null = null;
439
+ let lastPath = "";
440
+ let pathTimer: ReturnType<typeof setInterval> | undefined;
441
+ const ours = (t: EventTarget | null) => t instanceof Element && (t === root || /^LW-/.test(t.tagName));
442
+ const onPageMoved = (e: Event) => {
443
+ if (!ours(e.target)) queueRescan();
444
+ };
445
+ /** A transition or animation ended: soon when it was in a corner's column, where the bubble may stand. */
446
+ const onPageSettled = (e: Event) => {
447
+ if (ours(e.target)) return;
448
+ const r = e.target instanceof Element ? e.target.getBoundingClientRect() : null;
449
+ const v = view();
450
+ const x = spot.x;
451
+ const inColumn = !!r && r.width > 0 && (r.left < x + SIZE || r.right > v.w - x - SIZE);
452
+ queueRescan(inColumn ? "soon" : "later");
453
+ };
454
+ /** What a person did, or the window itself changed: looked at at once. */
455
+ const onPageActed = (e: Event) => {
456
+ if (!ours(e.target)) queueRescan("now");
457
+ };
458
+ const ACTED = ["popstate", "hashchange", "pointerup", "keyup"] as const;
459
+ function watchPage(on: boolean): void {
460
+ watcher?.disconnect();
461
+ watcher = null;
462
+ clearInterval(pathTimer);
463
+ for (const t of ACTED) window.removeEventListener(t, onPageActed, true);
464
+ window.removeEventListener("scroll", onPageMoved, true);
465
+ for (const t of ["transitionend", "animationend"] as const) window.removeEventListener(t, onPageSettled, true);
466
+ document.removeEventListener("visibilitychange", onPageActed);
467
+ if (!on) return;
468
+ if (typeof MutationObserver !== "undefined") {
469
+ watcher = new MutationObserver((records) => {
470
+ const theirs = records.some((r) => {
471
+ const t = r.target as Element;
472
+ if (t === document.documentElement && r.type === "attributes") return false;
473
+ return !(t.nodeType === 1 && /^LW-/.test(t.tagName)) && t !== root;
474
+ });
475
+ if (theirs) queueRescan();
476
+ });
477
+ watcher.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style", "hidden", "open", "aria-expanded"] });
478
+ }
479
+ for (const t of ACTED) window.addEventListener(t, onPageActed, { capture: true, passive: true });
480
+ // A toolbar's own opening ends after the press; a sticky bar sticks as the page scrolls.
481
+ window.addEventListener("scroll", onPageMoved, { capture: true, passive: true });
482
+ for (const t of ["transitionend", "animationend"] as const) window.addEventListener(t, onPageSettled, { capture: true, passive: true });
483
+ document.addEventListener("visibilitychange", onPageActed);
484
+ // A single-page app moves by history.pushState, which says nothing: the path is looked at once a second.
485
+ lastPath = pagePath();
486
+ pathTimer = setInterval(() => {
487
+ if (pagePath() === lastPath) return;
488
+ lastPath = pagePath();
489
+ layout();
490
+ queueRescan("now");
491
+ }, 1000);
492
+ }
493
+
342
494
  /** Everything placed from `spot` and `panelAt`, inside the window as it is now — and the call's card clear of the bubble. */
343
495
  function layout(): void {
344
496
  if (!bubble || !panel) return;
345
497
  const v = view();
346
- const y = clampBubbleY(spot.y, v.h);
347
- const other = spot.side === "right" ? "left" : "right";
348
- Object.assign(bubble.style, { [spot.side]: `${spot.x}px`, [other]: "", bottom: `${y}px`, top: "" });
498
+ const want = wanted();
499
+ const wantY = clampBubbleY(want.y, v.h);
500
+ // While the panel is open it stays where it was opened from — never moved under the person's eyes.
501
+ const wish = `${pagePath()}|${want.side}|${want.x}|${wantY}`;
502
+ const prev = shown && shownFor === wish ? shown : null;
503
+ // A side not read is free here: it was left unread because the wish's own place was free, and that place wins.
504
+ const read = blocked && avoidSetting().on ? { right: blocked.right ?? [], left: blocked.left ?? [] } : null;
505
+ const free = !isOpen || !shown ? (read ? settleSpot(prev, { side: want.side, y: wantY }, read, MARGIN, Math.max(MARGIN, v.h - SIZE - MARGIN)) : { side: want.side, y: wantY }) : shown;
506
+ shown = { side: free.side, x: want.x, y: free.y };
507
+ shownFor = wish;
508
+ const at = shown;
509
+ const y = at.y;
510
+ const other = at.side === "right" ? "left" : "right";
511
+ bubble.style.display = ruleHidden && !isOpen ? "none" : "";
512
+ Object.assign(bubble.style, { [at.side]: `${at.x}px`, [other]: "", bottom: `${y}px`, top: "" });
349
513
  if (panelAt) {
350
514
  // The height it had when it was picked up — a drag moves it, never resizes it under the hand.
351
515
  const box = { w: Math.min(PANEL_W, v.w - 32), h: Math.min(panelAt.h, v.h - 2 * MARGIN) };
352
516
  const p = clampPanel(panelAt, box, v);
353
517
  Object.assign(panel.style, { left: `${p.x}px`, top: `${p.y}px`, right: "", bottom: "", height: `${box.h}px` });
354
518
  } else {
355
- const a = anchorPanel({ ...spot, y }, v.h);
356
- Object.assign(panel.style, { [spot.side]: `${spot.x}px`, [other]: "", bottom: a.bottom !== undefined ? `${a.bottom}px` : "", top: a.top !== undefined ? `${a.top}px` : "", height: `${a.height}px` });
519
+ const a = anchorPanel(at, v.h);
520
+ Object.assign(panel.style, { [at.side]: `${at.x}px`, [other]: "", bottom: a.bottom !== undefined ? `${a.bottom}px` : "", top: a.top !== undefined ? `${a.top}px` : "", height: `${a.height}px` });
357
521
  }
358
522
  // The call's card and frame sit above the bubble while it is there and low on their side (ring.ts, callFrame.ts).
359
- const lift = root && root.style.display !== "none" ? liftFor({ ...spot, y }) : 0;
360
- if (lift) document.documentElement.style.setProperty("--lw-chat-lift", `${lift}px`);
361
- else document.documentElement.style.removeProperty("--lw-chat-lift");
523
+ const lift = root && root.style.display !== "none" && bubble.style.display !== "none" ? liftFor(at) : 0;
524
+ // Written only when it differs from what <html> carries (read back, so a host that rewrites its style attribute
525
+ // gets it again): a property there restyles the host's whole page, and layout() runs on every read of the page.
526
+ const liftNow = lift ? `${lift}px` : "";
527
+ if (document.documentElement.style.getPropertyValue("--lw-chat-lift") !== liftNow) {
528
+ if (liftNow) document.documentElement.style.setProperty("--lw-chat-lift", liftNow);
529
+ else document.documentElement.style.removeProperty("--lw-chat-lift");
530
+ }
362
531
  setLabel();
363
532
  }
364
533
 
@@ -398,7 +567,9 @@ function onBubbleUp(e: PointerEvent): void {
398
567
  panelAt = null;
399
568
  savePlace();
400
569
  }
401
- layout();
570
+ // Let go on a toolbar: to the nearest free place, read off the page as it is now.
571
+ if (avoidSetting().on) rescan();
572
+ else layout();
402
573
  }
403
574
 
404
575
  /* ── The panel, dragged by its head — the press is in the frame (as the call's, ui/callFrame) ─── */
@@ -443,7 +614,10 @@ function followHere(on: boolean): void {
443
614
  };
444
615
  for (const t of ["pointermove", "pointerup", "pointercancel"] as const) window.addEventListener(t, hostFollow, true);
445
616
  }
446
- const onResize = () => layout();
617
+ const onResize = () => {
618
+ layout();
619
+ queueRescan("now");
620
+ };
447
621
 
448
622
  function post(message: Record<string, unknown>): void {
449
623
  frame?.contentWindow?.postMessage({ source: HOST, ...message }, hostOrigin());
@@ -519,6 +693,9 @@ export function closeSupportChat(refocus = true): void {
519
693
  post({ type: "hidden" });
520
694
  if (refocus) bubble?.focus();
521
695
  void refreshUnread();
696
+ // Closed: free to step aside again from whatever the page did meanwhile.
697
+ layout();
698
+ queueRescan("now");
522
699
  }
523
700
 
524
701
  let stopFrames: (() => void) | null = null;
@@ -571,6 +748,7 @@ export function mountSupportChat(): void {
571
748
  document.addEventListener("keydown", onKey);
572
749
  window.addEventListener("focus", onFocus);
573
750
  window.addEventListener("resize", onResize);
751
+ watchPage(true);
574
752
  // The desk wrote, or opened a เรื่อง, or read: the badge asks again — while the panel is open, the panel tells it.
575
753
  let settle: ReturnType<typeof setTimeout> | undefined;
576
754
  stopFrames = onLiveFrame(() => {
@@ -595,6 +773,14 @@ export function unmountSupportChat(): void {
595
773
  document.removeEventListener("keydown", onKey);
596
774
  window.removeEventListener("focus", onFocus);
597
775
  window.removeEventListener("resize", onResize);
776
+ watchPage(false);
777
+ clearTimeout(rescanTimer);
778
+ rescanTimer = undefined;
779
+ calm = 0;
780
+ shown = null;
781
+ shownFor = "";
782
+ blocked = null;
783
+ ruleHidden = false;
598
784
  followHere(false);
599
785
  root?.remove();
600
786
  root = shadow = panel = frame = bubble = badge = label = null;