File size: 29,915 Bytes
ba0ce97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
<script lang="ts">
	import Modal from "../Modal.svelte";

	import LucideMessageSquareText from "~icons/lucide/message-square-text";
	import LucideMoveUpRight from "~icons/lucide/move-up-right";
	import LucidePencil from "~icons/lucide/pencil";
	import LucideSquare from "~icons/lucide/square";
	import LucideUndo2 from "~icons/lucide/undo-2";
	import LucideTrash2 from "~icons/lucide/trash-2";
	import LucideX from "~icons/lucide/x";
	import EosIconsLoading from "~icons/eos-icons/loading";

	type Tool = "comment" | "arrow" | "pen" | "rect";
	interface Point {
		x: number;
		y: number;
	}
	/** One drawn shape, in image-space coordinates (canvas pixels, not CSS pixels) */
	interface Annotation {
		tool: "arrow" | "pen" | "rect";
		color: string;
		points: Point[];
	}
	/**
	 * A numbered note anchored to a point (pin: w = h = 0) or a dragged region.
	 * The badge and dashed outline are baked into the exported image; the note
	 * text travels as plain text next to it (see formatScreenshotNotes), so no
	 * user text is ever rasterized.
	 */
	interface CommentAnnotation {
		id: number;
		x: number;
		y: number;
		w: number;
		h: number;
		note: string;
	}

	interface Props {
		/** PNG data URL of the captured screenshot */
		dataUrl: string;
		/** Called with the final (annotated) PNG data URL and the numbered note texts */
		onconfirm: (dataUrl: string, notes: string[]) => void;
		onclose: () => void;
	}

	let { dataUrl, onconfirm, onclose }: Props = $props();

	const COLORS = ["#ef4444", "#f59e0b", "#3b82f6", "#22c55e"];
	// Comments keep one fixed identity (blue badge, dashed region) so they read
	// as "referenced note" rather than a drawing; the palette only affects the
	// drawing tools
	const COMMENT_COLOR = "#3b82f6";
	const TOOLS: { id: Tool; label: string; icon: typeof LucidePencil }[] = [
		{ id: "comment", label: "Comment", icon: LucideMessageSquareText },
		{ id: "arrow", label: "Arrow", icon: LucideMoveUpRight },
		{ id: "pen", label: "Pen", icon: LucidePencil },
		{ id: "rect", label: "Rectangle", icon: LucideSquare },
	];

	let tool = $state<Tool>("comment");
	let color = $state(COLORS[0]);
	let annotations = $state<Annotation[]>([]);
	let current = $state<Annotation | null>(null);
	let comments = $state<CommentAnnotation[]>([]);
	/** Index into comments whose note is being edited (desktop popover target) */
	let editingIndex = $state<number | null>(null);
	/** Live drag preview for the comment tool, kept as origin + pointer */
	let draftRegion = $state<{ ox: number; oy: number; px: number; py: number } | null>(null);
	/** Bumped to re-trigger the focus effect when editingIndex itself doesn't change */
	let focusNonce = $state(0);
	/**
	 * True while a note's IME composition is active. Modal calls `onclose`
	 * without the event, so Escape can't be inspected for `isComposing` there;
	 * tracked here instead (same approach as ChatInput) so an Escape meant to
	 * cancel a composition doesn't dismiss the editor and drop the candidate.
	 */
	let composingNote = $state(false);
	let nextCommentId = 0;

	let canvasEl: HTMLCanvasElement | undefined = $state();
	let containerEl: HTMLDivElement | undefined = $state();
	let popoverEl: HTMLDivElement | undefined = $state();
	let image = $state<HTMLImageElement | null>(null);
	let loadFailed = $state(false);
	let resizeNonce = $state(0);

	// Below sm the note editor is an in-flow list instead of an anchored
	// popover: a floating input mid-canvas loses against the on-screen keyboard
	let isNarrow = $state(false);
	$effect(() => {
		const mq = window.matchMedia("(max-width: 639px)");
		isNarrow = mq.matches;
		const onChange = () => (isNarrow = mq.matches);
		mq.addEventListener("change", onChange);
		return () => mq.removeEventListener("change", onChange);
	});

	$effect(() => {
		const img = new Image();
		let cancelled = false;
		img.onload = () => {
			if (!cancelled) image = img;
		};
		img.onerror = () => {
			if (!cancelled) loadFailed = true;
		};
		img.src = dataUrl;
		return () => {
			// A new dataUrl replaces the whole session: drawings made on the old
			// image must not carry over to the new one
			cancelled = true;
			annotations = [];
			current = null;
			comments = [];
			editingIndex = null;
			composingNote = false;
			draftRegion = null;
			loadFailed = false;
		};
	});

	// Stroke width scales with the capture so annotations stay legible whether
	// the shot is a 400px widget or a 4096px page
	let strokeWidth = $derived(
		image ? Math.max(3, Math.round(Math.max(image.naturalWidth, image.naturalHeight) / 350)) : 3
	);
	let badgeRadius = $derived(Math.max(11, strokeWidth * 2.8));
	/** White halo ring width around badges (shared with the edge clamp) */
	let badgeHalo = $derived(Math.max(2, strokeWidth * 0.6));

	function drawAnnotation(ctx: CanvasRenderingContext2D, annotation: Annotation) {
		const pts = annotation.points;
		if (pts.length === 0) return;
		ctx.strokeStyle = annotation.color;
		ctx.fillStyle = annotation.color;
		ctx.lineWidth = strokeWidth;
		ctx.lineCap = "round";
		ctx.lineJoin = "round";

		if (annotation.tool === "pen") {
			if (pts.length === 1) {
				ctx.beginPath();
				ctx.arc(pts[0].x, pts[0].y, strokeWidth / 2, 0, Math.PI * 2);
				ctx.fill();
				return;
			}
			// Midpoint smoothing so fast strokes don't look like polylines
			ctx.beginPath();
			ctx.moveTo(pts[0].x, pts[0].y);
			for (let i = 1; i < pts.length - 1; i++) {
				const midX = (pts[i].x + pts[i + 1].x) / 2;
				const midY = (pts[i].y + pts[i + 1].y) / 2;
				ctx.quadraticCurveTo(pts[i].x, pts[i].y, midX, midY);
			}
			ctx.lineTo(pts[pts.length - 1].x, pts[pts.length - 1].y);
			ctx.stroke();
			return;
		}

		const start = pts[0];
		const end = pts[pts.length - 1];
		if (annotation.tool === "rect") {
			ctx.strokeRect(
				Math.min(start.x, end.x),
				Math.min(start.y, end.y),
				Math.abs(end.x - start.x),
				Math.abs(end.y - start.y)
			);
			return;
		}

		// Arrow: shaft stops short of the tip so it doesn't poke through the head
		const angle = Math.atan2(end.y - start.y, end.x - start.x);
		const head = strokeWidth * 3.5;
		ctx.beginPath();
		ctx.moveTo(start.x, start.y);
		ctx.lineTo(end.x - Math.cos(angle) * head * 0.6, end.y - Math.sin(angle) * head * 0.6);
		ctx.stroke();
		ctx.beginPath();
		ctx.moveTo(end.x, end.y);
		ctx.lineTo(
			end.x - head * Math.cos(angle - Math.PI / 6),
			end.y - head * Math.sin(angle - Math.PI / 6)
		);
		ctx.lineTo(
			end.x - head * Math.cos(angle + Math.PI / 6),
			end.y - head * Math.sin(angle + Math.PI / 6)
		);
		ctx.closePath();
		ctx.fill();
	}

	function drawCommentMarker(
		ctx: CanvasRenderingContext2D,
		comment: { x: number; y: number; w: number; h: number },
		label: string | null
	) {
		const outlineWidth = Math.max(2.5, strokeWidth);
		if (comment.w > 0 || comment.h > 0) {
			// Two-pass dash: a wider white casing under the blue keeps the
			// outline readable on any artifact, including blue and purple ones
			// (both dashes share the pattern and phase, so the white frames
			// each blue segment)
			ctx.lineCap = "butt";
			ctx.setLineDash([strokeWidth * 2.2, strokeWidth * 1.6]);
			ctx.strokeStyle = "rgba(255, 255, 255, 0.95)";
			ctx.lineWidth = outlineWidth + Math.max(2, strokeWidth * 0.7);
			ctx.strokeRect(comment.x, comment.y, comment.w, comment.h);
			ctx.strokeStyle = COMMENT_COLOR;
			ctx.lineWidth = outlineWidth;
			ctx.strokeRect(comment.x, comment.y, comment.w, comment.h);
			ctx.setLineDash([]);
		}
		if (label === null) return;
		// Badge sits on the pin point / the region's top-left corner, with a
		// white halo so the number stays readable on any background. Its center
		// is pulled inside the canvas so an edge pin doesn't export a clipped,
		// unreadable number.
		const center = badgeCenter(comment);
		ctx.beginPath();
		ctx.arc(center.x, center.y, badgeRadius, 0, Math.PI * 2);
		ctx.fillStyle = COMMENT_COLOR;
		ctx.fill();
		ctx.lineWidth = badgeHalo;
		ctx.strokeStyle = "#ffffff";
		ctx.beginPath();
		ctx.arc(center.x, center.y, badgeRadius, 0, Math.PI * 2);
		ctx.stroke();
		ctx.fillStyle = "#ffffff";
		ctx.font = `600 ${Math.round(badgeRadius * 1.1)}px system-ui, sans-serif`;
		ctx.textAlign = "center";
		ctx.textBaseline = "middle";
		ctx.fillText(label, center.x, center.y + badgeRadius * 0.06);
	}

	/**
	 * Where a comment's badge is drawn: the anchor point, clamped inward so the
	 * circle (and halo) stays fully inside the canvas at the edges. Hit-testing
	 * and popover anchoring use the same point so they match the pixels.
	 */
	function badgeCenter(comment: { x: number; y: number }): Point {
		const canvas = canvasEl;
		if (!canvas) return { x: comment.x, y: comment.y };
		const margin = badgeRadius + badgeHalo / 2;
		return {
			x: Math.min(Math.max(comment.x, margin), Math.max(canvas.width - margin, margin)),
			y: Math.min(Math.max(comment.y, margin), Math.max(canvas.height - margin, margin)),
		};
	}

	function normalizedDraft(region: { ox: number; oy: number; px: number; py: number }) {
		return {
			x: Math.min(region.ox, region.px),
			y: Math.min(region.oy, region.py),
			w: Math.abs(region.px - region.ox),
			h: Math.abs(region.py - region.oy),
		};
	}

	// Full redraw: the canvas holds the composited image at natural resolution,
	// so exporting is just toDataURL on it. Called from the tracking effect (its
	// state reads register the dependencies) and synchronously before export.
	function redraw() {
		const canvas = canvasEl;
		const img = image;
		if (!canvas || !img) return;
		const ctx = canvas.getContext("2d");
		if (!ctx) return;
		ctx.clearRect(0, 0, canvas.width, canvas.height);
		ctx.drawImage(img, 0, 0);
		for (const annotation of annotations) drawAnnotation(ctx, annotation);
		if (current) drawAnnotation(ctx, current);
		comments.forEach((comment, i) => drawCommentMarker(ctx, comment, String(i + 1)));
		if (draftRegion) drawCommentMarker(ctx, normalizedDraft(draftRegion), null);
	}
	$effect(() => {
		redraw();
	});

	function toImagePoint(e: PointerEvent): Point | null {
		const canvas = canvasEl;
		if (!canvas) return null;
		const rect = canvas.getBoundingClientRect();
		if (rect.width === 0 || rect.height === 0) return null;
		return {
			x: Math.min(Math.max(((e.clientX - rect.left) / rect.width) * canvas.width, 0), canvas.width),
			y: Math.min(
				Math.max(((e.clientY - rect.top) / rect.height) * canvas.height, 0),
				canvas.height
			),
		};
	}

	/**
	 * Topmost comment whose badge contains the point, for click-to-reopen.
	 * The hit target has a floor in screen pixels: on a heavily downscaled
	 * capture the badge's image-space radius can shrink to a couple of CSS
	 * pixels, which would make badges effectively unclickable.
	 */
	function commentIndexAt(point: Point): number | null {
		const canvas = canvasEl;
		const displayScale = canvas ? canvas.getBoundingClientRect().width / canvas.width : 1;
		const hitRadius = Math.max(
			badgeRadius * 1.4,
			displayScale > 0 ? 14 / displayScale : badgeRadius * 1.4
		);
		for (let i = comments.length - 1; i >= 0; i--) {
			const center = badgeCenter(comments[i]);
			if (Math.hypot(point.x - center.x, point.y - center.y) <= hitRadius) return i;
		}
		return null;
	}

	function onPointerDown(e: PointerEvent) {
		if (!image || e.button !== 0) return;
		// The canvas never needs focus: without this, a real mouse press moves
		// focus to the dialog AFTER this handler, and the resulting textarea
		// blur would immediately close or even delete the editor this handler
		// just opened (synthetic-event tests don't reproduce that default).
		e.preventDefault();
		const point = toImagePoint(e);
		if (!point) return;
		// A badge is clickable with any tool selected: clicking a number is an
		// unambiguous "show me this note". Clicking the badge of the note that's
		// already open keeps it open and pulls focus back to it (editingIndex
		// doesn't change on this path, so the focus effect needs the nudge).
		if (editingIndex !== null && commentIndexAt(point) === editingIndex) {
			focusNonce += 1;
			return;
		}
		// Settle any open editor first so indices are stable for the hit test
		// (an abandoned empty comment gets dropped here)
		commitEditing();
		const hit = commentIndexAt(point);
		if (hit !== null) {
			editingIndex = hit;
			return;
		}
		if (tool === "comment") {
			(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
			draftRegion = { ox: point.x, oy: point.y, px: point.x, py: point.y };
			return;
		}
		(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
		current = { tool, color, points: [point] };
	}

	function onPointerMove(e: PointerEvent) {
		const point = toImagePoint(e);
		if (!point) return;
		if (draftRegion) {
			draftRegion = { ...draftRegion, px: point.x, py: point.y };
			return;
		}
		if (current) {
			current =
				current.tool === "pen"
					? { ...current, points: [...current.points, point] }
					: { ...current, points: [current.points[0], point] };
			return;
		}
		// Idle hover: badges advertise their clickability
		if (canvasEl) {
			canvasEl.style.cursor = commentIndexAt(point) !== null ? "pointer" : "";
		}
	}

	function onPointerUp() {
		if (draftRegion) {
			const rect = normalizedDraft(draftRegion);
			const moved = Math.hypot(rect.w, rect.h) >= strokeWidth;
			// Click = pin (zero-size), drag = region; either way the editor opens
			comments = [
				...comments,
				moved
					? { id: nextCommentId++, ...rect, note: "" }
					: { id: nextCommentId++, x: draftRegion.ox, y: draftRegion.oy, w: 0, h: 0, note: "" },
			];
			editingIndex = comments.length - 1;
			draftRegion = null;
			return;
		}
		if (!current) return;
		const pts = current.points;
		const moved =
			pts.length > 1 &&
			Math.hypot(pts[pts.length - 1].x - pts[0].x, pts[pts.length - 1].y - pts[0].y) >= strokeWidth;
		// A pen tap leaves a visible dot; a zero-size arrow or rectangle would
		// just be clutter, so clicks without a drag are discarded for those
		if (current.tool === "pen" || moved) {
			annotations = [...annotations, current];
		}
		current = null;
	}

	function onPointerCancel() {
		current = null;
		draftRegion = null;
	}

	/** Close the note editor; a comment left with an empty note is discarded */
	function commitEditing() {
		// The composing textarea is going away, so compositionend may never fire
		composingNote = false;
		if (editingIndex === null) return;
		const index = editingIndex;
		editingIndex = null;
		const comment = comments[index];
		if (comment && !comment.note.trim()) {
			comments = comments.filter((_, i) => i !== index);
		}
	}

	function deleteComment(index: number) {
		comments = comments.filter((_, i) => i !== index);
		if (editingIndex === index) {
			// Deleting via the X suppresses the textarea's blur (to keep focus
			// controlled), so compositionend may never fire for a composing note
			composingNote = false;
			editingIndex = null;
		} else if (editingIndex !== null && editingIndex > index) editingIndex -= 1;
	}

	function onNoteBlur(e: FocusEvent, index: number) {
		// A blur from a textarea that is no longer the active editor (its
		// popover target switched within the same interaction) must not commit
		// against the new editor's index
		if (editingIndex !== index) return;
		// Focus moving within the popover (to its delete button) isn't a dismissal
		if (popoverEl && e.relatedTarget instanceof Node && popoverEl.contains(e.relatedTarget)) return;
		commitEditing();
	}

	/** Blur handler for mobile list rows: empty rows evaporate on leave */
	function onRowBlur(index: number) {
		if (editingIndex === index) {
			composingNote = false;
			editingIndex = null;
		}
		const comment = comments[index];
		if (comment && !comment.note.trim()) deleteComment(index);
	}

	function undo() {
		annotations = annotations.slice(0, -1);
	}

	function clearAll() {
		annotations = [];
		current = null;
		comments = [];
		editingIndex = null;
		composingNote = false;
		draftRegion = null;
	}

	function onKeydown(e: KeyboardEvent) {
		// Typing in a note input keeps native text editing shortcuts
		const target = e.target as HTMLElement | null;
		if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA")) return;
		if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === "z") {
			e.preventDefault();
			undo();
		}
	}

	// Escape reaches us through Modal's onclose: with a note editor open it
	// dismisses just the editor (mirroring how the panel's nested modals handle
	// Escape); otherwise it closes the whole modal. Modal dispatches the same
	// Escape twice when focus is inside the dialog (window capture listener
	// plus the dialog's own keydown), so duplicate calls within one event are
	// latched — otherwise the second call would fall through and close the
	// whole session right after the first one dismissed the editor.
	let closeRequestLatched = false;
	function requestClose() {
		// Escape that is cancelling an IME composition belongs to the IME
		if (composingNote) return;
		if (closeRequestLatched) return;
		closeRequestLatched = true;
		queueMicrotask(() => (closeRequestLatched = false));
		if (editingIndex !== null) {
			commitEditing();
			return;
		}
		onclose();
	}

	function confirm() {
		if (!canvasEl || !image) return;
		commitEditing();
		// Sweep any stragglers so no unnumbered/empty badge gets baked, then
		// repaint synchronously: the effect redraw only lands next flush
		comments = comments.filter((comment) => comment.note.trim());
		redraw();
		onconfirm(
			canvasEl.toDataURL("image/png"),
			comments.map((comment) => comment.note.trim())
		);
	}

	// Focus when the token is set, and again whenever it changes: the popover
	// element survives switching between comments (the {#if} block never
	// unmounts), so a mount-only focus would miss badge-to-badge jumps
	function autofocus(node: HTMLElement, token: unknown = true) {
		function apply(value: unknown) {
			if (value === false || value === null || value === undefined) return;
			// Deferred a task: focusing a just-mounted element synchronously
			// during the render flush doesn't reliably stick. setTimeout rather
			// than requestAnimationFrame — rAF can be throttled to never when the
			// page isn't actively compositing.
			setTimeout(() => {
				if (node.isConnected) {
					node.focus();
					node.scrollIntoView({ block: "nearest" });
				}
			}, 0);
		}
		apply(token);
		return {
			update: apply,
		};
	}

	/** Keep a note textarea as tall as its content (capped by CSS max-height) */
	function autogrow(node: HTMLTextAreaElement) {
		function resize() {
			node.style.height = "auto";
			const contentHeight = node.scrollHeight;
			const maxHeight = parseFloat(getComputedStyle(node).maxHeight);
			// The themed scrollbar is a classic 8px gutter rather than an overlay,
			// so a permanent overflow-y:auto would show a track even for a
			// single-line note (scrollHeight rounds above the box by a pixel).
			// Only allow scrolling once the note is actually clamped at max height.
			node.style.overflowY =
				Number.isFinite(maxHeight) && contentHeight > maxHeight ? "auto" : "hidden";
			node.style.height = `${contentHeight}px`;
		}
		resize();
		node.addEventListener("input", resize);
		return {
			destroy() {
				node.removeEventListener("input", resize);
			},
		};
	}

	/**
	 * Enter commits the note, Shift+Enter inserts a newline. Enter that is
	 * accepting an IME composition candidate (CJK input) must pass through,
	 * or the candidate would be committed as a premature note commit.
	 */
	function onNoteKeydown(e: KeyboardEvent) {
		if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
			e.preventDefault();
			(e.target as HTMLTextAreaElement).blur();
		}
	}

	// Popover focus is driven by state, not element mount: mount-time focus
	// proved unreliable for the reopen path. The popover element is read lazily
	// inside the deferred attempts (untracked on purpose) with a short bounded
	// retry, so the effect doesn't depend on bind:this flush ordering.
	$effect(() => {
		if (isNarrow || editingIndex === null) return;
		void comments[editingIndex]?.id;
		void focusNonce;
		let cancelled = false;
		let tries = 0;
		const attempt = () => {
			if (cancelled) return;
			const target = popoverEl?.querySelector("textarea");
			if (target) {
				target.focus();
				return;
			}
			if (++tries < 10) setTimeout(attempt, 16);
		};
		setTimeout(attempt, 0);
		return () => {
			cancelled = true;
		};
	});

	// ----- desktop popover positioning (image space -> container CSS space) -----
	let popoverStyle = $state("");
	$effect(() => {
		void resizeNonce;
		if (editingIndex === null || isNarrow) return;
		const comment = comments[editingIndex];
		const canvas = canvasEl;
		const wrap = containerEl;
		if (!comment || !canvas || !wrap) return;
		const canvasRect = canvas.getBoundingClientRect();
		const wrapRect = wrap.getBoundingClientRect();
		if (canvasRect.width === 0 || canvas.width === 0) return;
		const scaleX = canvasRect.width / canvas.width;
		const scaleY = canvasRect.height / canvas.height;
		// Anchor on the drawn badge (clamped inward at edges), not the raw point
		const center = badgeCenter(comment);
		const badgeX = canvasRect.left - wrapRect.left + center.x * scaleX;
		const badgeY = canvasRect.top - wrapRect.top + center.y * scaleY;
		const width = 280;
		const left = Math.min(
			Math.max(8, badgeX + badgeRadius * scaleX + 8),
			wrapRect.width - width - 8
		);
		// Bottom clamp leaves room for the textarea to grow a few lines
		// Reserve the popover's full potential height (textarea max-h-32 = 128px
		// plus padding and borders): the wrapper is overflow-hidden, so a short
		// reserve would clip long multiline notes near the bottom edge
		const top = Math.min(Math.max(8, badgeY + badgeRadius * scaleY + 8), wrapRect.height - 150);
		popoverStyle = `left:${left}px; top:${top}px; width:${width}px;`;
	});

	const toolBtnBase =
		"btn rounded-md px-2 py-1.5 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-40";
	const toolBtnActive = "bg-white text-gray-800 shadow-xs dark:bg-gray-600 dark:text-gray-100";
	const toolBtnInactive =
		"text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200";
	// overflow-y-hidden by default; autogrow flips it to auto only when the note
	// is clamped at max-height, so no scrollbar gutter shows for short notes
	const noteInput =
		"scrollbar-custom max-h-32 min-w-0 flex-1 resize-none overflow-y-hidden rounded-md border border-gray-200 bg-white px-2 py-1 text-sm leading-snug text-gray-800 outline-hidden placeholder:text-gray-400 focus:border-blue-400 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:focus:border-blue-500";
