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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user