musealpha / src /components /TextNoteNode.tsx
asdf98's picture
Upload 112 files
3d7d9b5 verified
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>
);
};