/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * Annotation popover — appears next to a pin when the user clicks * an existing annotation. Read mode shows the note + relative time * + entity context; edit mode swaps in a textarea with Enter-to-save * / Shift+Enter-newline / Esc-cancel semantics. */ import { useCallback, useEffect, useRef, useState } from 'react'; import { Pencil, Trash2, X, Check } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import type { Annotation } from '@/store/slices/annotationsSlice'; const MAX_NOTE_LEN = 2000; const SOFT_NOTE_LIMIT = 200; export interface AnnotationPopoverProps { annotation: Annotation; /** Anchor in canvas-relative pixel coordinates. */ anchorX: number; anchorY: number; /** Canvas dimensions for edge clamping (so the popover never falls off-screen). */ canvasWidth: number; canvasHeight: number; /** Resolved entity type, when the pin is anchored to a known IfcRoot. */ entityType?: string | null; onSave: (note: string) => void; onDelete: () => void; onClose: () => void; } const POPOVER_WIDTH = 280; const POPOVER_OFFSET_X = 16; function formatRelativeTime(timestamp: number): string { const diff = Date.now() - timestamp; const minute = 60_000; const hour = 60 * minute; const day = 24 * hour; const week = 7 * day; if (diff < minute) return 'just now'; if (diff < hour) return `${Math.floor(diff / minute)}m ago`; if (diff < day) return `${Math.floor(diff / hour)}h ago`; if (diff < week) return `${Math.floor(diff / day)}d ago`; return new Date(timestamp).toLocaleDateString(); } export function AnnotationPopover({ annotation, anchorX, anchorY, canvasWidth, canvasHeight, entityType, onSave, onDelete, onClose, }: AnnotationPopoverProps) { const [editing, setEditing] = useState(annotation.note.length === 0); const [draft, setDraft] = useState(annotation.note); const textareaRef = useRef(null); const containerRef = useRef(null); // Reset editor state when the popover is reused for a different // annotation. Without this, switching pins would carry the previous // pin's draft into the new popover. useEffect(() => { setEditing(annotation.note.length === 0); setDraft(annotation.note); }, [annotation.id, annotation.note]); // When the user enters edit mode, focus + select the textarea so // typing replaces the existing body cleanly. useEffect(() => { if (editing && textareaRef.current) { textareaRef.current.focus(); textareaRef.current.select(); } }, [editing]); // Close on outside click. Listening at the document level keeps // the popover predictable when the user mouses anywhere else. useEffect(() => { const handler = (e: MouseEvent) => { const node = containerRef.current; if (!node) return; if (node.contains(e.target as Node)) return; // Don't close when the click landed on the same pin — the // pin's onClick handler controls open/close itself. const closestPin = (e.target as HTMLElement).closest?.('[data-annotation-pin-id]'); if (closestPin?.getAttribute('data-annotation-pin-id') === annotation.id) return; onClose(); }; // Defer registration to next tick so the click that opened the // popover doesn't immediately close it. const id = window.setTimeout(() => { document.addEventListener('mousedown', handler); }, 0); return () => { window.clearTimeout(id); document.removeEventListener('mousedown', handler); }; }, [annotation.id, onClose]); const handleSave = useCallback(() => { onSave(draft); setEditing(false); }, [draft, onSave]); const handleCancel = useCallback(() => { setDraft(annotation.note); setEditing(false); if (annotation.note.length === 0) { // No saved body — user backed out of an edit on a freshly // committed pin with no body. Close the popover entirely. onClose(); } }, [annotation.note, onClose]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSave(); } else if (e.key === 'Escape') { e.preventDefault(); handleCancel(); } }, [handleSave, handleCancel], ); // Edge clamp the popover. Default: anchor to the right of the pin // with a 16px gap; flip left when the right edge would clip. const wantsLeft = anchorX + POPOVER_OFFSET_X + POPOVER_WIDTH > canvasWidth; const left = wantsLeft ? Math.max(8, anchorX - POPOVER_OFFSET_X - POPOVER_WIDTH) : Math.min(anchorX + POPOVER_OFFSET_X, canvasWidth - POPOVER_WIDTH - 8); const top = Math.min(Math.max(8, anchorY - 12), canvasHeight - 100); const charCountVisible = editing && draft.length >= SOFT_NOTE_LIMIT; const overSoftLimit = draft.length > SOFT_NOTE_LIMIT; const overHardLimit = draft.length > MAX_NOTE_LEN; return (
{/* Header — entity context + close. Amber accent strip on the left signals this is an annotation surface. */}
{entityType ? entityType : 'Annotation'} {annotation.entityExpressId !== null && ( #{annotation.entityExpressId} )}
{/* Body */}
{editing ? ( <>