"use client"; import { useState, useCallback, useEffect, useRef } from "react"; import { Handle, Position, useReactFlow, type NodeProps, type Node, } from "@xyflow/react"; import { Bold, Heading2, Italic, Link2, List, FilePenLine } from "lucide-react"; import type { Id } from "@/convex/_generated/dataModel"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import BaseNodeWrapper from "./base-node-wrapper"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; type TextNodeData = { content?: string; _status?: string; _statusMessage?: string; }; export type TextNode = Node; export default function TextNode({ id, data, selected }: NodeProps) { const { setNodes } = useReactFlow(); const { queueNodeDataUpdate } = useCanvasSync(); const [content, setContent] = useState(data.content ?? ""); const [isEditing, setIsEditing] = useState(false); const [isRichTextOpen, setIsRichTextOpen] = useState(false); const richEditorRef = useRef(null); // Sync von außen (Convex-Update) wenn nicht gerade editiert wird useEffect(() => { if (!isEditing) { // eslint-disable-next-line react-hooks/set-state-in-effect setContent(data.content ?? ""); } }, [data.content, isEditing]); // Debounced Save — 500ms nach letztem Tastendruck const saveContent = useDebouncedCallback( (newContent: string) => { void queueNodeDataUpdate({ nodeId: id as Id<"nodes">, data: { ...data, content: newContent, _status: undefined, _statusMessage: undefined, }, }); }, 500, ); const updateContent = useCallback( (newContent: string) => { setContent(newContent); setNodes((nodes) => nodes.map((node) => node.id === id ? { ...node, data: { ...node.data, content: newContent, }, } : node, ), ); saveContent(newContent); }, [id, saveContent, setNodes], ); const handleChange = useCallback( (e: React.ChangeEvent) => { updateContent(e.target.value); }, [updateContent], ); const wrapSelection = useCallback( (prefix: string, suffix = prefix, placeholder = "Text") => { const editor = richEditorRef.current; if (!editor) return; const start = editor.selectionStart; const end = editor.selectionEnd; const selectedText = content.slice(start, end); const injectedText = selectedText || placeholder; const nextContent = `${content.slice(0, start)}${prefix}${injectedText}${suffix}${content.slice(end)}`; updateContent(nextContent); requestAnimationFrame(() => { editor.focus(); const nextStart = start + prefix.length; const nextEnd = nextStart + injectedText.length; editor.setSelectionRange(nextStart, nextEnd); }); }, [content, updateContent], ); const prefixSelectedLines = useCallback( (prefix: string) => { const editor = richEditorRef.current; if (!editor) return; const start = editor.selectionStart; const end = editor.selectionEnd; const lineStart = content.lastIndexOf("\n", Math.max(0, start - 1)) + 1; const lineEndSearch = content.indexOf("\n", end); const lineEnd = lineEndSearch === -1 ? content.length : lineEndSearch; const selectedBlock = content.slice(lineStart, lineEnd); const nextBlock = selectedBlock .split("\n") .map((line) => (line.startsWith(prefix) ? line : `${prefix}${line}`)) .join("\n"); const nextContent = `${content.slice(0, lineStart)}${nextBlock}${content.slice(lineEnd)}`; updateContent(nextContent); requestAnimationFrame(() => { editor.focus(); editor.setSelectionRange(lineStart, lineStart + nextBlock.length); }); }, [content, updateContent], ); return ( <> , onClick: () => setIsRichTextOpen(true), }, ]} className="relative" >
📝 Text
{isEditing ? (