| 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<HTMLDivElement>(null);
|
| const isSelected = selectedNodeIds.includes(note.id);
|
|
|
|
|
| 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<TextNote>) => 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;
|
|
|
|
|
| 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) {
|
|
|
| setIsEditing(true);
|
| return;
|
| }
|
|
|
|
|
| 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 (
|
| <div className="absolute group" style={{ transform: `translate(${note.x}px, ${note.y}px)`, width: note.width, height: note.height || 'auto', zIndex: isEditing ? 200 : 20, pointerEvents: (isClickThrough || isAnnotationMode) ? 'none' : 'auto' }} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onContextMenu={e => e.preventDefault()}>
|
|
|
| {isEditing && (
|
| <div data-tb="" className="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 inline-flex items-center h-9 bg-[#222224] border border-[#3A3A3E] rounded-[10px] shadow-2xl pointer-events-auto z-[300] select-none overflow-visible" onMouseDown={e => e.preventDefault()} onPointerDown={e => e.stopPropagation()}>
|
|
|
| {/* FONT PICKER */}
|
| <div className="relative h-full flex items-center border-r border-[#3A3A3E]">
|
| <button onMouseDown={e => { e.preventDefault(); e.stopPropagation(); setFontMenuOpen(v => !v); }} className="h-full px-3 flex items-center gap-1.5 text-[11px] text-white/80 hover:bg-white/5 rounded-l-[10px] transition-colors">
|
| <span className="max-w-[60px] truncate font-medium">{fontLabel}</span>
|
| <ChevronDown size={10} className="opacity-40" />
|
| </button>
|
| {fontMenuOpen && (
|
| <div className="absolute top-full mt-1 left-0 w-[140px] bg-[#222224] border border-[#3A3A3E] rounded-lg shadow-2xl z-[400] py-1">
|
| {FONTS.map(f => (
|
| <button key={f.label} onMouseDown={e => { e.preventDefault(); e.stopPropagation(); updateNote({ fontFamily: f.value }); setFontMenuOpen(false); setTimeout(() => contentRef.current?.focus(), 0); }} className={`w-full text-left px-3 py-1.5 text-[12px] transition-colors ${fontFamily === f.value ? act : 'text-white/70 hover:bg-white/8'}`} style={{ fontFamily: f.value }}>{f.label}</button>
|
| ))}
|
| </div>
|
| )}
|
| </div>
|
|
|
| {/* FONT SIZE */}
|
| <div className="flex items-center h-full px-1 border-r border-[#3A3A3E]">
|
| <button onMouseDown={e => { e.preventDefault(); updateNote({ fontSize: Math.max(10, fontSize - 2) }); }} className="w-6 h-6 flex items-center justify-center rounded text-white/40 hover:text-white hover:bg-white/8"><Minus size={12} /></button>
|
| <span className="text-[11px] text-white/80 w-6 text-center font-mono tabular-nums select-none">{fontSize}</span>
|
| <button onMouseDown={e => { e.preventDefault(); updateNote({ fontSize: Math.min(72, fontSize + 2) }); }} className="w-6 h-6 flex items-center justify-center rounded text-white/40 hover:text-white hover:bg-white/8"><Plus size={12} /></button>
|
| </div>
|
|
|
| {/* B I U */}
|
| <div className="flex items-center h-full px-0.5 border-r border-[#3A3A3E]">
|
| <button onMouseDown={e => { e.preventDefault(); exec('bold'); }} className={`w-7 h-7 flex items-center justify-center rounded-md transition-colors ${fmtBold ? act : idle}`}><Bold size={14} /></button>
|
| <button onMouseDown={e => { e.preventDefault(); exec('italic'); }} className={`w-7 h-7 flex items-center justify-center rounded-md transition-colors ${fmtItalic ? act : idle}`}><Italic size={14} /></button>
|
| <button onMouseDown={e => { e.preventDefault(); exec('underline'); }} className={`w-7 h-7 flex items-center justify-center rounded-md transition-colors ${fmtUnderline ? act : idle}`}><Underline size={14} /></button>
|
| </div>
|
|
|
| {/* LISTS */}
|
| <div className="flex items-center h-full px-0.5 border-r border-[#3A3A3E]">
|
| <button onMouseDown={e => { e.preventDefault(); exec('insertUnorderedList'); }} className={`w-7 h-7 flex items-center justify-center rounded-md ${idle}`}><List size={14} /></button>
|
| <button onMouseDown={e => { e.preventDefault(); exec('insertOrderedList'); }} className={`w-7 h-7 flex items-center justify-center rounded-md ${idle}`}><ListOrdered size={14} /></button>
|
| </div>
|
|
|
| {/* ALIGNMENT */}
|
| <div className="flex items-center h-full px-0.5 border-r border-[#3A3A3E]">
|
| <button onMouseDown={e => { e.preventDefault(); updateNote({ alignment: 'left' }); }} className={`w-7 h-7 flex items-center justify-center rounded-md transition-colors ${alignment === 'left' ? act : idle}`}><AlignLeft size={14} /></button>
|
| <button onMouseDown={e => { e.preventDefault(); updateNote({ alignment: 'center' }); }} className={`w-7 h-7 flex items-center justify-center rounded-md transition-colors ${alignment === 'center' ? act : idle}`}><AlignCenter size={14} /></button>
|
| <button onMouseDown={e => { e.preventDefault(); updateNote({ alignment: 'right' }); }} className={`w-7 h-7 flex items-center justify-center rounded-md transition-colors ${alignment === 'right' ? act : idle}`}><AlignRight size={14} /></button>
|
| </div>
|
|
|
| {/* COLOR */}
|
| <div className="flex items-center h-full px-2 gap-1.5">
|
| {['#FFFFFF','#FFD60A','#FF453A','#30D158','#0A84FF'].map(c => (
|
| <button key={c} onMouseDown={e => { e.preventDefault(); updateNote({ color: c }); }} className={`w-3.5 h-3.5 rounded-full transition-transform ${color === c ? 'scale-125 ring-[1.5px] ring-white ring-offset-1 ring-offset-[#222224]' : 'opacity-50 hover:opacity-100 hover:scale-110'}`} style={{ backgroundColor: c }} />
|
| ))}
|
| </div>
|
| </div>
|
| )}
|
|
|
| {/* NOTE BODY */}
|
| <div className={`p-4 rounded-xl shadow-xl h-full transition-shadow ${isEditing ? 'ring-2 ring-white/20' : isSelected ? 'ring-1 ring-white/20' : 'hover:ring-1 hover:ring-white/8'}`} style={{ backgroundColor: isEditing ? '#141416' : bgColor }}>
|
| {isEditing ? (
|
| <div ref={contentRef} className="w-full h-full bg-transparent outline-none overflow-y-auto min-h-[40px]" style={{ color, fontSize: `${fontSize}px`, fontFamily, textAlign: alignment, lineHeight: 1.6, wordBreak: 'break-word' }} contentEditable suppressContentEditableWarning onBlur={handleBlur} onKeyDown={e => { e.stopPropagation(); if (e.key === 'Escape') finishEditing(); }} onKeyUp={syncFmt} onClick={syncFmt} onPointerDown={e => e.stopPropagation()} />
|
| ) : (
|
| <div className="select-none break-words h-full overflow-hidden" style={{ color, fontSize: `${fontSize}px`, fontFamily, textAlign: alignment, lineHeight: 1.6, wordBreak: 'break-word' }} dangerouslySetInnerHTML={{ __html: localText || '<span style="opacity:0.3;font-style:italic">Double-click to edit</span>' }} />
|
| )}
|
|
|
| {!isEditing && <button className="absolute -top-2 -right-2 w-5 h-5 bg-[#FF453A] text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg z-30 text-[10px]" onClick={e => { e.stopPropagation(); setTextNotes(prev => prev.filter(n => n.id !== note.id)); }} onPointerDown={e => e.stopPropagation()}>×</button>}
|
|
|
| <div className={`absolute bottom-1 right-1 w-3 h-3 cursor-nwse-resize opacity-0 ${isEditing || isSelected ? 'opacity-40' : 'group-hover:opacity-40'} transition-opacity z-30`} onPointerDown={e => { e.stopPropagation(); setIsResizing(true); (e.target as HTMLElement).setPointerCapture(e.pointerId); }} onPointerUp={handlePointerUp} onPointerMove={handlePointerMove}>
|
| <svg width="10" height="10" viewBox="0 0 10 10"><path d="M9 1L1 9M9 4L4 9M9 7L7 9" stroke="white" strokeWidth="1.2" strokeLinecap="round" opacity="0.5" /></svg>
|
| </div>
|
| </div>
|
| </div>
|
| );
|
| };
|
|
|