import React, { useState, useRef, useEffect, useCallback } from 'react'; import { useAppStore } from '../store'; import { TextNote } from '../types'; import { AlignLeft, AlignCenter, AlignRight, Bold, Italic, Underline, List, ListOrdered, ChevronDown, Minus, Plus } from 'lucide-react'; const FONTS = [ { label: 'System', value: 'system-ui, -apple-system, BlinkMacSystemFont, sans-serif' }, { label: 'Arial', value: 'Arial, Helvetica, sans-serif' }, { label: 'Georgia', value: 'Georgia, serif' }, { label: 'Times', value: '"Times New Roman", Times, serif' }, { label: 'Mono', value: 'Consolas, "Courier New", monospace' }, { label: 'Comic', value: '"Comic Sans MS", cursive' }, ]; export const TextNoteNode = ({ note }: { note: TextNote }) => { const { setTextNotes, zoom, isAnnotationMode, isClickThrough, selectedNodeIds, setSelectedNodeIds, updateSelectedNodes } = useAppStore(); const [isDragging, setIsDragging] = useState(false); const [isEditing, setIsEditing] = useState(false); const [localText, setLocalText] = useState(note.text); const [isResizing, setIsResizing] = useState(false); const [fontMenuOpen, setFontMenuOpen] = useState(false); const [fmtBold, setFmtBold] = useState(false); const [fmtItalic, setFmtItalic] = useState(false); const [fmtUnderline, setFmtUnderline] = useState(false); const contentRef = useRef(null); const isSelected = selectedNodeIds.includes(note.id); // Manual double-click detection — works reliably with pointer capture const lastClickTime = useRef(0); const lastClickPos = useRef({ x: 0, y: 0 }); const hasMoved = useRef(false); useEffect(() => { if (isEditing && contentRef.current) { contentRef.current.innerHTML = localText; contentRef.current.focus(); } }, [isEditing]); const syncFmt = useCallback(() => { if (!isEditing) return; setTimeout(() => { try { setFmtBold(document.queryCommandState('bold')); setFmtItalic(document.queryCommandState('italic')); setFmtUnderline(document.queryCommandState('underline')); } catch {} }, 10); }, [isEditing]); const updateNote = (changes: Partial) => setTextNotes(prev => prev.map(n => n.id === note.id ? { ...n, ...changes } : n)); const handlePointerDown = (e: React.PointerEvent) => { if (isClickThrough || isAnnotationMode) return; if (isEditing) { e.stopPropagation(); return; } e.stopPropagation(); if (e.button !== 0) return; // Check for double-click: two clicks within 400ms and 5px const now = Date.now(); const dx = Math.abs(e.clientX - lastClickPos.current.x); const dy = Math.abs(e.clientY - lastClickPos.current.y); const isDoubleClick = (now - lastClickTime.current < 400) && dx < 5 && dy < 5; lastClickTime.current = now; lastClickPos.current = { x: e.clientX, y: e.clientY }; if (isDoubleClick) { // Enter edit mode immediately — don't start drag setIsEditing(true); return; } // Single click — select and prepare drag if (!isSelected) { const ids = note.groupId ? (() => { let r: string[] = []; setTextNotes(p => { r = p.filter(n => n.groupId === note.groupId).map(n => n.id); return p; }); return r; })() : [note.id]; if (e.shiftKey) setSelectedNodeIds(s => [...new Set([...s, ...ids])]); else setSelectedNodeIds(ids); } hasMoved.current = false; setIsDragging(true); e.currentTarget.setPointerCapture(e.pointerId); }; const handlePointerMove = (e: React.PointerEvent) => { if (isResizing) { e.stopPropagation(); updateNote({ width: Math.max(120, note.width + e.movementX / zoom), height: Math.max(50, (note.height || 80) + e.movementY / zoom) }); } else if (isDragging) { e.stopPropagation(); hasMoved.current = true; updateSelectedNodes(e.movementX / zoom, e.movementY / zoom, note.id); } }; const handlePointerUp = (e: React.PointerEvent) => { if (isResizing) setIsResizing(false); else if (isDragging) setIsDragging(false); try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {} }; const finishEditing = () => { setIsEditing(false); setFontMenuOpen(false); setFmtBold(false); setFmtItalic(false); setFmtUnderline(false); const t = contentRef.current?.innerHTML || ''; setLocalText(t); updateNote({ text: t }); }; const handleBlur = (e: React.FocusEvent) => { const related = e.relatedTarget as HTMLElement | null; if (related?.closest?.('[data-tb]')) return; finishEditing(); }; const exec = (cmd: string) => { document.execCommand(cmd); contentRef.current?.focus(); syncFmt(); }; const color = note.color || '#FFFFFF'; const bgColor = note.bgColor || 'rgba(30,30,32,0.85)'; const fontSize = note.fontSize || 14; const fontFamily = note.fontFamily || FONTS[0].value; const alignment = note.alignment || 'left'; const fontLabel = FONTS.find(f => f.value === fontFamily)?.label || 'System'; const act = 'bg-white/15 text-white'; const idle = 'text-white/50 hover:bg-white/8 hover:text-white/90'; return (
e.preventDefault()}> {isEditing && (
e.preventDefault()} onPointerDown={e => e.stopPropagation()}> {/* FONT PICKER */}
{fontMenuOpen && (
{FONTS.map(f => ( ))}
)}
{/* FONT SIZE */}
{fontSize}
{/* B I U */}
{/* LISTS */}
{/* ALIGNMENT */}
{/* COLOR */}
{['#FFFFFF','#FFD60A','#FF453A','#30D158','#0A84FF'].map(c => (
)} {/* NOTE BODY */}
{isEditing ? (
{ e.stopPropagation(); if (e.key === 'Escape') finishEditing(); }} onKeyUp={syncFmt} onClick={syncFmt} onPointerDown={e => e.stopPropagation()} /> ) : (
Double-click to edit' }} /> )} {!isEditing && }
{ e.stopPropagation(); setIsResizing(true); (e.target as HTMLElement).setPointerCapture(e.pointerId); }} onPointerUp={handlePointerUp} onPointerMove={handlePointerMove}>
); };