diff --git a/components/canvas/nodes/ai-image-node.tsx b/components/canvas/nodes/ai-image-node.tsx index 1ac0147..bb13d10 100644 --- a/components/canvas/nodes/ai-image-node.tsx +++ b/components/canvas/nodes/ai-image-node.tsx @@ -22,7 +22,14 @@ import { Clock3, ShieldAlert, WifiOff, + Maximize2, + X, } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogTitle, +} from "@/components/ui/dialog"; type AiImageNodeData = { storageId?: string; @@ -67,6 +74,7 @@ export default function AiImageNode({ const [isGenerating, setIsGenerating] = useState(false); const [localError, setLocalError] = useState(null); + const [isOutputFullscreenOpen, setIsOutputFullscreenOpen] = useState(false); const generateImage = useAction(api.ai.generateImage); @@ -175,6 +183,15 @@ export default function AiImageNode({ , + onClick: () => setIsOutputFullscreenOpen(true), + disabled: !nodeData.url, + }, + ]} className="flex h-full w-full min-h-0 min-w-0 flex-col" > + + + + KI-Bildausgabe + +
+ {nodeData.url ? ( + // eslint-disable-next-line @next/next/no-img-element + {nodeData.prompt + ) : ( +
+ Keine Bildausgabe verfügbar +
+ )} +
+
+
); } diff --git a/components/canvas/nodes/base-node-wrapper.tsx b/components/canvas/nodes/base-node-wrapper.tsx index 41b5fa1..066f666 100644 --- a/components/canvas/nodes/base-node-wrapper.tsx +++ b/components/canvas/nodes/base-node-wrapper.tsx @@ -19,6 +19,15 @@ interface ResizeConfig { keepAspectRatio?: boolean; } +export interface NodeToolbarAction { + id: string; + label: string; + icon: ReactNode; + onClick: () => void; + disabled?: boolean; + className?: string; +} + const RESIZE_CONFIGS: Record = { frame: { minWidth: 200, minHeight: 150 }, group: { minWidth: 150, minHeight: 100 }, @@ -51,7 +60,11 @@ const INTERNAL_FIELDS = new Set([ "canvasId", ]); -function NodeToolbarActions() { +function NodeToolbarActions({ + actions = [], +}: { + actions?: NodeToolbarAction[]; +}) { const nodeId = useNodeId(); const { deleteElements, getNode, getNodes, getEdges, setNodes } = useReactFlow(); const { createNodeWithIntersection } = useCanvasPlacement(); @@ -135,6 +148,22 @@ function NodeToolbarActions() { return (
+ {actions.map((action) => ( + + ))}
)} - + ); } diff --git a/components/canvas/nodes/image-node.tsx b/components/canvas/nodes/image-node.tsx index 53112ef..c6f6617 100644 --- a/components/canvas/nodes/image-node.tsx +++ b/components/canvas/nodes/image-node.tsx @@ -9,10 +9,16 @@ import { type DragEvent, } from "react"; import { Handle, Position, type NodeProps, type Node } from "@xyflow/react"; +import { Maximize2, X } from "lucide-react"; import { useTranslations } from "next-intl"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; import BaseNodeWrapper from "./base-node-wrapper"; +import { + Dialog, + DialogContent, + DialogTitle, +} from "@/components/ui/dialog"; import { toast } from "@/lib/toast"; import { computeMediaNodeSize } from "@/lib/canvas-utils"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; @@ -79,6 +85,7 @@ export default function ImageNode({ const fileInputRef = useRef(null); const [isUploading, setIsUploading] = useState(false); const [isDragOver, setIsDragOver] = useState(false); + const [isFullscreenOpen, setIsFullscreenOpen] = useState(false); const hasAutoSizedRef = useRef(false); useEffect(() => { @@ -253,11 +260,21 @@ export default function ImageNode({ const showFilename = Boolean(data.filename && data.url); return ( - + <> + , + onClick: () => setIsFullscreenOpen(true), + disabled: !data.url, + }, + ]} + > - - + + + + + + {data.filename ?? "Bild"} + +
+ {data.url ? ( + // eslint-disable-next-line @next/next/no-img-element -- Convex storage URL, volle Auflösung wie Asset-Node + {data.filename + ) : ( +
+ Kein Bild verfügbar +
+ )} +
+
+
+ ); } diff --git a/components/canvas/nodes/text-node.tsx b/components/canvas/nodes/text-node.tsx index b7114f0..2b11224 100644 --- a/components/canvas/nodes/text-node.tsx +++ b/components/canvas/nodes/text-node.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; import { Handle, Position, @@ -8,10 +8,18 @@ import { 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; @@ -26,6 +34,8 @@ export default function TextNode({ id, data, selected }: NodeProps) { 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(() => { @@ -51,9 +61,8 @@ export default function TextNode({ id, data, selected }: NodeProps) { 500, ); - const handleChange = useCallback( - (e: React.ChangeEvent) => { - const newContent = e.target.value; + const updateContent = useCallback( + (newContent: string) => { setContent(newContent); setNodes((nodes) => nodes.map((node) => @@ -73,13 +82,79 @@ export default function TextNode({ id, data, selected }: NodeProps) { [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" + > ) { )} - - + + + + + + + RichText Editor + + Schnelle Formatierung mit Markdown-Syntax. + + + +
+
+ + + + + +
+ +