File size: 12,883 Bytes
3d7d9b5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 | 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);
// 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<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;
// 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 (
<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>
);
};
|