feat: introduce image editing capabilities and enhance canvas component organization

- Added new image editing node types including curves, color adjustment, light adjustment, detail adjustment, and render, expanding the functionality of the canvas.
- Updated the canvas command palette and sidebar to categorize and display new image editing nodes, improving user navigation and accessibility.
- Implemented collapsible categories in the sidebar for better organization of node types, enhancing the overall user experience.
- Refactored canvas components to support the new image editing features, ensuring seamless integration with existing functionalities.
This commit is contained in:
2026-03-29 22:33:59 +02:00
parent 81f0b1d7a3
commit db98fabcc6
9 changed files with 369 additions and 27 deletions

View File

@@ -2,9 +2,34 @@
import { useEffect, useRef, useState } from "react";
import { useTheme } from "next-themes";
import { Moon, Sun } from "lucide-react";
import {
Bot,
ClipboardList,
Crop,
FolderOpen,
Frame,
GitBranch,
GitCompare,
Image,
ImageOff,
Layers,
LayoutPanelTop,
MessageSquare,
Moon,
Package,
Palette,
Presentation,
Repeat,
Sparkles,
Split,
StickyNote,
Sun,
Type,
Video,
Wand2,
type LucideIcon,
} from "lucide-react";
import { CanvasNodeTemplatePicker } from "@/components/canvas/canvas-node-template-picker";
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
import { useCenteredFlowNodePosition } from "@/hooks/use-centered-flow-node-position";
import {
@@ -18,6 +43,48 @@ import {
CommandSeparator,
} from "@/components/ui/command";
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
import {
NODE_CATEGORY_META,
NODE_CATEGORIES_ORDERED,
catalogEntriesByCategory,
getTemplateForCatalogType,
isNodePaletteEnabled,
} from "@/lib/canvas-node-catalog";
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,
curves: Sparkles,
"color-adjust": Palette,
"light-adjust": Sparkles,
"detail-adjust": Wand2,
render: Image,
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,
};
export function CanvasCommandPalette() {
const [open, setOpen] = useState(false);
@@ -25,6 +92,7 @@ export function CanvasCommandPalette() {
const getCenteredPosition = useCenteredFlowNodePosition();
const { setTheme } = useTheme();
const nodeCountRef = useRef(0);
const byCategory = catalogEntriesByCategory();
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
@@ -64,7 +132,40 @@ export function CanvasCommandPalette() {
<CommandInput placeholder="Suchen …" />
<CommandList>
<CommandEmpty>Keine Treffer.</CommandEmpty>
<CanvasNodeTemplatePicker onPick={handleAddNode} />
{NODE_CATEGORIES_ORDERED.map((categoryId) => {
const entries = byCategory.get(categoryId) ?? [];
if (entries.length === 0) return null;
return (
<CommandGroup
key={categoryId}
heading={NODE_CATEGORY_META[categoryId].label}
>
{entries.map((entry) => {
const template = getTemplateForCatalogType(entry.type);
const enabled = isNodePaletteEnabled(entry) && Boolean(template);
const Icon = CATALOG_ICONS[entry.type] ?? ClipboardList;
return (
<CommandItem
key={entry.type}
disabled={!enabled}
keywords={[
entry.label,
entry.type,
NODE_CATEGORY_META[categoryId].label,
]}
onSelect={() => {
if (!template) return;
handleAddNode(template);
}}
>
<Icon className="size-4" />
{entry.label}
</CommandItem>
);
})}
</CommandGroup>
);
})}
<CommandSeparator />
<CommandGroup heading="Erscheinungsbild">
<CommandItem

View File

@@ -1,8 +1,11 @@
"use client";
import { useState } from "react";
import {
Bot,
ClipboardList,
ChevronDown,
ChevronRight,
Crop,
FolderOpen,
Frame,
@@ -55,6 +58,11 @@ const CATALOG_ICONS: Partial<Record<string, LucideIcon>> = {
upscale: Wand2,
"style-transfer": Wand2,
"face-restore": Sparkles,
curves: Sparkles,
"color-adjust": Palette,
"light-adjust": Sparkles,
"detail-adjust": Wand2,
render: Image,
splitter: Split,
loop: Repeat,
agent: Bot,
@@ -111,6 +119,13 @@ type CanvasSidebarProps = {
export default function CanvasSidebar({ canvasId }: CanvasSidebarProps) {
const canvas = useAuthQuery(api.canvases.get, { canvasId });
const byCategory = catalogEntriesByCategory();
const [collapsedByCategory, setCollapsedByCategory] = useState<
Partial<Record<(typeof NODE_CATEGORIES_ORDERED)[number], boolean>>
>(() =>
Object.fromEntries(
NODE_CATEGORIES_ORDERED.map((categoryId) => [categoryId, categoryId !== "source"]),
),
);
return (
<aside className="flex w-60 shrink-0 flex-col border-r border-border/80 bg-background">
@@ -134,16 +149,35 @@ export default function CanvasSidebar({ canvasId }: CanvasSidebarProps) {
const entries = byCategory.get(categoryId) ?? [];
if (entries.length === 0) return null;
const { label } = NODE_CATEGORY_META[categoryId];
const isCollapsed = collapsedByCategory[categoryId] ?? categoryId !== "source";
return (
<div key={categoryId} className="mb-4 last:mb-0">
<h2 className="mb-2 px-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</h2>
<div className="flex flex-col gap-1.5">
{entries.map((entry) => (
<SidebarRow key={entry.type} entry={entry} />
))}
</div>
<button
type="button"
onClick={() =>
setCollapsedByCategory((prev) => ({
...prev,
[categoryId]: !(prev[categoryId] ?? categoryId !== "source"),
}))
}
className="mb-2 flex w-full items-center justify-between rounded-md px-0.5 py-1 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground"
aria-expanded={!isCollapsed}
aria-controls={`sidebar-category-${categoryId}`}
>
<span>{label}</span>
{isCollapsed ? (
<ChevronRight className="size-3.5 shrink-0" />
) : (
<ChevronDown className="size-3.5 shrink-0" />
)}
</button>
{!isCollapsed ? (
<div id={`sidebar-category-${categoryId}`} className="flex flex-col gap-1.5">
{entries.map((entry) => (
<SidebarRow key={entry.type} entry={entry} />
))}
</div>
) : null}
</div>
);
})}

View File

@@ -102,20 +102,23 @@ export default function CanvasToolbar({
>
{NODE_CATEGORIES_ORDERED.map((categoryId: NodeCategoryId) => {
const entries = byCategory.get(categoryId) ?? [];
const creatable = entries.filter(isNodePaletteEnabled);
if (creatable.length === 0) return null;
if (entries.length === 0) return null;
return (
<div key={categoryId}>
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground">
{NODE_CATEGORY_META[categoryId].label}
</DropdownMenuLabel>
{creatable.map((entry) => {
{entries.map((entry) => {
const template = getTemplateForCatalogType(entry.type);
if (!template) return null;
const enabled = isNodePaletteEnabled(entry) && Boolean(template);
return (
<DropdownMenuItem
key={entry.type}
onSelect={() => void handleAddNode(template)}
disabled={!enabled}
onSelect={() => {
if (!template) return;
void handleAddNode(template);
}}
>
{entry.label}
</DropdownMenuItem>

View File

@@ -37,6 +37,7 @@ import { toast } from "@/lib/toast";
import { msg, type CanvasNodeDeleteBlockReason } from "@/lib/toast-messages";
import {
enqueueCanvasOp,
readCanvasOps,
readCanvasSnapshot,
resolveCanvasOp,
writeCanvasSnapshot,
@@ -391,6 +392,97 @@ function applyPinnedNodePositions(
});
}
function applyPinnedNodePositionsReadOnly(
nodes: RFNode[],
pinned: ReadonlyMap<string, { x: number; y: number }>,
): RFNode[] {
return nodes.map((node) => {
const pin = pinned.get(node.id);
if (!pin) return node;
if (positionsMatchPin(node.position, pin)) return node;
return { ...node, position: { x: pin.x, y: pin.y } };
});
}
function inferPendingConnectionNodeHandoff(
previousNodes: RFNode[],
incomingConvexNodes: Doc<"nodes">[],
pendingConnectionCreates: ReadonlySet<string>,
resolvedRealIdByClientRequest: Map<string, Id<"nodes">>,
): void {
const unresolvedClientRequestIds: string[] = [];
for (const clientRequestId of pendingConnectionCreates) {
if (resolvedRealIdByClientRequest.has(clientRequestId)) continue;
const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${clientRequestId}`;
const optimisticNodePresent = previousNodes.some(
(node) => node.id === optimisticNodeId,
);
if (optimisticNodePresent) {
unresolvedClientRequestIds.push(clientRequestId);
}
}
if (unresolvedClientRequestIds.length !== 1) return;
const previousIds = new Set(previousNodes.map((node) => node.id));
const newlyAppearedIncomingRealNodeIds = incomingConvexNodes
.map((node) => node._id as string)
.filter((id) => !isOptimisticNodeId(id))
.filter((id) => !previousIds.has(id));
if (newlyAppearedIncomingRealNodeIds.length !== 1) return;
const inferredClientRequestId = unresolvedClientRequestIds[0]!;
const inferredRealId = newlyAppearedIncomingRealNodeIds[0] as Id<"nodes">;
resolvedRealIdByClientRequest.set(inferredClientRequestId, inferredRealId);
}
function isMoveNodeOpPayload(
payload: unknown,
): payload is { nodeId: Id<"nodes">; positionX: number; positionY: number } {
if (typeof payload !== "object" || payload === null) return false;
const record = payload as Record<string, unknown>;
return (
typeof record.nodeId === "string" &&
typeof record.positionX === "number" &&
typeof record.positionY === "number"
);
}
function isBatchMoveNodesOpPayload(
payload: unknown,
): payload is {
moves: { nodeId: Id<"nodes">; positionX: number; positionY: number }[];
} {
if (typeof payload !== "object" || payload === null) return false;
const record = payload as Record<string, unknown>;
if (!Array.isArray(record.moves)) return false;
return record.moves.every(isMoveNodeOpPayload);
}
function getPendingMovePinsFromLocalOps(
canvasId: string,
): Map<string, { x: number; y: number }> {
const pins = new Map<string, { x: number; y: number }>();
for (const op of readCanvasOps(canvasId)) {
if (op.type === "moveNode" && isMoveNodeOpPayload(op.payload)) {
pins.set(op.payload.nodeId as string, {
x: op.payload.positionX,
y: op.payload.positionY,
});
continue;
}
if (op.type === "batchMoveNodes" && isBatchMoveNodesOpPayload(op.payload)) {
for (const move of op.payload.moves) {
pins.set(move.nodeId as string, {
x: move.positionX,
y: move.positionY,
});
}
}
}
return pins;
}
function mergeNodesPreservingLocalState(
previousNodes: RFNode[],
incomingNodes: RFNode[],
@@ -1403,6 +1495,13 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
useLayoutEffect(() => {
if (!convexNodes || isResizing.current) return;
setNodes((previousNodes) => {
inferPendingConnectionNodeHandoff(
previousNodes,
convexNodes,
pendingConnectionCreatesRef.current,
resolvedRealIdByClientRequestRef.current,
);
/** RF setzt `node.dragging` + Position oft bevor `onNodeDragStart` `isDraggingRef` setzt — ohne diese Zeile zieht useLayoutEffect Convex-Stand darüber („Kleben“). */
const anyRfNodeDragging = previousNodes.some((n) =>
Boolean((n as { dragging?: boolean }).dragging),
@@ -1447,11 +1546,15 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
),
pendingLocalPositionUntilConvexMatchesRef.current,
);
const mergedWithOpPins = applyPinnedNodePositionsReadOnly(
merged,
getPendingMovePinsFromLocalOps(canvasId as string),
);
/** Nicht am Drag-Ende leeren (moveNode läuft oft async): solange Convex alt ist, Eintrag behalten und erst bei übereinstimmendem Snapshot entfernen. */
const incomingById = new Map(
filteredIncoming.map((n) => [n.id, n]),
);
for (const n of merged) {
for (const n of mergedWithOpPins) {
if (!preferLocalPositionNodeIdsRef.current.has(n.id)) continue;
const inc = incomingById.get(n.id);
if (!inc) continue;
@@ -1464,9 +1567,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
preferLocalPositionNodeIdsRef.current.delete(n.id);
}
}
return merged;
return mergedWithOpPins;
});
}, [convexNodes, edges, storageUrlsById]);
}, [canvasId, convexNodes, edges, storageUrlsById]);
useEffect(() => {
if (isDragging.current) return;