feat: enhance canvas components with improved sidebar and toolbar functionality
- Updated CanvasSidebar to accept canvasId as a prop, enabling dynamic content based on the current canvas. - Refactored CanvasToolbar to implement a dropdown menu for adding nodes, improving usability and organization. - Introduced new node types and updated existing ones in the node template picker for better categorization and searchability. - Enhanced AssetNode to utilize context for asset browser interactions, streamlining asset management on the canvas. - Improved overall layout and styling for better user experience across canvas components.
This commit is contained in:
@@ -50,8 +50,8 @@ export default async function CanvasPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen overflow-hidden">
|
<div className="flex h-screen w-screen overflow-hidden">
|
||||||
<CanvasSidebar />
|
<CanvasSidebar canvasId={typedCanvasId} />
|
||||||
<div className="relative flex-1">
|
<div className="relative min-h-0 min-w-0 flex-1">
|
||||||
<ConnectionBanner />
|
<ConnectionBanner />
|
||||||
<Canvas canvasId={typedCanvasId} />
|
<Canvas canvasId={typedCanvasId} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useAction, useMutation } from "convex/react";
|
import { useAction, useMutation } from "convex/react";
|
||||||
import { X, Search, Loader2, AlertCircle } from "lucide-react";
|
import { X, Search, Loader2, AlertCircle } from "lucide-react";
|
||||||
@@ -35,6 +43,25 @@ export interface AssetBrowserSessionState {
|
|||||||
totalPages: number;
|
totalPages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Canvas-weit: bleibt beim Wechsel optimistic_… → echte Node-ID erhalten (sonst remount = Panel zu). */
|
||||||
|
export type AssetBrowserTargetApi = {
|
||||||
|
targetNodeId: string | null;
|
||||||
|
openForNode: (nodeId: string) => void;
|
||||||
|
close: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AssetBrowserTargetContext = createContext<AssetBrowserTargetApi | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
export function useAssetBrowserTarget(): AssetBrowserTargetApi {
|
||||||
|
const v = useContext(AssetBrowserTargetContext);
|
||||||
|
if (!v) {
|
||||||
|
throw new Error("useAssetBrowserTarget must be used within AssetBrowserTargetContext.Provider");
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
canvasId: string;
|
canvasId: string;
|
||||||
@@ -50,7 +77,6 @@ export function AssetBrowserPanel({
|
|||||||
initialState,
|
initialState,
|
||||||
onStateChange,
|
onStateChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
|
||||||
const [term, setTerm] = useState(initialState?.term ?? "");
|
const [term, setTerm] = useState(initialState?.term ?? "");
|
||||||
const [debouncedTerm, setDebouncedTerm] = useState(initialState?.term ?? "");
|
const [debouncedTerm, setDebouncedTerm] = useState(initialState?.term ?? "");
|
||||||
const [assetType, setAssetType] = useState<AssetType>(initialState?.assetType ?? "photo");
|
const [assetType, setAssetType] = useState<AssetType>(initialState?.assetType ?? "photo");
|
||||||
@@ -69,11 +95,6 @@ export function AssetBrowserPanel({
|
|||||||
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
|
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
|
||||||
const isSelecting = selectingAssetKey !== null;
|
const isSelecting = selectingAssetKey !== null;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsMounted(true);
|
|
||||||
return () => setIsMounted(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
setDebouncedTerm(term);
|
setDebouncedTerm(term);
|
||||||
@@ -429,9 +450,5 @@ export function AssetBrowserPanel({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isMounted) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return createPortal(modal, document.body);
|
return createPortal(modal, document.body);
|
||||||
}
|
}
|
||||||
|
|||||||
225
components/canvas/canvas-app-menu.tsx
Normal file
225
components/canvas/canvas-app-menu.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useMutation } from "convex/react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import {
|
||||||
|
Monitor,
|
||||||
|
Moon,
|
||||||
|
Pencil,
|
||||||
|
Sun,
|
||||||
|
Trash2,
|
||||||
|
Menu,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/convex/_generated/api";
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
import { msg } from "@/lib/toast-messages";
|
||||||
|
import { useAuthQuery } from "@/hooks/use-auth-query";
|
||||||
|
|
||||||
|
type CanvasAppMenuProps = {
|
||||||
|
canvasId: Id<"canvases">;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CanvasAppMenu({ canvasId }: CanvasAppMenuProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const canvas = useAuthQuery(api.canvases.get, { canvasId });
|
||||||
|
const removeCanvas = useMutation(api.canvases.remove);
|
||||||
|
const renameCanvas = useMutation(api.canvases.update);
|
||||||
|
const { theme = "system", setTheme } = useTheme();
|
||||||
|
|
||||||
|
const [renameOpen, setRenameOpen] = useState(false);
|
||||||
|
const [renameValue, setRenameValue] = useState("");
|
||||||
|
const [renameSaving, setRenameSaving] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (renameOpen && canvas?.name !== undefined) {
|
||||||
|
setRenameValue(canvas.name);
|
||||||
|
}
|
||||||
|
}, [renameOpen, canvas?.name]);
|
||||||
|
|
||||||
|
const handleRename = async () => {
|
||||||
|
const trimmed = renameValue.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
const { title, desc } = msg.dashboard.renameEmpty;
|
||||||
|
toast.error(title, desc);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (trimmed === canvas?.name) {
|
||||||
|
setRenameOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRenameSaving(true);
|
||||||
|
try {
|
||||||
|
await renameCanvas({ canvasId, name: trimmed });
|
||||||
|
toast.success(msg.dashboard.renameSuccess.title);
|
||||||
|
setRenameOpen(false);
|
||||||
|
} catch {
|
||||||
|
toast.error(msg.dashboard.renameFailed.title);
|
||||||
|
} finally {
|
||||||
|
setRenameSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setDeleteBusy(true);
|
||||||
|
try {
|
||||||
|
await removeCanvas({ canvasId });
|
||||||
|
toast.success("Projekt gelöscht");
|
||||||
|
setDeleteOpen(false);
|
||||||
|
router.replace("/dashboard");
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
toast.error("Löschen fehlgeschlagen");
|
||||||
|
} finally {
|
||||||
|
setDeleteBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="absolute top-4 right-4 z-20">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="size-10 rounded-lg border-border/80 bg-card/95 shadow-md backdrop-blur-sm"
|
||||||
|
aria-label="Canvas-Menü"
|
||||||
|
title="Canvas-Menü"
|
||||||
|
>
|
||||||
|
<Menu className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-52">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
setRenameOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
Projekt umbenennen
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onSelect={() => setDeleteOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
Projekt löschen
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger>
|
||||||
|
<Sun className="size-4" />
|
||||||
|
Erscheinungsbild
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
<DropdownMenuItem onSelect={() => setTheme("light")}>
|
||||||
|
<Sun className="size-4" />
|
||||||
|
Hell
|
||||||
|
{theme === "light" ? " ✓" : ""}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => setTheme("dark")}>
|
||||||
|
<Moon className="size-4" />
|
||||||
|
Dunkel
|
||||||
|
{theme === "dark" ? " ✓" : ""}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => setTheme("system")}>
|
||||||
|
<Monitor className="size-4" />
|
||||||
|
System
|
||||||
|
{theme === "system" ? " ✓" : ""}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md" showCloseButton>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Projekt umbenennen</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<Input
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") void handleRename();
|
||||||
|
}}
|
||||||
|
placeholder="Name"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setRenameOpen(false)}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleRename()}
|
||||||
|
disabled={renameSaving}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md" showCloseButton>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Projekt löschen?</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
„{canvas?.name ?? "dieses Projekt"}“ und alle Knoten werden dauerhaft
|
||||||
|
gelöscht. Das lässt sich nicht rückgängig machen.
|
||||||
|
</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDeleteOpen(false)}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => void handleDelete()}
|
||||||
|
disabled={deleteBusy}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
FolderOpen,
|
||||||
Frame,
|
Frame,
|
||||||
GitCompare,
|
GitCompare,
|
||||||
Image,
|
Image,
|
||||||
|
Package,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
StickyNote,
|
StickyNote,
|
||||||
Type,
|
Type,
|
||||||
|
Video,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -23,6 +26,9 @@ const NODE_ICONS: Record<CanvasNodeTemplate["type"], LucideIcon> = {
|
|||||||
note: StickyNote,
|
note: StickyNote,
|
||||||
frame: Frame,
|
frame: Frame,
|
||||||
compare: GitCompare,
|
compare: GitCompare,
|
||||||
|
group: FolderOpen,
|
||||||
|
asset: Package,
|
||||||
|
video: Video,
|
||||||
};
|
};
|
||||||
|
|
||||||
const NODE_SEARCH_KEYWORDS: Partial<
|
const NODE_SEARCH_KEYWORDS: Partial<
|
||||||
@@ -33,7 +39,10 @@ const NODE_SEARCH_KEYWORDS: Partial<
|
|||||||
prompt: ["prompt", "ai", "generate", "ki-bild", "ki", "bild"],
|
prompt: ["prompt", "ai", "generate", "ki-bild", "ki", "bild"],
|
||||||
note: ["note", "sticky", "notiz"],
|
note: ["note", "sticky", "notiz"],
|
||||||
frame: ["frame", "artboard"],
|
frame: ["frame", "artboard"],
|
||||||
compare: ["compare", "before", "after"],
|
compare: ["compare", "before", "after", "vergleich"],
|
||||||
|
group: ["group", "gruppe", "folder"],
|
||||||
|
asset: ["asset", "freepik", "stock"],
|
||||||
|
video: ["video", "pexels", "clip"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CanvasNodeTemplatePickerProps = {
|
export type CanvasNodeTemplatePickerProps = {
|
||||||
|
|||||||
@@ -1,73 +1,155 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
const nodeTemplates = [
|
import {
|
||||||
{ type: "image", label: "Bild", icon: "🖼️", category: "Quelle" },
|
Bot,
|
||||||
{ type: "asset", label: "FreePik", icon: "🛍️", category: "Quelle" },
|
ClipboardList,
|
||||||
{ type: "video", label: "Pexels", icon: "🎬", category: "Quelle" },
|
Crop,
|
||||||
{ type: "text", label: "Text", icon: "📝", category: "Quelle" },
|
FolderOpen,
|
||||||
{ type: "prompt", label: "KI-Bild", icon: "✨", category: "Quelle" },
|
Frame,
|
||||||
{ type: "note", label: "Notiz", icon: "📌", category: "Layout" },
|
GitBranch,
|
||||||
{ type: "frame", label: "Frame", icon: "🖥️", category: "Layout" },
|
GitCompare,
|
||||||
{ type: "group", label: "Gruppe", icon: "📁", category: "Layout" },
|
Image,
|
||||||
{ type: "compare", label: "Vergleich", icon: "🔀", category: "Layout" },
|
ImageOff,
|
||||||
] as const;
|
Layers,
|
||||||
|
LayoutPanelTop,
|
||||||
|
MessageSquare,
|
||||||
|
Package,
|
||||||
|
Palette,
|
||||||
|
Presentation,
|
||||||
|
Repeat,
|
||||||
|
Sparkles,
|
||||||
|
Split,
|
||||||
|
StickyNote,
|
||||||
|
Type,
|
||||||
|
Video,
|
||||||
|
Wand2,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
const categories = [...new Set(nodeTemplates.map((template) => template.category))];
|
import { CanvasUserMenu } from "@/components/canvas/canvas-user-menu";
|
||||||
|
import { useAuthQuery } from "@/hooks/use-auth-query";
|
||||||
|
import { api } from "@/convex/_generated/api";
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
|
import {
|
||||||
|
NODE_CATEGORY_META,
|
||||||
|
NODE_CATEGORIES_ORDERED,
|
||||||
|
catalogEntriesByCategory,
|
||||||
|
isNodePaletteEnabled,
|
||||||
|
type NodeCatalogEntry,
|
||||||
|
} from "@/lib/canvas-node-catalog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const CATALOG_ICONS: Partial<Record<string, LucideIcon>> = {
|
||||||
|
image: Image,
|
||||||
|
text: Type,
|
||||||
|
prompt: Sparkles,
|
||||||
|
color: Palette,
|
||||||
|
video: Video,
|
||||||
|
asset: Package,
|
||||||
|
"ai-image": Sparkles,
|
||||||
|
"ai-text": Type,
|
||||||
|
"ai-video": Video,
|
||||||
|
"agent-output": Bot,
|
||||||
|
crop: Crop,
|
||||||
|
"bg-remove": ImageOff,
|
||||||
|
upscale: Wand2,
|
||||||
|
"style-transfer": Wand2,
|
||||||
|
"face-restore": Sparkles,
|
||||||
|
splitter: Split,
|
||||||
|
loop: Repeat,
|
||||||
|
agent: Bot,
|
||||||
|
mixer: Layers,
|
||||||
|
switch: GitBranch,
|
||||||
|
group: FolderOpen,
|
||||||
|
frame: Frame,
|
||||||
|
note: StickyNote,
|
||||||
|
"text-overlay": LayoutPanelTop,
|
||||||
|
compare: GitCompare,
|
||||||
|
comment: MessageSquare,
|
||||||
|
presentation: Presentation,
|
||||||
|
};
|
||||||
|
|
||||||
|
function SidebarRow({ entry }: { entry: NodeCatalogEntry }) {
|
||||||
|
const enabled = isNodePaletteEnabled(entry);
|
||||||
|
const Icon = CATALOG_ICONS[entry.type] ?? ClipboardList;
|
||||||
|
|
||||||
function SidebarItem({
|
|
||||||
type,
|
|
||||||
label,
|
|
||||||
icon,
|
|
||||||
}: {
|
|
||||||
type: string;
|
|
||||||
label: string;
|
|
||||||
icon: string;
|
|
||||||
}) {
|
|
||||||
const onDragStart = (event: React.DragEvent) => {
|
const onDragStart = (event: React.DragEvent) => {
|
||||||
event.dataTransfer.setData("application/lemonspace-node-type", type);
|
if (!enabled) return;
|
||||||
|
event.dataTransfer.setData("application/lemonspace-node-type", entry.type);
|
||||||
event.dataTransfer.effectAllowed = "move";
|
event.dataTransfer.effectAllowed = "move";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hint = entry.disabledHint ?? "Noch nicht verfügbar";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
draggable
|
draggable={enabled}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
className="flex cursor-grab items-center gap-2 rounded-lg border bg-card px-3 py-2 text-sm transition-colors hover:bg-accent active:cursor-grabbing"
|
title={enabled ? `${entry.label} — auf den Canvas ziehen` : hint}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors",
|
||||||
|
enabled
|
||||||
|
? "cursor-grab border-border/80 bg-card hover:bg-accent active:cursor-grabbing"
|
||||||
|
: "cursor-not-allowed border-transparent bg-muted/30 text-muted-foreground",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<span>{icon}</span>
|
<Icon className="size-4 shrink-0 opacity-80" />
|
||||||
<span>{label}</span>
|
<span className="min-w-0 flex-1 truncate">{entry.label}</span>
|
||||||
|
{entry.phase > 1 ? (
|
||||||
|
<span className="shrink-0 text-[10px] font-medium tabular-nums text-muted-foreground/80">
|
||||||
|
P{entry.phase}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CanvasSidebar() {
|
type CanvasSidebarProps = {
|
||||||
|
canvasId: Id<"canvases">;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CanvasSidebar({ canvasId }: CanvasSidebarProps) {
|
||||||
|
const canvas = useAuthQuery(api.canvases.get, { canvasId });
|
||||||
|
const byCategory = catalogEntriesByCategory();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex w-56 flex-col border-r bg-background">
|
<aside className="flex w-60 shrink-0 flex-col border-r border-border/80 bg-background">
|
||||||
<div className="border-b px-4 py-3">
|
<div className="border-b border-border/80 px-4 py-4">
|
||||||
<h2 className="text-sm font-semibold">Nodes</h2>
|
{canvas === undefined ? (
|
||||||
<p className="text-xs text-muted-foreground">Auf den Canvas ziehen</p>
|
<div className="h-12 animate-pulse rounded-md bg-muted/50" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Canvas
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-1 line-clamp-2 text-base font-semibold leading-snug text-foreground">
|
||||||
|
{canvas?.name ?? "…"}
|
||||||
|
</h1>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-3">
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
{categories.map((category) => (
|
{NODE_CATEGORIES_ORDERED.map((categoryId) => {
|
||||||
<div key={category} className="mb-4">
|
const entries = byCategory.get(categoryId) ?? [];
|
||||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
if (entries.length === 0) return null;
|
||||||
{category}
|
const { label } = NODE_CATEGORY_META[categoryId];
|
||||||
</h3>
|
return (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div key={categoryId} className="mb-4 last:mb-0">
|
||||||
{nodeTemplates
|
<h2 className="mb-2 px-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
.filter((template) => template.category === category)
|
{label}
|
||||||
.map((template) => (
|
</h2>
|
||||||
<SidebarItem
|
<div className="flex flex-col gap-1.5">
|
||||||
key={template.type}
|
{entries.map((entry) => (
|
||||||
type={template.type}
|
<SidebarRow key={entry.type} entry={entry} />
|
||||||
label={template.label}
|
|
||||||
icon={template.icon}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CanvasUserMenu />
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,68 +1,187 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
|
import {
|
||||||
|
Hand,
|
||||||
|
MessageSquare,
|
||||||
|
MousePointer2,
|
||||||
|
Plus,
|
||||||
|
Redo2,
|
||||||
|
Scissors,
|
||||||
|
Undo2,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import { CreditDisplay } from "@/components/canvas/credit-display";
|
import { CreditDisplay } from "@/components/canvas/credit-display";
|
||||||
import { ExportButton } from "@/components/canvas/export-button";
|
import { ExportButton } from "@/components/canvas/export-button";
|
||||||
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
|
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
|
||||||
import { useCenteredFlowNodePosition } from "@/hooks/use-centered-flow-node-position";
|
import { useCenteredFlowNodePosition } from "@/hooks/use-centered-flow-node-position";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
CANVAS_NODE_TEMPLATES,
|
DropdownMenu,
|
||||||
type CanvasNodeTemplate,
|
DropdownMenuContent,
|
||||||
} from "@/lib/canvas-node-templates";
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
NODE_CATEGORY_META,
|
||||||
|
NODE_CATEGORIES_ORDERED,
|
||||||
|
catalogEntriesByCategory,
|
||||||
|
getTemplateForCatalogType,
|
||||||
|
isNodePaletteEnabled,
|
||||||
|
type NodeCategoryId,
|
||||||
|
} from "@/lib/canvas-node-catalog";
|
||||||
|
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
|
||||||
|
|
||||||
|
export type CanvasNavTool = "select" | "hand" | "scissor" | "comment";
|
||||||
|
|
||||||
interface CanvasToolbarProps {
|
interface CanvasToolbarProps {
|
||||||
canvasName?: string;
|
canvasName?: string;
|
||||||
|
activeTool: CanvasNavTool;
|
||||||
|
onToolChange: (tool: CanvasNavTool) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CanvasToolbar({
|
export default function CanvasToolbar({
|
||||||
canvasName,
|
canvasName,
|
||||||
|
activeTool,
|
||||||
|
onToolChange,
|
||||||
}: CanvasToolbarProps) {
|
}: CanvasToolbarProps) {
|
||||||
const { createNodeWithIntersection } = useCanvasPlacement();
|
const { createNodeWithIntersection } = useCanvasPlacement();
|
||||||
const getCenteredPosition = useCenteredFlowNodePosition();
|
const getCenteredPosition = useCenteredFlowNodePosition();
|
||||||
const nodeCountRef = useRef(0);
|
const nodeCountRef = useRef(0);
|
||||||
|
|
||||||
const handleAddNode = async (
|
const handleAddNode = async (template: CanvasNodeTemplate) => {
|
||||||
type: CanvasNodeTemplate["type"],
|
|
||||||
data: CanvasNodeTemplate["defaultData"],
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
) => {
|
|
||||||
const stagger = (nodeCountRef.current % 8) * 24;
|
const stagger = (nodeCountRef.current % 8) * 24;
|
||||||
nodeCountRef.current += 1;
|
nodeCountRef.current += 1;
|
||||||
await createNodeWithIntersection({
|
await createNodeWithIntersection({
|
||||||
type,
|
type: template.type,
|
||||||
position: getCenteredPosition(width, height, stagger),
|
position: getCenteredPosition(template.width, template.height, stagger),
|
||||||
width,
|
width: template.width,
|
||||||
height,
|
height: template.height,
|
||||||
data,
|
data: template.defaultData,
|
||||||
clientRequestId: crypto.randomUUID(),
|
clientRequestId: crypto.randomUUID(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const byCategory = catalogEntriesByCategory();
|
||||||
|
|
||||||
|
const toolBtn = (tool: CanvasNavTool, icon: React.ReactNode, label: string) => (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant={activeTool === tool ? "secondary" : "ghost"}
|
||||||
|
className="size-9 shrink-0"
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
aria-pressed={activeTool === tool}
|
||||||
|
onClick={() => onToolChange(tool)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute top-4 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1 rounded-xl border bg-card/90 p-1.5 shadow-lg backdrop-blur-sm">
|
<div className="absolute top-4 left-1/2 z-10 flex max-w-[min(calc(100vw-12rem),52rem)] -translate-x-1/2 items-center gap-0.5 rounded-xl border border-border/80 bg-card/95 p-1.5 shadow-lg backdrop-blur-sm">
|
||||||
{CANVAS_NODE_TEMPLATES.map((template) => (
|
<DropdownMenu>
|
||||||
<button
|
<DropdownMenuTrigger asChild>
|
||||||
key={template.type}
|
<Button
|
||||||
onClick={() =>
|
type="button"
|
||||||
void handleAddNode(
|
size="icon"
|
||||||
template.type,
|
variant="ghost"
|
||||||
template.defaultData,
|
className="size-9 shrink-0"
|
||||||
template.width,
|
aria-label="Knoten hinzufügen"
|
||||||
template.height,
|
title="Knoten hinzufügen"
|
||||||
)
|
>
|
||||||
}
|
<Plus className="size-4" />
|
||||||
className="rounded-lg px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
</Button>
|
||||||
title={`${template.label} hinzufuegen`}
|
</DropdownMenuTrigger>
|
||||||
type="button"
|
<DropdownMenuContent
|
||||||
|
align="start"
|
||||||
|
className="max-h-[min(24rem,70vh)] w-56 overflow-y-auto"
|
||||||
>
|
>
|
||||||
{template.label}
|
{NODE_CATEGORIES_ORDERED.map((categoryId: NodeCategoryId) => {
|
||||||
</button>
|
const entries = byCategory.get(categoryId) ?? [];
|
||||||
))}
|
const creatable = entries.filter(isNodePaletteEnabled);
|
||||||
<div className="ml-1 h-6 w-px bg-border" />
|
if (creatable.length === 0) return null;
|
||||||
<CreditDisplay />
|
return (
|
||||||
<ExportButton canvasName={canvasName ?? "canvas"} />
|
<div key={categoryId}>
|
||||||
|
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground">
|
||||||
|
{NODE_CATEGORY_META[categoryId].label}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{creatable.map((entry) => {
|
||||||
|
const template = getTemplateForCatalogType(entry.type);
|
||||||
|
if (!template) return null;
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={entry.type}
|
||||||
|
onSelect={() => void handleAddNode(template)}
|
||||||
|
>
|
||||||
|
{entry.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{toolBtn(
|
||||||
|
"select",
|
||||||
|
<MousePointer2 className="size-4" />,
|
||||||
|
"Auswahl (V) — schwenken: Leertaste gedrückt halten und ziehen",
|
||||||
|
)}
|
||||||
|
{toolBtn(
|
||||||
|
"hand",
|
||||||
|
<Hand className="size-4" />,
|
||||||
|
"Hand (H) — schwenken: Leertaste gedrückt halten und ziehen oder linke Maustaste",
|
||||||
|
)}
|
||||||
|
{toolBtn("scissor", <Scissors className="size-4" />, "Kanten schneiden")}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="size-9 shrink-0"
|
||||||
|
disabled
|
||||||
|
aria-label="Kommentar (folgt)"
|
||||||
|
title="Kommentar — folgt"
|
||||||
|
>
|
||||||
|
<MessageSquare className="size-4 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="size-9 shrink-0"
|
||||||
|
disabled
|
||||||
|
aria-label="Rückgängig (folgt)"
|
||||||
|
title="Rückgängig — folgt"
|
||||||
|
>
|
||||||
|
<Undo2 className="size-4 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="size-9 shrink-0"
|
||||||
|
disabled
|
||||||
|
aria-label="Wiederholen (folgt)"
|
||||||
|
title="Wiederholen — folgt"
|
||||||
|
>
|
||||||
|
<Redo2 className="size-4 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="mx-1 h-6 w-px shrink-0 bg-border/80" />
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 items-center justify-end gap-1 sm:flex-initial">
|
||||||
|
<CreditDisplay />
|
||||||
|
<ExportButton canvasName={canvasName ?? "canvas"} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
80
components/canvas/canvas-user-menu.tsx
Normal file
80
components/canvas/canvas-user-menu.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { LayoutDashboard, LogOut } from "lucide-react";
|
||||||
|
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
import { msg } from "@/lib/toast-messages";
|
||||||
|
|
||||||
|
function getInitials(nameOrEmail: string) {
|
||||||
|
const normalized = nameOrEmail.trim();
|
||||||
|
if (!normalized) return "U";
|
||||||
|
const parts = normalized.split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
||||||
|
}
|
||||||
|
return normalized.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CanvasUserMenu() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { data: session, isPending } = authClient.useSession();
|
||||||
|
|
||||||
|
const displayName = session?.user.name?.trim() || session?.user.email || "Nutzer";
|
||||||
|
const initials = getInitials(displayName);
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
toast.info(msg.auth.signedOut.title);
|
||||||
|
await authClient.signOut();
|
||||||
|
router.replace("/auth/sign-in");
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isPending && !session?.user) {
|
||||||
|
return (
|
||||||
|
<div className="border-t p-3">
|
||||||
|
<div className="h-10 animate-pulse rounded-lg bg-muted/60" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-border/80 p-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<Avatar className="size-9 shrink-0 border border-border/60">
|
||||||
|
<AvatarFallback className="bg-muted text-xs font-medium text-muted-foreground">
|
||||||
|
{initials}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">{displayName}</p>
|
||||||
|
{session?.user.email ? (
|
||||||
|
<p className="truncate text-xs text-muted-foreground">{session.user.email}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-col gap-1">
|
||||||
|
<Button variant="ghost" size="sm" className="h-9 w-full justify-start" asChild>
|
||||||
|
<Link href="/dashboard">
|
||||||
|
<LayoutDashboard className="mr-2 size-4 shrink-0" />
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 w-full justify-start text-muted-foreground"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSignOut()}
|
||||||
|
>
|
||||||
|
<LogOut className="mr-2 size-4 shrink-0" />
|
||||||
|
Abmelden
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type MouseEvent as ReactMouseEvent,
|
type MouseEvent as ReactMouseEvent,
|
||||||
@@ -55,13 +56,20 @@ import {
|
|||||||
DEFAULT_ASPECT_RATIO,
|
DEFAULT_ASPECT_RATIO,
|
||||||
parseAspectRatioString,
|
parseAspectRatioString,
|
||||||
} from "@/lib/image-formats";
|
} from "@/lib/image-formats";
|
||||||
import CanvasToolbar from "@/components/canvas/canvas-toolbar";
|
import CanvasToolbar, {
|
||||||
|
type CanvasNavTool,
|
||||||
|
} from "@/components/canvas/canvas-toolbar";
|
||||||
|
import { CanvasAppMenu } from "@/components/canvas/canvas-app-menu";
|
||||||
import { CanvasCommandPalette } from "@/components/canvas/canvas-command-palette";
|
import { CanvasCommandPalette } from "@/components/canvas/canvas-command-palette";
|
||||||
import {
|
import {
|
||||||
CanvasConnectionDropMenu,
|
CanvasConnectionDropMenu,
|
||||||
type ConnectionDropMenuState,
|
type ConnectionDropMenuState,
|
||||||
} from "@/components/canvas/canvas-connection-drop-menu";
|
} from "@/components/canvas/canvas-connection-drop-menu";
|
||||||
import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context";
|
import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context";
|
||||||
|
import {
|
||||||
|
AssetBrowserTargetContext,
|
||||||
|
type AssetBrowserTargetApi,
|
||||||
|
} from "@/components/canvas/asset-browser-panel";
|
||||||
import CustomConnectionLine from "@/components/canvas/custom-connection-line";
|
import CustomConnectionLine from "@/components/canvas/custom-connection-line";
|
||||||
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
|
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
|
||||||
|
|
||||||
@@ -765,12 +773,29 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Freepik-Panel: State canvas-weit, damit es den optimistic_… → Real-ID-Wechsel überlebt. */
|
||||||
|
const [assetBrowserTargetNodeId, setAssetBrowserTargetNodeId] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const assetBrowserTargetApi: AssetBrowserTargetApi = useMemo(
|
||||||
|
() => ({
|
||||||
|
targetNodeId: assetBrowserTargetNodeId,
|
||||||
|
openForNode: (nodeId: string) => setAssetBrowserTargetNodeId(nodeId),
|
||||||
|
close: () => setAssetBrowserTargetNodeId(null),
|
||||||
|
}),
|
||||||
|
[assetBrowserTargetNodeId],
|
||||||
|
);
|
||||||
|
|
||||||
/** Pairing: create kann vor oder nach Drag-Ende fertig sein. Kanten-Split + Position in einem Convex-Roundtrip wenn split ansteht. */
|
/** Pairing: create kann vor oder nach Drag-Ende fertig sein. Kanten-Split + Position in einem Convex-Roundtrip wenn split ansteht. */
|
||||||
const syncPendingMoveForClientRequest = useCallback(
|
const syncPendingMoveForClientRequest = useCallback(
|
||||||
(clientRequestId: string | undefined, realId?: Id<"nodes">) => {
|
(clientRequestId: string | undefined, realId?: Id<"nodes">) => {
|
||||||
if (!clientRequestId) return;
|
if (!clientRequestId) return;
|
||||||
|
|
||||||
if (realId !== undefined) {
|
if (realId !== undefined) {
|
||||||
|
const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${clientRequestId}`;
|
||||||
|
setAssetBrowserTargetNodeId((current) =>
|
||||||
|
current === optimisticNodeId ? (realId as string) : current,
|
||||||
|
);
|
||||||
const pendingMove = pendingMoveAfterCreateRef.current.get(clientRequestId);
|
const pendingMove = pendingMoveAfterCreateRef.current.get(clientRequestId);
|
||||||
const splitPayload =
|
const splitPayload =
|
||||||
pendingEdgeSplitByClientRequestRef.current.get(clientRequestId);
|
pendingEdgeSplitByClientRequestRef.current.get(clientRequestId);
|
||||||
@@ -866,6 +891,53 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
const [scissorStrokePreview, setScissorStrokePreview] = useState<
|
const [scissorStrokePreview, setScissorStrokePreview] = useState<
|
||||||
{ x: number; y: number }[] | null
|
{ x: number; y: number }[] | null
|
||||||
>(null);
|
>(null);
|
||||||
|
const [navTool, setNavTool] = useState<CanvasNavTool>("select");
|
||||||
|
|
||||||
|
const handleNavToolChange = useCallback((tool: CanvasNavTool) => {
|
||||||
|
if (tool === "scissor") {
|
||||||
|
setScissorsMode(true);
|
||||||
|
setNavTool("scissor");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setScissorsMode(false);
|
||||||
|
setNavTool(tool);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auswahl (V) / Hand (H) — ergänzt die Leertaste (Standard: panActivationKeyCode Space beim Ziehen)
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
if (isEditableKeyboardTarget(e.target)) return;
|
||||||
|
const key = e.key.length === 1 ? e.key.toLowerCase() : "";
|
||||||
|
if (key === "v") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleNavToolChange("select");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === "h") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleNavToolChange("hand");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [handleNavToolChange]);
|
||||||
|
|
||||||
|
const { flowPanOnDrag, flowSelectionOnDrag } = useMemo(() => {
|
||||||
|
const panMiddleRight: number[] = [1, 2];
|
||||||
|
if (scissorsMode) {
|
||||||
|
return { flowPanOnDrag: panMiddleRight, flowSelectionOnDrag: false };
|
||||||
|
}
|
||||||
|
if (navTool === "hand") {
|
||||||
|
return { flowPanOnDrag: true, flowSelectionOnDrag: false };
|
||||||
|
}
|
||||||
|
if (navTool === "comment") {
|
||||||
|
return { flowPanOnDrag: panMiddleRight, flowSelectionOnDrag: true };
|
||||||
|
}
|
||||||
|
return { flowPanOnDrag: panMiddleRight, flowSelectionOnDrag: true };
|
||||||
|
}, [scissorsMode, navTool]);
|
||||||
|
|
||||||
const edgesRef = useRef(edges);
|
const edgesRef = useRef(edges);
|
||||||
edgesRef.current = edges;
|
edgesRef.current = edges;
|
||||||
const scissorsModeRef = useRef(scissorsMode);
|
const scissorsModeRef = useRef(scissorsMode);
|
||||||
@@ -873,6 +945,8 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
|
|
||||||
// Drag-Lock: während des Drags kein Convex-Override
|
// Drag-Lock: während des Drags kein Convex-Override
|
||||||
const isDragging = useRef(false);
|
const isDragging = useRef(false);
|
||||||
|
// Resize-Lock: kein Convex→lokal während aktiver Größenänderung (veraltete Maße überschreiben sonst den Resize)
|
||||||
|
const isResizing = useRef(false);
|
||||||
|
|
||||||
// Delete-Lock: Nodes die gerade gelöscht werden, nicht aus Convex-Sync wiederherstellen
|
// Delete-Lock: Nodes die gerade gelöscht werden, nicht aus Convex-Sync wiederherstellen
|
||||||
const deletingNodeIds = useRef<Set<string>>(new Set());
|
const deletingNodeIds = useRef<Set<string>>(new Set());
|
||||||
@@ -946,7 +1020,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
|
|
||||||
// ─── Convex → Lokaler State Sync ──────────────────────────────
|
// ─── Convex → Lokaler State Sync ──────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!convexNodes || isDragging.current) return;
|
if (!convexNodes || isDragging.current || isResizing.current) return;
|
||||||
setNodes((previousNodes) => {
|
setNodes((previousNodes) => {
|
||||||
const prevDataById = new Map(
|
const prevDataById = new Map(
|
||||||
previousNodes.map((node) => [node.id, node.data as Record<string, unknown>]),
|
previousNodes.map((node) => [node.id, node.data as Record<string, unknown>]),
|
||||||
@@ -1000,6 +1074,16 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
// ─── Node Changes (Drag, Select, Remove) ─────────────────────
|
// ─── Node Changes (Drag, Select, Remove) ─────────────────────
|
||||||
const onNodesChange = useCallback(
|
const onNodesChange = useCallback(
|
||||||
(changes: NodeChange[]) => {
|
(changes: NodeChange[]) => {
|
||||||
|
for (const c of changes) {
|
||||||
|
if (c.type === "dimensions") {
|
||||||
|
if (c.resizing === true) {
|
||||||
|
isResizing.current = true;
|
||||||
|
} else if (c.resizing === false) {
|
||||||
|
isResizing.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const removedIds = new Set<string>();
|
const removedIds = new Set<string>();
|
||||||
for (const c of changes) {
|
for (const c of changes) {
|
||||||
if (c.type === "remove") {
|
if (c.type === "remove") {
|
||||||
@@ -1023,6 +1107,22 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
change.resizing === true || change.resizing === false;
|
change.resizing === true || change.resizing === false;
|
||||||
|
|
||||||
if (node.type === "asset") {
|
if (node.type === "asset") {
|
||||||
|
const nodeResizing = Boolean(
|
||||||
|
(node as { resizing?: boolean }).resizing,
|
||||||
|
);
|
||||||
|
const hasResizingTrueInBatch = changes.some(
|
||||||
|
(c) =>
|
||||||
|
c.type === "dimensions" &&
|
||||||
|
"id" in c &&
|
||||||
|
c.id === change.id &&
|
||||||
|
c.resizing === true,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!isActiveResize &&
|
||||||
|
(nodeResizing || hasResizingTrueInBatch)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (!isActiveResize) {
|
if (!isActiveResize) {
|
||||||
return change;
|
return change;
|
||||||
}
|
}
|
||||||
@@ -1671,6 +1771,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
deletingNodeIds.current.add(id);
|
deletingNodeIds.current.add(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removedTargetSet = new Set(idsToDelete);
|
||||||
|
setAssetBrowserTargetNodeId((cur) =>
|
||||||
|
cur !== null && removedTargetSet.has(cur) ? null : cur,
|
||||||
|
);
|
||||||
|
|
||||||
const bridgeCreates = computeBridgeCreatesForDeletedNodes(
|
const bridgeCreates = computeBridgeCreatesForDeletedNodes(
|
||||||
deletedNodes,
|
deletedNodes,
|
||||||
nodes,
|
nodes,
|
||||||
@@ -1805,6 +1910,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape" && scissorsModeRef.current) {
|
if (e.key === "Escape" && scissorsModeRef.current) {
|
||||||
setScissorsMode(false);
|
setScissorsMode(false);
|
||||||
|
setNavTool("select");
|
||||||
setScissorStrokePreview(null);
|
setScissorStrokePreview(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1813,7 +1919,13 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
if (!k) return;
|
if (!k) return;
|
||||||
if (isEditableKeyboardTarget(e.target)) return;
|
if (isEditableKeyboardTarget(e.target)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setScissorsMode((prev) => !prev);
|
if (scissorsModeRef.current) {
|
||||||
|
setScissorsMode(false);
|
||||||
|
setNavTool("select");
|
||||||
|
} else {
|
||||||
|
setScissorsMode(true);
|
||||||
|
setNavTool("scissor");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener("keydown", onKeyDown);
|
document.addEventListener("keydown", onKeyDown);
|
||||||
return () => document.removeEventListener("keydown", onKeyDown);
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
@@ -1922,8 +2034,14 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
syncPendingMoveForClientRequest(clientRequestId, realId)
|
syncPendingMoveForClientRequest(clientRequestId, realId)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<AssetBrowserTargetContext.Provider value={assetBrowserTargetApi}>
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
<CanvasToolbar canvasName={canvas?.name ?? "canvas"} />
|
<CanvasToolbar
|
||||||
|
canvasName={canvas?.name ?? "canvas"}
|
||||||
|
activeTool={navTool}
|
||||||
|
onToolChange={handleNavToolChange}
|
||||||
|
/>
|
||||||
|
<CanvasAppMenu canvasId={canvasId} />
|
||||||
<CanvasCommandPalette />
|
<CanvasCommandPalette />
|
||||||
<CanvasConnectionDropMenu
|
<CanvasConnectionDropMenu
|
||||||
state={connectionDropMenu}
|
state={connectionDropMenu}
|
||||||
@@ -1931,7 +2049,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
onPick={handleConnectionDropPick}
|
onPick={handleConnectionDropPick}
|
||||||
/>
|
/>
|
||||||
{scissorsMode ? (
|
{scissorsMode ? (
|
||||||
<div className="pointer-events-none absolute top-3 left-1/2 z-50 max-w-[min(100%-2rem,28rem)] -translate-x-1/2 rounded-lg bg-popover/95 px-3 py-1.5 text-center text-xs text-popover-foreground shadow-md ring-1 ring-foreground/10">
|
<div className="pointer-events-none absolute top-14 left-1/2 z-50 max-w-[min(100%-2rem,28rem)] -translate-x-1/2 rounded-lg bg-popover/95 px-3 py-1.5 text-center text-xs text-popover-foreground shadow-md ring-1 ring-foreground/10">
|
||||||
Scherenmodus — Kante anklicken oder ziehen zum Durchtrennen ·{" "}
|
Scherenmodus — Kante anklicken oder ziehen zum Durchtrennen ·{" "}
|
||||||
<span className="whitespace-nowrap">Esc oder K beenden</span> · Mitte/Rechtsklick zum
|
<span className="whitespace-nowrap">Esc oder K beenden</span> · Mitte/Rechtsklick zum
|
||||||
Verschieben
|
Verschieben
|
||||||
@@ -1990,7 +2108,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
deleteKeyCode={["Backspace", "Delete"]}
|
deleteKeyCode={["Backspace", "Delete"]}
|
||||||
multiSelectionKeyCode="Shift"
|
multiSelectionKeyCode="Shift"
|
||||||
nodesConnectable={!scissorsMode}
|
nodesConnectable={!scissorsMode}
|
||||||
panOnDrag={scissorsMode ? [1, 2] : true}
|
panOnDrag={flowPanOnDrag}
|
||||||
|
selectionOnDrag={flowSelectionOnDrag}
|
||||||
|
panActivationKeyCode="Space"
|
||||||
proOptions={{ hideAttribution: true }}
|
proOptions={{ hideAttribution: true }}
|
||||||
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
|
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
|
||||||
className={cn("bg-background", scissorsMode && "canvas-scissors-mode")}
|
className={cn("bg-background", scissorsMode && "canvas-scissors-mode")}
|
||||||
@@ -2006,6 +2126,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</AssetBrowserTargetContext.Provider>
|
||||||
</CanvasPlacementProvider>
|
</CanvasPlacementProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export default function AiImageNode({
|
|||||||
if (!canvasId) throw new Error("Missing canvasId");
|
if (!canvasId) throw new Error("Missing canvasId");
|
||||||
|
|
||||||
const prompt = nodeData.prompt;
|
const prompt = nodeData.prompt;
|
||||||
if (!prompt) throw new Error("No prompt — use Generate from a KI-Bild node");
|
if (!prompt) throw new Error("No prompt — Generierung vom Prompt-Knoten aus starten");
|
||||||
|
|
||||||
const edges = getEdges();
|
const edges = getEdges();
|
||||||
const incomingEdges = edges.filter((e) => e.target === id);
|
const incomingEdges = edges.filter((e) => e.target === id);
|
||||||
@@ -178,7 +178,7 @@ export default function AiImageNode({
|
|||||||
<div className="shrink-0 border-b border-border px-3 py-2">
|
<div className="shrink-0 border-b border-border px-3 py-2">
|
||||||
<div className="flex items-center gap-1.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
|
<div className="flex items-center gap-1.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
|
||||||
<ImageIcon className="h-3.5 w-3.5" />
|
<ImageIcon className="h-3.5 w-3.5" />
|
||||||
AI Image
|
Bildausgabe
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ export default function AiImageNode({
|
|||||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||||
<ImageIcon className="h-10 w-10 opacity-30" />
|
<ImageIcon className="h-10 w-10 opacity-30" />
|
||||||
<p className="px-6 text-center text-xs opacity-60">
|
<p className="px-6 text-center text-xs opacity-60">
|
||||||
Verbinde einen KI-Bild-Knoten und starte die Generierung dort.
|
Verbinde einen Prompt-Knoten und starte die Generierung dort.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type MouseEvent,
|
type MouseEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
import { Handle, Position, useStore, type Node, type NodeProps } from "@xyflow/react";
|
||||||
import { useMutation } from "convex/react";
|
import { useMutation } from "convex/react";
|
||||||
import { ExternalLink, ImageIcon } from "lucide-react";
|
import { ExternalLink, ImageIcon } from "lucide-react";
|
||||||
import BaseNodeWrapper from "./base-node-wrapper";
|
import BaseNodeWrapper from "./base-node-wrapper";
|
||||||
import {
|
import {
|
||||||
AssetBrowserPanel,
|
AssetBrowserPanel,
|
||||||
|
useAssetBrowserTarget,
|
||||||
type AssetBrowserSessionState,
|
type AssetBrowserSessionState,
|
||||||
} from "@/components/canvas/asset-browser-panel";
|
} from "@/components/canvas/asset-browser-panel";
|
||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
@@ -40,7 +43,9 @@ type AssetNodeData = {
|
|||||||
export type AssetNodeType = Node<AssetNodeData, "asset">;
|
export type AssetNodeType = Node<AssetNodeData, "asset">;
|
||||||
|
|
||||||
export default function AssetNode({ id, data, selected, width, height }: NodeProps<AssetNodeType>) {
|
export default function AssetNode({ id, data, selected, width, height }: NodeProps<AssetNodeType>) {
|
||||||
const [panelOpen, setPanelOpen] = useState(false);
|
const { targetNodeId, openForNode, close: closeAssetBrowser } =
|
||||||
|
useAssetBrowserTarget();
|
||||||
|
const panelOpen = targetNodeId === id;
|
||||||
const [loadedPreviewUrl, setLoadedPreviewUrl] = useState<string | null>(null);
|
const [loadedPreviewUrl, setLoadedPreviewUrl] = useState<string | null>(null);
|
||||||
const [failedPreviewUrl, setFailedPreviewUrl] = useState<string | null>(null);
|
const [failedPreviewUrl, setFailedPreviewUrl] = useState<string | null>(null);
|
||||||
const [browserState, setBrowserState] = useState<AssetBrowserSessionState>({
|
const [browserState, setBrowserState] = useState<AssetBrowserSessionState>({
|
||||||
@@ -52,6 +57,31 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
|||||||
});
|
});
|
||||||
const resizeNode = useMutation(api.nodes.resize);
|
const resizeNode = useMutation(api.nodes.resize);
|
||||||
|
|
||||||
|
const edges = useStore((s) => s.edges);
|
||||||
|
const nodes = useStore((s) => s.nodes);
|
||||||
|
|
||||||
|
const linkedSearchTerm = useMemo(() => {
|
||||||
|
const incoming = edges.filter((e) => e.target === id);
|
||||||
|
for (const edge of incoming) {
|
||||||
|
const sourceNode = nodes.find((n) => n.id === edge.source);
|
||||||
|
if (sourceNode?.type !== "text") continue;
|
||||||
|
const content = (sourceNode.data as { content?: string }).content;
|
||||||
|
if (typeof content === "string" && content.trim().length > 0) {
|
||||||
|
return content.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}, [edges, id, nodes]);
|
||||||
|
|
||||||
|
const openAssetBrowser = useCallback(() => {
|
||||||
|
setBrowserState((s) =>
|
||||||
|
linkedSearchTerm
|
||||||
|
? { ...s, term: linkedSearchTerm, results: [], page: 1, totalPages: 1 }
|
||||||
|
: s,
|
||||||
|
);
|
||||||
|
openForNode(id);
|
||||||
|
}, [id, linkedSearchTerm, openForNode]);
|
||||||
|
|
||||||
const hasAsset = typeof data.assetId === "number";
|
const hasAsset = typeof data.assetId === "number";
|
||||||
const previewUrl = data.url ?? data.previewUrl;
|
const previewUrl = data.url ?? data.previewUrl;
|
||||||
const isPreviewLoading = Boolean(
|
const isPreviewLoading = Boolean(
|
||||||
@@ -143,8 +173,9 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={hasAsset ? "ghost" : "default"}
|
variant={hasAsset ? "ghost" : "default"}
|
||||||
className="h-6 px-2 text-xs"
|
className="nodrag h-6 px-2 text-xs"
|
||||||
onClick={() => setPanelOpen(true)}
|
onClick={openAssetBrowser}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{hasAsset ? "Change" : "Browse Assets"}
|
{hasAsset ? "Change" : "Browse Assets"}
|
||||||
@@ -239,7 +270,7 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
|||||||
canvasId={data.canvasId}
|
canvasId={data.canvasId}
|
||||||
initialState={browserState}
|
initialState={browserState}
|
||||||
onStateChange={setBrowserState}
|
onStateChange={setBrowserState}
|
||||||
onClose={() => setPanelOpen(false)}
|
onClose={closeAssetBrowser}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ export default function PromptNode({
|
|||||||
<div className="flex h-full flex-col gap-2 p-3">
|
<div className="flex h-full flex-col gap-2 p-3">
|
||||||
<div className="flex items-center gap-1.5 text-xs font-medium text-violet-600 dark:text-violet-400">
|
<div className="flex items-center gap-1.5 text-xs font-medium text-violet-600 dark:text-violet-400">
|
||||||
<Sparkles className="h-3.5 w-3.5" />
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
Eingabe
|
KI-Bild
|
||||||
</div>
|
</div>
|
||||||
{inputMeta.hasTextInput ? (
|
{inputMeta.hasTextInput ? (
|
||||||
<div className="flex-1 overflow-auto rounded-md border border-violet-500/30 bg-violet-500/5 px-3 py-2">
|
<div className="flex-1 overflow-auto rounded-md border border-violet-500/30 bg-violet-500/5 px-3 py-2">
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function RecentTransactions() {
|
|||||||
Noch keine Aktivität
|
Noch keine Aktivität
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground/70">
|
<p className="mt-1 text-xs text-muted-foreground/70">
|
||||||
Erstelle dein erstes KI-Bild im Canvas
|
Erstelle dein erstes Bild im Canvas (Prompt-Knoten)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
284
lib/canvas-node-catalog.ts
Normal file
284
lib/canvas-node-catalog.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import type { Doc } from "@/convex/_generated/dataModel";
|
||||||
|
import { nodeTypes } from "@/components/canvas/node-types";
|
||||||
|
import {
|
||||||
|
CANVAS_NODE_TEMPLATES,
|
||||||
|
type CanvasNodeTemplate,
|
||||||
|
} from "@/lib/canvas-node-templates";
|
||||||
|
|
||||||
|
/** PRD-Kategorien (Reihenfolge für Sidebar / Dropdown). */
|
||||||
|
export type NodeCategoryId =
|
||||||
|
| "source"
|
||||||
|
| "ai-output"
|
||||||
|
| "transform"
|
||||||
|
| "control"
|
||||||
|
| "layout";
|
||||||
|
|
||||||
|
export const NODE_CATEGORY_META: Record<
|
||||||
|
NodeCategoryId,
|
||||||
|
{ label: string; order: number }
|
||||||
|
> = {
|
||||||
|
source: { label: "Quelle", order: 0 },
|
||||||
|
"ai-output": { label: "KI-Ausgabe", order: 1 },
|
||||||
|
transform: { label: "Transformation", order: 2 },
|
||||||
|
control: { label: "Steuerung & Flow", order: 3 },
|
||||||
|
layout: { label: "Canvas & Layout", order: 4 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NODE_CATEGORIES_ORDERED: NodeCategoryId[] = (
|
||||||
|
Object.keys(NODE_CATEGORY_META) as NodeCategoryId[]
|
||||||
|
).sort((a, b) => NODE_CATEGORY_META[a].order - NODE_CATEGORY_META[b].order);
|
||||||
|
|
||||||
|
export type CatalogNodeType = Doc<"nodes">["type"];
|
||||||
|
|
||||||
|
export type NodeCatalogEntry = {
|
||||||
|
type: CatalogNodeType;
|
||||||
|
label: string;
|
||||||
|
category: NodeCategoryId;
|
||||||
|
phase: 1 | 2 | 3;
|
||||||
|
/** React-Flow-Komponente vorhanden. */
|
||||||
|
implemented: boolean;
|
||||||
|
/** Wird typischerweise vom KI-System erzeugt — nicht aus Palette/DnD anlegbar. */
|
||||||
|
systemOutput?: boolean;
|
||||||
|
/** Kurzer Hinweis für Tooltip (disabled). */
|
||||||
|
disabledHint?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const REACT_FLOW_TYPES = new Set<string>(Object.keys(nodeTypes));
|
||||||
|
|
||||||
|
function entry(
|
||||||
|
partial: Omit<NodeCatalogEntry, "implemented"> & { implemented?: boolean },
|
||||||
|
): NodeCatalogEntry {
|
||||||
|
const implemented = partial.implemented ?? REACT_FLOW_TYPES.has(partial.type);
|
||||||
|
return { ...partial, implemented };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vollständige Node-Taxonomie laut PRD / Convex `nodeType` (eine Zeile pro PRD-Node).
|
||||||
|
*/
|
||||||
|
export const NODE_CATALOG: readonly NodeCatalogEntry[] = [
|
||||||
|
// Quelle
|
||||||
|
entry({
|
||||||
|
type: "image",
|
||||||
|
label: "Bild",
|
||||||
|
category: "source",
|
||||||
|
phase: 1,
|
||||||
|
}),
|
||||||
|
entry({
|
||||||
|
type: "text",
|
||||||
|
label: "Text",
|
||||||
|
category: "source",
|
||||||
|
phase: 1,
|
||||||
|
}),
|
||||||
|
entry({
|
||||||
|
type: "color",
|
||||||
|
label: "Farbe / Palette",
|
||||||
|
category: "source",
|
||||||
|
phase: 2,
|
||||||
|
implemented: false,
|
||||||
|
disabledHint: "Folgt in Phase 2",
|
||||||
|
}),
|
||||||
|
entry({
|
||||||
|
type: "video",
|
||||||
|
label: "Video",
|
||||||
|
category: "source",
|
||||||
|
phase: 2,
|
||||||
|
}),
|
||||||
|
entry({
|
||||||
|
type: "asset",
|
||||||
|
label: "Asset (Stock)",
|
||||||
|
category: "source",
|
||||||
|
phase: 2,
|
||||||
|
}),
|
||||||
|
// KI-Ausgabe (Prompt-Knoten: steuert Generierung, ersetzt früheres „KI-Bild“ in der Palette)
|
||||||
|
entry({
|
||||||
|
type: "prompt",
|
||||||
|
label: "KI-Bild",
|
||||||
|
category: "ai-output",
|
||||||
|
phase: 1,
|
||||||
|
}),
|
||||||
|
entry({
|
||||||
|
type: "ai-text",
|
||||||
|
label: "KI-Text",
|
||||||
|
category: "ai-output",
|
||||||
|
phase: 2,
|
||||||
|
systemOutput: true,
|
||||||
|
disabledHint: "Wird von der KI erzeugt",
|
||||||
|
}),
|
||||||
|
entry({
|
||||||
|
type: "ai-video",
|
||||||
|
label: "KI-Video",
|
||||||
|
category: "ai-output",
|
||||||
|
phase: 2,
|
||||||
|
systemOutput: true,
|
||||||
|
disabledHint: "Wird von der KI erzeugt",
|
||||||
|
}),
|
||||||
|
entry({
|
||||||
|
type: "agent-output",
|
||||||
|
label: "Agent-Ausgabe",
|
||||||
|
category: "ai-output",
|
||||||
|
phase: 3,
|
||||||
|
systemOutput: true,
|
||||||
|
disabledHint: "Wird vom Agenten erzeugt",
|
||||||
|
}),
|
||||||
|
// Transformation
|
||||||
|
entry({
|
||||||
|
type: "crop",
|
||||||
|
label: "Crop / Resize",
|
||||||
|
category: "transform",
|
||||||
|
phase: 2,
|
||||||
|
implemented: false,
|
||||||
|
disabledHint: "Folgt in Phase 2",
|
||||||
|
}),
|
||||||
|
entry({
|
||||||
|
type: "bg-remove",
|
||||||
|
label: "BG entfernen",
|
||||||
|
category: "transform",
|
||||||
|
phase: 2,
|
||||||
|
implemented: false,
|
||||||
|
disabledHint: "Folgt in Phase 2",
|
||||||
|
}),
|
||||||
|
entry({
|
||||||
|
type: "upscale",
|
||||||
|
label: "Upscale",
|
||||||
|
category: "transform",
|
||||||
|
phase: 2,
|
||||||
|
implemented: false,
|
||||||
|
disabledHint: "Folgt in Phase 2",
|
||||||
|
}),
|
||||||
|
entry({
|
||||||
|
type: "style-transfer",
|
||||||
|
label: "Style Transfer",
|
||||||
|
category: "transform",
|
||||||
|
phase: 3,
|
||||||
|
implemented: false,
|
||||||
|
disabledHint: "Folgt in Phase 3",
|
||||||
|
}),
|
||||||
|
entry({
|
||||||
|
type: "face-restore",
|
||||||
|
label: "Gesicht",
|
||||||
|
category: "transform",
|
||||||
|
phase: 3,
|
||||||
|
implemented: false,
|
||||||
|
disabledHint: "Folgt in Phase 3",
|
||||||
|
}),
|
||||||
|
// Steuerung & Flow
|
||||||
|
entry({
|
||||||
|
type: "splitter",
|
||||||
|
label: "Splitter",
|
||||||
|
category: "control",
|
||||||
|
phase: 2,
|
||||||
|
implemented: false,
|
||||||
|
disabledHint: "Folgt in Phase 2",
|
||||||
|
}),
|
||||||
|
entry({
|
||||||
|
type: "loop",
|
||||||
|
label: "Loop",
|
||||||
|
category: "control",
|
||||||
|
phase: 2,
|
||||||
|
implemented: false,
|
||||||
|
disabledHint: "Folgt in Phase 2",
|
||||||
|
}),
|
||||||
|
entry({
|
||||||
|
type: "agent",
|
||||||
|
label: "Agent",
|
||||||
|
category: "control",
|
||||||
|
phase: 2,
|
||||||
|
implemented: false,
|
||||||
|
disabledHint: "Folgt in Phase 2",
|
||||||
|
}),
|
||||||
|
entry({
|
||||||
|
type: "mixer",
|
||||||
|
label: "Mixer / Merge",
|
||||||
|
category: "control",
|
||||||
|
phase: 3,
|
||||||
|
implemented: false,
|
||||||
|
disabledHint: "Folgt in Phase 3",
|
||||||
|
}),
|
||||||
|
entry({
|
||||||
|
type: "switch",
|
||||||
|
label: "Weiche",
|
||||||
|
category: "control",
|
||||||
|
phase: 3,
|
||||||
|
implemented: false,
|
||||||
|
disabledHint: "Folgt in Phase 3",
|
||||||
|
}),
|
||||||
|
// Canvas & Layout
|
||||||
|
entry({
|
||||||
|
type: "group",
|
||||||
|
label: "Gruppe",
|
||||||
|
category: "layout",
|
||||||
|
phase: 1,
|
||||||
|
}),
|
||||||
|
entry({
|
||||||
|
type: "frame",
|
||||||
|
label: "Frame",
|
||||||
|
category: "layout",
|
||||||
|
phase: 1,
|
||||||
|
}),
|
||||||
|
entry({
|
||||||
|
type: "note",
|
||||||
|
label: "Notiz",
|
||||||
|
category: "layout",
|
||||||
|
phase: 1,
|
||||||
|
}),
|
||||||
|
entry({
|
||||||
|
type: "text-overlay",
|
||||||
|
label: "Text-Overlay",
|
||||||
|
category: "layout",
|
||||||
|
phase: 2,
|
||||||
|
implemented: false,
|
||||||
|
disabledHint: "Folgt in Phase 2",
|
||||||
|
}),
|
||||||
|
entry({
|
||||||
|
type: "compare",
|
||||||
|
label: "Vergleich",
|
||||||
|
category: "layout",
|
||||||
|
phase: 1,
|
||||||
|
}),
|
||||||
|
entry({
|
||||||
|
type: "comment",
|
||||||
|
label: "Kommentar",
|
||||||
|
category: "layout",
|
||||||
|
phase: 3,
|
||||||
|
implemented: false,
|
||||||
|
disabledHint: "Folgt in Phase 3",
|
||||||
|
}),
|
||||||
|
entry({
|
||||||
|
type: "presentation",
|
||||||
|
label: "Präsentation",
|
||||||
|
category: "layout",
|
||||||
|
phase: 3,
|
||||||
|
implemented: false,
|
||||||
|
disabledHint: "Folgt in Phase 3",
|
||||||
|
}),
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TEMPLATE_BY_TYPE = new Map<string, CanvasNodeTemplate>(
|
||||||
|
CANVAS_NODE_TEMPLATES.map((t) => [t.type, t]),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Sidebar / „+“: nur mit React-Flow-Typ, ohne systemOutput, mit Template. */
|
||||||
|
export function isNodePaletteEnabled(entry: NodeCatalogEntry): boolean {
|
||||||
|
if (!entry.implemented || entry.systemOutput) return false;
|
||||||
|
return TEMPLATE_BY_TYPE.has(entry.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTemplateForCatalogType(
|
||||||
|
type: string,
|
||||||
|
): CanvasNodeTemplate | undefined {
|
||||||
|
return TEMPLATE_BY_TYPE.get(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function catalogEntriesByCategory(): Map<
|
||||||
|
NodeCategoryId,
|
||||||
|
NodeCatalogEntry[]
|
||||||
|
> {
|
||||||
|
const map = new Map<NodeCategoryId, NodeCatalogEntry[]>();
|
||||||
|
for (const id of NODE_CATEGORIES_ORDERED) {
|
||||||
|
map.set(id, []);
|
||||||
|
}
|
||||||
|
for (const e of NODE_CATALOG) {
|
||||||
|
map.get(e.category)?.push(e);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ export const CANVAS_NODE_TEMPLATES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "prompt",
|
type: "prompt",
|
||||||
label: "KI-Bild",
|
label: "Prompt",
|
||||||
width: 320,
|
width: 320,
|
||||||
height: 220,
|
height: 220,
|
||||||
defaultData: { prompt: "", model: "", aspectRatio: "1:1" },
|
defaultData: { prompt: "", model: "", aspectRatio: "1:1" },
|
||||||
@@ -36,11 +36,32 @@ export const CANVAS_NODE_TEMPLATES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "compare",
|
type: "compare",
|
||||||
label: "Compare",
|
label: "Vergleich",
|
||||||
width: 500,
|
width: 500,
|
||||||
height: 380,
|
height: 380,
|
||||||
defaultData: {},
|
defaultData: {},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "group",
|
||||||
|
label: "Gruppe",
|
||||||
|
width: 400,
|
||||||
|
height: 300,
|
||||||
|
defaultData: { label: "Gruppe" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "asset",
|
||||||
|
label: "Asset (Stock)",
|
||||||
|
width: 260,
|
||||||
|
height: 240,
|
||||||
|
defaultData: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "video",
|
||||||
|
label: "Video",
|
||||||
|
width: 320,
|
||||||
|
height: 180,
|
||||||
|
defaultData: {},
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type CanvasNodeTemplate = (typeof CANVAS_NODE_TEMPLATES)[number];
|
export type CanvasNodeTemplate = (typeof CANVAS_NODE_TEMPLATES)[number];
|
||||||
|
|||||||
Reference in New Issue
Block a user