musealpha / uiprototype2 /src /components /TextNoteNode.tsx
asdf98's picture
Upload 112 files
3d7d9b5 verified
import React, { useState, useRef, useEffect } from "react";
import { useAppStore } from "../store";
import { TextNote } from "../types";
import {
AlignLeft,
AlignCenter,
AlignRight,
Bold,
Italic,
Underline,
List,
ListOrdered,
Link,
} from "lucide-react";
export const TextNoteNode = ({ note }: { note: TextNote }) => {
const {
setTextNotes,
zoom,
pan,
isAnnotationMode,
isClickThrough,
selectedNodeIds,
setSelectedNodeIds,
updateSelectedNodes,
} = useAppStore();
const [isDragging, setIsDragging] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [localText, setLocalText] = useState(note.text);
const contentEditableRef = useRef<HTMLDivElement>(null);
const isSelected = selectedNodeIds.includes(note.id);
useEffect(() => {
if (isEditing && contentEditableRef.current) {
if (contentEditableRef.current.innerHTML !== localText) {
contentEditableRef.current.innerHTML = localText;
}
contentEditableRef.current.focus();
}
}, [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) return; // let user click inside text
if ((e.target as HTMLElement).tagName.toLowerCase() === "a") {
// let the link click happen
e.stopPropagation();
return;
}
e.stopPropagation();
if (e.button === 2) {
return;
}
if (!isSelected) {
setTextNotes((prev) => {
let idsToSelect = [note.id];
if (note.groupId) {
idsToSelect = prev
.filter((n) => n.groupId === note.groupId)
.map((n) => n.id);
}
if (e.shiftKey) {
setSelectedNodeIds((sel) =>
Array.from(new Set([...sel, ...idsToSelect])),
);
} else {
setSelectedNodeIds(idsToSelect);
}
return prev;
});
}
setIsDragging(true);
e.currentTarget.setPointerCapture(e.pointerId);
};
const handlePointerMoveRoot = (e: React.PointerEvent) => {
if (isResizing) {
handleResizeMove(e);
} else if (isDragging) {
e.stopPropagation();
updateSelectedNodes(e.movementX / zoom, e.movementY / zoom, note.id);
}
};
const handlePointerUpRoot = (e: React.PointerEvent) => {
if (isResizing) {
handleResizeEnd(e);
} else if (isDragging) {
setIsDragging(false);
e.currentTarget.releasePointerCapture(e.pointerId);
}
};
const handleDoubleClick = (e: React.MouseEvent) => {
e.stopPropagation();
setIsEditing(true);
};
const handleBlur = () => {
setIsEditing(false);
const newText = contentEditableRef.current?.innerHTML || "";
setLocalText(newText);
updateNote({ text: newText });
};
const handleKeyDown = (e: React.KeyboardEvent) => {
e.stopPropagation(); // prevent app-level shortcuts
if (e.key === "Escape") {
handleBlur();
}
};
const [isResizing, setIsResizing] = useState(false);
const handleResizeStart = (e: React.PointerEvent) => {
e.stopPropagation();
setIsResizing(true);
e.currentTarget.setPointerCapture(e.pointerId);
};
const handleResizeMove = (e: React.PointerEvent) => {
if (isResizing) {
e.stopPropagation();
updateNote({
width: Math.max(100, note.width + e.movementX / zoom),
height: Math.max(50, (note.height || 80) + e.movementY / zoom),
});
}
};
const handleResizeEnd = (e: React.PointerEvent) => {
if (isResizing) {
setIsResizing(false);
e.currentTarget.releasePointerCapture(e.pointerId);
}
};
const colors = ["#FFFFFF", "#FFD60A", "#FF453A", "#32D74B", "#0A84FF"];
const fonts = ["Inter", "Courier New", "Times New Roman", "Georgia", "Arial"];
// Apply default styles
const alignment = note.alignment || "left";
const isBold = note.isBold || false;
const isItalic = note.isItalic || false;
const isUnderline = note.isUnderline || false;
const color = note.color || "#FFFFFF";
const bgColor = note.bgColor || "rgba(0,0,0,0.6)";
const fontSize = note.fontSize || 14;
const fontFamily = note.fontFamily || "Inter";
return (
<div
className="absolute group"
style={{
transform: `translate(${note.x}px, ${note.y}px)`,
width: note.width,
height: note.height || "auto",
zIndex: 20,
pointerEvents: isClickThrough || isAnnotationMode ? "none" : "auto",
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMoveRoot}
onPointerUp={handlePointerUpRoot}
onDoubleClick={handleDoubleClick}
onContextMenu={(e) => e.preventDefault()}
>
{/* Floating Toolbar */}
{isEditing && (
<div
className="absolute bottom-full mb-2 left-0 flex items-center gap-1.5 p-1.5 bg-[#2A2A2E] border border-[#3A3A3E] rounded-md shadow-xl pointer-events-auto z-50 text-ui-secondary text-sm"
onPointerDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<div className="flex items-center pr-1 border-r border-[#3A3A3E]">
<select
value={fontFamily}
onChange={(e) => updateNote({ fontFamily: e.target.value })}
className="bg-transparent text-white text-xs outline-none cursor-pointer py-1 max-w-[100px] font-sans"
>
{fonts.map((f) => (
<option key={f} value={f} className="bg-[#2A2A2E]">
{f}
</option>
))}
</select>
</div>
<button
onClick={() => document.execCommand("bold")}
className={`p-1 pl-2 rounded hover:bg-white/10 hover:text-white`}
>
<Bold size={14} />
</button>
<button
onClick={() => document.execCommand("italic")}
className={`p-1 rounded hover:bg-white/10 hover:text-white`}
>
<Italic size={14} />
</button>
<button
onClick={() => document.execCommand("underline")}
className={`p-1 rounded hover:bg-white/10 hover:text-white`}
>
<Underline size={14} />
</button>
<button
onClick={() => {
const url = prompt("Enter link URL:");
if (url) document.execCommand("createLink", false, url);
}}
className={`p-1 pr-2 rounded hover:bg-white/10 hover:text-white`}
>
<Link size={14} />
</button>
<div className="w-[1px] h-4 bg-[#3A3A3E]"></div>
<button
onClick={() => document.execCommand("insertUnorderedList")}
className={`p-1 pl-2 rounded hover:bg-white/10 hover:text-white`}
>
<List size={14} />
</button>
<button
onClick={() => document.execCommand("insertOrderedList")}
className={`p-1 pr-2 rounded hover:bg-white/10 hover:text-white`}
>
<ListOrdered size={14} />
</button>
<div className="w-[1px] h-4 bg-[#3A3A3E]"></div>
<button
onClick={() => updateNote({ alignment: "left" })}
className={`p-1 pl-2 rounded hover:bg-white/10 ${alignment === "left" ? "text-white" : ""}`}
>
<AlignLeft size={14} />
</button>
<button
onClick={() => updateNote({ alignment: "center" })}
className={`p-1 rounded hover:bg-white/10 ${alignment === "center" ? "text-white" : ""}`}
>
<AlignCenter size={14} />
</button>
<button
onClick={() => updateNote({ alignment: "right" })}
className={`p-1 pr-2 rounded hover:bg-white/10 ${alignment === "right" ? "text-white" : ""}`}
>
<AlignRight size={14} />
</button>
<div className="w-[1px] h-4 bg-[#3A3A3E]"></div>
<div className="flex items-center gap-1 px-1">
<button
onClick={() =>
updateNote({ fontSize: Math.max(10, fontSize - 2) })
}
className="hover:text-white px-1"
>
-
</button>
<span className="text-white text-xs w-6 text-center select-none">
{fontSize}
</span>
<button
onClick={() =>
updateNote({ fontSize: Math.min(72, fontSize + 2) })
}
className="hover:text-white px-1"
>
+
</button>
</div>
<div className="w-[1px] h-4 bg-[#3A3A3E]"></div>
<div className="flex items-center gap-1.5 pl-1 pr-1">
{colors.map((c) => (
<button
key={c}
onClick={() => updateNote({ color: c })}
className={`w-3.5 h-3.5 rounded-full ${color === c ? "ring-1 ring-offset-1 ring-offset-[#2A2A2E] ring-white" : ""} hover:scale-110 transition-transform`}
style={{ backgroundColor: c }}
/>
))}
</div>
</div>
)}
<div
className={`p-4 rounded-xl shadow-xl transition-all duration-200 ${isEditing ? "ring-1 ring-white/20 bg-black/80" : "hover:ring-1 hover:ring-white/10 bg-black/40 backdrop-blur-sm"}`}
style={{
backgroundColor: isEditing ? "#1A1A1A" : bgColor,
}}
>
{isEditing ? (
<div
ref={contentEditableRef}
className="w-full h-full bg-transparent outline-none hide-scrollbar overflow-y-auto min-h-[50px] rich-text-editor"
style={{
color: color,
fontSize: `${fontSize}px`,
fontFamily: fontFamily,
textAlign: alignment,
}}
contentEditable
suppressContentEditableWarning
onBlur={handleBlur}
onKeyDown={handleKeyDown}
onPointerDown={(e) => e.stopPropagation()}
/>
) : (
<div
className="select-none break-words h-full rich-text-editor"
style={{
color: color,
fontSize: `${fontSize}px`,
fontFamily: fontFamily,
textAlign: alignment,
}}
dangerouslySetInnerHTML={{
__html:
localText ||
'<span class="text-white/30 italic">Double click to edit text</span>',
}}
/>
)}
{/* Delete button (only visible on hover) */}
{!isEditing && (
<button
className="absolute -top-2 -right-2 w-6 h-6 bg-[#FF453A] border hover:bg-red-500 border-white text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg z-30"
onClick={(e) => {
e.stopPropagation();
setTextNotes((prev) => prev.filter((n) => n.id !== note.id));
}}
>
<div className="w-2.5 h-[1.5px] bg-white rounded-full translate-y-[-0.5px]"></div>
</button>
)}
{/* Resize Handle (only visible on hover/editing) */}
<div
className={`absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize opacity-0 ${isEditing ? "opacity-100" : "group-hover:opacity-100"} transition-opacity z-30 flex items-end justify-end p-1`}
onPointerDown={handleResizeStart}
onPointerUp={handleResizeEnd}
onPointerMove={handleResizeMove}
>
<div className="w-2 h-2 rounded-tl-sm bg-white/50 border-b border-r border-white/80"></div>
</div>
</div>
</div>
);
};