@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/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
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export type FrameLayout = "card" | "full";
|
|
2
|
+
export interface FrameMessage {
|
|
3
|
+
source?: string;
|
|
4
|
+
type?: string;
|
|
5
|
+
width?: number;
|
|
6
|
+
height?: number;
|
|
7
|
+
layout?: FrameLayout;
|
|
8
|
+
phase?: "start" | "move" | "end";
|
|
9
|
+
x?: number;
|
|
10
|
+
y?: number;
|
|
11
|
+
reason?: string;
|
|
12
|
+
op?: unknown;
|
|
13
|
+
}
|
|
14
|
+
export interface FrameHandlers {
|
|
15
|
+
/** The call is on screen in the frame. */
|
|
16
|
+
ready(): void;
|
|
17
|
+
/** Nothing left to show — the frame is gone already. */
|
|
18
|
+
done(): void;
|
|
19
|
+
/** It could not sign in or never came up — the frame is gone; the caller's own card carries the call. */
|
|
20
|
+
failed(reason: string): void;
|
|
21
|
+
}
|
|
22
|
+
/** The support host's page for this call. Pure, for the tests. */
|
|
23
|
+
export declare function frameUrl(supportHost: string, realmKey: string, token: string, id: string): string;
|
|
24
|
+
/** Within the window, by at least the margin — a corner dragged off screen comes back. Pure, for the tests. */
|
|
25
|
+
export declare function clampTo(p: {
|
|
26
|
+
x: number;
|
|
27
|
+
y: number;
|
|
28
|
+
}, box: {
|
|
29
|
+
w: number;
|
|
30
|
+
h: number;
|
|
31
|
+
}, view: {
|
|
32
|
+
w: number;
|
|
33
|
+
h: number;
|
|
34
|
+
}): {
|
|
35
|
+
x: number;
|
|
36
|
+
y: number;
|
|
37
|
+
};
|
|
38
|
+
/** Is this message from our frame, about the call? */
|
|
39
|
+
export declare function fromFrame(e: {
|
|
40
|
+
origin: string;
|
|
41
|
+
source: unknown;
|
|
42
|
+
data: unknown;
|
|
43
|
+
}, expected: {
|
|
44
|
+
origin: string;
|
|
45
|
+
window: unknown;
|
|
46
|
+
}): FrameMessage | null;
|
|
47
|
+
export declare function callFrameFor(): string | null;
|
|
48
|
+
/** Open the call's frame — once per call; the same call again changes nothing. Shown when it says it is ready. */
|
|
49
|
+
export declare function openCallFrame(src: string, id: string, h: FrameHandlers, readyWithinMs?: number): void;
|
|
50
|
+
export declare function closeCallFrame(): void;
|
|
@@ -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/dist/ui/ring.d.ts
CHANGED
|
@@ -14,6 +14,12 @@ export declare function ringAbout(template: string, call: RingCall): string;
|
|
|
14
14
|
export declare function showRing(call: RingCall, handlers: RingHandlers): void;
|
|
15
15
|
/** Opened: the card says where to answer, and stops ringing. `blockedUrl` — the browser refused the tab: a link, which is a fresh press. */
|
|
16
16
|
export declare function ringOpened(blockedUrl?: string): void;
|
|
17
|
+
/**
|
|
18
|
+
* 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 —
|
|
19
|
+
* the ring and the title stay, rung from this page, until the call stops ringing. `false` brings ours back (the frame
|
|
20
|
+
* failed: our card carries the call again).
|
|
21
|
+
*/
|
|
22
|
+
export declare function quietRing(id: string, quiet?: boolean): void;
|
|
17
23
|
/** The call is no longer ringing for this person — answered, declined, missed or ended. */
|
|
18
24
|
export declare function hideRing(id?: string): void;
|
|
19
25
|
export declare function ringShownFor(): string | null;
|
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/config.ts
CHANGED
|
@@ -161,6 +161,20 @@ export interface DebugCaptureConfig {
|
|
|
161
161
|
*/
|
|
162
162
|
ring?: boolean;
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* The call itself, in this app. Default true (0.16).
|
|
166
|
+
*
|
|
167
|
+
* When a call rings, the support host's own call — its ring card, its call
|
|
168
|
+
* corner, its screen-sharing question, its voice settings — opens in a frame
|
|
169
|
+
* in this page, signed in with the same token the portal button uses: the
|
|
170
|
+
* reporter answers, talks and shows their screen without leaving the app.
|
|
171
|
+
* The frame needs nothing of the host but leave to frame the support host and
|
|
172
|
+
* to give it the microphone, camera and screen (no `frame-src` or
|
|
173
|
+
* `Permissions-Policy` that forbids them). Should the frame not come up, the
|
|
174
|
+
* card of 0.15 carries the call: open the portal to answer. `false`: always that.
|
|
175
|
+
*/
|
|
176
|
+
callFrame?: boolean;
|
|
177
|
+
|
|
164
178
|
/**
|
|
165
179
|
* Where this library's own notifications go.
|
|
166
180
|
*
|
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
|
+
}
|