@lightworkai.official/debug-capture 0.15.0 → 0.17.0
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 +46 -0
- package/dist/config.d.ts +13 -0
- package/dist/debug-capture.js +10 -7
- package/dist/index.d.ts +2 -0
- package/dist/index.mjs +16 -13
- package/dist/ink.d.ts +102 -0
- package/dist/ui/callFrame.d.ts +50 -0
- package/dist/ui/inkOverlay.d.ts +18 -0
- package/dist/ui/ring.d.ts +6 -0
- package/package.json +1 -1
- package/src/config.ts +14 -0
- package/src/index.ts +3 -0
- package/src/ink.ts +386 -0
- package/src/live.ts +71 -7
- package/src/ui/callFrame.ts +272 -0
- package/src/ui/inkOverlay.ts +59 -0
- package/src/ui/ring.ts +10 -0
package/src/live.ts
CHANGED
|
@@ -9,10 +9,14 @@
|
|
|
9
9
|
* for the host's signed token at `/ingest/live-ticket`, told about their own
|
|
10
10
|
* requests and calls and nothing else — and a call rings here as a card.
|
|
11
11
|
*
|
|
12
|
-
* Answering happens
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* Answering happens in THIS app since 0.16 (`callFrame`, default on): the
|
|
13
|
+
* support host's own call — ring card, call corner, screen sharing, voice
|
|
14
|
+
* settings — opens in a frame here (ui/callFrame), signed in by the same
|
|
15
|
+
* token the portal button uses; our card steps aside once the frame is up,
|
|
16
|
+
* and comes back if it never does. Before 0.16, and with `callFrame: false`,
|
|
17
|
+
* the card's button opens the reporter's request on the support host — a tab
|
|
18
|
+
* opened INSIDE the press (a browser allows nothing later), its address set
|
|
19
|
+
* once the token is in hand.
|
|
16
20
|
*
|
|
17
21
|
* Quiet by design. No identity, calls off, a support host older than this
|
|
18
22
|
* package, a token refused: no socket, no card, nothing in the console — the
|
|
@@ -20,7 +24,8 @@
|
|
|
20
24
|
*/
|
|
21
25
|
import { getConfig } from "./config";
|
|
22
26
|
import { preparePortalUrl } from "./portal";
|
|
23
|
-
import { hideRing, ringOpened, ringShownFor, showRing } from "./ui/ring";
|
|
27
|
+
import { hideRing, quietRing, ringOpened, ringShownFor, showRing } from "./ui/ring";
|
|
28
|
+
import { callFrameFor, closeCallFrame, frameUrl, openCallFrame } from "./ui/callFrame";
|
|
24
29
|
|
|
25
30
|
export interface RingingCall {
|
|
26
31
|
id: string;
|
|
@@ -33,6 +38,7 @@ export interface RingingCall {
|
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
const RETRY_MS = [2000, 5000, 15_000, 30_000, 60_000];
|
|
41
|
+
const LIVE_STATES = new Set(["ringing", "active"]);
|
|
36
42
|
const PING_MS = 20_000;
|
|
37
43
|
|
|
38
44
|
let started = false;
|
|
@@ -109,6 +115,7 @@ export function stopLiveRing(): void {
|
|
|
109
115
|
}
|
|
110
116
|
socket = null;
|
|
111
117
|
hideRing();
|
|
118
|
+
closeCallFrame();
|
|
112
119
|
}
|
|
113
120
|
|
|
114
121
|
function schedule(slow = false): void {
|
|
@@ -178,10 +185,67 @@ async function connect(): Promise<void> {
|
|
|
178
185
|
};
|
|
179
186
|
}
|
|
180
187
|
|
|
181
|
-
/** The
|
|
188
|
+
/** The call this tab holds in its frame — taken back after a reload, so the call goes on. */
|
|
189
|
+
const FRAMED_KEY = "lw-call-framed";
|
|
190
|
+
function framedHere(): string | null {
|
|
191
|
+
try {
|
|
192
|
+
return sessionStorage.getItem(FRAMED_KEY);
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function markFramed(id: string | null): void {
|
|
198
|
+
try {
|
|
199
|
+
if (id) sessionStorage.setItem(FRAMED_KEY, id);
|
|
200
|
+
else sessionStorage.removeItem(FRAMED_KEY);
|
|
201
|
+
} catch {
|
|
202
|
+
/* this tab only, when it can */
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let framing: string | null = null;
|
|
207
|
+
/** The call in a frame here (0.16): the support host's own call UI, signed in with the host's token. */
|
|
208
|
+
async function frameFor(call: RingingCall): Promise<void> {
|
|
209
|
+
const config = getConfig();
|
|
210
|
+
if (config.callFrame === false || callFrameFor() === call.id || framing === call.id) return;
|
|
211
|
+
framing = call.id;
|
|
212
|
+
const token = await hostToken();
|
|
213
|
+
framing = null;
|
|
214
|
+
if (!token) return;
|
|
215
|
+
markFramed(call.id);
|
|
216
|
+
openCallFrame(frameUrl(config.host, config.realmKey, token, call.id), call.id, {
|
|
217
|
+
ready: () => quietRing(call.id),
|
|
218
|
+
done: () => {
|
|
219
|
+
markFramed(null);
|
|
220
|
+
hideRing(call.id);
|
|
221
|
+
},
|
|
222
|
+
// Our card carries the call again — still ringing, its button opens the portal to answer.
|
|
223
|
+
failed: () => {
|
|
224
|
+
markFramed(null);
|
|
225
|
+
quietRing(call.id, false);
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The support host's word on a call: ringing here shows the card (and its frame); anything else takes the card away — the frame stays, it IS the call. */
|
|
182
231
|
function hear(call: RingingCall): void {
|
|
183
|
-
if (!ringsHere(call))
|
|
232
|
+
if (!ringsHere(call)) {
|
|
233
|
+
hideRing(call.id);
|
|
234
|
+
// Answered in this tab's frame, then the page reloaded: the frame comes back and takes the call back.
|
|
235
|
+
if (call.status === "active" && framedHere() === call.id && !callFrameFor()) void frameFor(call);
|
|
236
|
+
// Over: the frame says its last word and goes — and goes anyway if it cannot.
|
|
237
|
+
if (!LIVE_STATES.has(call.status) && callFrameFor() === call.id) {
|
|
238
|
+
setTimeout(() => {
|
|
239
|
+
if (callFrameFor() === call.id) {
|
|
240
|
+
closeCallFrame();
|
|
241
|
+
markFramed(null);
|
|
242
|
+
}
|
|
243
|
+
}, 12_000);
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
184
247
|
showRing(call, { open: () => answer(call), decline: () => void decline(call) });
|
|
248
|
+
void frameFor(call);
|
|
185
249
|
// Never longer than it rings: a socket that dropped mid-ring must not leave a card up for good.
|
|
186
250
|
clearTimeout(expiry);
|
|
187
251
|
const left = new Date(call.ringUntil).getTime() - Date.now();
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The call, in this app — the support host's own call UI in a frame (0.16; the support host's docs/CALLS-PLAN.md §15).
|
|
3
|
+
*
|
|
4
|
+
* A reporter used to answer in a new tab on the support host: a hand-off, a popup a browser might block, and the app
|
|
5
|
+
* with the problem left behind just when the team asks to see it. Now the support host's `/embed/call` opens here, in a
|
|
6
|
+
* frame of our own — the same ring card, call corner, screen-sharing question and voice settings as its portal, so
|
|
7
|
+
* they never drift apart — signed in by the token the portal button uses, given the microphone, camera and screen.
|
|
8
|
+
*
|
|
9
|
+
* The frame says how to hold it (its messages, below): ready, its size and whether a moment covers the page, a drag
|
|
10
|
+
* of its top line, done, failed. We listen only to that frame's own window, from the support host's origin.
|
|
11
|
+
*
|
|
12
|
+
* Its own shadow root, like the ring card: it lands on pages we do not control. Marked as ours, so a report filed
|
|
13
|
+
* during a call does not photograph it.
|
|
14
|
+
*
|
|
15
|
+
* And the team's marks (0.17, ชี้ · วาด · กรอบ): the frame hands each one up (`ink`) and ui/inkOverlay draws it over
|
|
16
|
+
* the page. So the call can tell whether the tab being shared is this one — where the marks can be seen — this page
|
|
17
|
+
* is named for the call while it lasts (Capture Handle, Chrome and Edge: `lw-call:<id>`, readable by the support host
|
|
18
|
+
* only); the frame compares it with what it captured.
|
|
19
|
+
*/
|
|
20
|
+
import { readInkOp } from "../ink";
|
|
21
|
+
import { inkOnPage, removeInkLayer } from "./inkOverlay";
|
|
22
|
+
|
|
23
|
+
const HOST_TAG = "lw-call-frame";
|
|
24
|
+
const SOURCE = "lw-call";
|
|
25
|
+
const MARGIN = 16;
|
|
26
|
+
/** The call corner's width — the support host's dock, plus the frame's room for its shadow. */
|
|
27
|
+
const CARD_WIDTH = 368;
|
|
28
|
+
|
|
29
|
+
export type FrameLayout = "card" | "full";
|
|
30
|
+
export interface FrameMessage {
|
|
31
|
+
source?: string;
|
|
32
|
+
type?: string;
|
|
33
|
+
width?: number;
|
|
34
|
+
height?: number;
|
|
35
|
+
layout?: FrameLayout;
|
|
36
|
+
phase?: "start" | "move" | "end";
|
|
37
|
+
x?: number;
|
|
38
|
+
y?: number;
|
|
39
|
+
reason?: string;
|
|
40
|
+
op?: unknown;
|
|
41
|
+
}
|
|
42
|
+
export interface FrameHandlers {
|
|
43
|
+
/** The call is on screen in the frame. */
|
|
44
|
+
ready(): void;
|
|
45
|
+
/** Nothing left to show — the frame is gone already. */
|
|
46
|
+
done(): void;
|
|
47
|
+
/** It could not sign in or never came up — the frame is gone; the caller's own card carries the call. */
|
|
48
|
+
failed(reason: string): void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let host: HTMLElement | null = null;
|
|
52
|
+
let frame: HTMLIFrameElement | null = null;
|
|
53
|
+
let callId: string | null = null;
|
|
54
|
+
let origin = "";
|
|
55
|
+
let handlers: FrameHandlers | null = null;
|
|
56
|
+
let layout: FrameLayout = "card";
|
|
57
|
+
let size = { width: CARD_WIDTH, height: 0 };
|
|
58
|
+
let readyTimer: ReturnType<typeof setTimeout> | undefined;
|
|
59
|
+
let isReady = false;
|
|
60
|
+
/** Where the corner was moved to (left, top), or null: bottom right. Kept for this tab. */
|
|
61
|
+
let placed: { x: number; y: number } | null = null;
|
|
62
|
+
let drag: { sx: number; sy: number; x: number; y: number } | null = null;
|
|
63
|
+
const PLACE_KEY = "lw-call-frame-place";
|
|
64
|
+
|
|
65
|
+
/** The support host's page for this call. Pure, for the tests. */
|
|
66
|
+
export function frameUrl(supportHost: string, realmKey: string, token: string, id: string): string {
|
|
67
|
+
const url = new URL("embed/call", supportHost.endsWith("/") ? supportHost : `${supportHost}/`);
|
|
68
|
+
url.searchParams.set("realm", realmKey);
|
|
69
|
+
url.searchParams.set("token", token);
|
|
70
|
+
url.searchParams.set("call", id);
|
|
71
|
+
return url.toString();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Within the window, by at least the margin — a corner dragged off screen comes back. Pure, for the tests. */
|
|
75
|
+
export function clampTo(p: { x: number; y: number }, box: { w: number; h: number }, view: { w: number; h: number }): { x: number; y: number } {
|
|
76
|
+
return {
|
|
77
|
+
x: Math.min(Math.max(p.x, MARGIN / 2), Math.max(MARGIN / 2, view.w - box.w - MARGIN / 2)),
|
|
78
|
+
y: Math.min(Math.max(p.y, MARGIN / 2), Math.max(MARGIN / 2, view.h - box.h - MARGIN / 2)),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Is this message from our frame, about the call? */
|
|
83
|
+
export function fromFrame(e: { origin: string; source: unknown; data: unknown }, expected: { origin: string; window: unknown }): FrameMessage | null {
|
|
84
|
+
if (e.origin !== expected.origin || e.source !== expected.window) return null;
|
|
85
|
+
const d = e.data as FrameMessage | null;
|
|
86
|
+
return d && typeof d === "object" && d.source === SOURCE && typeof d.type === "string" ? d : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function callFrameFor(): string | null {
|
|
90
|
+
return host?.isConnected ? callId : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Open the call's frame — once per call; the same call again changes nothing. Shown when it says it is ready. */
|
|
94
|
+
export function openCallFrame(src: string, id: string, h: FrameHandlers, readyWithinMs = 15_000): void {
|
|
95
|
+
if (typeof document === "undefined") return;
|
|
96
|
+
if (callId === id && host?.isConnected) return;
|
|
97
|
+
closeCallFrame();
|
|
98
|
+
callId = id;
|
|
99
|
+
handlers = h;
|
|
100
|
+
isReady = false;
|
|
101
|
+
layout = "card";
|
|
102
|
+
size = { width: CARD_WIDTH, height: 0 };
|
|
103
|
+
origin = new URL(src).origin;
|
|
104
|
+
try {
|
|
105
|
+
placed = JSON.parse(sessionStorage.getItem(PLACE_KEY) ?? "null");
|
|
106
|
+
} catch {
|
|
107
|
+
placed = null;
|
|
108
|
+
}
|
|
109
|
+
host = document.createElement(HOST_TAG);
|
|
110
|
+
host.dataset.debugReporter = "true";
|
|
111
|
+
const root = host.attachShadow({ mode: "open" });
|
|
112
|
+
const style = document.createElement("style");
|
|
113
|
+
style.textContent = `:host { all: initial; }
|
|
114
|
+
iframe { position: fixed; z-index: 2147483003; border: 0; background: transparent; color-scheme: normal; visibility: hidden; }
|
|
115
|
+
iframe.on { visibility: visible; }`;
|
|
116
|
+
frame = document.createElement("iframe");
|
|
117
|
+
// What a call needs: to hear and be heard, to show a face or a screen, to play the other side's voice.
|
|
118
|
+
frame.allow = "microphone; camera; display-capture; autoplay; clipboard-write; fullscreen";
|
|
119
|
+
frame.title = "สายจากทีมงาน";
|
|
120
|
+
frame.dataset.lwCallFrame = id;
|
|
121
|
+
frame.setAttribute("allowtransparency", "true");
|
|
122
|
+
frame.src = src;
|
|
123
|
+
root.append(style, frame);
|
|
124
|
+
document.body.append(host);
|
|
125
|
+
place();
|
|
126
|
+
window.addEventListener("message", onMessage);
|
|
127
|
+
window.addEventListener("resize", place);
|
|
128
|
+
nameTab(`lw-call:${id}`);
|
|
129
|
+
// Never came up — a host that forbids the frame, a network that dropped it: the caller's card carries the call.
|
|
130
|
+
readyTimer = setTimeout(() => {
|
|
131
|
+
if (!isReady) fail("timeout");
|
|
132
|
+
}, readyWithinMs);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function closeCallFrame(): void {
|
|
136
|
+
if (host) nameTab(null);
|
|
137
|
+
removeInkLayer();
|
|
138
|
+
clearTimeout(readyTimer);
|
|
139
|
+
window.removeEventListener("message", onMessage);
|
|
140
|
+
window.removeEventListener("resize", place);
|
|
141
|
+
host?.remove();
|
|
142
|
+
host = null;
|
|
143
|
+
frame = null;
|
|
144
|
+
callId = null;
|
|
145
|
+
handlers = null;
|
|
146
|
+
drag = null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function fail(reason: string): void {
|
|
150
|
+
const h = handlers;
|
|
151
|
+
closeCallFrame();
|
|
152
|
+
h?.failed(reason);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function onMessage(e: MessageEvent): void {
|
|
156
|
+
if (!frame) return;
|
|
157
|
+
const m = fromFrame(e, { origin, window: frame.contentWindow });
|
|
158
|
+
if (!m) return;
|
|
159
|
+
switch (m.type) {
|
|
160
|
+
case "ready":
|
|
161
|
+
isReady = true;
|
|
162
|
+
clearTimeout(readyTimer);
|
|
163
|
+
frame.classList.add("on");
|
|
164
|
+
handlers?.ready();
|
|
165
|
+
return;
|
|
166
|
+
case "size":
|
|
167
|
+
if (typeof m.width === "number" && typeof m.height === "number") size = { width: m.width, height: m.height };
|
|
168
|
+
if (m.layout === "card" || m.layout === "full") {
|
|
169
|
+
// About to cover the page: the call is told where its corner is, so it stays there while the frame grows.
|
|
170
|
+
if (m.layout === "full" && layout === "card") tellCorner();
|
|
171
|
+
layout = m.layout;
|
|
172
|
+
}
|
|
173
|
+
return place();
|
|
174
|
+
case "drag":
|
|
175
|
+
return onDrag(m);
|
|
176
|
+
case "home":
|
|
177
|
+
placed = null;
|
|
178
|
+
savePlace();
|
|
179
|
+
return place();
|
|
180
|
+
case "done": {
|
|
181
|
+
const h = handlers;
|
|
182
|
+
closeCallFrame();
|
|
183
|
+
h?.done();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
case "failed":
|
|
187
|
+
return fail(m.reason ?? "failed");
|
|
188
|
+
case "ink": {
|
|
189
|
+
// Read again here — never drawn as received.
|
|
190
|
+
const op = readInkOp(m.op);
|
|
191
|
+
if (op) inkOnPage(op);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* This page, named for the call while it lasts — so a screen shared from the call's frame can be told to be THIS tab
|
|
199
|
+
* (`MediaStreamTrack.getCaptureHandle()`), where the team's marks show. Only the support host may read the name; a
|
|
200
|
+
* browser without Capture Handle simply has no name to give, and the call says it cannot tell.
|
|
201
|
+
*/
|
|
202
|
+
type CaptureHandleConfig = { handle?: string; exposeOrigin?: boolean; permittedOrigins?: string[] };
|
|
203
|
+
function nameTab(handle: string | null): void {
|
|
204
|
+
const md = (typeof navigator === "undefined" ? undefined : navigator.mediaDevices) as (MediaDevices & { setCaptureHandleConfig?: (c: CaptureHandleConfig) => void }) | undefined;
|
|
205
|
+
try {
|
|
206
|
+
md?.setCaptureHandleConfig?.(handle ? { handle, exposeOrigin: false, permittedOrigins: [origin] } : {});
|
|
207
|
+
} catch {
|
|
208
|
+
/* not a top-level page, or not this browser */
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The corner moved by its top line: from where the press began, by how far the pointer went on the screen. */
|
|
213
|
+
function onDrag(m: FrameMessage): void {
|
|
214
|
+
if (!frame || layout !== "card" || typeof m.x !== "number" || typeof m.y !== "number") return;
|
|
215
|
+
if (m.phase === "start") {
|
|
216
|
+
const r = frame.getBoundingClientRect();
|
|
217
|
+
drag = { sx: m.x, sy: m.y, x: r.left, y: r.top };
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (!drag) return;
|
|
221
|
+
placed = clampTo({ x: drag.x + (m.x - drag.sx), y: drag.y + (m.y - drag.sy) }, boxOf(), viewOf());
|
|
222
|
+
place();
|
|
223
|
+
if (m.phase === "end") {
|
|
224
|
+
drag = null;
|
|
225
|
+
savePlace();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Where the corner is on the page — the frame asks nothing else of us, and is told nothing else. */
|
|
230
|
+
function tellCorner(): void {
|
|
231
|
+
if (!frame?.contentWindow) return;
|
|
232
|
+
const r = frame.getBoundingClientRect();
|
|
233
|
+
frame.contentWindow.postMessage({ source: "lw-call-host", type: "corner", x: Math.round(r.left), y: Math.round(r.top), width: Math.round(r.width) }, origin);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function savePlace(): void {
|
|
237
|
+
try {
|
|
238
|
+
if (placed) sessionStorage.setItem(PLACE_KEY, JSON.stringify(placed));
|
|
239
|
+
else sessionStorage.removeItem(PLACE_KEY);
|
|
240
|
+
} catch {
|
|
241
|
+
/* this tab only, when it can */
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const viewOf = () => ({ w: window.innerWidth, h: window.innerHeight });
|
|
246
|
+
/**
|
|
247
|
+
* The corner: always the dock's width — the call fills whatever width it is given, so a width it reported while it
|
|
248
|
+
* had the whole window must never come back as the corner's (measured: 1264 px after a screen share) — and the
|
|
249
|
+
* height it says it needs.
|
|
250
|
+
*/
|
|
251
|
+
function boxOf(): { w: number; h: number } {
|
|
252
|
+
const view = viewOf();
|
|
253
|
+
return { w: Math.min(CARD_WIDTH, view.w - MARGIN), h: Math.min(size.height || 0, view.h - MARGIN) };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The whole window for a moment that covers it; otherwise the corner — bottom right, or where it was moved. */
|
|
257
|
+
function place(): void {
|
|
258
|
+
if (!frame) return;
|
|
259
|
+
const s = frame.style;
|
|
260
|
+
if (layout === "full") {
|
|
261
|
+
Object.assign(s, { left: "0px", top: "0px", right: "", bottom: "", width: "100vw", height: "100vh" });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const box = boxOf();
|
|
265
|
+
const narrow = viewOf().w < 480;
|
|
266
|
+
s.width = narrow ? `calc(100vw - ${MARGIN}px)` : `${box.w}px`;
|
|
267
|
+
s.height = `${box.h}px`;
|
|
268
|
+
if (placed && !narrow) {
|
|
269
|
+
const p = clampTo(placed, box, viewOf());
|
|
270
|
+
Object.assign(s, { left: `${p.x}px`, top: `${p.y}px`, right: "", bottom: "" });
|
|
271
|
+
} else Object.assign(s, { left: narrow ? `${MARGIN / 2}px` : "", top: "", right: narrow ? "" : `${MARGIN / 2}px`, bottom: `${MARGIN / 2}px` });
|
|
272
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The team's marks on this page (0.17) — ชี้ · วาด · กรอบ, drawn by the support team on the screen this person is
|
|
3
|
+
* sharing with them, shown here where they are working (the support host's docs/CALLS-PLAN.md §17).
|
|
4
|
+
*
|
|
5
|
+
* The call lives in its frame in a corner (ui/callFrame), which cannot draw outside itself; it hands each mark up and
|
|
6
|
+
* this layer draws it over the whole page: never in the way (no pointer events — every click goes to the app beneath),
|
|
7
|
+
* gone when the marks fade, and taken away with the call. The same drawing as the support host's (../ink). Marked as
|
|
8
|
+
* ours, so a report filed meanwhile does not photograph it.
|
|
9
|
+
*
|
|
10
|
+
* It covers the page exactly as the shared picture does: a tab is shared as its viewport, so a mark at a fraction of
|
|
11
|
+
* the picture is at that fraction of the window.
|
|
12
|
+
*/
|
|
13
|
+
import { InkPainter, InkScene, type InkOp } from "../ink";
|
|
14
|
+
|
|
15
|
+
const HOST_TAG = "lw-call-ink";
|
|
16
|
+
|
|
17
|
+
let host: HTMLElement | null = null;
|
|
18
|
+
let scene: InkScene | null = null;
|
|
19
|
+
let painter: InkPainter | null = null;
|
|
20
|
+
|
|
21
|
+
function mount(): void {
|
|
22
|
+
if (host?.isConnected && painter) return;
|
|
23
|
+
host = document.createElement(HOST_TAG);
|
|
24
|
+
host.dataset.debugReporter = "true";
|
|
25
|
+
const root = host.attachShadow({ mode: "open" });
|
|
26
|
+
const style = document.createElement("style");
|
|
27
|
+
// Over the page and its scrollbar, as the shared picture is; under the call's own frame.
|
|
28
|
+
style.textContent = `:host { all: initial; }
|
|
29
|
+
canvas { position: fixed; left: 0; top: 0; width: 100vw; height: 100vh; pointer-events: none; z-index: 2147483002; }`;
|
|
30
|
+
const canvas = document.createElement("canvas");
|
|
31
|
+
canvas.setAttribute("aria-hidden", "true");
|
|
32
|
+
canvas.dataset.lwCallInk = "";
|
|
33
|
+
root.append(style, canvas);
|
|
34
|
+
document.body.append(host);
|
|
35
|
+
scene = new InkScene();
|
|
36
|
+
painter = new InkPainter(canvas, scene, () => ({ w: window.innerWidth, h: window.innerHeight }));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A mark from the team, as the call's frame handed it up (already read by `readInkOp`). */
|
|
40
|
+
export function inkOnPage(op: InkOp): void {
|
|
41
|
+
if (typeof document === "undefined") return;
|
|
42
|
+
if (op.op === "clear" && !host) return;
|
|
43
|
+
mount();
|
|
44
|
+
scene!.apply(op);
|
|
45
|
+
painter!.kick();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function inkMarksOnPage(): number {
|
|
49
|
+
return scene?.size ?? 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The call is over: the layer goes. */
|
|
53
|
+
export function removeInkLayer(): void {
|
|
54
|
+
painter?.stop();
|
|
55
|
+
host?.remove();
|
|
56
|
+
host = null;
|
|
57
|
+
scene = null;
|
|
58
|
+
painter = null;
|
|
59
|
+
}
|
package/src/ui/ring.ts
CHANGED
|
@@ -185,6 +185,16 @@ export function ringOpened(blockedUrl?: string): void {
|
|
|
185
185
|
}
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
/**
|
|
189
|
+
* The call's own frame is up (ui/callFrame, 0.16): its ring card is the one to press, so ours steps out of sight —
|
|
190
|
+
* the ring and the title stay, rung from this page, until the call stops ringing. `false` brings ours back (the frame
|
|
191
|
+
* failed: our card carries the call again).
|
|
192
|
+
*/
|
|
193
|
+
export function quietRing(id: string, quiet = true): void {
|
|
194
|
+
if (!host?.isConnected || shownId !== id) return;
|
|
195
|
+
host.style.display = quiet ? "none" : "";
|
|
196
|
+
}
|
|
197
|
+
|
|
188
198
|
/** The call is no longer ringing for this person — answered, declined, missed or ended. */
|
|
189
199
|
export function hideRing(id?: string): void {
|
|
190
200
|
if (id && shownId && id !== shownId) return;
|