/* 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/. */ /** * The inline note input that appears at the click site when the user * drops a fresh pin with the Annotate tool. Shape mirrors the popover's * edit mode so muscle memory carries over, but the chrome is lighter * (a guiding label, no entity-context header) since this is a * commit-or-cancel surface. */ import { useCallback, useEffect, useRef, useState } from 'react'; import { Check, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; const MAX_NOTE_LEN = 2000; const SOFT_NOTE_LIMIT = 200; const INPUT_WIDTH = 280; const INPUT_OFFSET_X = 16; export interface AnnotationDropInputProps { anchorX: number; anchorY: number; canvasWidth: number; canvasHeight: number; /** Resolved entity type when the drop landed on a known mesh. */ entityType?: string | null; entityExpressId?: number | null; onSave: (note: string) => void; onCancel: () => void; } export function AnnotationDropInput({ anchorX, anchorY, canvasWidth, canvasHeight, entityType, entityExpressId, onSave, onCancel, }: AnnotationDropInputProps) { const [draft, setDraft] = useState(''); const textareaRef = useRef(null); const containerRef = useRef(null); useEffect(() => { textareaRef.current?.focus(); }, []); // Cancel on outside click, but defer registration so the click that // dropped the pin doesn't immediately close the input. useEffect(() => { const handler = (e: MouseEvent) => { const node = containerRef.current; if (!node) return; if (node.contains(e.target as Node)) return; // Empty draft on outside-click → silent cancel; non-empty // → commit the draft (matches "blur to save" feel without // destroying typed content). An over-limit draft is rejected // consistently with the disabled save button. if (draft.trim().length === 0 || draft.length > MAX_NOTE_LEN) { onCancel(); } else { onSave(draft); } }; const id = window.setTimeout(() => { document.addEventListener('mousedown', handler); }, 0); return () => { window.clearTimeout(id); document.removeEventListener('mousedown', handler); }; }, [draft, onSave, onCancel]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (draft.trim().length === 0 || draft.length > MAX_NOTE_LEN) { // Over-limit Enter does nothing — match the disabled button. if (draft.trim().length === 0) onCancel(); } else { onSave(draft); } } else if (e.key === 'Escape') { e.preventDefault(); onCancel(); } }, [draft, onSave, onCancel], ); const wantsLeft = anchorX + INPUT_OFFSET_X + INPUT_WIDTH > canvasWidth; const left = wantsLeft ? Math.max(8, anchorX - INPUT_OFFSET_X - INPUT_WIDTH) : Math.min(anchorX + INPUT_OFFSET_X, canvasWidth - INPUT_WIDTH - 8); const top = Math.min(Math.max(8, anchorY - 8), canvasHeight - 140); const charCountVisible = draft.length >= SOFT_NOTE_LIMIT; const overSoftLimit = draft.length > SOFT_NOTE_LIMIT; const overHardLimit = draft.length > MAX_NOTE_LEN; return (
{/* Guiding label — explicit so the user knows what to type and establishes "this is for capturing intent, not chat". */}
What's worth noting? {entityType && ( · {entityType} {entityExpressId !== null && entityExpressId !== undefined && ` #${entityExpressId}`} )}