Spaces:
Paused
Paused
Download src/lib/components/chat/ScreenshotAnnotationModal.svelte from Mike0021/chat-ui-agent-demo: direct link, hf CLI and curl.
- Browser
- Download file 29.9 kB
-
https://huggingface.co/spaces/Mike0021/chat-ui-agent-demo/resolve/main/src/lib/components/chat/ScreenshotAnnotationModal.svelte
- Command line
-
hf download hf://spaces/Mike0021/chat-ui-agent-demo/src/lib/components/chat/ScreenshotAnnotationModal.svelte
-
curl -L -o ScreenshotAnnotationModal.svelte https://huggingface.co/spaces/Mike0021/chat-ui-agent-demo/resolve/main/src/lib/components/chat/ScreenshotAnnotationModal.svelte
29.9 kB
| <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> | |