@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.
- package/README.md +12 -2
- package/dist/config.d.ts +11 -2
- package/dist/debug-capture.js +16 -16
- package/dist/index.mjs +23 -23
- package/dist/ui/bubblePlace.d.ts +108 -0
- package/dist/ui/chatBubble.d.ts +5 -0
- package/package.json +1 -1
- package/src/config.ts +11 -2
- package/src/ui/bubblePlace.ts +277 -0
- package/src/ui/chatBubble.ts +197 -11
package/src/ui/chatBubble.ts
CHANGED
|
@@ -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
|
|
347
|
-
const
|
|
348
|
-
|
|
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(
|
|
356
|
-
Object.assign(panel.style, { [
|
|
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(
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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 = () =>
|
|
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;
|