@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.
- package/README.md +241 -0
- package/dist/budget.d.ts +22 -0
- package/dist/bundle.d.ts +20 -0
- package/dist/capture/actionTrail.d.ts +7 -0
- package/dist/capture/cause.d.ts +3 -0
- package/dist/capture/consoleBuffer.d.ts +4 -0
- package/dist/capture/crashWatcher.d.ts +1 -0
- package/dist/capture/networkBuffer.d.ts +4 -0
- package/dist/capture/redact.d.ts +9 -0
- package/dist/capture/stepCorrelation.d.ts +41 -0
- package/dist/config.d.ts +217 -0
- package/dist/context.d.ts +2 -0
- package/dist/debug-capture.js +116 -0
- package/dist/embed.d.ts +1 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.mjs +129 -0
- package/dist/install.d.ts +5 -0
- package/dist/mytickets/api.d.ts +115 -0
- package/dist/mytickets/format.d.ts +84 -0
- package/dist/mytickets/sanitize.d.ts +66 -0
- package/dist/mytickets/strings.d.ts +74 -0
- package/dist/mytickets/toolbar.d.ts +17 -0
- package/dist/reporter.d.ts +27 -0
- package/dist/screenshot.d.ts +7 -0
- package/dist/signature.d.ts +18 -0
- package/dist/submit.d.ts +8 -0
- package/dist/types.d.ts +175 -0
- package/dist/ui/annotator.d.ts +34 -0
- package/dist/ui/arrow.d.ts +25 -0
- package/dist/ui/element.d.ts +9 -0
- package/dist/ui/strings.d.ts +42 -0
- package/dist/ui/styles.d.ts +13 -0
- package/dist/ui/toast.d.ts +11 -0
- package/package.json +53 -0
- package/src/budget.ts +62 -0
- package/src/bundle.ts +274 -0
- package/src/capture/actionTrail.ts +693 -0
- package/src/capture/cause.ts +49 -0
- package/src/capture/consoleBuffer.ts +80 -0
- package/src/capture/crashWatcher.ts +61 -0
- package/src/capture/networkBuffer.ts +315 -0
- package/src/capture/redact.ts +117 -0
- package/src/capture/stepCorrelation.ts +160 -0
- package/src/config.ts +299 -0
- package/src/context.ts +81 -0
- package/src/embed.ts +35 -0
- package/src/index.ts +109 -0
- package/src/install.ts +59 -0
- package/src/mytickets/api.ts +226 -0
- package/src/mytickets/format.ts +191 -0
- package/src/mytickets/sanitize.ts +221 -0
- package/src/mytickets/strings.ts +217 -0
- package/src/mytickets/toolbar.ts +48 -0
- package/src/reporter.ts +53 -0
- package/src/screenshot.ts +238 -0
- package/src/signature.ts +40 -0
- package/src/styles.css +400 -0
- package/src/submit.ts +143 -0
- package/src/types.ts +169 -0
- package/src/ui/annotator.ts +698 -0
- package/src/ui/arrow.ts +62 -0
- package/src/ui/element.ts +362 -0
- package/src/ui/strings.ts +113 -0
- package/src/ui/styles.ts +96 -0
- 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
|
+
}
|