| 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;
|
| if ((e.target as HTMLElement).tagName.toLowerCase() === "a") {
|
|
|
| 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();
|
| 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"];
|
|
|
|
|
| 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>
|
| );
|
| };
|
|
|