Spaces:
Paused
Paused
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>
|