@lightworkai.official/debug-capture 0.6.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.
Files changed (65) hide show
  1. package/README.md +241 -0
  2. package/dist/budget.d.ts +22 -0
  3. package/dist/bundle.d.ts +20 -0
  4. package/dist/capture/actionTrail.d.ts +7 -0
  5. package/dist/capture/cause.d.ts +3 -0
  6. package/dist/capture/consoleBuffer.d.ts +4 -0
  7. package/dist/capture/crashWatcher.d.ts +1 -0
  8. package/dist/capture/networkBuffer.d.ts +4 -0
  9. package/dist/capture/redact.d.ts +9 -0
  10. package/dist/capture/stepCorrelation.d.ts +41 -0
  11. package/dist/config.d.ts +217 -0
  12. package/dist/context.d.ts +2 -0
  13. package/dist/debug-capture.js +116 -0
  14. package/dist/embed.d.ts +1 -0
  15. package/dist/index.d.ts +45 -0
  16. package/dist/index.mjs +129 -0
  17. package/dist/install.d.ts +5 -0
  18. package/dist/mytickets/api.d.ts +115 -0
  19. package/dist/mytickets/format.d.ts +84 -0
  20. package/dist/mytickets/sanitize.d.ts +66 -0
  21. package/dist/mytickets/strings.d.ts +74 -0
  22. package/dist/mytickets/toolbar.d.ts +17 -0
  23. package/dist/reporter.d.ts +27 -0
  24. package/dist/screenshot.d.ts +7 -0
  25. package/dist/signature.d.ts +18 -0
  26. package/dist/submit.d.ts +8 -0
  27. package/dist/types.d.ts +175 -0
  28. package/dist/ui/annotator.d.ts +34 -0
  29. package/dist/ui/arrow.d.ts +25 -0
  30. package/dist/ui/element.d.ts +9 -0
  31. package/dist/ui/strings.d.ts +42 -0
  32. package/dist/ui/styles.d.ts +13 -0
  33. package/dist/ui/toast.d.ts +11 -0
  34. package/package.json +53 -0
  35. package/src/budget.ts +62 -0
  36. package/src/bundle.ts +274 -0
  37. package/src/capture/actionTrail.ts +693 -0
  38. package/src/capture/cause.ts +49 -0
  39. package/src/capture/consoleBuffer.ts +80 -0
  40. package/src/capture/crashWatcher.ts +61 -0
  41. package/src/capture/networkBuffer.ts +315 -0
  42. package/src/capture/redact.ts +117 -0
  43. package/src/capture/stepCorrelation.ts +160 -0
  44. package/src/config.ts +299 -0
  45. package/src/context.ts +81 -0
  46. package/src/embed.ts +35 -0
  47. package/src/index.ts +109 -0
  48. package/src/install.ts +59 -0
  49. package/src/mytickets/api.ts +226 -0
  50. package/src/mytickets/format.ts +191 -0
  51. package/src/mytickets/sanitize.ts +221 -0
  52. package/src/mytickets/strings.ts +217 -0
  53. package/src/mytickets/toolbar.ts +48 -0
  54. package/src/reporter.ts +53 -0
  55. package/src/screenshot.ts +238 -0
  56. package/src/signature.ts +40 -0
  57. package/src/styles.css +400 -0
  58. package/src/submit.ts +143 -0
  59. package/src/types.ts +169 -0
  60. package/src/ui/annotator.ts +698 -0
  61. package/src/ui/arrow.ts +62 -0
  62. package/src/ui/element.ts +362 -0
  63. package/src/ui/strings.ts +113 -0
  64. package/src/ui/styles.ts +96 -0
  65. package/src/ui/toast.ts +138 -0
