feat: add cmdk dependency and enhance canvas node creation with edge splitting functionality
- Introduced the cmdk package for improved command palette capabilities. - Enhanced the canvas placement context to support creating nodes with edge splitting, allowing for more dynamic node interactions. - Updated the canvas inner component to utilize optimistic updates for node creation, improving user experience during interactions. - Refactored node handling logic to incorporate new mutation types and streamline data management.
This commit is contained in:
152
components/canvas/canvas-command-palette.tsx
Normal file
152
components/canvas/canvas-command-palette.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
GitCompare,
|
||||||
|
Image,
|
||||||
|
Moon,
|
||||||
|
Sparkles,
|
||||||
|
StickyNote,
|
||||||
|
Sun,
|
||||||
|
Type,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandSeparator,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
CANVAS_NODE_TEMPLATES,
|
||||||
|
type CanvasNodeTemplate,
|
||||||
|
} from "@/lib/canvas-node-templates";
|
||||||
|
|
||||||
|
const NODE_ICONS: Record<CanvasNodeTemplate["type"], LucideIcon> = {
|
||||||
|
image: Image,
|
||||||
|
text: Type,
|
||||||
|
prompt: Sparkles,
|
||||||
|
note: StickyNote,
|
||||||
|
frame: Frame,
|
||||||
|
compare: GitCompare,
|
||||||
|
};
|
||||||
|
|
||||||
|
const NODE_SEARCH_KEYWORDS: Partial<
|
||||||
|
Record<CanvasNodeTemplate["type"], string[]>
|
||||||
|
> = {
|
||||||
|
image: ["image", "photo", "foto"],
|
||||||
|
text: ["text", "typo"],
|
||||||
|
prompt: ["prompt", "ai", "generate"],
|
||||||
|
note: ["note", "sticky", "notiz"],
|
||||||
|
frame: ["frame", "artboard"],
|
||||||
|
compare: ["compare", "before", "after"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CanvasCommandPalette() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { createNodeWithIntersection } = useCanvasPlacement();
|
||||||
|
const { setTheme } = useTheme();
|
||||||
|
const nodeCountRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (!e.metaKey && !e.ctrlKey) return;
|
||||||
|
if (e.key.toLowerCase() !== "k") return;
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen((prev) => !prev);
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddNode = async (
|
||||||
|
type: CanvasNodeTemplate["type"],
|
||||||
|
data: CanvasNodeTemplate["defaultData"],
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
) => {
|
||||||
|
const offset = (nodeCountRef.current % 8) * 24;
|
||||||
|
nodeCountRef.current += 1;
|
||||||
|
await createNodeWithIntersection({
|
||||||
|
type,
|
||||||
|
position: { x: 100 + offset, y: 100 + offset },
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
title="Befehle"
|
||||||
|
description="Knoten hinzufuegen oder Erscheinungsbild aendern"
|
||||||
|
>
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Suchen …" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>Keine Treffer.</CommandEmpty>
|
||||||
|
<CommandGroup heading="Knoten">
|
||||||
|
{CANVAS_NODE_TEMPLATES.map((template) => {
|
||||||
|
const Icon = NODE_ICONS[template.type];
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={template.type}
|
||||||
|
keywords={NODE_SEARCH_KEYWORDS[template.type] ?? []}
|
||||||
|
onSelect={() =>
|
||||||
|
void handleAddNode(
|
||||||
|
template.type,
|
||||||
|
template.defaultData,
|
||||||
|
template.width,
|
||||||
|
template.height,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
{template.label}
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup heading="Erscheinungsbild">
|
||||||
|
<CommandItem
|
||||||
|
keywords={["light", "hell", "day"]}
|
||||||
|
onSelect={() => {
|
||||||
|
setTheme("light");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sun className="size-4" />
|
||||||
|
Hell
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem
|
||||||
|
keywords={["dark", "dunkel", "night"]}
|
||||||
|
onSelect={() => {
|
||||||
|
setTheme("dark");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Moon className="size-4" />
|
||||||
|
Dunkel
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
<div className="border-t px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
<span className="font-mono tracking-wide">⌘K · Ctrl+K</span>
|
||||||
|
<span className="ml-2">Palette umschalten</span>
|
||||||
|
</div>
|
||||||
|
</Command>
|
||||||
|
</CommandDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,13 +7,56 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useMutation } from "convex/react";
|
import type { ReactMutation } from "convex/react";
|
||||||
|
import type { FunctionReference } from "convex/server";
|
||||||
import { useReactFlow, useStore, type Edge as RFEdge } from "@xyflow/react";
|
import { useReactFlow, useStore, type Edge as RFEdge } from "@xyflow/react";
|
||||||
|
|
||||||
import { api } from "@/convex/_generated/api";
|
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
||||||
|
|
||||||
|
type CreateNodeMutation = ReactMutation<
|
||||||
|
FunctionReference<
|
||||||
|
"mutation",
|
||||||
|
"public",
|
||||||
|
{
|
||||||
|
canvasId: Id<"canvases">;
|
||||||
|
type: string;
|
||||||
|
positionX: number;
|
||||||
|
positionY: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
data: unknown;
|
||||||
|
parentId?: Id<"nodes">;
|
||||||
|
zIndex?: number;
|
||||||
|
},
|
||||||
|
Id<"nodes">
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
|
||||||
|
type CreateNodeWithEdgeSplitMutation = ReactMutation<
|
||||||
|
FunctionReference<
|
||||||
|
"mutation",
|
||||||
|
"public",
|
||||||
|
{
|
||||||
|
canvasId: Id<"canvases">;
|
||||||
|
type: string;
|
||||||
|
positionX: number;
|
||||||
|
positionY: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
data: unknown;
|
||||||
|
parentId?: Id<"nodes">;
|
||||||
|
zIndex?: number;
|
||||||
|
splitEdgeId: Id<"edges">;
|
||||||
|
newNodeTargetHandle?: string;
|
||||||
|
newNodeSourceHandle?: string;
|
||||||
|
splitSourceHandle?: string;
|
||||||
|
splitTargetHandle?: string;
|
||||||
|
},
|
||||||
|
Id<"nodes">
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
|
||||||
type FlowPoint = { x: number; y: number };
|
type FlowPoint = { x: number; y: number };
|
||||||
|
|
||||||
type CreateNodeWithIntersectionInput = {
|
type CreateNodeWithIntersectionInput = {
|
||||||
@@ -87,18 +130,19 @@ function normalizeHandle(handle: string | null | undefined): string | undefined
|
|||||||
|
|
||||||
interface CanvasPlacementProviderProps {
|
interface CanvasPlacementProviderProps {
|
||||||
canvasId: Id<"canvases">;
|
canvasId: Id<"canvases">;
|
||||||
|
createNode: CreateNodeMutation;
|
||||||
|
createNodeWithEdgeSplit: CreateNodeWithEdgeSplitMutation;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CanvasPlacementProvider({
|
export function CanvasPlacementProvider({
|
||||||
canvasId,
|
canvasId,
|
||||||
|
createNode,
|
||||||
|
createNodeWithEdgeSplit,
|
||||||
children,
|
children,
|
||||||
}: CanvasPlacementProviderProps) {
|
}: CanvasPlacementProviderProps) {
|
||||||
const { flowToScreenPosition } = useReactFlow();
|
const { flowToScreenPosition } = useReactFlow();
|
||||||
const edges = useStore((store) => store.edges);
|
const edges = useStore((store) => store.edges);
|
||||||
const createNode = useMutation(api.nodes.create);
|
|
||||||
const createEdge = useMutation(api.edges.create);
|
|
||||||
const removeEdge = useMutation(api.edges.remove);
|
|
||||||
|
|
||||||
const createNodeWithIntersection = useCallback(
|
const createNodeWithIntersection = useCallback(
|
||||||
async ({
|
async ({
|
||||||
@@ -130,7 +174,7 @@ export function CanvasPlacementProvider({
|
|||||||
hitEdgeFromClientPosition ??
|
hitEdgeFromClientPosition ??
|
||||||
getIntersectedPersistedEdge(centerClientPosition, edges);
|
getIntersectedPersistedEdge(centerClientPosition, edges);
|
||||||
|
|
||||||
const nodeId = await createNode({
|
const nodePayload = {
|
||||||
canvasId,
|
canvasId,
|
||||||
type,
|
type,
|
||||||
positionX: position.x,
|
positionX: position.x,
|
||||||
@@ -143,47 +187,36 @@ export function CanvasPlacementProvider({
|
|||||||
canvasId,
|
canvasId,
|
||||||
},
|
},
|
||||||
...(zIndex !== undefined ? { zIndex } : {}),
|
...(zIndex !== undefined ? { zIndex } : {}),
|
||||||
});
|
};
|
||||||
|
|
||||||
if (!hitEdge) {
|
if (!hitEdge) {
|
||||||
return nodeId;
|
return await createNode(nodePayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handles = NODE_HANDLE_MAP[type];
|
const handles = NODE_HANDLE_MAP[type];
|
||||||
if (!hasHandleKey(handles, "source") || !hasHandleKey(handles, "target")) {
|
if (!hasHandleKey(handles, "source") || !hasHandleKey(handles, "target")) {
|
||||||
return nodeId;
|
return await createNode(nodePayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createEdge({
|
return await createNodeWithEdgeSplit({
|
||||||
canvasId,
|
...nodePayload,
|
||||||
sourceNodeId: hitEdge.source as Id<"nodes">,
|
splitEdgeId: hitEdge.id as Id<"edges">,
|
||||||
targetNodeId: nodeId,
|
newNodeTargetHandle: normalizeHandle(handles.target),
|
||||||
sourceHandle: normalizeHandle(hitEdge.sourceHandle),
|
newNodeSourceHandle: normalizeHandle(handles.source),
|
||||||
targetHandle: normalizeHandle(handles.target),
|
splitSourceHandle: normalizeHandle(hitEdge.sourceHandle),
|
||||||
|
splitTargetHandle: normalizeHandle(hitEdge.targetHandle),
|
||||||
});
|
});
|
||||||
|
|
||||||
await createEdge({
|
|
||||||
canvasId,
|
|
||||||
sourceNodeId: nodeId,
|
|
||||||
targetNodeId: hitEdge.target as Id<"nodes">,
|
|
||||||
sourceHandle: normalizeHandle(handles.source),
|
|
||||||
targetHandle: normalizeHandle(hitEdge.targetHandle),
|
|
||||||
});
|
|
||||||
|
|
||||||
await removeEdge({ edgeId: hitEdge.id as Id<"edges"> });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Canvas placement] edge split failed", {
|
console.error("[Canvas placement] edge split failed", {
|
||||||
edgeId: hitEdge.id,
|
edgeId: hitEdge.id,
|
||||||
nodeId,
|
|
||||||
type,
|
type,
|
||||||
error: String(error),
|
error: String(error),
|
||||||
});
|
});
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return nodeId;
|
|
||||||
},
|
},
|
||||||
[canvasId, createEdge, createNode, edges, flowToScreenPosition, removeEdge],
|
[canvasId, createNode, createNodeWithEdgeSplit, edges, flowToScreenPosition],
|
||||||
);
|
);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
|
|||||||
@@ -5,51 +5,10 @@ import { useRef } from "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 {
|
||||||
const nodeTemplates = [
|
CANVAS_NODE_TEMPLATES,
|
||||||
{
|
type CanvasNodeTemplate,
|
||||||
type: "image",
|
} from "@/lib/canvas-node-templates";
|
||||||
label: "Bild",
|
|
||||||
width: 280,
|
|
||||||
height: 180,
|
|
||||||
defaultData: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
label: "Text",
|
|
||||||
width: 256,
|
|
||||||
height: 120,
|
|
||||||
defaultData: { content: "" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "prompt",
|
|
||||||
label: "Prompt",
|
|
||||||
width: 320,
|
|
||||||
height: 220,
|
|
||||||
defaultData: { prompt: "", model: "", aspectRatio: "1:1" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "note",
|
|
||||||
label: "Notiz",
|
|
||||||
width: 220,
|
|
||||||
height: 120,
|
|
||||||
defaultData: { content: "" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "frame",
|
|
||||||
label: "Frame",
|
|
||||||
width: 360,
|
|
||||||
height: 240,
|
|
||||||
defaultData: { label: "Untitled", exportWidth: 1080, exportHeight: 1080 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "compare",
|
|
||||||
label: "Compare",
|
|
||||||
width: 500,
|
|
||||||
height: 380,
|
|
||||||
defaultData: {},
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
interface CanvasToolbarProps {
|
interface CanvasToolbarProps {
|
||||||
canvasName?: string;
|
canvasName?: string;
|
||||||
@@ -62,8 +21,8 @@ export default function CanvasToolbar({
|
|||||||
const nodeCountRef = useRef(0);
|
const nodeCountRef = useRef(0);
|
||||||
|
|
||||||
const handleAddNode = async (
|
const handleAddNode = async (
|
||||||
type: (typeof nodeTemplates)[number]["type"],
|
type: CanvasNodeTemplate["type"],
|
||||||
data: (typeof nodeTemplates)[number]["defaultData"],
|
data: CanvasNodeTemplate["defaultData"],
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
) => {
|
) => {
|
||||||
@@ -80,7 +39,7 @@ export default function CanvasToolbar({
|
|||||||
|
|
||||||
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 -translate-x-1/2 items-center gap-1 rounded-xl border bg-card/90 p-1.5 shadow-lg backdrop-blur-sm">
|
||||||
{nodeTemplates.map((template) => (
|
{CANVAS_NODE_TEMPLATES.map((template) => (
|
||||||
<button
|
<button
|
||||||
key={template.type}
|
key={template.type}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|||||||
@@ -26,11 +26,12 @@ import { msg } from "@/lib/toast-messages";
|
|||||||
|
|
||||||
import { useConvexAuth, useMutation, useQuery } from "convex/react";
|
import { useConvexAuth, useMutation, useQuery } from "convex/react";
|
||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Doc, Id } from "@/convex/_generated/dataModel";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
import { nodeTypes } from "./node-types";
|
import { nodeTypes } from "./node-types";
|
||||||
import {
|
import {
|
||||||
|
convexNodeDocWithMergedStorageUrl,
|
||||||
convexNodeToRF,
|
convexNodeToRF,
|
||||||
convexEdgeToRF,
|
convexEdgeToRF,
|
||||||
NODE_DEFAULTS,
|
NODE_DEFAULTS,
|
||||||
@@ -38,6 +39,7 @@ import {
|
|||||||
resolveMediaAspectRatio,
|
resolveMediaAspectRatio,
|
||||||
} from "@/lib/canvas-utils";
|
} from "@/lib/canvas-utils";
|
||||||
import CanvasToolbar from "@/components/canvas/canvas-toolbar";
|
import CanvasToolbar from "@/components/canvas/canvas-toolbar";
|
||||||
|
import { CanvasCommandPalette } from "@/components/canvas/canvas-command-palette";
|
||||||
import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context";
|
import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context";
|
||||||
|
|
||||||
interface CanvasInnerProps {
|
interface CanvasInnerProps {
|
||||||
@@ -338,6 +340,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
api.edges.list,
|
api.edges.list,
|
||||||
shouldSkipCanvasQueries ? "skip" : { canvasId },
|
shouldSkipCanvasQueries ? "skip" : { canvasId },
|
||||||
);
|
);
|
||||||
|
const storageUrlsById = useQuery(
|
||||||
|
api.storage.batchGetUrlsForCanvas,
|
||||||
|
shouldSkipCanvasQueries ? "skip" : { canvasId },
|
||||||
|
);
|
||||||
const canvas = useQuery(
|
const canvas = useQuery(
|
||||||
api.canvases.get,
|
api.canvases.get,
|
||||||
shouldSkipCanvasQueries ? "skip" : { canvasId },
|
shouldSkipCanvasQueries ? "skip" : { canvasId },
|
||||||
@@ -347,7 +353,40 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
const moveNode = useMutation(api.nodes.move);
|
const moveNode = useMutation(api.nodes.move);
|
||||||
const resizeNode = useMutation(api.nodes.resize);
|
const resizeNode = useMutation(api.nodes.resize);
|
||||||
const batchMoveNodes = useMutation(api.nodes.batchMove);
|
const batchMoveNodes = useMutation(api.nodes.batchMove);
|
||||||
const createNode = useMutation(api.nodes.create);
|
const createNode = useMutation(api.nodes.create).withOptimisticUpdate(
|
||||||
|
(localStore, args) => {
|
||||||
|
const current = localStore.getQuery(api.nodes.list, {
|
||||||
|
canvasId: args.canvasId,
|
||||||
|
});
|
||||||
|
if (current === undefined) return;
|
||||||
|
|
||||||
|
const tempId =
|
||||||
|
`optimistic_${Date.now()}_${Math.random().toString(36).slice(2, 11)}` as Id<"nodes">;
|
||||||
|
|
||||||
|
const synthetic: Doc<"nodes"> = {
|
||||||
|
_id: tempId,
|
||||||
|
_creationTime: Date.now(),
|
||||||
|
canvasId: args.canvasId,
|
||||||
|
type: args.type as Doc<"nodes">["type"],
|
||||||
|
positionX: args.positionX,
|
||||||
|
positionY: args.positionY,
|
||||||
|
width: args.width,
|
||||||
|
height: args.height,
|
||||||
|
status: "idle",
|
||||||
|
retryCount: 0,
|
||||||
|
data: args.data,
|
||||||
|
parentId: args.parentId,
|
||||||
|
zIndex: args.zIndex,
|
||||||
|
};
|
||||||
|
|
||||||
|
localStore.setQuery(
|
||||||
|
api.nodes.list,
|
||||||
|
{ canvasId: args.canvasId },
|
||||||
|
[...current, synthetic],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const createNodeWithEdgeSplit = useMutation(api.nodes.createWithEdgeSplit);
|
||||||
const batchRemoveNodes = useMutation(api.nodes.batchRemove);
|
const batchRemoveNodes = useMutation(api.nodes.batchRemove);
|
||||||
const createEdge = useMutation(api.edges.create);
|
const createEdge = useMutation(api.edges.create);
|
||||||
const removeEdge = useMutation(api.edges.remove);
|
const removeEdge = useMutation(api.edges.remove);
|
||||||
@@ -432,14 +471,27 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!convexNodes || isDragging.current) return;
|
if (!convexNodes || isDragging.current) return;
|
||||||
setNodes((previousNodes) => {
|
setNodes((previousNodes) => {
|
||||||
const incomingNodes = withResolvedCompareData(convexNodes.map(convexNodeToRF), edges);
|
const prevDataById = new Map(
|
||||||
|
previousNodes.map((node) => [node.id, node.data as Record<string, unknown>]),
|
||||||
|
);
|
||||||
|
const enriched = convexNodes.map((node) =>
|
||||||
|
convexNodeDocWithMergedStorageUrl(
|
||||||
|
node,
|
||||||
|
storageUrlsById,
|
||||||
|
prevDataById,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const incomingNodes = withResolvedCompareData(
|
||||||
|
enriched.map(convexNodeToRF),
|
||||||
|
edges,
|
||||||
|
);
|
||||||
// Nodes, die gerade optimistisch gelöscht werden, nicht wiederherstellen
|
// Nodes, die gerade optimistisch gelöscht werden, nicht wiederherstellen
|
||||||
const filteredIncoming = deletingNodeIds.current.size > 0
|
const filteredIncoming = deletingNodeIds.current.size > 0
|
||||||
? incomingNodes.filter((node) => !deletingNodeIds.current.has(node.id))
|
? incomingNodes.filter((node) => !deletingNodeIds.current.has(node.id))
|
||||||
: incomingNodes;
|
: incomingNodes;
|
||||||
return mergeNodesPreservingLocalState(previousNodes, filteredIncoming);
|
return mergeNodesPreservingLocalState(previousNodes, filteredIncoming);
|
||||||
});
|
});
|
||||||
}, [convexNodes, edges]);
|
}, [convexNodes, edges, storageUrlsById]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!convexEdges) return;
|
if (!convexEdges) return;
|
||||||
@@ -976,9 +1028,14 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CanvasPlacementProvider canvasId={canvasId}>
|
<CanvasPlacementProvider
|
||||||
|
canvasId={canvasId}
|
||||||
|
createNode={createNode}
|
||||||
|
createNodeWithEdgeSplit={createNodeWithEdgeSplit}
|
||||||
|
>
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
<CanvasToolbar canvasName={canvas?.name ?? "canvas"} />
|
<CanvasToolbar canvasName={canvas?.name ?? "canvas"} />
|
||||||
|
<CanvasCommandPalette />
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { NodeResizeControl, NodeToolbar, Position, useNodeId, useReactFlow } from "@xyflow/react";
|
import { NodeResizeControl, NodeToolbar, Position, useNodeId, useReactFlow } from "@xyflow/react";
|
||||||
import { Trash2, Copy } from "lucide-react";
|
import { Trash2, Copy, Loader2 } from "lucide-react";
|
||||||
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
|
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
|
||||||
import { NodeErrorBoundary } from "./node-error-boundary";
|
import { NodeErrorBoundary } from "./node-error-boundary";
|
||||||
|
|
||||||
@@ -46,6 +46,7 @@ function NodeToolbarActions() {
|
|||||||
const nodeId = useNodeId();
|
const nodeId = useNodeId();
|
||||||
const { deleteElements, getNode, getNodes, setNodes } = useReactFlow();
|
const { deleteElements, getNode, getNodes, setNodes } = useReactFlow();
|
||||||
const { createNodeWithIntersection } = useCanvasPlacement();
|
const { createNodeWithIntersection } = useCanvasPlacement();
|
||||||
|
const [isDuplicating, setIsDuplicating] = useState(false);
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (!nodeId) return;
|
if (!nodeId) return;
|
||||||
@@ -53,10 +54,13 @@ function NodeToolbarActions() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDuplicate = async () => {
|
const handleDuplicate = async () => {
|
||||||
if (!nodeId) return;
|
if (!nodeId || isDuplicating) return;
|
||||||
const node = getNode(nodeId);
|
const node = getNode(nodeId);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
|
||||||
|
setIsDuplicating(true);
|
||||||
|
|
||||||
|
try {
|
||||||
// Strip internal/runtime fields, keep only user content
|
// Strip internal/runtime fields, keep only user content
|
||||||
const originalData = (node.data ?? {}) as Record<string, unknown>;
|
const originalData = (node.data ?? {}) as Record<string, unknown>;
|
||||||
const cleanedData: Record<string, unknown> = {};
|
const cleanedData: Record<string, unknown> = {};
|
||||||
@@ -112,6 +116,9 @@ function NodeToolbarActions() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
selectCreatedNode();
|
selectCreatedNode();
|
||||||
|
} finally {
|
||||||
|
setIsDuplicating(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopPropagation = (e: React.MouseEvent | React.PointerEvent) => {
|
const stopPropagation = (e: React.MouseEvent | React.PointerEvent) => {
|
||||||
@@ -123,12 +130,17 @@ function NodeToolbarActions() {
|
|||||||
<div className="flex items-center gap-1 rounded-lg border bg-card p-1 shadow-md">
|
<div className="flex items-center gap-1 rounded-lg border bg-card p-1 shadow-md">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => { stopPropagation(e); handleDuplicate(); }}
|
onClick={(e) => { stopPropagation(e); void handleDuplicate(); }}
|
||||||
onPointerDown={stopPropagation}
|
onPointerDown={stopPropagation}
|
||||||
title="Duplicate"
|
title={isDuplicating ? "Duplicating…" : "Duplicate"}
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
disabled={isDuplicating}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:cursor-wait"
|
||||||
>
|
>
|
||||||
<Copy size={14} />
|
{isDuplicating ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Copy size={14} />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
195
components/ui/command.tsx
Normal file
195
components/ui/command.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
} from "@/components/ui/input-group"
|
||||||
|
import { SearchIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Command({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive
|
||||||
|
data-slot="command"
|
||||||
|
className={cn(
|
||||||
|
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandDialog({
|
||||||
|
title = "Command Palette",
|
||||||
|
description = "Search for a command to run...",
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
className?: string
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
showCloseButton={showCloseButton}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
|
return (
|
||||||
|
<div data-slot="command-input-wrapper" className="p-1 pb-0">
|
||||||
|
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
data-slot="command-input"
|
||||||
|
className={cn(
|
||||||
|
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon>
|
||||||
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
data-slot="command-list"
|
||||||
|
className={cn(
|
||||||
|
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandEmpty({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
data-slot="command-empty"
|
||||||
|
className={cn("py-6 text-center text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
data-slot="command-group"
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
data-slot="command-separator"
|
||||||
|
className={cn("-mx-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
data-slot="command-item"
|
||||||
|
className={cn(
|
||||||
|
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
|
||||||
|
</CommandPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="command-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
}
|
||||||
168
components/ui/dialog.tsx
Normal file
168
components/ui/dialog.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
size="icon-sm"
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-none font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
156
components/ui/input-group.tsx
Normal file
156
components/ui/input-group.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
|
||||||
|
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-group"
|
||||||
|
role="group"
|
||||||
|
className={cn(
|
||||||
|
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupAddonVariants = cva(
|
||||||
|
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
align: {
|
||||||
|
"inline-start":
|
||||||
|
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
|
||||||
|
"inline-end":
|
||||||
|
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
|
||||||
|
"block-start":
|
||||||
|
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
|
||||||
|
"block-end":
|
||||||
|
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
align: "inline-start",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupAddon({
|
||||||
|
className,
|
||||||
|
align = "inline-start",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="input-group-addon"
|
||||||
|
data-align={align}
|
||||||
|
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest("button")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupButtonVariants = cva(
|
||||||
|
"flex items-center gap-2 text-sm shadow-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||||
|
sm: "",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
|
||||||
|
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "xs",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupButton({
|
||||||
|
className,
|
||||||
|
type = "button",
|
||||||
|
variant = "ghost",
|
||||||
|
size = "xs",
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||||
|
VariantProps<typeof inputGroupButtonVariants>) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type={type}
|
||||||
|
data-size={size}
|
||||||
|
variant={variant}
|
||||||
|
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupTextarea({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupText,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupTextarea,
|
||||||
|
}
|
||||||
18
components/ui/textarea.tsx
Normal file
18
components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
@@ -47,29 +47,10 @@ export const list = query({
|
|||||||
const user = await requireAuth(ctx);
|
const user = await requireAuth(ctx);
|
||||||
await getCanvasOrThrow(ctx, canvasId, user.userId);
|
await getCanvasOrThrow(ctx, canvasId, user.userId);
|
||||||
|
|
||||||
const nodes = await ctx.db
|
return await ctx.db
|
||||||
.query("nodes")
|
.query("nodes")
|
||||||
.withIndex("by_canvas", (q) => q.eq("canvasId", canvasId))
|
.withIndex("by_canvas", (q) => q.eq("canvasId", canvasId))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
return Promise.all(
|
|
||||||
nodes.map(async (node) => {
|
|
||||||
const data = node.data as Record<string, unknown> | undefined;
|
|
||||||
if (!data?.storageId) {
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = await ctx.storage.getUrl(data.storageId as Id<"_storage">);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...node,
|
|
||||||
data: {
|
|
||||||
...data,
|
|
||||||
url: url ?? undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -173,6 +154,72 @@ export const create = mutation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neuen Node erzeugen und eine bestehende Kante in zwei Kanten aufteilen (ein Roundtrip).
|
||||||
|
*/
|
||||||
|
export const createWithEdgeSplit = mutation({
|
||||||
|
args: {
|
||||||
|
canvasId: v.id("canvases"),
|
||||||
|
type: v.string(),
|
||||||
|
positionX: v.number(),
|
||||||
|
positionY: v.number(),
|
||||||
|
width: v.number(),
|
||||||
|
height: v.number(),
|
||||||
|
data: v.any(),
|
||||||
|
parentId: v.optional(v.id("nodes")),
|
||||||
|
zIndex: v.optional(v.number()),
|
||||||
|
splitEdgeId: v.id("edges"),
|
||||||
|
newNodeTargetHandle: v.optional(v.string()),
|
||||||
|
newNodeSourceHandle: v.optional(v.string()),
|
||||||
|
splitSourceHandle: v.optional(v.string()),
|
||||||
|
splitTargetHandle: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const user = await requireAuth(ctx);
|
||||||
|
await getCanvasOrThrow(ctx, args.canvasId, user.userId);
|
||||||
|
|
||||||
|
const edge = await ctx.db.get(args.splitEdgeId);
|
||||||
|
if (!edge || edge.canvasId !== args.canvasId) {
|
||||||
|
throw new Error("Edge not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeId = await ctx.db.insert("nodes", {
|
||||||
|
canvasId: args.canvasId,
|
||||||
|
type: args.type as Doc<"nodes">["type"],
|
||||||
|
positionX: args.positionX,
|
||||||
|
positionY: args.positionY,
|
||||||
|
width: args.width,
|
||||||
|
height: args.height,
|
||||||
|
status: "idle",
|
||||||
|
retryCount: 0,
|
||||||
|
data: args.data,
|
||||||
|
parentId: args.parentId,
|
||||||
|
zIndex: args.zIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.db.insert("edges", {
|
||||||
|
canvasId: args.canvasId,
|
||||||
|
sourceNodeId: edge.sourceNodeId,
|
||||||
|
targetNodeId: nodeId,
|
||||||
|
sourceHandle: args.splitSourceHandle,
|
||||||
|
targetHandle: args.newNodeTargetHandle,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.db.insert("edges", {
|
||||||
|
canvasId: args.canvasId,
|
||||||
|
sourceNodeId: nodeId,
|
||||||
|
targetNodeId: edge.targetNodeId,
|
||||||
|
sourceHandle: args.newNodeSourceHandle,
|
||||||
|
targetHandle: args.splitTargetHandle,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.db.delete(args.splitEdgeId);
|
||||||
|
await ctx.db.patch(args.canvasId, { updatedAt: Date.now() });
|
||||||
|
|
||||||
|
return nodeId;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Node-Position auf dem Canvas verschieben.
|
* Node-Position auf dem Canvas verschieben.
|
||||||
*/
|
*/
|
||||||
@@ -409,8 +456,6 @@ export const batchRemove = mutation({
|
|||||||
if (!firstNode) throw new Error("Node not found");
|
if (!firstNode) throw new Error("Node not found");
|
||||||
await getCanvasOrThrow(ctx, firstNode.canvasId, user.userId);
|
await getCanvasOrThrow(ctx, firstNode.canvasId, user.userId);
|
||||||
|
|
||||||
const nodeIdSet = new Set(nodeIds.map((id) => id.toString()));
|
|
||||||
|
|
||||||
for (const nodeId of nodeIds) {
|
for (const nodeId of nodeIds) {
|
||||||
const node = await ctx.db.get(nodeId);
|
const node = await ctx.db.get(nodeId);
|
||||||
if (!node) continue;
|
if (!node) continue;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { mutation } from "./_generated/server";
|
import { mutation, query } from "./_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
import { requireAuth } from "./helpers";
|
import { requireAuth } from "./helpers";
|
||||||
|
import type { Id } from "./_generated/dataModel";
|
||||||
|
|
||||||
export const generateUploadUrl = mutation({
|
export const generateUploadUrl = mutation({
|
||||||
args: {},
|
args: {},
|
||||||
@@ -8,3 +10,41 @@ export const generateUploadUrl = mutation({
|
|||||||
return await ctx.storage.generateUploadUrl();
|
return await ctx.storage.generateUploadUrl();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signierte URLs für alle Storage-Assets eines Canvas (gebündelt).
|
||||||
|
* `nodes.list` liefert keine URLs mehr, damit Node-Liste schnell bleibt.
|
||||||
|
*/
|
||||||
|
export const batchGetUrlsForCanvas = query({
|
||||||
|
args: { canvasId: v.id("canvases") },
|
||||||
|
handler: async (ctx, { canvasId }) => {
|
||||||
|
const user = await requireAuth(ctx);
|
||||||
|
const canvas = await ctx.db.get(canvasId);
|
||||||
|
if (!canvas || canvas.ownerId !== user.userId) {
|
||||||
|
throw new Error("Canvas not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = await ctx.db
|
||||||
|
.query("nodes")
|
||||||
|
.withIndex("by_canvas", (q) => q.eq("canvasId", canvasId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const ids = new Set<Id<"_storage">>();
|
||||||
|
for (const node of nodes) {
|
||||||
|
const data = node.data as Record<string, unknown> | undefined;
|
||||||
|
const sid = data?.storageId;
|
||||||
|
if (typeof sid === "string" && sid.length > 0) {
|
||||||
|
ids.add(sid as Id<"_storage">);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await Promise.all(
|
||||||
|
[...ids].map(
|
||||||
|
async (id) =>
|
||||||
|
[id, (await ctx.storage.getUrl(id)) ?? undefined] as const,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.fromEntries(entries) as Record<string, string | undefined>;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
46
lib/canvas-node-templates.ts
Normal file
46
lib/canvas-node-templates.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
export const CANVAS_NODE_TEMPLATES = [
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
label: "Bild",
|
||||||
|
width: 280,
|
||||||
|
height: 180,
|
||||||
|
defaultData: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Text",
|
||||||
|
width: 256,
|
||||||
|
height: 120,
|
||||||
|
defaultData: { content: "" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "prompt",
|
||||||
|
label: "Prompt",
|
||||||
|
width: 320,
|
||||||
|
height: 220,
|
||||||
|
defaultData: { prompt: "", model: "", aspectRatio: "1:1" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "note",
|
||||||
|
label: "Notiz",
|
||||||
|
width: 220,
|
||||||
|
height: 120,
|
||||||
|
defaultData: { content: "" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "frame",
|
||||||
|
label: "Frame",
|
||||||
|
width: 360,
|
||||||
|
height: 240,
|
||||||
|
defaultData: { label: "Untitled", exportWidth: 1080, exportHeight: 1080 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "compare",
|
||||||
|
label: "Compare",
|
||||||
|
width: 500,
|
||||||
|
height: 380,
|
||||||
|
defaultData: {},
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type CanvasNodeTemplate = (typeof CANVAS_NODE_TEMPLATES)[number];
|
||||||
@@ -7,6 +7,46 @@ import type { Doc } from "@/convex/_generated/dataModel";
|
|||||||
* Convex speichert positionX/positionY als separate Felder,
|
* Convex speichert positionX/positionY als separate Felder,
|
||||||
* React Flow erwartet position: { x, y }.
|
* React Flow erwartet position: { x, y }.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Reichert Node-Dokumente mit `data.url` an (aus gebündelter Storage-URL-Map).
|
||||||
|
* Behält eine zuvor gemappte URL bei, solange die Batch-Query noch lädt.
|
||||||
|
*/
|
||||||
|
export function convexNodeDocWithMergedStorageUrl(
|
||||||
|
node: Doc<"nodes">,
|
||||||
|
urlByStorage: Record<string, string | undefined> | undefined,
|
||||||
|
previousDataByNodeId: Map<string, Record<string, unknown>>,
|
||||||
|
): Doc<"nodes"> {
|
||||||
|
const data = node.data as Record<string, unknown> | undefined;
|
||||||
|
const sid = data?.storageId;
|
||||||
|
if (typeof sid !== "string") {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urlByStorage) {
|
||||||
|
const fromBatch = urlByStorage[sid];
|
||||||
|
if (fromBatch !== undefined) {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: { ...data, url: fromBatch },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = previousDataByNodeId.get(node._id);
|
||||||
|
if (
|
||||||
|
prev?.url !== undefined &&
|
||||||
|
typeof prev.storageId === "string" &&
|
||||||
|
prev.storageId === sid
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: { ...data, url: prev.url },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
export function convexNodeToRF(node: Doc<"nodes">): RFNode {
|
export function convexNodeToRF(node: Doc<"nodes">): RFNode {
|
||||||
return {
|
return {
|
||||||
id: node._id,
|
id: node._id,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"better-auth": "^1.5.6",
|
"better-auth": "^1.5.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"convex": "^1.34.0",
|
"convex": "^1.34.0",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"goey-toast": "^0.3.0",
|
"goey-toast": "^0.3.0",
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -44,6 +44,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
cmdk:
|
||||||
|
specifier: ^1.1.1
|
||||||
|
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
convex:
|
convex:
|
||||||
specifier: ^1.34.0
|
specifier: ^1.34.0
|
||||||
version: 1.34.0(react@19.2.4)
|
version: 1.34.0(react@19.2.4)
|
||||||
|
|||||||
Reference in New Issue
Block a user