</script>

<svelte:window onkeydown={onKeydown} onresize={() => (resizeNonce += 1)} />

<!-- Width override needs the important marker: Modal's own max-w-[90dvw] targets
     the same property and stylesheet order between two utilities isn't guaranteed.
     min() keeps the desktop cap at 64rem while mobile stretches nearly edge to
     edge so the screenshot gets the room. -->
<Modal
	width="max-w-[min(64rem,calc(100dvw-0.75rem))]!"
	closeOnBackdrop={false}
	onclose={requestClose}
>
	<div class="flex flex-col gap-2.5 p-2.5 sm:gap-3 sm:p-4">
		<div class="flex flex-wrap items-center gap-x-1.5 gap-y-2 sm:gap-2">
			<h2 class="mr-auto text-sm font-semibold text-gray-800 dark:text-gray-200">
				Annotate screenshot
			</h2>

			<div class="flex items-center rounded-lg bg-gray-100 p-0.5 dark:bg-gray-800">
				{#each TOOLS as toolOption (toolOption.id)}
					<button
						type="button"
						class="{toolBtnBase} {tool === toolOption.id ? toolBtnActive : toolBtnInactive}"
						title={toolOption.label}
						aria-pressed={tool === toolOption.id}
						onclick={() => (tool = toolOption.id)}
					>
						<toolOption.icon />
					</button>
				{/each}
			</div>

			<!-- The palette only applies to the drawing tools; comments keep their
			     fixed identity, so the swatches mute while the comment tool is active -->
			<div
				class="flex items-center gap-1.5 px-1 {tool === 'comment'
					? 'pointer-events-none opacity-40'
					: ''}"
			>
				{#each COLORS as colorOption (colorOption)}
					<button
						type="button"
						class="size-4.5 rounded-full transition-transform hover:scale-110 {color === colorOption
							? 'ring-2 ring-gray-500 ring-offset-2 dark:ring-gray-300 dark:ring-offset-gray-800'
							: ''}"
						style="background-color: {colorOption}"
						title="Draw in this color"
						aria-pressed={color === colorOption}
						onclick={() => (color = colorOption)}
					></button>
				{/each}
			</div>

			<div class="flex items-center gap-0.5 text-gray-500 dark:text-gray-400">
				<button
					type="button"
					class="btn rounded-md p-1.5 text-xs hover:bg-gray-100 hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-gray-800 dark:hover:text-gray-300"
					title="Undo drawing (Ctrl+Z)"
					disabled={annotations.length === 0}
					onclick={undo}
				>
					<LucideUndo2 />
				</button>
				<button
					type="button"
					class="btn rounded-md p-1.5 text-xs hover:bg-gray-100 hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-gray-800 dark:hover:text-gray-300"
					title="Clear all annotations"
					disabled={annotations.length === 0 && comments.length === 0}
					onclick={clearAll}
				>
					<LucideTrash2 />
				</button>
			</div>
		</div>

		<!-- On mobile the image bleeds to the modal edges (negative margin swallows
		     the container padding) so every horizontal pixel goes to the screenshot -->
		<div
			bind:this={containerEl}
			class="relative flex min-h-40 items-center justify-center overflow-hidden rounded-lg border border-gray-200 bg-gray-50 max-sm:-mx-2.5 max-sm:rounded-none max-sm:border-x-0 dark:border-gray-700 dark:bg-gray-950"
		>
			{#if loadFailed}
				<p class="p-8 text-sm text-gray-500">Could not load the screenshot.</p>
			{:else if !image}
				<EosIconsLoading class="text-2xl text-gray-400" />
			{:else}
				<canvas
					bind:this={canvasEl}
					width={image.naturalWidth}
					height={image.naturalHeight}
					class="max-h-[75dvh] max-w-full cursor-crosshair touch-none select-none max-sm:max-h-[70dvh]"
					onpointerdown={onPointerDown}
					onpointermove={onPointerMove}
					onpointerup={onPointerUp}
					onpointercancel={onPointerCancel}
				></canvas>
			{/if}

			{#if !isNarrow && editingIndex !== null && comments[editingIndex]}
				<!-- ownIndex freezes which comment this editor instance belongs to:
				     the blur handler must not act when the editing target has
				     already moved on (stale blur committing against the new index) -->
				{@const ownIndex = editingIndex}
				<div
					bind:this={popoverEl}
					class="absolute z-10 flex items-start gap-1.5 rounded-lg border border-gray-200 bg-white p-1.5 shadow-lg dark:border-gray-600 dark:bg-gray-800"
					style={popoverStyle}
				>
					<span
						class="mt-0.5 flex size-5 flex-none items-center justify-center rounded-full bg-blue-500 text-xs font-semibold text-white"
					>
						{editingIndex + 1}
					</span>
					{#key comments[editingIndex].id}
						<textarea
							rows="1"
							class={noteInput}
							placeholder="What about this area?"
							bind:value={comments[editingIndex].note}
							onkeydown={onNoteKeydown}
							oncompositionstart={() => (composingNote = true)}
							oncompositionend={() => (composingNote = false)}
							onblur={(e) => onNoteBlur(e, ownIndex)}
							use:autogrow
						></textarea>
					{/key}
					<button
						type="button"
						class="btn flex-none rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
						title="Delete note"
						onpointerdown={(e) => e.preventDefault()}
						onclick={() => {
							if (editingIndex !== null) deleteComment(editingIndex);
						}}
					>
						<LucideX />
					</button>
				</div>
			{/if}
		</div>

		{#if isNarrow && comments.length > 0}
			<ul class="flex flex-col gap-1.5">
				{#each comments as comment, i (comment.id)}
					<li class="flex items-start gap-2">
						<span
							class="mt-1 flex size-5 flex-none items-center justify-center rounded-full bg-blue-500 text-xs font-semibold text-white"
						>
							{i + 1}
						</span>
						<textarea
							rows="1"
							class={noteInput}
							placeholder="What about this area?"
							bind:value={comment.note}
							onkeydown={onNoteKeydown}
							oncompositionstart={() => (composingNote = true)}
							oncompositionend={() => (composingNote = false)}
							onblur={() => onRowBlur(i)}
							use:autofocus={editingIndex === i ? editingIndex : null}
							use:autogrow
						></textarea>
						<button
							type="button"
							class="btn flex-none rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
							title="Delete note"
							onpointerdown={(e) => e.preventDefault()}
							onclick={() => deleteComment(i)}
						>
							<LucideX />
						</button>
					</li>
				{/each}
			</ul>
		{/if}

		<div class="flex items-center justify-end gap-2">
			<button
				type="button"
				class="btn rounded-xl px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
				onclick={() => onclose()}
			>
				Cancel
			</button>
			<button
				type="button"
				class="inline-flex items-center gap-1.5 rounded-xl border border-gray-900 bg-gray-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-black focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:outline-hidden disabled:opacity-60 dark:border-gray-100 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white dark:focus:ring-offset-gray-800"
				disabled={!image}
				onclick={confirm}
			>
				Add to chat
			</button>
		</div>
	</div>
</Modal>