@lightworkai.official/debug-capture 0.19.6 → 0.19.8
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 +3 -2
- package/dist/debug-capture.js +16 -16
- package/dist/index.mjs +23 -23
- package/dist/ui/bubblePlace.d.ts +31 -27
- package/package.json +1 -1
- package/src/ui/bubblePlace.ts +91 -31
- package/src/ui/chatBubble.ts +79 -43
package/dist/ui/bubblePlace.d.ts
CHANGED
|
@@ -14,11 +14,14 @@
|
|
|
14
14
|
* Content that scrolls under is not: a floating button over content is what a floating button is. A host may name
|
|
15
15
|
* more (`chat.avoid: "<selector>"`) or turn it off (`false`).
|
|
16
16
|
*
|
|
17
|
-
* THE NEAREST FREE PLACE.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
17
|
+
* THE NEAREST FREE PLACE (`placeSpot`). What a place costs is the climb plus the move in from its edge, and
|
|
18
|
+
* SWITCH_COST more on the other side. Up or down its own column; or a column further in, just past what stands in
|
|
19
|
+
* the way; or the other corner. A FAB lifts it above. A toolbar column as tall as the window puts it just inside the
|
|
20
|
+
* toolbar, at the same height. 0.19.7 only knew the two corner columns, so on a short window, with the ERP's tray
|
|
21
|
+
* filling the right edge and its sidebar the left, it found no free place and stayed on the tray (the owner's
|
|
22
|
+
* screenshot, 11-02). When a nearer place frees up it goes back: the tray shut, it is home again. A shift of less than
|
|
23
|
+
* STAY_MARGIN does not move it, so it does not jitter as the page settles.
|
|
24
|
+
|
|
22
25
|
* A PAGE'S OWN PLACE. The desk's rules (ปุ่มแชท › ตำแหน่งปุ่ม): pages whose path starts so — hidden there, or
|
|
23
26
|
* started in a corner at a height. The most specific rule wins; a person who moved it keeps their place.
|
|
24
27
|
*
|
|
@@ -35,6 +38,8 @@ export type Side = "right" | "left";
|
|
|
35
38
|
export declare const CLEAR = 8;
|
|
36
39
|
/** What moving to the other side costs, in px of climbing — a short lift stays on its side, a long one crosses. */
|
|
37
40
|
export declare const SWITCH_COST = 160;
|
|
41
|
+
/** A free place this much nearer (in the same px of cost) is needed to move it from one it already stands in. */
|
|
42
|
+
export declare const STAY_MARGIN = 48;
|
|
38
43
|
/** An obstacle is smaller than this share of the window — a box as big as the page is the page. */
|
|
39
44
|
export declare const OBSTACLE_MAX_AREA = 0.34;
|
|
40
45
|
/**
|
|
@@ -47,31 +52,28 @@ export declare function blockedRanges(obstacles: readonly Rect[], col: {
|
|
|
47
52
|
}, viewH: number, size: number): [number, number][];
|
|
48
53
|
/** The free height nearest `want` within [min, max], outside every range (open ends) — null when none. Up wins a tie. Pure. */
|
|
49
54
|
export declare function nearestFreeY(want: number, ranges: readonly [number, number][], min: number, max: number): number | null;
|
|
50
|
-
|
|
51
|
-
* Where the bubble stands: `want` on its side when free, else the nearest free height there — or the other side's when
|
|
52
|
-
* that is cheaper (SWITCH_COST plus its own move). Nothing free anywhere: `want`, as it was. Pure.
|
|
53
|
-
*/
|
|
54
|
-
export declare function chooseSpot(want: {
|
|
55
|
-
side: Side;
|
|
56
|
-
y: number;
|
|
57
|
-
}, free: Record<Side, readonly [number, number][]>, min: number, max: number): {
|
|
55
|
+
export interface Spot {
|
|
58
56
|
side: Side;
|
|
57
|
+
/** From its side's edge, px. */
|
|
58
|
+
x: number;
|
|
59
|
+
/** Up from the bottom, px. */
|
|
59
60
|
y: number;
|
|
60
|
-
}
|
|
61
|
+
}
|
|
62
|
+
/** The page's boxes across the bubble's column at `x` from `side`'s edge — read off the page, or given in the tests. */
|
|
63
|
+
export type ReadColumn = (side: Side, x: number) => readonly Rect[];
|
|
64
|
+
/** How many columns further in it looks on a side, past what stands in the way. */
|
|
65
|
+
export declare const MAX_SHIFTS = 3;
|
|
61
66
|
/**
|
|
62
|
-
* Where
|
|
63
|
-
*
|
|
67
|
+
* Where the bubble stands: `want` when it is free there; else the cheapest free place — up or down its column, a column
|
|
68
|
+
* further in just past what stands in the way (a toolbar column as tall as the window: just left of it, at the same
|
|
69
|
+
* height), or the other side's (SWITCH_COST more). What a place costs is the climb plus the move in. A place it already
|
|
70
|
+
* stands in (`prev`), still free and within STAY_MARGIN of the best, is kept — no jitter as the page settles. Columns
|
|
71
|
+
* are read only as needed (`read`), and never past the middle of the window. Nothing free anywhere: `want`. Pure.
|
|
64
72
|
*/
|
|
65
|
-
export declare function
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
side: Side;
|
|
70
|
-
y: number;
|
|
71
|
-
}, free: Record<Side, readonly [number, number][]>, min: number, max: number): {
|
|
72
|
-
side: Side;
|
|
73
|
-
y: number;
|
|
74
|
-
};
|
|
73
|
+
export declare function placeSpot(want: Spot, read: ReadColumn, view: {
|
|
74
|
+
w: number;
|
|
75
|
+
h: number;
|
|
76
|
+
}, size: number, min: number, max: number, prev?: Spot | null): Spot;
|
|
75
77
|
export interface PlaceRule {
|
|
76
78
|
/** "/saraban/edoc-management" — the page and every page under it; `*` one segment, `**` any. */
|
|
77
79
|
path: string;
|
|
@@ -89,7 +91,9 @@ export declare function placeFor(pathname: string, rules: readonly PlaceRule[] |
|
|
|
89
91
|
* - a host's selector (`extra`): what it names, whole, wherever it is;
|
|
90
92
|
* - in a small floating box (its nearest `fixed`, or `sticky` stuck at its edge): what shows there, by its own box —
|
|
91
93
|
* the control it is part of, else itself;
|
|
92
|
-
* - in a box as big as the window, or on a page that does not scroll: a control not inside a scroller
|
|
94
|
+
* - in a box as big as the window, or on a page that does not scroll: a control not inside a scroller — a disabled one
|
|
95
|
+
* too, found by its own selector, as no hit test reaches it (the ERP's ผลการจำแนก panel on a phone, 11-02: its
|
|
96
|
+
* greyed มอบหมายผู้ดำเนินการ was under the bubble);
|
|
93
97
|
* - anything else scrolls with the page, and is not.
|
|
94
98
|
*/
|
|
95
99
|
export declare function scanColumns(cols: readonly {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lightworkai.official/debug-capture",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.8",
|
|
4
4
|
"description": "Report-a-problem widget: rolling network/console/action capture, optional rrweb session replay, annotated screenshot, and submission to a Lightwork Support host",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "./dist/index.mjs",
|
package/src/ui/bubblePlace.ts
CHANGED
|
@@ -14,11 +14,14 @@
|
|
|
14
14
|
* Content that scrolls under is not: a floating button over content is what a floating button is. A host may name
|
|
15
15
|
* more (`chat.avoid: "<selector>"`) or turn it off (`false`).
|
|
16
16
|
*
|
|
17
|
-
* THE NEAREST FREE PLACE.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
17
|
+
* THE NEAREST FREE PLACE (`placeSpot`). What a place costs is the climb plus the move in from its edge, and
|
|
18
|
+
* SWITCH_COST more on the other side. Up or down its own column; or a column further in, just past what stands in
|
|
19
|
+
* the way; or the other corner. A FAB lifts it above. A toolbar column as tall as the window puts it just inside the
|
|
20
|
+
* toolbar, at the same height. 0.19.7 only knew the two corner columns, so on a short window, with the ERP's tray
|
|
21
|
+
* filling the right edge and its sidebar the left, it found no free place and stayed on the tray (the owner's
|
|
22
|
+
* screenshot, 11-02). When a nearer place frees up it goes back: the tray shut, it is home again. A shift of less than
|
|
23
|
+
* STAY_MARGIN does not move it, so it does not jitter as the page settles.
|
|
24
|
+
|
|
22
25
|
* A PAGE'S OWN PLACE. The desk's rules (ปุ่มแชท › ตำแหน่งปุ่ม): pages whose path starts so — hidden there, or
|
|
23
26
|
* started in a corner at a height. The most specific rule wins; a person who moved it keeps their place.
|
|
24
27
|
*
|
|
@@ -37,6 +40,8 @@ export type Side = "right" | "left";
|
|
|
37
40
|
export const CLEAR = 8;
|
|
38
41
|
/** What moving to the other side costs, in px of climbing — a short lift stays on its side, a long one crosses. */
|
|
39
42
|
export const SWITCH_COST = 160;
|
|
43
|
+
/** A free place this much nearer (in the same px of cost) is needed to move it from one it already stands in. */
|
|
44
|
+
export const STAY_MARGIN = 48;
|
|
40
45
|
/** An obstacle is smaller than this share of the window — a box as big as the page is the page. */
|
|
41
46
|
export const OBSTACLE_MAX_AREA = 0.34;
|
|
42
47
|
|
|
@@ -72,33 +77,58 @@ export function nearestFreeY(want: number, ranges: readonly [number, number][],
|
|
|
72
77
|
});
|
|
73
78
|
}
|
|
74
79
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const here = nearestFreeY(want.y, free[want.side], min, max);
|
|
82
|
-
const there = nearestFreeY(want.y, free[other], min, max);
|
|
83
|
-
if (here === null && there === null) return want;
|
|
84
|
-
const costHere = here === null ? Infinity : Math.abs(here - want.y);
|
|
85
|
-
const costThere = there === null ? Infinity : SWITCH_COST + Math.abs(there - want.y);
|
|
86
|
-
return costHere <= costThere ? { side: want.side, y: here! } : { side: other, y: there! };
|
|
80
|
+
export interface Spot {
|
|
81
|
+
side: Side;
|
|
82
|
+
/** From its side's edge, px. */
|
|
83
|
+
x: number;
|
|
84
|
+
/** Up from the bottom, px. */
|
|
85
|
+
y: number;
|
|
87
86
|
}
|
|
87
|
+
/** The page's boxes across the bubble's column at `x` from `side`'s edge — read off the page, or given in the tests. */
|
|
88
|
+
export type ReadColumn = (side: Side, x: number) => readonly Rect[];
|
|
89
|
+
/** How many columns further in it looks on a side, past what stands in the way. */
|
|
90
|
+
export const MAX_SHIFTS = 3;
|
|
88
91
|
|
|
89
92
|
/**
|
|
90
|
-
* Where
|
|
91
|
-
*
|
|
93
|
+
* Where the bubble stands: `want` when it is free there; else the cheapest free place — up or down its column, a column
|
|
94
|
+
* further in just past what stands in the way (a toolbar column as tall as the window: just left of it, at the same
|
|
95
|
+
* height), or the other side's (SWITCH_COST more). What a place costs is the climb plus the move in. A place it already
|
|
96
|
+
* stands in (`prev`), still free and within STAY_MARGIN of the best, is kept — no jitter as the page settles. Columns
|
|
97
|
+
* are read only as needed (`read`), and never past the middle of the window. Nothing free anywhere: `want`. Pure.
|
|
92
98
|
*/
|
|
93
|
-
export function
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
export function placeSpot(want: Spot, read: ReadColumn, view: { w: number; h: number }, size: number, min: number, max: number, prev: Spot | null = null): Spot {
|
|
100
|
+
const boxes = new Map<string, readonly Rect[]>();
|
|
101
|
+
const at = (side: Side, x: number) => {
|
|
102
|
+
const k = `${side}:${x}`;
|
|
103
|
+
if (!boxes.has(k)) boxes.set(k, read(side, x));
|
|
104
|
+
return boxes.get(k)!;
|
|
105
|
+
};
|
|
106
|
+
const col = (side: Side, x: number) => (side === "right" ? { left: view.w - x - size, right: view.w - x } : { left: x, right: x + size });
|
|
107
|
+
const ranges = (side: Side, x: number) => blockedRanges(at(side, x), col(side, x), view.h, size);
|
|
108
|
+
const cost = (s: Spot) => Math.abs(s.y - want.y) + Math.abs(s.x - want.x) + (s.side === want.side ? 0 : SWITCH_COST);
|
|
109
|
+
const freeAt = (s: Spot) => nearestFreeY(s.y, ranges(s.side, s.x), min, max) === s.y;
|
|
110
|
+
const keep = (best: Spot) => (prev && (prev.side !== best.side || prev.x !== best.x || prev.y !== best.y) && cost(prev) <= cost(best) + STAY_MARGIN && freeAt(prev) ? prev : best);
|
|
111
|
+
if (freeAt(want)) return keep(want);
|
|
112
|
+
let best: Spot | null = null;
|
|
113
|
+
const other: Side = want.side === "right" ? "left" : "right";
|
|
114
|
+
for (const side of [want.side, other]) {
|
|
115
|
+
const away = side === want.side ? 0 : SWITCH_COST;
|
|
116
|
+
if (best && away >= cost(best)) break;
|
|
117
|
+
let x = want.x;
|
|
118
|
+
for (let i = 0; i <= MAX_SHIFTS; i++) {
|
|
119
|
+
const y = nearestFreeY(want.y, ranges(side, x), min, max);
|
|
120
|
+
if (y !== null && (!best || cost({ side, x, y }) < cost(best))) best = { side, x, y };
|
|
121
|
+
// What stands where it wants to be, at this column: the next column in is just past it.
|
|
122
|
+
const c = col(side, x);
|
|
123
|
+
const band = { top: view.h - want.y - size - CLEAR, bottom: view.h - want.y + CLEAR };
|
|
124
|
+
const inWay = at(side, x).filter((r) => r.top < band.bottom && r.bottom > band.top && r.left < c.right && r.right > c.left);
|
|
125
|
+
if (!inWay.length) break;
|
|
126
|
+
const next = Math.round(side === "right" ? Math.max(...inWay.map((r) => view.w - r.left + CLEAR)) : Math.max(...inWay.map((r) => r.right + CLEAR)));
|
|
127
|
+
if (next <= x || next + size > view.w / 2 || (best && away + next - want.x >= cost(best))) break;
|
|
128
|
+
x = next;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return best ? keep(best) : want;
|
|
102
132
|
}
|
|
103
133
|
|
|
104
134
|
/* ── A page's own place: the desk's rules ───────────────────────────────── */
|
|
@@ -145,6 +175,8 @@ const OURS = /^LW-/;
|
|
|
145
175
|
const CONTROL =
|
|
146
176
|
'a[href],button,input,select,textarea,summary,label,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[role="checkbox"],[role="switch"],[role="radio"],[role="option"],[role="slider"],[role="combobox"],[role="textbox"],[contenteditable=""],[contenteditable="true"],[tabindex]:not([tabindex="-1"])';
|
|
147
177
|
const SCROLLS = /(auto|scroll|overlay)/;
|
|
178
|
+
/** A control that takes no press now — no hit test finds it (`disabled:pointer-events-none`), yet it shows, and takes one soon. */
|
|
179
|
+
const DISABLED = 'button:disabled,input:disabled,select:disabled,textarea:disabled,[aria-disabled="true"]';
|
|
148
180
|
|
|
149
181
|
/**
|
|
150
182
|
* The page's pieces the bubble must not cover, for each column — sampled every `step` px down it at three points
|
|
@@ -152,12 +184,15 @@ const SCROLLS = /(auto|scroll|overlay)/;
|
|
|
152
184
|
* - a host's selector (`extra`): what it names, whole, wherever it is;
|
|
153
185
|
* - in a small floating box (its nearest `fixed`, or `sticky` stuck at its edge): what shows there, by its own box —
|
|
154
186
|
* the control it is part of, else itself;
|
|
155
|
-
* - in a box as big as the window, or on a page that does not scroll: a control not inside a scroller
|
|
187
|
+
* - in a box as big as the window, or on a page that does not scroll: a control not inside a scroller — a disabled one
|
|
188
|
+
* too, found by its own selector, as no hit test reaches it (the ERP's ผลการจำแนก panel on a phone, 11-02: its
|
|
189
|
+
* greyed มอบหมายผู้ดำเนินการ was under the bubble);
|
|
156
190
|
* - anything else scrolls with the page, and is not.
|
|
157
191
|
*/
|
|
158
192
|
export function scanColumns(cols: readonly { left: number; right: number }[], view: { w: number; h: number }, opts: { step?: number; extra?: string | null } = {}): Rect[][] {
|
|
159
193
|
if (typeof document === "undefined" || typeof document.elementsFromPoint !== "function") return cols.map(() => []);
|
|
160
|
-
|
|
194
|
+
// 16 px: anything a hand presses is taller. Each point is a hit test, ~30 µs on the ERP's เอกสาร page.
|
|
195
|
+
const step = opts.step ?? 16;
|
|
161
196
|
const html = document.documentElement;
|
|
162
197
|
const body = document.body;
|
|
163
198
|
const styles = new Map<Element, CSSStyleDeclaration>();
|
|
@@ -205,6 +240,16 @@ export function scanColumns(cols: readonly { left: number; right: number }[], vi
|
|
|
205
240
|
return { float, scrolled, opacity };
|
|
206
241
|
};
|
|
207
242
|
const pins = new Map<Element, ReturnType<typeof pinOf>>();
|
|
243
|
+
/*
|
|
244
|
+
* The page's topmost piece at a point. elementsFromPoint hit-tests every layer down to the root — 450 of them took
|
|
245
|
+
* 30–37 ms on the ERP's เอกสาร page, against 14 ms for elementFromPoint, which stops at the first; the whole stack
|
|
246
|
+
* is asked for only where this library's own box is the first.
|
|
247
|
+
*/
|
|
248
|
+
const topAt = (x: number, y: number): Element | null => {
|
|
249
|
+
const first = document.elementFromPoint(x, y);
|
|
250
|
+
if (!first || !OURS.test(first.tagName)) return first;
|
|
251
|
+
return document.elementsFromPoint(x, y).find((e) => !OURS.test(e.tagName)) ?? null;
|
|
252
|
+
};
|
|
208
253
|
return cols.map((col) => {
|
|
209
254
|
const seen = new Set<Element>();
|
|
210
255
|
const kept = new Set<Element>();
|
|
@@ -221,10 +266,12 @@ export function scanColumns(cols: readonly { left: number; right: number }[], vi
|
|
|
221
266
|
right: r.right,
|
|
222
267
|
});
|
|
223
268
|
};
|
|
269
|
+
/** The frames as big as the window met down this column (null: a page that does not scroll) — their disabled controls are looked for below. */
|
|
270
|
+
const frames = new Set<Element | null>();
|
|
224
271
|
const xs = [col.left + 6, (col.left + col.right) / 2, col.right - 6];
|
|
225
272
|
for (let y = step / 2; y < view.h; y += step) {
|
|
226
273
|
for (const x of xs) {
|
|
227
|
-
const top =
|
|
274
|
+
const top = topAt(x, y);
|
|
228
275
|
if (!top || top === html || top === body || seen.has(top)) continue;
|
|
229
276
|
seen.add(top);
|
|
230
277
|
if (opts.extra) {
|
|
@@ -249,10 +296,23 @@ export function scanColumns(cols: readonly { left: number; right: number }[], vi
|
|
|
249
296
|
continue;
|
|
250
297
|
}
|
|
251
298
|
// The page itself — a scrolling page, or what is in a frame as big as the window: only a control that stays put.
|
|
299
|
+
if (!scrolled && (float || !pageScrolls)) frames.add(float);
|
|
252
300
|
if (scrolled || !own || big(own.getBoundingClientRect())) continue;
|
|
253
301
|
keep(own);
|
|
254
302
|
}
|
|
255
303
|
}
|
|
304
|
+
for (const frame of frames) {
|
|
305
|
+
for (const el of (frame ?? body)?.querySelectorAll(DISABLED) ?? []) {
|
|
306
|
+
const r = el.getBoundingClientRect();
|
|
307
|
+
if (!r.width || !r.height || r.right <= col.left || r.left >= col.right || big(r) || style(el).visibility === "hidden") continue;
|
|
308
|
+
const pin = pins.get(el) ?? (pins.set(el, pinOf(el)), pins.get(el)!);
|
|
309
|
+
if (pin.float !== frame || pin.scrolled || pin.opacity < 0.05) continue;
|
|
310
|
+
// Not under another layer: what is on top at its middle (in this column) is it, or the frame's piece around it.
|
|
311
|
+
const on = topAt(Math.min(Math.max((r.left + r.right) / 2, col.left + 6), col.right - 6), (r.top + r.bottom) / 2);
|
|
312
|
+
if (on && on !== el && !el.contains(on) && !on.contains(el)) continue;
|
|
313
|
+
keep(el);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
256
316
|
return out;
|
|
257
317
|
});
|
|
258
318
|
}
|
package/src/ui/chatBubble.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { chatPlace, getConfig } from "../config";
|
|
|
18
18
|
import { hostToken, onLiveFrame } from "../live";
|
|
19
19
|
import { openSupportPortal } from "../portal";
|
|
20
20
|
import { launch } from "../reporter";
|
|
21
|
-
import { blockedRanges, placeFor,
|
|
21
|
+
import { blockedRanges, placeFor, placeSpot, scanColumns, type PlaceRule, type Rect, type Side } from "./bubblePlace";
|
|
22
22
|
import { followHostFont, OWN_FONTS } from "./hostFont";
|
|
23
23
|
import { strings } from "./strings";
|
|
24
24
|
|
|
@@ -254,7 +254,7 @@ function wearLook(look: DeskLook | null): void {
|
|
|
254
254
|
const face = shadow?.querySelector<HTMLImageElement>(".face");
|
|
255
255
|
const url = lookFaceUrl(getConfig().host, look);
|
|
256
256
|
// Its rules and its switch may have changed where it stands.
|
|
257
|
-
queueRescan(
|
|
257
|
+
queueRescan("now");
|
|
258
258
|
if (face) {
|
|
259
259
|
if (!url) {
|
|
260
260
|
bubble.classList.remove("has-face");
|
|
@@ -311,7 +311,7 @@ function show(on: boolean): void {
|
|
|
311
311
|
// Its words in the host page's own font — read again here, after the host has drawn itself.
|
|
312
312
|
if (on) followHostFont(root);
|
|
313
313
|
layout();
|
|
314
|
-
if (on) queueRescan(
|
|
314
|
+
if (on) queueRescan("now");
|
|
315
315
|
}
|
|
316
316
|
|
|
317
317
|
/* ── Where it is: the config's place, or where the person put it ───────── */
|
|
@@ -354,10 +354,27 @@ function savePlace(): void {
|
|
|
354
354
|
const pagePath = () => window.location?.pathname ?? "/";
|
|
355
355
|
/** Where it stands now — `spot` (or the page's rule), moved clear of the page's floating controls. */
|
|
356
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 (
|
|
357
|
+
/** What `shown` was chosen for — the page and the wish; while they hold, a free `shown` is kept (placeSpot's prev). */
|
|
358
358
|
let shownFor = "";
|
|
359
|
-
/**
|
|
360
|
-
|
|
359
|
+
/**
|
|
360
|
+
* The page's boxes across each column read since the last look, for the window size they were read at — null: not
|
|
361
|
+
* looked at yet, or not stepping aside. `placeSpot` asks for columns as it needs them; one not read is read then.
|
|
362
|
+
*/
|
|
363
|
+
let columns: { w: number; h: number; boxes: Map<string, Rect[]> } | null = null;
|
|
364
|
+
/** What the last placing saw at home and chose — a look that changes neither is "nothing new" (queueRescan). */
|
|
365
|
+
let placedSig = "";
|
|
366
|
+
function readColumn(side: Side, x: number): Rect[] {
|
|
367
|
+
const v = view();
|
|
368
|
+
if (!columns || columns.w !== v.w || columns.h !== v.h) columns = { w: v.w, h: v.h, boxes: new Map() };
|
|
369
|
+
const k = `${side}:${x}`;
|
|
370
|
+
let boxes = columns.boxes.get(k);
|
|
371
|
+
if (!boxes) {
|
|
372
|
+
const col = side === "right" ? { left: v.w - x - SIZE, right: v.w - x } : { left: x, right: x + SIZE };
|
|
373
|
+
boxes = scanColumns([col], v, { extra: avoidSetting().extra })[0] ?? [];
|
|
374
|
+
columns.boxes.set(k, boxes);
|
|
375
|
+
}
|
|
376
|
+
return boxes;
|
|
377
|
+
}
|
|
361
378
|
/** Hidden on this page by the desk's rule (ซ่อนปุ่มแชทในหน้านี้). */
|
|
362
379
|
let ruleHidden = false;
|
|
363
380
|
|
|
@@ -374,32 +391,29 @@ function wanted(): BubbleSpot {
|
|
|
374
391
|
if (movedByHand || !rule || rule.show === "hide") return spot;
|
|
375
392
|
return { side: rule.show, x: spot.x, y: rule.y };
|
|
376
393
|
}
|
|
377
|
-
/**
|
|
394
|
+
/** Look at the page afresh — its columns read again as placing asks for them — and place again. */
|
|
378
395
|
function rescan(): void {
|
|
379
396
|
if (!bubble) return;
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
blocked = null;
|
|
397
|
+
if (!avoidSetting().on) {
|
|
398
|
+
columns = null;
|
|
383
399
|
return layout();
|
|
384
400
|
}
|
|
401
|
+
const before = placedSig;
|
|
385
402
|
const v = view();
|
|
386
|
-
|
|
387
|
-
const colR = { left: v.w - x - SIZE, right: v.w - x };
|
|
388
|
-
const colL = { left: x, right: x + SIZE };
|
|
389
|
-
const [foundR = [], foundL = []] = scanColumns([colR, colL], v, { extra });
|
|
390
|
-
const right = blockedRanges(foundR, colR, v.h, SIZE);
|
|
391
|
-
const left = blockedRanges(foundL, colL, v.h, SIZE);
|
|
392
|
-
// Nothing new where it could stand: the page is looked at less often while that holds (queueRescan).
|
|
393
|
-
calm = JSON.stringify([right, left]) === JSON.stringify(blocked && [blocked.right, blocked.left]) ? Math.min(calm + 1, CALM_STEPS) : 0;
|
|
394
|
-
blocked = { right, left };
|
|
403
|
+
columns = { w: v.w, h: v.h, boxes: new Map() };
|
|
395
404
|
layout();
|
|
405
|
+
// Nothing new where it could stand: the page is looked at less often while that holds (queueRescan).
|
|
406
|
+
calm = placedSig === before ? Math.min(calm + 1, CALM_STEPS) : 0;
|
|
396
407
|
}
|
|
397
408
|
/*
|
|
398
|
-
* How often the page is read (a read is two
|
|
409
|
+
* How often the page is read (a read is a column or two of hit tests). A page that keeps changing — a clock, a
|
|
399
410
|
* live list — said "look again" ten times a second, and a read every 400 ms cost it half a second of script in ten
|
|
400
411
|
* (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
|
|
401
412
|
* 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),
|
|
402
|
-
* and resets it
|
|
413
|
+
* and resets it; a transition or animation that ends in a corner's column — something there just finished appearing
|
|
414
|
+
* — 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
|
|
415
|
+
* clear, and the next waited out the doubling). Never while the panel is open (it does not move then), while it is
|
|
416
|
+
* dragged, or in a hidden tab.
|
|
403
417
|
*/
|
|
404
418
|
const RESCAN_MS = 400;
|
|
405
419
|
const CALM_STEPS = 3;
|
|
@@ -408,17 +422,14 @@ let rescanTimer: ReturnType<typeof setTimeout> | undefined;
|
|
|
408
422
|
let rescanDue = 0;
|
|
409
423
|
let lastRescan = 0;
|
|
410
424
|
let calm = 0;
|
|
411
|
-
function queueRescan(
|
|
425
|
+
function queueRescan(when: "now" | "soon" | "later" = "later"): void {
|
|
412
426
|
if (!root) return;
|
|
413
427
|
const now = Date.now();
|
|
414
|
-
if (
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
}
|
|
420
|
-
if (rescanTimer) return;
|
|
421
|
-
const wait = urgent ? URGENT_MS : Math.max(0, RESCAN_MS * 2 ** calm - (now - lastRescan));
|
|
428
|
+
if (when !== "later") calm = 0;
|
|
429
|
+
const wait = when === "now" ? URGENT_MS : Math.max(0, RESCAN_MS * 2 ** calm - (now - lastRescan));
|
|
430
|
+
// One read waiting at a time — moved sooner when this asks sooner.
|
|
431
|
+
if (rescanTimer && rescanDue <= now + wait) return;
|
|
432
|
+
clearTimeout(rescanTimer);
|
|
422
433
|
rescanDue = now + wait;
|
|
423
434
|
rescanTimer = setTimeout(() => {
|
|
424
435
|
rescanTimer = undefined;
|
|
@@ -431,16 +442,31 @@ function queueRescan(urgent = false): void {
|
|
|
431
442
|
let watcher: MutationObserver | null = null;
|
|
432
443
|
let lastPath = "";
|
|
433
444
|
let pathTimer: ReturnType<typeof setInterval> | undefined;
|
|
434
|
-
const
|
|
445
|
+
const ours = (t: EventTarget | null) => t instanceof Element && (t === root || /^LW-/.test(t.tagName));
|
|
446
|
+
const onPageMoved = (e: Event) => {
|
|
447
|
+
if (!ours(e.target)) queueRescan();
|
|
448
|
+
};
|
|
449
|
+
/** A transition or animation ended: soon when it was in a corner's column, where the bubble may stand. */
|
|
450
|
+
const onPageSettled = (e: Event) => {
|
|
451
|
+
if (ours(e.target)) return;
|
|
452
|
+
const r = e.target instanceof Element ? e.target.getBoundingClientRect() : null;
|
|
453
|
+
const v = view();
|
|
454
|
+
const x = spot.x;
|
|
455
|
+
const inColumn = !!r && r.width > 0 && (r.left < x + SIZE || r.right > v.w - x - SIZE);
|
|
456
|
+
queueRescan(inColumn ? "soon" : "later");
|
|
457
|
+
};
|
|
435
458
|
/** What a person did, or the window itself changed: looked at at once. */
|
|
436
|
-
const onPageActed = () =>
|
|
459
|
+
const onPageActed = (e: Event) => {
|
|
460
|
+
if (!ours(e.target)) queueRescan("now");
|
|
461
|
+
};
|
|
437
462
|
const ACTED = ["popstate", "hashchange", "pointerup", "keyup"] as const;
|
|
438
463
|
function watchPage(on: boolean): void {
|
|
439
464
|
watcher?.disconnect();
|
|
440
465
|
watcher = null;
|
|
441
466
|
clearInterval(pathTimer);
|
|
442
467
|
for (const t of ACTED) window.removeEventListener(t, onPageActed, true);
|
|
443
|
-
|
|
468
|
+
window.removeEventListener("scroll", onPageMoved, true);
|
|
469
|
+
for (const t of ["transitionend", "animationend"] as const) window.removeEventListener(t, onPageSettled, true);
|
|
444
470
|
document.removeEventListener("visibilitychange", onPageActed);
|
|
445
471
|
if (!on) return;
|
|
446
472
|
if (typeof MutationObserver !== "undefined") {
|
|
@@ -452,11 +478,12 @@ function watchPage(on: boolean): void {
|
|
|
452
478
|
});
|
|
453
479
|
if (theirs) queueRescan();
|
|
454
480
|
});
|
|
455
|
-
watcher.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style", "hidden", "open", "aria-expanded"] });
|
|
481
|
+
watcher.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style", "hidden", "open", "aria-expanded", "disabled", "aria-disabled"] });
|
|
456
482
|
}
|
|
457
483
|
for (const t of ACTED) window.addEventListener(t, onPageActed, { capture: true, passive: true });
|
|
458
484
|
// A toolbar's own opening ends after the press; a sticky bar sticks as the page scrolls.
|
|
459
|
-
|
|
485
|
+
window.addEventListener("scroll", onPageMoved, { capture: true, passive: true });
|
|
486
|
+
for (const t of ["transitionend", "animationend"] as const) window.addEventListener(t, onPageSettled, { capture: true, passive: true });
|
|
460
487
|
document.addEventListener("visibilitychange", onPageActed);
|
|
461
488
|
// A single-page app moves by history.pushState, which says nothing: the path is looked at once a second.
|
|
462
489
|
lastPath = pagePath();
|
|
@@ -464,7 +491,7 @@ function watchPage(on: boolean): void {
|
|
|
464
491
|
if (pagePath() === lastPath) return;
|
|
465
492
|
lastPath = pagePath();
|
|
466
493
|
layout();
|
|
467
|
-
queueRescan(
|
|
494
|
+
queueRescan("now");
|
|
468
495
|
}, 1000);
|
|
469
496
|
}
|
|
470
497
|
|
|
@@ -477,9 +504,12 @@ function layout(): void {
|
|
|
477
504
|
// While the panel is open it stays where it was opened from — never moved under the person's eyes.
|
|
478
505
|
const wish = `${pagePath()}|${want.side}|${want.x}|${wantY}`;
|
|
479
506
|
const prev = shown && shownFor === wish ? shown : null;
|
|
480
|
-
const
|
|
481
|
-
|
|
507
|
+
const home = { side: want.side, x: want.x, y: wantY };
|
|
508
|
+
const placing = !!columns && avoidSetting().on;
|
|
509
|
+
const free = !isOpen || !shown ? (placing ? placeSpot(home, readColumn, v, SIZE, MARGIN, Math.max(MARGIN, v.h - SIZE - MARGIN), prev) : home) : shown;
|
|
510
|
+
shown = { side: free.side, x: free.x, y: free.y };
|
|
482
511
|
shownFor = wish;
|
|
512
|
+
if (placing) placedSig = JSON.stringify([blockedRanges(readColumn(home.side, home.x), home.side === "right" ? { left: v.w - home.x - SIZE, right: v.w - home.x } : { left: home.x, right: home.x + SIZE }, v.h, SIZE), shown]);
|
|
483
513
|
const at = shown;
|
|
484
514
|
const y = at.y;
|
|
485
515
|
const other = at.side === "right" ? "left" : "right";
|
|
@@ -496,8 +526,13 @@ function layout(): void {
|
|
|
496
526
|
}
|
|
497
527
|
// The call's card and frame sit above the bubble while it is there and low on their side (ring.ts, callFrame.ts).
|
|
498
528
|
const lift = root && root.style.display !== "none" && bubble.style.display !== "none" ? liftFor(at) : 0;
|
|
499
|
-
|
|
500
|
-
|
|
529
|
+
// Written only when it differs from what <html> carries (read back, so a host that rewrites its style attribute
|
|
530
|
+
// gets it again): a property there restyles the host's whole page, and layout() runs on every read of the page.
|
|
531
|
+
const liftNow = lift ? `${lift}px` : "";
|
|
532
|
+
if (document.documentElement.style.getPropertyValue("--lw-chat-lift") !== liftNow) {
|
|
533
|
+
if (liftNow) document.documentElement.style.setProperty("--lw-chat-lift", liftNow);
|
|
534
|
+
else document.documentElement.style.removeProperty("--lw-chat-lift");
|
|
535
|
+
}
|
|
501
536
|
setLabel();
|
|
502
537
|
}
|
|
503
538
|
|
|
@@ -586,7 +621,7 @@ function followHere(on: boolean): void {
|
|
|
586
621
|
}
|
|
587
622
|
const onResize = () => {
|
|
588
623
|
layout();
|
|
589
|
-
queueRescan(
|
|
624
|
+
queueRescan("now");
|
|
590
625
|
};
|
|
591
626
|
|
|
592
627
|
function post(message: Record<string, unknown>): void {
|
|
@@ -665,7 +700,7 @@ export function closeSupportChat(refocus = true): void {
|
|
|
665
700
|
void refreshUnread();
|
|
666
701
|
// Closed: free to step aside again from whatever the page did meanwhile.
|
|
667
702
|
layout();
|
|
668
|
-
queueRescan(
|
|
703
|
+
queueRescan("now");
|
|
669
704
|
}
|
|
670
705
|
|
|
671
706
|
let stopFrames: (() => void) | null = null;
|
|
@@ -749,7 +784,8 @@ export function unmountSupportChat(): void {
|
|
|
749
784
|
calm = 0;
|
|
750
785
|
shown = null;
|
|
751
786
|
shownFor = "";
|
|
752
|
-
|
|
787
|
+
columns = null;
|
|
788
|
+
placedSig = "";
|
|
753
789
|
ruleHidden = false;
|
|
754
790
|
followHere(false);
|
|
755
791
|
root?.remove();
|