@@ -0,0 +1,698 @@
1
+ /**
2
+ * Draw on the screenshot before filing it.
3
+ *
4
+ * "The button on the right" is not a bug report; a red box around it is. This is
5
+ * the highest-value thing in the widget, because it turns a picture of a whole
6
+ * screen into a picture of the problem.
7
+ *
8
+ * The original does this with fabric.js and gets selection, handles and a delete
9
+ * control for free. This is plain canvas — fabric is ~290 kB, and a scene graph
10
+ * to draw four shapes is not a cost every host app should carry. What it must
11
+ * NOT be is a downgrade, so everything a person actually does to an annotation
12
+ * is here: draw, select, move, resize from any handle, delete from the ✕ on the
13
+ * selection, retype a label, clear.
14
+ *
15
+ * Rotation is the one thing fabric gives that this does not. Nobody rotates a
16
+ * box drawn around a broken button.
17
+ */
18
+
19
+ import { arrowPath } from "./arrow";
20
+
21
+ export type AnnotatorTool = "rect" | "arrow" | "highlight" | "text";
22
+
23
+ interface Shape {
24
+ tool: AnnotatorTool;
25
+ /** Image-space, so a shape survives the canvas being laid out at any width. */
26
+ x1: number;
27
+ y1: number;
28
+ x2: number;
29
+ y2: number;
30
+ text?: string;
31
+ /** Text only: drawn height of the glyphs, resized by its handle. */
32
+ size?: number;
33
+ }
34
+
35
+ /*
36
+ * The same ink as the support app's own annotator.
37
+ *
38
+ * The two are separate implementations on purpose — fabric there, plain canvas
39
+ * here — but a box drawn in one has to look like a box drawn in the other, or
40
+ * the same person annotating from two screens produces two different-looking
41
+ * tickets. These constants are the whole of that agreement, so they are the
42
+ * first thing to check when either side changes.
43
+ */
44
+ const STROKE = "#f44336";
45
+ const HIGHLIGHT = "rgba(250, 204, 21, 0.4)";
46
+ /** Matches the app's FONT_SIZE. */
47
+ const TEXT_SIZE = 22;
48
+ const SELECTED = "#2563eb";
49
+ /** Handle and control sizes, in CSS pixels — constant however the image scales. */
50
+ const HANDLE = 9;
51
+ const DELETE_R = 11;
52
+ const DELETE_GAP = 18;
53
+ /** A drag shorter than this was a click that missed, not a shape. */
54
+ const CLICK_SLOP = 4;
55
+ const MIN_SIZE = 8;
56
+
57
+ /** Corner and edge handles, as unit offsets within the shape's box. */
58
+ const GRIPS = [
59
+ { id: "nw", fx: 0, fy: 0, cursor: "nwse-resize" },
60
+ { id: "n", fx: 0.5, fy: 0, cursor: "ns-resize" },
61
+ { id: "ne", fx: 1, fy: 0, cursor: "nesw-resize" },
62
+ { id: "e", fx: 1, fy: 0.5, cursor: "ew-resize" },
63
+ { id: "se", fx: 1, fy: 1, cursor: "nwse-resize" },
64
+ { id: "s", fx: 0.5, fy: 1, cursor: "ns-resize" },
65
+ { id: "sw", fx: 0, fy: 1, cursor: "nesw-resize" },
66
+ { id: "w", fx: 0, fy: 0.5, cursor: "ew-resize" },
67
+ ] as const;
68
+
69
+ type GripId = (typeof GRIPS)[number]["id"] | "tail" | "head" | "size";
70
+
71
+ export interface Annotator {
72
+ setTool(tool: AnnotatorTool | null): void;
73
+ deleteSelected(): void;
74
+ clear(): void;
75
+ /** The annotated image at FULL resolution — never the on-screen scale. */
76
+ toDataUrl(): string;
77
+ hasShapes(): boolean;
78
+ hasSelection(): boolean;
79
+ destroy(): void;
80
+ }
81
+
82
+ interface Box {
83
+ left: number;
84
+ top: number;
85
+ right: number;
86
+ bottom: number;
87
+ }
88
+
89
+ export function createAnnotator(
90
+ canvas: HTMLCanvasElement,
91
+ image: HTMLImageElement,
92
+ /**
93
+ * The drawing or the active tool changed — the toolbar mirrors both, so it
94
+ * has to hear about a tool being consumed as well as a shape being added.
95
+ */
96
+ notify: () => void,
97
+ ): Annotator {
98
+ const shapes: Shape[] = [];
99
+ let tool: AnnotatorTool | null = null;
100
+ let selected = -1;
101
+ let drafting: Shape | null = null;
102
+ /** What the current pointer drag is doing to the selection. */
103
+ let grabbing: { grip: GripId | "body"; x: number; y: number; start: Shape } | null = null;
104
+
105
+ const ctx = canvas.getContext("2d");
106
+
107
+ /**
108
+ * Image pixels per CSS pixel. The canvas backing store is the image's own
109
+ * size and CSS scales it down, so a handle drawn at a fixed backing-store size
110
+ * would shrink on a big screenshot. Everything chrome-sized multiplies by this.
111
+ */
112
+ function px(): number {
113
+ const width = canvas.getBoundingClientRect().width;
114
+ return width > 0 ? canvas.width / width : 1;
115
+ }
116
+
117
+ function pointAt(event: PointerEvent): { x: number; y: number } {
118
+ const box = canvas.getBoundingClientRect();
119
+ return {
120
+ x: ((event.clientX - box.left) / box.width) * canvas.width,
121
+ y: ((event.clientY - box.top) / box.height) * canvas.height,
122
+ };
123
+ }
124
+
125
+ /* --------------------------------------------------------- geometry ---- */
126
+
127
+ function textWidth(s: Shape): number {
128
+ const size = s.size ?? TEXT_SIZE;
129
+ if (!ctx) return (s.text?.length ?? 0) * size * 0.6;
130
+ ctx.save();
131
+ ctx.font = `600 ${size}px system-ui, sans-serif`;
132
+ const w = ctx.measureText(s.text ?? "").width;
133
+ ctx.restore();
134
+ return w;
135
+ }
136
+
137
+ function boxOf(s: Shape): Box {
138
+ if (s.tool === "text") {
139
+ const size = s.size ?? TEXT_SIZE;
140
+ return { left: s.x1, top: s.y1, right: s.x1 + textWidth(s), bottom: s.y1 + size * 1.25 };
141
+ }
142
+ return {
143
+ left: Math.min(s.x1, s.x2),
144
+ top: Math.min(s.y1, s.y2),
145
+ right: Math.max(s.x1, s.x2),
146
+ bottom: Math.max(s.y1, s.y2),
147
+ };
148
+ }
149
+
150
+ /** Which handles a shape offers. An arrow has two ends, not eight sides. */
151
+ function gripsOf(s: Shape): { id: GripId; x: number; y: number; cursor: string }[] {
152
+ if (s.tool === "arrow") {
153
+ return [
154
+ { id: "tail", x: s.x1, y: s.y1, cursor: "move" },
155
+ { id: "head", x: s.x2, y: s.y2, cursor: "move" },
156
+ ];
157
+ }
158
+ const b = boxOf(s);
159
+ if (s.tool === "text") {
160
+ return [{ id: "size", x: b.right, y: b.bottom, cursor: "nwse-resize" }];
161
+ }
162
+ return GRIPS.map((g) => ({
163
+ id: g.id as GripId,
164
+ x: b.left + (b.right - b.left) * g.fx,
165
+ y: b.top + (b.bottom - b.top) * g.fy,
166
+ cursor: g.cursor,
167
+ }));
168
+ }
169
+
170
+ function deleteAt(s: Shape): { x: number; y: number } {
171
+ const b = boxOf(s);
172
+ return { x: b.right + DELETE_GAP * px(), y: b.top - DELETE_GAP * px() };
173
+ }
174
+
175
+ /* ---------------------------------------------------------- drawing ---- */
176
+
177
+ function drawArrow(c: CanvasRenderingContext2D, s: Shape, scale: number): void {
178
+ // The SAME scale the caller used for lineWidth. Reading px() here instead
179
+ // meant the exported arrow kept a full-size head on a hairline shaft,
180
+ // because the export draws at a different scale from the screen.
181
+ const { shaft, head } = arrowPath(
182
+ { x: s.x1, y: s.y1 },
183
+ { x: s.x2, y: s.y2 },
184
+ Math.max(10, 16 * scale),
185
+ );
186
+ if (shaft) {
187
+ // Butt cap: a round cap would bulge past the head's base edge and put
188
+ // back the spur this geometry exists to remove.
189
+ c.lineCap = "butt";
190
+ c.beginPath();
191
+ c.moveTo(shaft.from.x, shaft.from.y);
192
+ c.lineTo(shaft.to.x, shaft.to.y);
193
+ c.stroke();
194
+ }
195
+ c.beginPath();
196
+ c.moveTo(head[0].x, head[0].y);
197
+ c.lineTo(head[1].x, head[1].y);
198
+ c.lineTo(head[2].x, head[2].y);
199
+ c.closePath();
200
+ c.fill();
201
+ }
202
+
203
+ function drawShape(c: CanvasRenderingContext2D, s: Shape, scale: number): void {
204
+ c.save();
205
+ c.lineWidth = Math.max(2, 3 * scale);
206
+ c.strokeStyle = STROKE;
207
+ c.fillStyle = STROKE;
208
+ const b = boxOf(s);
209
+
210
+ if (s.tool === "rect") {
211
+ c.strokeRect(b.left, b.top, b.right - b.left, b.bottom - b.top);
212
+ } else if (s.tool === "arrow") {
213
+ drawArrow(c, s, scale);
214
+ } else if (s.tool === "highlight") {
215
+ c.fillStyle = HIGHLIGHT;
216
+ c.fillRect(b.left, b.top, b.right - b.left, b.bottom - b.top);
217
+ } else if (s.tool === "text" && s.text) {
218
+ const size = s.size ?? TEXT_SIZE;
219
+ c.font = `600 ${size}px system-ui, sans-serif`;
220
+ c.textBaseline = "top";
221
+ // A plate behind it: red text on an arbitrary screenshot is unreadable
222
+ // about half the time.
223
+ const pad = size * 0.22;
224
+ c.fillStyle = "rgba(255,255,255,0.92)";
225
+ c.fillRect(b.left - pad, b.top - pad, b.right - b.left + pad * 2, b.bottom - b.top + pad * 2);
226
+ c.fillStyle = STROKE;
227
+ c.fillText(s.text, b.left, b.top);
228
+ }
229
+ c.restore();
230
+ }
231
+
232
+ function drawSelection(c: CanvasRenderingContext2D, s: Shape, scale: number): void {
233
+ const b = boxOf(s);
234
+ c.save();
235
+ c.strokeStyle = SELECTED;
236
+ c.lineWidth = Math.max(1, 1.5 * scale);
237
+ c.setLineDash([6 * scale, 4 * scale]);
238
+ c.strokeRect(b.left, b.top, b.right - b.left, b.bottom - b.top);
239
+ c.setLineDash([]);
240
+
241
+ const size = HANDLE * scale;
242
+ for (const g of gripsOf(s)) {
243
+ c.fillStyle = "#fff";
244
+ c.strokeStyle = SELECTED;
245
+ c.lineWidth = Math.max(1, 1.5 * scale);
246
+ c.beginPath();
247
+ c.rect(g.x - size / 2, g.y - size / 2, size, size);
248
+ c.fill();
249
+ c.stroke();
250
+ }
251
+
252
+ // The ✕, as the original's delete control: on the selection, not in a menu.
253
+ const d = deleteAt(s);
254
+ const r = DELETE_R * scale;
255
+ c.beginPath();
256
+ c.arc(d.x, d.y, r, 0, Math.PI * 2);
257
+ c.fillStyle = "#dc2626";
258
+ c.fill();
259
+ c.strokeStyle = "#fff";
260
+ c.lineWidth = Math.max(1.5, 2 * scale);
261
+ c.beginPath();
262
+ c.moveTo(d.x - r * 0.4, d.y - r * 0.4);
263
+ c.lineTo(d.x + r * 0.4, d.y + r * 0.4);
264
+ c.moveTo(d.x + r * 0.4, d.y - r * 0.4);
265
+ c.lineTo(d.x - r * 0.4, d.y + r * 0.4);
266
+ c.stroke();
267
+ c.restore();
268
+ }
269
+
270
+ function render(): void {
271
+ if (!ctx) return;
272
+ const scale = px();
273
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
274
+ ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
275
+ shapes.forEach((s, i) => {
276
+ if (i !== editing) drawShape(ctx, s, scale);
277
+ });
278
+ if (drafting) drawShape(ctx, drafting, scale);
279
+ const current = shapes[selected];
280
+ if (current && !drafting) drawSelection(ctx, current, scale);
281
+ }
282
+
283
+ /* ------------------------------------------------------- hit testing ---- */
284
+
285
+ function near(ax: number, ay: number, bx: number, by: number, radius: number): boolean {
286
+ return Math.abs(ax - bx) <= radius && Math.abs(ay - by) <= radius;
287
+ }
288
+
289
+ function gripAt(x: number, y: number): GripId | null {
290
+ const s = shapes[selected];
291
+ if (!s) return null;
292
+ const r = (HANDLE * px()) / 2 + 3 * px();
293
+ for (const g of gripsOf(s)) if (near(x, y, g.x, g.y, r)) return g.id;
294
+ return null;
295
+ }
296
+
297
+ function overDelete(x: number, y: number): boolean {
298
+ const s = shapes[selected];
299
+ if (!s) return false;
300
+ const d = deleteAt(s);
301
+ return near(x, y, d.x, d.y, DELETE_R * px());
302
+ }
303
+
304
+ /** Topmost first — the shape drawn last is the one the eye treats as on top. */
305
+ function shapeAt(x: number, y: number): number {
306
+ const pad = 6 * px();
307
+ for (let i = shapes.length - 1; i >= 0; i -= 1) {
308
+ const s = shapes[i]!;
309
+ if (s.tool === "arrow") {
310
+ // A line has no area; measure distance to the segment instead.
311
+ const dx = s.x2 - s.x1;
312
+ const dy = s.y2 - s.y1;
313
+ const len = Math.hypot(dx, dy) || 1;
314
+ const t = Math.max(0, Math.min(1, ((x - s.x1) * dx + (y - s.y1) * dy) / (len * len)));
315
+ if (Math.hypot(x - (s.x1 + t * dx), y - (s.y1 + t * dy)) <= pad + 4 * px()) return i;
316
+ continue;
317
+ }
318
+ const b = boxOf(s);
319
+ if (x >= b.left - pad && x <= b.right + pad && y >= b.top - pad && y <= b.bottom + pad) return i;
320
+ }
321
+ return -1;
322
+ }
323
+
324
+ /* ------------------------------------------------------------ input ---- */
325
+
326
+ function resize(s: Shape, grip: GripId, start: Shape, dx: number, dy: number): void {
327
+ if (grip === "tail") {
328
+ s.x1 = start.x1 + dx;
329
+ s.y1 = start.y1 + dy;
330
+ return;
331
+ }
332
+ if (grip === "head") {
333
+ s.x2 = start.x2 + dx;
334
+ s.y2 = start.y2 + dy;
335
+ return;
336
+ }
337
+ if (grip === "size") {
338
+ const grown = (start.size ?? 24) + dy;
339
+ s.size = Math.max(10, grown);
340
+ return;
341
+ }
342
+ const b = boxOf(start);
343
+ let { left, top, right, bottom } = b;
344
+ if (grip.includes("w")) left = Math.min(right - MIN_SIZE, left + dx);
345
+ if (grip.includes("e")) right = Math.max(left + MIN_SIZE, right + dx);
346
+ if (grip.includes("n")) top = Math.min(bottom - MIN_SIZE, top + dy);
347
+ if (grip.includes("s")) bottom = Math.max(top + MIN_SIZE, bottom + dy);
348
+ s.x1 = left;
349
+ s.y1 = top;
350
+ s.x2 = right;
351
+ s.y2 = bottom;
352
+ }
353
+
354
+ function move(s: Shape, start: Shape, dx: number, dy: number): void {
355
+ s.x1 = start.x1 + dx;
356
+ s.y1 = start.y1 + dy;
357
+ s.x2 = start.x2 + dx;
358
+ s.y2 = start.y2 + dy;
359
+ }
360
+
361
+ /* ------------------------------------------------------ text editing ---- */
362
+
363
+ /**
364
+ * Type on the image, not in a dialog.
365
+ *
366
+ * This was `window.prompt`, which is the wrong shape for the job in every
367
+ * way: it steals focus from the page, it renders nowhere near the place you
368
+ * clicked, it cannot show the text at the size and colour it will actually
369
+ * be, and on a report dialog it stacks a system modal on top of a modal.
370
+ * fabric and every canvas tool put a caret where you clicked, so this does.
371
+ *
372
+ * A real <input> positioned over the canvas, matched to the shape's own font,
373
+ * committed on Enter or blur and abandoned on Escape. The shape being edited
374
+ * is hidden from the canvas while its editor is open, so the text does not
375
+ * render twice.
376
+ */
377
+ let editor: HTMLInputElement | null = null;
378
+ /** Index of the shape being edited, or -1 while composing a new one. */
379
+ let editing = -1;
380
+ let editorAt = { x: 0, y: 0, size: 24 };
381
+
382
+ /** CSS pixels per image pixel — the inverse of px(). */
383
+ function viewScale(): number {
384
+ const width = canvas.getBoundingClientRect().width;
385
+ return width > 0 ? width / canvas.width : 1;
386
+ }
387
+
388
+ function measure(text: string, sizePx: number): number {
389
+ if (!ctx) return text.length * sizePx * 0.6;
390
+ ctx.save();
391
+ ctx.font = `600 ${sizePx}px system-ui, sans-serif`;
392
+ const w = ctx.measureText(text || " ").width;
393
+ ctx.restore();
394
+ return w;
395
+ }
396
+
397
+ function placeEditor(): void {
398
+ if (!editor) return;
399
+ const host = canvas.parentElement;
400
+ if (!host) return;
401
+ const rect = canvas.getBoundingClientRect();
402
+ const hostRect = host.getBoundingClientRect();
403
+ const scale = viewScale();
404
+ const fontPx = editorAt.size * scale;
405
+ editor.style.left = `${rect.left - hostRect.left + editorAt.x * scale}px`;
406
+ editor.style.top = `${rect.top - hostRect.top + editorAt.y * scale}px`;
407
+ editor.style.fontSize = `${fontPx}px`;
408
+ // Grow with the text, like a caret on a canvas rather than a form field.
409
+ editor.style.width = `${Math.max(40, measure(editor.value, fontPx) + fontPx)}px`;
410
+ }
411
+
412
+ function closeEditor(commit: boolean): void {
413
+ const input = editor;
414
+ if (!input) return;
415
+ editor = null; // cleared first: removing it fires blur, which re-enters here
416
+ const value = input.value.trim();
417
+ const index = editing;
418
+ editing = -1;
419
+ input.remove();
420
+
421
+ if (commit && value) {
422
+ if (index >= 0 && shapes[index]) {
423
+ shapes[index]!.text = value;
424
+ selected = index;
425
+ } else {
426
+ shapes.push({
427
+ tool: "text",
428
+ x1: editorAt.x,
429
+ y1: editorAt.y,
430
+ x2: editorAt.x,
431
+ y2: editorAt.y,
432
+ text: value,
433
+ size: editorAt.size,
434
+ });
435
+ selected = shapes.length - 1;
436
+ }
437
+ notify();
438
+ } else if (index >= 0) {
439
+ selected = index;
440
+ }
441
+ canvas.focus();
442
+ render();
443
+ }
444
+
445
+ function openEditor(x: number, y: number, index = -1): void {
446
+ closeEditor(true);
447
+ const host = canvas.parentElement;
448
+ if (!host) return;
449
+ if (getComputedStyle(host).position === "static") host.style.position = "relative";
450
+
451
+ const existing = index >= 0 ? shapes[index] : undefined;
452
+ editing = index;
453
+ editorAt = {
454
+ x: existing?.x1 ?? x,
455
+ y: existing?.y1 ?? y,
456
+ size: existing?.size ?? Math.round(24 * px()),
457
+ };
458
+
459
+ const input = document.createElement("input");
460
+ input.type = "text";
461
+ input.className = "text-edit";
462
+ input.value = existing?.text ?? "";
463
+ input.setAttribute("aria-label", "ข้อความบนภาพ");
464
+ // "ข้อความ", not "พิมพ์ข้อความ": the app seeds a new text shape with exactly
465
+ // this word, so an empty box in either tool reads the same.
466
+ input.placeholder = "ข้อความ";
467
+ editor = input;
468
+ host.append(input);
469
+ placeEditor();
470
+
471
+ input.addEventListener("input", placeEditor);
472
+ input.addEventListener("keydown", (event) => {
473
+ // Stop here: the canvas listens for Delete and Escape, and a Backspace
474
+ // while typing must edit the word, not delete the shape.
475
+ event.stopPropagation();
476
+ if (event.key === "Enter") closeEditor(true);
477
+ else if (event.key === "Escape") closeEditor(false);
478
+ });
479
+ /*
480
+ * Focus on the next frame, and only start listening for blur once it has
481
+ * actually landed.
482
+ *
483
+ * The canvas carries tabIndex=0 so it can take Delete and Escape, which
484
+ * means the browser's own focus-on-mousedown moves focus to the CANVAS
485
+ * after this handler returns. Focusing the input synchronously here lost
486
+ * the race: the input blurred immediately, committed an empty value, and
487
+ * removed itself before a single key could be typed — the tool looked like
488
+ * it did nothing at all.
489
+ */
490
+ requestAnimationFrame(() => {
491
+ if (editor !== input) return; // superseded while we waited
492
+ input.focus();
493
+ input.select();
494
+ input.addEventListener("blur", () => closeEditor(true));
495
+ });
496
+ render();
497
+ }
498
+
499
+ function onPointerDown(event: PointerEvent): void {
500
+ const { x, y } = pointAt(event);
501
+ canvas.setPointerCapture(event.pointerId);
502
+
503
+ // The ✕ and the handles belong to the current selection, so they are tested
504
+ // before anything else — they sit outside the shape and would otherwise be
505
+ // swallowed by whatever is behind them.
506
+ if (overDelete(x, y)) {
507
+ deleteSelected();
508
+ return;
509
+ }
510
+ const grip = gripAt(x, y);
511
+ if (grip && shapes[selected]) {
512
+ grabbing = { grip, x, y, start: { ...shapes[selected]! } };
513
+ return;
514
+ }
515
+
516
+ if (tool === "text") {
517
+ // Stop the canvas taking focus from the caret we are about to place.
518
+ event.preventDefault();
519
+ if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
520
+ tool = null;
521
+ // Tell the toolbar the tool is spent, HERE and not once text is
522
+ // committed. It used to fire only on commit, so cancelling an empty
523
+ // caret left the button stuck pressed while the tool was already null —
524
+ // and the next click on ข้อความ read "pressed" and toggled it OFF, so
525
+ // clicking the image did nothing until you pressed the button twice.
526
+ notify();
527
+ openEditor(x, y);
528
+ return;
529
+ }
530
+
531
+ if (tool) {
532
+ drafting = { tool, x1: x, y1: y, x2: x, y2: y };
533
+ selected = -1;
534
+ return;
535
+ }
536
+
537
+ const hit = shapeAt(x, y);
538
+ selected = hit;
539
+ grabbing = hit >= 0 ? { grip: "body", x, y, start: { ...shapes[hit]! } } : null;
540
+ render();
541
+ }
542
+
543
+ function onPointerMove(event: PointerEvent): void {
544
+ const { x, y } = pointAt(event);
545
+
546
+ if (drafting) {
547
+ drafting.x2 = x;
548
+ drafting.y2 = y;
549
+ render();
550
+ return;
551
+ }
552
+
553
+ if (grabbing) {
554
+ const s = shapes[selected];
555
+ if (!s) return;
556
+ const dx = x - grabbing.x;
557
+ const dy = y - grabbing.y;
558
+ if (grabbing.grip === "body") move(s, grabbing.start, dx, dy);
559
+ else resize(s, grabbing.grip, grabbing.start, dx, dy);
560
+ render();
561
+ return;
562
+ }
563
+
564
+ // Cursor as the affordance: a handle that does not say it is draggable is a
565
+ // handle nobody drags.
566
+ if (tool) {
567
+ canvas.style.cursor = "crosshair";
568
+ return;
569
+ }
570
+ if (overDelete(x, y)) {
571
+ canvas.style.cursor = "pointer";
572
+ return;
573
+ }
574
+ const grip = gripAt(x, y);
575
+ if (grip) {
576
+ const found = shapes[selected] ? gripsOf(shapes[selected]!).find((g) => g.id === grip) : null;
577
+ canvas.style.cursor = found?.cursor ?? "pointer";
578
+ return;
579
+ }
580
+ canvas.style.cursor = shapeAt(x, y) >= 0 ? "move" : "default";
581
+ }
582
+
583
+ function onPointerUp(): void {
584
+ if (drafting) {
585
+ const tiny =
586
+ Math.abs(drafting.x2 - drafting.x1) < CLICK_SLOP &&
587
+ Math.abs(drafting.y2 - drafting.y1) < CLICK_SLOP;
588
+ if (!tiny) {
589
+ shapes.push(drafting);
590
+ selected = shapes.length - 1;
591
+ // One shape per tool press, as the original does: it stops a stray drag
592
+ // from covering the screenshot in boxes, and it hands the new shape to
593
+ // you selected so you can immediately move or resize it.
594
+ tool = null;
595
+ notify();
596
+ }
597
+ drafting = null;
598
+ }
599
+ if (grabbing) {
600
+ grabbing = null;
601
+ notify();
602
+ }
603
+ render();
604
+ }
605
+
606
+ /** Retype a label without deleting and redrawing it. */
607
+ function onDoubleClick(event: MouseEvent): void {
608
+ const box = canvas.getBoundingClientRect();
609
+ const x = ((event.clientX - box.left) / box.width) * canvas.width;
610
+ const y = ((event.clientY - box.top) / box.height) * canvas.height;
611
+ const hit = shapeAt(x, y);
612
+ const s = shapes[hit];
613
+ if (!s || s.tool !== "text") return;
614
+ event.preventDefault();
615
+ openEditor(s.x1, s.y1, hit);
616
+ }
617
+
618
+ function onKeyDown(event: KeyboardEvent): void {
619
+ if (selected < 0) return;
620
+ if (event.key === "Delete" || event.key === "Backspace") {
621
+ event.preventDefault();
622
+ deleteSelected();
623
+ } else if (event.key === "Escape") {
624
+ selected = -1;
625
+ render();
626
+ }
627
+ }
628
+
629
+ function deleteSelected(): void {
630
+ if (selected < 0) return;
631
+ shapes.splice(selected, 1);
632
+ selected = -1;
633
+ notify();
634
+ render();
635
+ }
636
+
637
+ canvas.addEventListener("pointerdown", onPointerDown);
638
+ canvas.addEventListener("pointermove", onPointerMove);
639
+ canvas.addEventListener("pointerup", onPointerUp);
640
+ canvas.addEventListener("pointercancel", onPointerUp);
641
+ canvas.addEventListener("dblclick", onDoubleClick);
642
+ // On the canvas, not the window: a Backspace meant for the note field must not
643
+ // delete a shape.
644
+ canvas.tabIndex = 0;
645
+ canvas.addEventListener("keydown", onKeyDown);
646
+
647
+ render();
648
+
649
+ return {
650
+ setTool(next) {
651
+ tool = next;
652
+ selected = -1;
653
+ canvas.style.cursor = next ? "crosshair" : "default";
654
+ render();
655
+ },
656
+ deleteSelected,
657
+ clear() {
658
+ shapes.length = 0;
659
+ selected = -1;
660
+ notify();
661
+ render();
662
+ },
663
+ hasShapes: () => shapes.length > 0,
664
+ hasSelection: () => selected >= 0,
665
+ toDataUrl() {
666
+ // Redrawn at the image's own resolution and WITHOUT the selection chrome:
667
+ // exporting the on-screen canvas would ship handles and a ✕ into the
668
+ // ticket, at whatever size the dialog happened to be.
669
+ const full = document.createElement("canvas");
670
+ full.width = image.naturalWidth;
671
+ full.height = image.naturalHeight;
672
+ const c = full.getContext("2d");
673
+ if (!c) return canvas.toDataURL("image/png");
674
+ c.drawImage(image, 0, 0);
675
+ /*
676
+ * The scale the user drew at, NOT 1.
677
+ *
678
+ * Shapes live in image space, and on screen a stroke is `3 * px()` image
679
+ * pixels so that it LOOKS 3 CSS pixels wide. Exporting at 1 made every
680
+ * stroke 3 image pixels — on a Retina screenshot that is a quarter of
681
+ * what was drawn, so the box someone put round a broken button arrived in
682
+ * the ticket as a hairline.
683
+ */
684
+ const scale = px();
685
+ for (const s of shapes) drawShape(c, s, scale);
686
+ return full.toDataURL("image/png");
687
+ },
688
+ destroy() {
689
+ closeEditor(false);
690
+ canvas.removeEventListener("pointerdown", onPointerDown);
691
+ canvas.removeEventListener("pointermove", onPointerMove);
692
+ canvas.removeEventListener("pointerup", onPointerUp);
693
+ canvas.removeEventListener("pointercancel", onPointerUp);
694
+ canvas.removeEventListener("dblclick", onDoubleClick);
695
+ canvas.removeEventListener("keydown", onKeyDown);
696
+ },
697
+ };
698
+ }