@lightworkai.official/debug-capture 0.16.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 +18 -0
- package/dist/debug-capture.js +9 -8
- package/dist/index.d.ts +2 -0
- package/dist/index.mjs +15 -14
- package/dist/ink.d.ts +102 -0
- package/dist/ui/callFrame.d.ts +1 -14
- package/dist/ui/inkOverlay.d.ts +18 -0
- package/package.json +1 -1
- package/src/index.ts +3 -0
- package/src/ink.ts +386 -0
- package/src/ui/callFrame.ts +32 -0
- package/src/ui/inkOverlay.ts +59 -0
package/dist/ink.d.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marks on a shared screen — ชี้ · วาด · กรอบ (0.17; the support host's docs/CALLS-PLAN.md §17).
|
|
3
|
+
*
|
|
4
|
+
* Whoever watches a shared screen points at it, draws on it and boxes a part of it; whoever shares it sees the marks
|
|
5
|
+
* on their own page, where they are working — the way Google Meet's "disappearing ink" and the co-browsing tools'
|
|
6
|
+
* laser pointer do it. One module draws them everywhere: the watcher's own view (the support host), the sharer's page
|
|
7
|
+
* on the support host, and the host app's page (this widget, under its call frame) — so they look and fade alike.
|
|
8
|
+
*
|
|
9
|
+
* What travels is small and plain: an op names a mark (`id`), its kind and colour once, then points as fractions of
|
|
10
|
+
* the shared picture (0–1 across, 0–1 down) — a picture of any size maps onto a page of any size. Everything received
|
|
11
|
+
* goes through `readInkOp` first: anything else, or too much of it, is dropped, never drawn.
|
|
12
|
+
*
|
|
13
|
+
* Time is each side's own: a mark fades from when THIS side saw it end, so two clocks never need to agree.
|
|
14
|
+
*/
|
|
15
|
+
export type InkKind = "pen" | "rect" | "laser";
|
|
16
|
+
export type InkPoint = readonly [number, number];
|
|
17
|
+
export type InkOp = {
|
|
18
|
+
op: "begin";
|
|
19
|
+
id: string;
|
|
20
|
+
kind: InkKind;
|
|
21
|
+
color: string;
|
|
22
|
+
pts: InkPoint[];
|
|
23
|
+
} | {
|
|
24
|
+
op: "add";
|
|
25
|
+
id: string;
|
|
26
|
+
pts: InkPoint[];
|
|
27
|
+
} | {
|
|
28
|
+
op: "end";
|
|
29
|
+
id: string;
|
|
30
|
+
} | {
|
|
31
|
+
op: "clear";
|
|
32
|
+
};
|
|
33
|
+
/** At most this many points in one op — a pointer's ~30 ms, many times over. */
|
|
34
|
+
export declare const INK_MAX_POINTS_PER_OP = 64;
|
|
35
|
+
/** A line or a box stays this long after it is finished, then fades over INK_FADE_MS. */
|
|
36
|
+
export declare const INK_HOLD_MS = 3500;
|
|
37
|
+
export declare const INK_FADE_MS = 700;
|
|
38
|
+
/** The pointer's tail, and how long its dot stays after the pointer left the picture. */
|
|
39
|
+
export declare const LASER_TRAIL_MS = 450;
|
|
40
|
+
export declare const LASER_LINGER_MS = 900;
|
|
41
|
+
/** A mark that stopped receiving anything this long ago is finished, ended or not — a dropped connection never leaves one up. */
|
|
42
|
+
export declare const INK_STALE_MS = 20000;
|
|
43
|
+
/** The colours, one per tool — seen on any page: each is drawn on a white halo. */
|
|
44
|
+
export declare const INK_COLORS: Record<InkKind, string>;
|
|
45
|
+
/** An op as received — from a socket, from another window — or null: never drawn. Points clamped into the picture. */
|
|
46
|
+
export declare function readInkOp(v: unknown): InkOp | null;
|
|
47
|
+
/** How visible a mark is at `now`, 0–1. */
|
|
48
|
+
export declare function inkAlpha(kind: InkKind, endedAt: number | null, now: number): number;
|
|
49
|
+
export declare class InkScene {
|
|
50
|
+
private marks;
|
|
51
|
+
apply(op: InkOp, now?: number): void;
|
|
52
|
+
/** Something still to draw at `now`; the gone ones are let go. */
|
|
53
|
+
alive(now?: number): boolean;
|
|
54
|
+
get size(): number;
|
|
55
|
+
/** Draw every mark onto a context sized `w` × `h` (CSS pixels — the caller has set the device-pixel scale). */
|
|
56
|
+
draw(ctx: CanvasRenderingContext2D, w: number, h: number, now?: number): void;
|
|
57
|
+
}
|
|
58
|
+
/** A canvas of `w` × `h` CSS pixels, sharp on this screen; its context scaled to CSS pixels. */
|
|
59
|
+
export declare function sizeInkCanvas(canvas: HTMLCanvasElement, w: number, h: number, dpr?: number): CanvasRenderingContext2D | null;
|
|
60
|
+
/** Paints a scene on a canvas on every frame while it has marks, and stops — a page with no marks pays nothing. */
|
|
61
|
+
export declare class InkPainter {
|
|
62
|
+
private readonly canvas;
|
|
63
|
+
private readonly scene;
|
|
64
|
+
private readonly box;
|
|
65
|
+
private frame;
|
|
66
|
+
constructor(canvas: HTMLCanvasElement, scene: InkScene, box: () => {
|
|
67
|
+
w: number;
|
|
68
|
+
h: number;
|
|
69
|
+
});
|
|
70
|
+
/** Something changed: paint until there is nothing left. */
|
|
71
|
+
kick(): void;
|
|
72
|
+
stop(): void;
|
|
73
|
+
}
|
|
74
|
+
/** One op every this often while drawing — ~30 a second at most. */
|
|
75
|
+
export declare const INK_SEND_EVERY_MS = 33;
|
|
76
|
+
/** A mark held still says so this often — well inside INK_STALE_MS. */
|
|
77
|
+
export declare const INK_KEEP_MS = 5000;
|
|
78
|
+
/**
|
|
79
|
+
* The watcher's pen: every point drawn on its own scene at once (it must feel like ink), and sent to the sharer in
|
|
80
|
+
* batches — begin, then a points op each ~33 ms, then end — never a message per pointer event.
|
|
81
|
+
*/
|
|
82
|
+
export declare class InkSender {
|
|
83
|
+
private readonly send;
|
|
84
|
+
private readonly scene;
|
|
85
|
+
private readonly onChange;
|
|
86
|
+
private id;
|
|
87
|
+
private kind;
|
|
88
|
+
private pending;
|
|
89
|
+
private last;
|
|
90
|
+
private lastSent;
|
|
91
|
+
private timer;
|
|
92
|
+
private keep;
|
|
93
|
+
constructor(send: (op: InkOp) => void, scene: InkScene, onChange?: () => void);
|
|
94
|
+
get drawing(): InkKind | null;
|
|
95
|
+
begin(kind: InkKind, p: InkPoint): void;
|
|
96
|
+
private stillHere;
|
|
97
|
+
move(p: InkPoint): void;
|
|
98
|
+
/** Send what is waiting now. */
|
|
99
|
+
flush(): void;
|
|
100
|
+
end(): void;
|
|
101
|
+
clear(): void;
|
|
102
|
+
}
|
package/dist/ui/callFrame.d.ts
CHANGED
|
@@ -1,17 +1,3 @@
|
|
|
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
1
|
export type FrameLayout = "card" | "full";
|
|
16
2
|
export interface FrameMessage {
|
|
17
3
|
source?: string;
|
|
@@ -23,6 +9,7 @@ export interface FrameMessage {
|
|
|
23
9
|
x?: number;
|
|
24
10
|
y?: number;
|
|
25
11
|
reason?: string;
|
|
12
|
+
op?: unknown;
|
|
26
13
|
}
|
|
27
14
|
export interface FrameHandlers {
|
|
28
15
|
/** The call is on screen in the frame. */
|
|
@@ -0,0 +1,18 @@
|
|
|
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 { type InkOp } from "../ink";
|
|
14
|
+
/** A mark from the team, as the call's frame handed it up (already read by `readInkOp`). */
|
|
15
|
+
export declare function inkOnPage(op: InkOp): void;
|
|
16
|
+
export declare function inkMarksOnPage(): number;
|
|
17
|
+
/** The call is over: the layer goes. */
|
|
18
|
+
export declare function removeInkLayer(): void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lightworkai.official/debug-capture",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
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/index.ts
CHANGED
|
@@ -44,6 +44,9 @@ export { defineReporterElement, setErroredQueriesSource, REPORTER_TAG } from "./
|
|
|
44
44
|
export { showToast } from "./ui/toast";
|
|
45
45
|
// A call from the support team, rung in the host app — started by install; a host that renders its own UI may start or stop it.
|
|
46
46
|
export { startLiveRing, stopLiveRing } from "./live";
|
|
47
|
+
// Marks on a shared screen (0.17) — the same drawing on the support host and here.
|
|
48
|
+
export { readInkOp, InkScene, InkPainter, InkSender, sizeInkCanvas, inkAlpha, INK_COLORS, INK_HOLD_MS, INK_FADE_MS, INK_STALE_MS, INK_MAX_POINTS_PER_OP, LASER_LINGER_MS, LASER_TRAIL_MS, INK_SEND_EVERY_MS, INK_KEEP_MS } from "./ink";
|
|
49
|
+
export type { InkOp, InkKind, InkPoint } from "./ink";
|
|
47
50
|
|
|
48
51
|
/*
|
|
49
52
|
* The annotator ENGINE, shared by every framework package.
|
package/src/ink.ts
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marks on a shared screen — ชี้ · วาด · กรอบ (0.17; the support host's docs/CALLS-PLAN.md §17).
|
|
3
|
+
*
|
|
4
|
+
* Whoever watches a shared screen points at it, draws on it and boxes a part of it; whoever shares it sees the marks
|
|
5
|
+
* on their own page, where they are working — the way Google Meet's "disappearing ink" and the co-browsing tools'
|
|
6
|
+
* laser pointer do it. One module draws them everywhere: the watcher's own view (the support host), the sharer's page
|
|
7
|
+
* on the support host, and the host app's page (this widget, under its call frame) — so they look and fade alike.
|
|
8
|
+
*
|
|
9
|
+
* What travels is small and plain: an op names a mark (`id`), its kind and colour once, then points as fractions of
|
|
10
|
+
* the shared picture (0–1 across, 0–1 down) — a picture of any size maps onto a page of any size. Everything received
|
|
11
|
+
* goes through `readInkOp` first: anything else, or too much of it, is dropped, never drawn.
|
|
12
|
+
*
|
|
13
|
+
* Time is each side's own: a mark fades from when THIS side saw it end, so two clocks never need to agree.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type InkKind = "pen" | "rect" | "laser";
|
|
17
|
+
export type InkPoint = readonly [number, number];
|
|
18
|
+
export type InkOp =
|
|
19
|
+
| { op: "begin"; id: string; kind: InkKind; color: string; pts: InkPoint[] }
|
|
20
|
+
| { op: "add"; id: string; pts: InkPoint[] }
|
|
21
|
+
| { op: "end"; id: string }
|
|
22
|
+
| { op: "clear" };
|
|
23
|
+
|
|
24
|
+
/** At most this many points in one op — a pointer's ~30 ms, many times over. */
|
|
25
|
+
export const INK_MAX_POINTS_PER_OP = 64;
|
|
26
|
+
/** A line or a box stays this long after it is finished, then fades over INK_FADE_MS. */
|
|
27
|
+
export const INK_HOLD_MS = 3500;
|
|
28
|
+
export const INK_FADE_MS = 700;
|
|
29
|
+
/** The pointer's tail, and how long its dot stays after the pointer left the picture. */
|
|
30
|
+
export const LASER_TRAIL_MS = 450;
|
|
31
|
+
export const LASER_LINGER_MS = 900;
|
|
32
|
+
/** A mark that stopped receiving anything this long ago is finished, ended or not — a dropped connection never leaves one up. */
|
|
33
|
+
export const INK_STALE_MS = 20_000;
|
|
34
|
+
/** The colours, one per tool — seen on any page: each is drawn on a white halo. */
|
|
35
|
+
export const INK_COLORS: Record<InkKind, string> = { pen: "#ef4444", rect: "#f59e0b", laser: "#ef4444" };
|
|
36
|
+
|
|
37
|
+
const MAX_MARKS = 120;
|
|
38
|
+
const MAX_POINTS = 4000;
|
|
39
|
+
const ID = /^[A-Za-z0-9_-]{1,32}$/;
|
|
40
|
+
const COLOR = /^#[0-9a-fA-F]{6}$/;
|
|
41
|
+
const KINDS: readonly InkKind[] = ["pen", "rect", "laser"];
|
|
42
|
+
|
|
43
|
+
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
|
44
|
+
|
|
45
|
+
function readPoints(v: unknown, min: number): InkPoint[] | null {
|
|
46
|
+
if (!Array.isArray(v) || v.length < min || v.length > INK_MAX_POINTS_PER_OP) return null;
|
|
47
|
+
const out: InkPoint[] = [];
|
|
48
|
+
for (const p of v) {
|
|
49
|
+
if (!Array.isArray(p) || p.length !== 2) return null;
|
|
50
|
+
const [x, y] = p as unknown[];
|
|
51
|
+
if (typeof x !== "number" || typeof y !== "number" || !Number.isFinite(x) || !Number.isFinite(y)) return null;
|
|
52
|
+
out.push([clamp01(x), clamp01(y)]);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** An op as received — from a socket, from another window — or null: never drawn. Points clamped into the picture. */
|
|
58
|
+
export function readInkOp(v: unknown): InkOp | null {
|
|
59
|
+
if (!v || typeof v !== "object") return null;
|
|
60
|
+
const o = v as Record<string, unknown>;
|
|
61
|
+
if (o.op === "clear") return { op: "clear" };
|
|
62
|
+
if (typeof o.id !== "string" || !ID.test(o.id)) return null;
|
|
63
|
+
if (o.op === "end") return { op: "end", id: o.id };
|
|
64
|
+
if (o.op === "add") {
|
|
65
|
+
const pts = readPoints(o.pts, 1);
|
|
66
|
+
return pts ? { op: "add", id: o.id, pts } : null;
|
|
67
|
+
}
|
|
68
|
+
if (o.op === "begin") {
|
|
69
|
+
if (!KINDS.includes(o.kind as InkKind) || typeof o.color !== "string" || !COLOR.test(o.color)) return null;
|
|
70
|
+
const pts = readPoints(o.pts, 1);
|
|
71
|
+
return pts ? { op: "begin", id: o.id, kind: o.kind as InkKind, color: o.color, pts } : null;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* ── The marks ─────────────────────────────────────────────────────────── */
|
|
77
|
+
|
|
78
|
+
interface Mark {
|
|
79
|
+
kind: InkKind;
|
|
80
|
+
color: string;
|
|
81
|
+
pts: { x: number; y: number; t: number }[];
|
|
82
|
+
endedAt: number | null;
|
|
83
|
+
lastAt: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** When a mark ended — told, or taken as ended once nothing came for INK_STALE_MS. */
|
|
87
|
+
function endOf(m: Mark, now: number): number | null {
|
|
88
|
+
if (m.endedAt !== null) return m.endedAt;
|
|
89
|
+
return now - m.lastAt > INK_STALE_MS ? m.lastAt : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** How visible a mark is at `now`, 0–1. */
|
|
93
|
+
export function inkAlpha(kind: InkKind, endedAt: number | null, now: number): number {
|
|
94
|
+
if (endedAt === null) return 1;
|
|
95
|
+
const since = now - endedAt;
|
|
96
|
+
if (kind === "laser") return since >= LASER_LINGER_MS ? 0 : 1 - since / LASER_LINGER_MS;
|
|
97
|
+
if (since <= INK_HOLD_MS) return 1;
|
|
98
|
+
return Math.max(0, 1 - (since - INK_HOLD_MS) / INK_FADE_MS);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class InkScene {
|
|
102
|
+
private marks = new Map<string, Mark>();
|
|
103
|
+
|
|
104
|
+
apply(op: InkOp, now = Date.now()): void {
|
|
105
|
+
if (op.op === "clear") {
|
|
106
|
+
this.marks.clear();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (op.op === "begin") {
|
|
110
|
+
// The oldest goes first when there are too many — a page is never asked to draw without end.
|
|
111
|
+
while (this.marks.size >= MAX_MARKS) this.marks.delete(this.marks.keys().next().value as string);
|
|
112
|
+
this.marks.delete(op.id);
|
|
113
|
+
this.marks.set(op.id, { kind: op.kind, color: op.color, pts: op.pts.map(([x, y]) => ({ x, y, t: now })), endedAt: null, lastAt: now });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const m = this.marks.get(op.id);
|
|
117
|
+
if (!m || endOf(m, now) !== null) return;
|
|
118
|
+
if (op.op === "end") {
|
|
119
|
+
m.endedAt = now;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
m.lastAt = now;
|
|
123
|
+
if (m.kind === "rect") {
|
|
124
|
+
// A box is its first corner and the corner being dragged: the latest point replaces the second.
|
|
125
|
+
const [x, y] = op.pts[op.pts.length - 1]!;
|
|
126
|
+
m.pts = [m.pts[0]!, { x, y, t: now }];
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
for (const [x, y] of op.pts) m.pts.push({ x, y, t: now });
|
|
130
|
+
if (m.pts.length > MAX_POINTS) m.pts.splice(0, m.pts.length - MAX_POINTS);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Something still to draw at `now`; the gone ones are let go. */
|
|
134
|
+
alive(now = Date.now()): boolean {
|
|
135
|
+
for (const [id, m] of this.marks) if (inkAlpha(m.kind, endOf(m, now), now) <= 0) this.marks.delete(id);
|
|
136
|
+
return this.marks.size > 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
get size(): number {
|
|
140
|
+
return this.marks.size;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Draw every mark onto a context sized `w` × `h` (CSS pixels — the caller has set the device-pixel scale). */
|
|
144
|
+
draw(ctx: CanvasRenderingContext2D, w: number, h: number, now = Date.now()): void {
|
|
145
|
+
ctx.clearRect(0, 0, w, h);
|
|
146
|
+
const unit = Math.max(1, Math.min(w, h));
|
|
147
|
+
for (const m of this.marks.values()) {
|
|
148
|
+
const alpha = inkAlpha(m.kind, endOf(m, now), now);
|
|
149
|
+
if (alpha <= 0 || !m.pts.length) continue;
|
|
150
|
+
ctx.save();
|
|
151
|
+
ctx.lineCap = "round";
|
|
152
|
+
ctx.lineJoin = "round";
|
|
153
|
+
if (m.kind === "pen") drawPen(ctx, m, w, h, Math.max(3, unit * 0.0045), alpha);
|
|
154
|
+
else if (m.kind === "rect") drawRect(ctx, m, w, h, Math.max(2.5, unit * 0.003), alpha);
|
|
155
|
+
else drawLaser(ctx, m, w, h, Math.max(6, unit * 0.008), alpha, now);
|
|
156
|
+
ctx.restore();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function pathThrough(ctx: CanvasRenderingContext2D, pts: { x: number; y: number }[], w: number, h: number): void {
|
|
162
|
+
ctx.beginPath();
|
|
163
|
+
ctx.moveTo(pts[0]!.x * w, pts[0]!.y * h);
|
|
164
|
+
if (pts.length === 1) {
|
|
165
|
+
ctx.lineTo(pts[0]!.x * w + 0.01, pts[0]!.y * h);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// Through the midpoints: a line drawn by a hand, not a polygon.
|
|
169
|
+
for (let i = 1; i < pts.length - 1; i++) {
|
|
170
|
+
const a = pts[i]!;
|
|
171
|
+
const b = pts[i + 1]!;
|
|
172
|
+
ctx.quadraticCurveTo(a.x * w, a.y * h, ((a.x + b.x) / 2) * w, ((a.y + b.y) / 2) * h);
|
|
173
|
+
}
|
|
174
|
+
const last = pts[pts.length - 1]!;
|
|
175
|
+
ctx.lineTo(last.x * w, last.y * h);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function drawPen(ctx: CanvasRenderingContext2D, m: Mark, w: number, h: number, width: number, alpha: number): void {
|
|
179
|
+
ctx.globalAlpha = alpha;
|
|
180
|
+
pathThrough(ctx, m.pts, w, h);
|
|
181
|
+
ctx.strokeStyle = "rgba(255,255,255,0.92)";
|
|
182
|
+
ctx.lineWidth = width + 3;
|
|
183
|
+
ctx.stroke();
|
|
184
|
+
ctx.strokeStyle = m.color;
|
|
185
|
+
ctx.lineWidth = width;
|
|
186
|
+
ctx.stroke();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function drawRect(ctx: CanvasRenderingContext2D, m: Mark, w: number, h: number, width: number, alpha: number): void {
|
|
190
|
+
const a = m.pts[0]!;
|
|
191
|
+
const b = m.pts[m.pts.length - 1]!;
|
|
192
|
+
const x = Math.min(a.x, b.x) * w;
|
|
193
|
+
const y = Math.min(a.y, b.y) * h;
|
|
194
|
+
const rw = Math.abs(b.x - a.x) * w;
|
|
195
|
+
const rh = Math.abs(b.y - a.y) * h;
|
|
196
|
+
ctx.globalAlpha = alpha;
|
|
197
|
+
ctx.fillStyle = m.color;
|
|
198
|
+
ctx.globalAlpha = alpha * 0.14;
|
|
199
|
+
ctx.fillRect(x, y, rw, rh);
|
|
200
|
+
ctx.globalAlpha = alpha;
|
|
201
|
+
ctx.strokeStyle = "rgba(255,255,255,0.92)";
|
|
202
|
+
ctx.lineWidth = width + 3;
|
|
203
|
+
ctx.strokeRect(x, y, rw, rh);
|
|
204
|
+
ctx.strokeStyle = m.color;
|
|
205
|
+
ctx.lineWidth = width;
|
|
206
|
+
ctx.strokeRect(x, y, rw, rh);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function drawLaser(ctx: CanvasRenderingContext2D, m: Mark, w: number, h: number, r: number, alpha: number, now: number): void {
|
|
210
|
+
// The tail: the last moments of the pointer, fading with their age.
|
|
211
|
+
const tail = m.pts.filter((p) => now - p.t <= LASER_TRAIL_MS);
|
|
212
|
+
for (let i = 1; i < tail.length; i++) {
|
|
213
|
+
const p = tail[i - 1]!;
|
|
214
|
+
const q = tail[i]!;
|
|
215
|
+
ctx.globalAlpha = alpha * Math.max(0, 1 - (now - q.t) / LASER_TRAIL_MS) * 0.7;
|
|
216
|
+
ctx.strokeStyle = m.color;
|
|
217
|
+
ctx.lineWidth = r * 0.9;
|
|
218
|
+
ctx.beginPath();
|
|
219
|
+
ctx.moveTo(p.x * w, p.y * h);
|
|
220
|
+
ctx.lineTo(q.x * w, q.y * h);
|
|
221
|
+
ctx.stroke();
|
|
222
|
+
}
|
|
223
|
+
const dot = m.pts[m.pts.length - 1]!;
|
|
224
|
+
ctx.globalAlpha = alpha;
|
|
225
|
+
ctx.shadowColor = m.color;
|
|
226
|
+
ctx.shadowBlur = r * 2;
|
|
227
|
+
ctx.fillStyle = m.color;
|
|
228
|
+
ctx.beginPath();
|
|
229
|
+
ctx.arc(dot.x * w, dot.y * h, r, 0, Math.PI * 2);
|
|
230
|
+
ctx.fill();
|
|
231
|
+
ctx.shadowBlur = 0;
|
|
232
|
+
ctx.strokeStyle = "rgba(255,255,255,0.95)";
|
|
233
|
+
ctx.lineWidth = 2;
|
|
234
|
+
ctx.stroke();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/* ── Drawing a scene on a canvas, for as long as it has something to show ──── */
|
|
238
|
+
|
|
239
|
+
/** A canvas of `w` × `h` CSS pixels, sharp on this screen; its context scaled to CSS pixels. */
|
|
240
|
+
export function sizeInkCanvas(canvas: HTMLCanvasElement, w: number, h: number, dpr = typeof window === "undefined" ? 1 : window.devicePixelRatio || 1): CanvasRenderingContext2D | null {
|
|
241
|
+
const pw = Math.max(1, Math.round(w * dpr));
|
|
242
|
+
const ph = Math.max(1, Math.round(h * dpr));
|
|
243
|
+
if (canvas.width !== pw) canvas.width = pw;
|
|
244
|
+
if (canvas.height !== ph) canvas.height = ph;
|
|
245
|
+
const ctx = canvas.getContext("2d");
|
|
246
|
+
ctx?.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
247
|
+
return ctx;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Paints a scene on a canvas on every frame while it has marks, and stops — a page with no marks pays nothing. */
|
|
251
|
+
export class InkPainter {
|
|
252
|
+
private frame = 0;
|
|
253
|
+
constructor(
|
|
254
|
+
private readonly canvas: HTMLCanvasElement,
|
|
255
|
+
private readonly scene: InkScene,
|
|
256
|
+
private readonly box: () => { w: number; h: number },
|
|
257
|
+
) {}
|
|
258
|
+
|
|
259
|
+
/** Something changed: paint until there is nothing left. */
|
|
260
|
+
kick(): void {
|
|
261
|
+
if (this.frame || typeof requestAnimationFrame === "undefined") return;
|
|
262
|
+
const step = () => {
|
|
263
|
+
this.frame = 0;
|
|
264
|
+
const { w, h } = this.box();
|
|
265
|
+
const ctx = sizeInkCanvas(this.canvas, w, h);
|
|
266
|
+
const now = Date.now();
|
|
267
|
+
const alive = this.scene.alive(now);
|
|
268
|
+
if (ctx) this.scene.draw(ctx, w, h, now);
|
|
269
|
+
if (alive) this.frame = requestAnimationFrame(step);
|
|
270
|
+
};
|
|
271
|
+
this.frame = requestAnimationFrame(step);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
stop(): void {
|
|
275
|
+
if (this.frame && typeof cancelAnimationFrame !== "undefined") cancelAnimationFrame(this.frame);
|
|
276
|
+
this.frame = 0;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* ── Drawing as the watcher: local at once, sent in small batches ──────────── */
|
|
281
|
+
|
|
282
|
+
/** One op every this often while drawing — ~30 a second at most. */
|
|
283
|
+
export const INK_SEND_EVERY_MS = 33;
|
|
284
|
+
/** A mark held still says so this often — well inside INK_STALE_MS. */
|
|
285
|
+
export const INK_KEEP_MS = 5000;
|
|
286
|
+
/** Closer than this to the last point (a fraction of the picture) is the same point. */
|
|
287
|
+
const SAME = 0.0012;
|
|
288
|
+
|
|
289
|
+
let seq = 0;
|
|
290
|
+
const newId = () => `${Date.now().toString(36)}${(seq++).toString(36)}${Math.random().toString(36).slice(2, 6)}`.slice(0, 32);
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The watcher's pen: every point drawn on its own scene at once (it must feel like ink), and sent to the sharer in
|
|
294
|
+
* batches — begin, then a points op each ~33 ms, then end — never a message per pointer event.
|
|
295
|
+
*/
|
|
296
|
+
export class InkSender {
|
|
297
|
+
private id: string | null = null;
|
|
298
|
+
private kind: InkKind | null = null;
|
|
299
|
+
private pending: InkPoint[] = [];
|
|
300
|
+
private last: InkPoint | null = null;
|
|
301
|
+
private lastSent = 0;
|
|
302
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
303
|
+
private keep: ReturnType<typeof setInterval> | undefined;
|
|
304
|
+
|
|
305
|
+
constructor(
|
|
306
|
+
private readonly send: (op: InkOp) => void,
|
|
307
|
+
private readonly scene: InkScene,
|
|
308
|
+
private readonly onChange: () => void = () => undefined,
|
|
309
|
+
) {}
|
|
310
|
+
|
|
311
|
+
get drawing(): InkKind | null {
|
|
312
|
+
return this.id ? this.kind : null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
begin(kind: InkKind, p: InkPoint): void {
|
|
316
|
+
this.end();
|
|
317
|
+
this.id = newId();
|
|
318
|
+
this.kind = kind;
|
|
319
|
+
this.last = p;
|
|
320
|
+
this.lastSent = Date.now();
|
|
321
|
+
const op: InkOp = { op: "begin", id: this.id, kind, color: INK_COLORS[kind], pts: [p] };
|
|
322
|
+
this.scene.apply(op);
|
|
323
|
+
this.send(op);
|
|
324
|
+
// Held still (a pointer resting on a button, a pen pressed and thinking): a word every few seconds, so neither side
|
|
325
|
+
// takes the mark as finished (INK_STALE_MS) while it is still being held.
|
|
326
|
+
this.keep = setInterval(() => this.stillHere(), INK_KEEP_MS);
|
|
327
|
+
this.onChange();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private stillHere(): void {
|
|
331
|
+
if (!this.id || !this.last || Date.now() - this.lastSent < INK_KEEP_MS) return;
|
|
332
|
+
this.flush();
|
|
333
|
+
const op: InkOp = { op: "add", id: this.id, pts: [this.last] };
|
|
334
|
+
this.scene.apply(op);
|
|
335
|
+
this.send(op);
|
|
336
|
+
this.lastSent = Date.now();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
move(p: InkPoint): void {
|
|
340
|
+
if (!this.id || !this.kind) return;
|
|
341
|
+
// Nothing sent for so long the other side may have taken the mark as finished (a tab whose timers were held back):
|
|
342
|
+
// a new one, or the move is lost.
|
|
343
|
+
if (Date.now() - this.lastSent > INK_STALE_MS / 2) return this.begin(this.kind, p);
|
|
344
|
+
if (this.last && Math.abs(p[0] - this.last[0]) < SAME && Math.abs(p[1] - this.last[1]) < SAME) return;
|
|
345
|
+
this.last = p;
|
|
346
|
+
this.scene.apply({ op: "add", id: this.id, pts: [p] });
|
|
347
|
+
this.onChange();
|
|
348
|
+
// A box only needs where its corner is now; a line, every point.
|
|
349
|
+
if (this.kind === "rect") this.pending = [p];
|
|
350
|
+
else this.pending.push(p);
|
|
351
|
+
if (this.pending.length >= INK_MAX_POINTS_PER_OP) this.flush();
|
|
352
|
+
else if (!this.timer) this.timer = setTimeout(() => this.flush(), Math.max(0, INK_SEND_EVERY_MS - (Date.now() - this.lastSent)));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Send what is waiting now. */
|
|
356
|
+
flush(): void {
|
|
357
|
+
clearTimeout(this.timer);
|
|
358
|
+
this.timer = undefined;
|
|
359
|
+
if (!this.id || !this.pending.length) return;
|
|
360
|
+
this.send({ op: "add", id: this.id, pts: this.pending });
|
|
361
|
+
this.pending = [];
|
|
362
|
+
this.lastSent = Date.now();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
end(): void {
|
|
366
|
+
if (!this.id) return;
|
|
367
|
+
clearInterval(this.keep);
|
|
368
|
+
this.keep = undefined;
|
|
369
|
+
this.flush();
|
|
370
|
+
const op: InkOp = { op: "end", id: this.id };
|
|
371
|
+
this.scene.apply(op);
|
|
372
|
+
this.send(op);
|
|
373
|
+
this.id = null;
|
|
374
|
+
this.kind = null;
|
|
375
|
+
this.last = null;
|
|
376
|
+
this.onChange();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
clear(): void {
|
|
380
|
+
this.end();
|
|
381
|
+
const op: InkOp = { op: "clear" };
|
|
382
|
+
this.scene.apply(op);
|
|
383
|
+
this.send(op);
|
|
384
|
+
this.onChange();
|
|
385
|
+
}
|
|
386
|
+
}
|
package/src/ui/callFrame.ts
CHANGED
|
@@ -11,7 +11,14 @@
|
|
|
11
11
|
*
|
|
12
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
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.
|
|
14
19
|
*/
|
|
20
|
+
import { readInkOp } from "../ink";
|
|
21
|
+
import { inkOnPage, removeInkLayer } from "./inkOverlay";
|
|
15
22
|
|
|
16
23
|
const HOST_TAG = "lw-call-frame";
|
|
17
24
|
const SOURCE = "lw-call";
|
|
@@ -30,6 +37,7 @@ export interface FrameMessage {
|
|
|
30
37
|
x?: number;
|
|
31
38
|
y?: number;
|
|
32
39
|
reason?: string;
|
|
40
|
+
op?: unknown;
|
|
33
41
|
}
|
|
34
42
|
export interface FrameHandlers {
|
|
35
43
|
/** The call is on screen in the frame. */
|
|
@@ -117,6 +125,7 @@ iframe.on { visibility: visible; }`;
|
|
|
117
125
|
place();
|
|
118
126
|
window.addEventListener("message", onMessage);
|
|
119
127
|
window.addEventListener("resize", place);
|
|
128
|
+
nameTab(`lw-call:${id}`);
|
|
120
129
|
// Never came up — a host that forbids the frame, a network that dropped it: the caller's card carries the call.
|
|
121
130
|
readyTimer = setTimeout(() => {
|
|
122
131
|
if (!isReady) fail("timeout");
|
|
@@ -124,6 +133,8 @@ iframe.on { visibility: visible; }`;
|
|
|
124
133
|
}
|
|
125
134
|
|
|
126
135
|
export function closeCallFrame(): void {
|
|
136
|
+
if (host) nameTab(null);
|
|
137
|
+
removeInkLayer();
|
|
127
138
|
clearTimeout(readyTimer);
|
|
128
139
|
window.removeEventListener("message", onMessage);
|
|
129
140
|
window.removeEventListener("resize", place);
|
|
@@ -174,6 +185,27 @@ function onMessage(e: MessageEvent): void {
|
|
|
174
185
|
}
|
|
175
186
|
case "failed":
|
|
176
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 */
|
|
177
209
|
}
|
|
178
210
|
}
|
|
179
211
|
|
|
@@ -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
|
+
}
|