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

@@ -4,7 +4,7 @@
| Version | Status | Datum | Projekt | | Version | Status | Datum | Projekt |
|---------|--------|-------|---------| |---------|--------|-------|---------|
| v1.3 | Draft | März 2026 | lemonspace.app | | v1.4 | Draft | März 2026 | lemonspace.app |
--- ---
@@ -24,6 +24,7 @@
| v1.1 | Monorepo verworfen → Zwei unabhängige Repos (lemonspace-web + lemonspace-landing), Auth-Cookie-Sharing via .lemonspace.io | | v1.1 | Monorepo verworfen → Zwei unabhängige Repos (lemonspace-web + lemonspace-landing), Auth-Cookie-Sharing via .lemonspace.io |
| v1.2 | Pricing überarbeitet: Credit-Abstraktion (1 Cr = €0,01 intern), Tiers €8/€59/€119 (Business→Max), Top-Up-System (fix + Custom mit Bonus-Staffel), Marge nach LS-Gebühr + USt validiert | | v1.2 | Pricing überarbeitet: Credit-Abstraktion (1 Cr = €0,01 intern), Tiers €8/€59/€119 (Business→Max), Top-Up-System (fix + Custom mit Bonus-Staffel), Marge nach LS-Gebühr + USt validiert |
| v1.3 | Payment: Lemon Squeezy → Polar.sh (niedrigere Gebühren, Better Auth Plugin, Open Source). Gebührenmodell angepasst: 4% + $0,40 + 1,5% intl. + 0,5% Subscription | | v1.3 | Payment: Lemon Squeezy → Polar.sh (niedrigere Gebühren, Better Auth Plugin, Open Source). Gebührenmodell angepasst: 4% + $0,40 + 1,5% intl. + 0,5% Subscription |
| v1.4 | Bildbearbeitung: Neue Kategorie 4 „Bildbearbeitung" mit non-destruktivem Adjustment-Stack (zwischen Transformation und Steuerung). 4 Adjustment-Nodes (Kurven, Farbe, Licht, Detail) + Render-Node. Alle Operationen credit-frei (client-seitig via Canvas API / WebGL). Steuerung → Kat. 5, Canvas & Layout → Kat. 6. Phase 2. |
--- ---
@@ -66,7 +67,7 @@ Freepik Spaces ist ein leistungsstarkes Tool für KI-gestützte kreative Workflo
### 4.2 Node-System ### 4.2 Node-System
Das Canvas-System basiert auf einem erweiterbaren Node-Modell. Nodes sind typisierte Bausteine, die untereinander verbunden werden und Daten weitergeben. Es gibt fünf Kategorien. Das Canvas-System basiert auf einem erweiterbaren Node-Modell. Nodes sind typisierte Bausteine, die untereinander verbunden werden und Daten weitergeben. Es gibt sechs Kategorien.
#### Kategorie 1: Quelle #### Kategorie 1: Quelle
@@ -102,7 +103,37 @@ KI-Ausgabe-Nodes sind das Ergebnis einer Modell-Operation. Sie werden vom System
| Style Transfer | Überträgt visuellen Stil eines Referenzbildes auf einen anderen Input. | 3 | | Style Transfer | Überträgt visuellen Stil eines Referenzbildes auf einen anderen Input. | 3 |
| Gesicht | Face Restoration via GFPGAN. Verbessert Gesichtsdetails in generierten oder degradierten Bildern. | 3 | | Gesicht | Face Restoration via GFPGAN. Verbessert Gesichtsdetails in generierten oder degradierten Bildern. | 3 |
#### Kategorie 4: Steuerung & Flow #### Kategorie 4: Bildbearbeitung
Bildbearbeitungs-Nodes arbeiten **non-destruktiv**. Sie verändern das Originalbild nicht, sondern definieren Adjustments, die als Stack auf das Eingangsbild angewendet werden. Erst der Render-Node materialisiert das Ergebnis als neues Bild. Adjustments sind jederzeit änder-, umsortier- und löschbar — wie Adjustment Layers in Photoshop.
**Architektur-Prinzip: Adjustment-Stack**
```
Bild-Node (Original)
→ Kurven-Node (Kontrast-S-Kurve)
→ Farbe-Node (Sättigung +20, Tint Warm)
→ Detail-Node (Sharpen 40%)
→ Render-Node → Neues Bild (materialisiert)
```
Jeder Adjustment-Node hat einen Eingang (Bild oder vorheriger Adjustment) und einen Ausgang. Die Kette ist beliebig lang und umsortierbar. Das Originalbild bleibt unverändert — identischer Input kann mit verschiedenen Adjustment-Stacks zu verschiedenen Varianten führen (Branching).
**Live-Vorschau:** Jeder Adjustment-Node zeigt eine Echtzeit-Vorschau des Bildes mit allen bisherigen Adjustments. Die Verarbeitung läuft client-seitig (Canvas 2D API / WebGL) — kein Server-Roundtrip, keine Credits.
| Node | Beschreibung | Phase |
|------|--------------|-------|
| Kurven | Tonwert-Kurven (RGB + Einzelkanäle). Kontrollpunkte per Drag auf der Kurve. Presets: Kontrast, Aufhellen, Abdunkeln, Film-Look, Cross-Process. Zusätzlich: Levels (Schwarz-/Weißpunkt, Gamma) und Histogram-Anzeige. | 2 |
| Farbe | HSL-Regler (Hue, Saturation, Luminance — global + pro Farbbereich). Color Balance (Schatten/Mitten/Lichter). Selective Color. Temperature/Tint. Vibrance vs. Saturation. Presets: Warm, Cool, Vintage, Desaturate. | 2 |
| Licht | Brightness, Contrast, Exposure, Highlights, Shadows, Whites, Blacks. HDR-Tone-Mapping (local contrast). Vignette (Stärke, Größe, Rundheit). Presets: HDR, Low Key, High Key, Flat. | 2 |
| Detail | Unscharf maskieren (Amount, Radius, Threshold). Clarity / Structure (Midtone Contrast). Denoise (Luminance, Color). Grain (Amount, Size). Presets: Schärfen für Web, Schärfen für Print, Soft Glow, Film Grain. | 2 |
| Render | Materialisierer: Wendet den gesamten Adjustment-Stack an und erzeugt ein neues Bild (in Convex Storage). Unterstützt Ausgabe-Auflösung (Original, 2×, Custom) und Format (PNG, JPG mit Qualitätsstufe, WebP). Trigger: manueller „Render"-Button am Node. | 2 |
> **Credits:** Alle Adjustment-Nodes (Kurven, Farbe, Licht, Detail) sind **credit-frei** — die Verarbeitung läuft vollständig im Browser. Nur der Render-Node erzeugt serverseitig ein finales Bild in Convex Storage (ebenfalls credit-frei, da keine KI-API involviert).
> **Technische Umsetzung:** Phase-2-Entscheidung zwischen Canvas 2D API (breite Kompatibilität, einfacher) und WebGL/WebGPU (performanter bei großen Bildern, Shader-Pipeline). Für den MVP reicht Canvas 2D; WebGL wird evaluiert wenn Performance-Grenzen erreicht werden.
#### Kategorie 5: Steuerung & Flow
| Node | Semantik | Beschreibung | Phase | | Node | Semantik | Beschreibung | Phase |
|------|----------|--------------|-------| |------|----------|--------------|-------|
@@ -112,7 +143,7 @@ KI-Ausgabe-Nodes sind das Ergebnis einer Modell-Operation. Sie werden vom System
| Mixer / Merge | N → 1 | Kombiniert N Inputs zu 1 Output durch Überblendung, Komposition oder Selektion. | 3 | | Mixer / Merge | N → 1 | Kombiniert N Inputs zu 1 Output durch Überblendung, Komposition oder Selektion. | 3 |
| Weiche | 1 → Pfad A/B/... | Bedingter Router. Leitet den Input anhand einer definierbaren Bedingung auf einen von mehreren Ausgangspfaden. | 3 | | Weiche | 1 → Pfad A/B/... | Bedingter Router. Leitet den Input anhand einer definierbaren Bedingung auf einen von mehreren Ausgangspfaden. | 3 |
#### Kategorie 5: Canvas & Layout #### Kategorie 6: Canvas & Layout
| Node | Beschreibung | Phase | | Node | Beschreibung | Phase |
|------|--------------|-------| |------|--------------|-------|
@@ -241,7 +272,7 @@ Dokumentierter Migrations-Pfad bei Skalierung: Convex Cloud mit EU-Standort. Con
│ │ │ │
│ Node-Kategorien: │ │ Node-Kategorien: │
│ [Quelle] [KI-Ausgabe] [Transformation] │ │ [Quelle] [KI-Ausgabe] [Transformation] │
│ [Steuerung] [Canvas & Layout] [Bildbearbeitung] [Steuerung] [Canvas & Layout] │
└───────────────────────┬──────────────────────────────────┘ └───────────────────────┬──────────────────────────────────┘
┌─────────▼─────────┐ ┌─────────▼─────────┐
@@ -277,6 +308,7 @@ Node (Basis)
├── type (image | text | prompt | color | video | asset | ├── type (image | text | prompt | color | video | asset |
│ ai-image | ai-text | ai-video | agent-output | │ ai-image | ai-text | ai-video | agent-output |
│ crop | bg-remove | upscale | style-transfer | face-restore | │ crop | bg-remove | upscale | style-transfer | face-restore |
│ curves | color-adjust | light | detail | render |
│ splitter | loop | agent | mixer | switch | │ splitter | loop | agent | mixer | switch |
│ group | frame | note | text-overlay | compare | comment | presentation) │ group | frame | note | text-overlay | compare | comment | presentation)
├── position { x, y } ├── position { x, y }
@@ -376,6 +408,8 @@ Credits = ROUND(API-Kosten × Markup ÷ Kurs). Agent-Calls haben höheren Markup
| Upscaling | Real-ESRGAN (self-hosted) | €0 | — | 0 Cr | Alle Tiers | | Upscaling | Real-ESRGAN (self-hosted) | €0 | — | 0 Cr | Alle Tiers |
| Face Restoration | GFPGAN (self-hosted) | €0 | — | 0 Cr | Alle Tiers | | Face Restoration | GFPGAN (self-hosted) | €0 | — | 0 Cr | Alle Tiers |
| Canvas-Operationen | — | €0 | — | 0 Cr | Alle Tiers | | Canvas-Operationen | — | €0 | — | 0 Cr | Alle Tiers |
| Bildbearbeitung (Kurven, Farbe, Licht, Detail) | Client-seitig | €0 | — | 0 Cr | Alle Tiers |
| Render (Adjustment-Stack materialisieren) | Server-seitig (jimp/Canvas) | €0 | — | 0 Cr | Alle Tiers |
| Export (PNG/ZIP) | — | €0 | — | 0 Cr | Alle Tiers | | Export (PNG/ZIP) | — | €0 | — | 0 Cr | Alle Tiers |
### Credit Reservation + Commit ### Credit Reservation + Commit
@@ -476,6 +510,7 @@ Agent Status: analyzing
- Quelle: Prompt, Farbe / Palette, Video, Asset - Quelle: Prompt, Farbe / Palette, Video, Asset
- KI-Ausgabe: KI-Text, KI-Video - KI-Ausgabe: KI-Text, KI-Video
- Transformation: Crop / Resize, BG entfernen, Upscale - Transformation: Crop / Resize, BG entfernen, Upscale
- Bildbearbeitung: Kurven, Farbe, Licht, Detail, Render
- Steuerung: Splitter, Loop, Agent - Steuerung: Splitter, Loop, Agent
- Canvas & Layout: Text-Overlay, Compare - Canvas & Layout: Text-Overlay, Compare
@@ -493,6 +528,13 @@ Agent Status: analyzing
| Self-hosted KI-Services (rembg, Real-ESRGAN) | ☐ Offen | | Self-hosted KI-Services (rembg, Real-ESRGAN) | ☐ Offen |
| Freepik Asset Browser (Stock-Fotos, Vektoren) | ☐ Offen | | Freepik Asset Browser (Stock-Fotos, Vektoren) | ☐ Offen |
| Prompt-History und Re-Generation | ☐ Offen | | Prompt-History und Re-Generation | ☐ Offen |
| Bildbearbeitung: Non-destruktiver Adjustment-Stack (Client-seitige Architektur) | ☐ Offen |
| Kurven-Node: RGB/Einzelkanal-Kurven, Levels, Histogram | ☐ Offen |
| Farbe-Node: HSL, Color Balance, Selective Color, Temperature/Tint | ☐ Offen |
| Licht-Node: Brightness, Contrast, Exposure, Highlights/Shadows, HDR, Vignette | ☐ Offen |
| Detail-Node: Sharpen, Clarity, Denoise, Grain | ☐ Offen |
| Render-Node: Stack-Materialisierung, Auflösungs- und Formatwahl, Convex Storage | ☐ Offen |
| Preset-System für Adjustment-Nodes (Built-in + User-defined) | ☐ Offen |
### Phase 3 — Kollaboration & Polish ### Phase 3 — Kollaboration & Polish
@@ -541,6 +583,9 @@ Agent Status: analyzing
| Weiche: Bedingungslogik | ⏳ Visueller Rule-Builder vs. Ausdruckssprache | | Weiche: Bedingungslogik | ⏳ Visueller Rule-Builder vs. Ausdruckssprache |
| Mixer: Blend Modes | ⏳ min. Normal, Multiply, Screen, Overlay | | Mixer: Blend Modes | ⏳ min. Normal, Multiply, Screen, Overlay |
| Canvas-Export | ⏳ PNG, PDF, ZIP (Phase 3, Library TBD) | | Canvas-Export | ⏳ PNG, PDF, ZIP (Phase 3, Library TBD) |
| Bildbearbeitung: Rendering-Engine | ⏳ Canvas 2D API (einfacher, breite Kompatibilität) vs. WebGL/WebGPU (performanter bei großen Bildern). MVP: Canvas 2D, WebGL bei Bedarf. |
| Bildbearbeitung: Preset-Persistierung | ⏳ User-Presets in Convex speichern vs. nur Built-in-Presets. |
| Bildbearbeitung: Render-Node Server-Engine | ⏳ jimp (bereits im Stack, pure JS) vs. sharp (performanter, aber arm64-Problem). Alternativ: Client rendert und uploaded Ergebnis. |
--- ---
@@ -595,4 +640,4 @@ Die Software wird unter der Business Source License 1.1 (BSL 1.1) veröffentlich
--- ---
*LemonSpace PRD v1.1 — März 2026* *LemonSpace PRD v1.4 — März 2026*

View File

@@ -2,9 +2,34 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTheme } from "next-themes"; 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 { 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 { import {
@@ -18,6 +43,48 @@ import {
CommandSeparator, CommandSeparator,
} from "@/components/ui/command"; } from "@/components/ui/command";
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates"; 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() { export function CanvasCommandPalette() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -25,6 +92,7 @@ export function CanvasCommandPalette() {
const getCenteredPosition = useCenteredFlowNodePosition(); const getCenteredPosition = useCenteredFlowNodePosition();
const { setTheme } = useTheme(); const { setTheme } = useTheme();
const nodeCountRef = useRef(0); const nodeCountRef = useRef(0);
const byCategory = catalogEntriesByCategory();
useEffect(() => { useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
@@ -64,7 +132,40 @@ export function CanvasCommandPalette() {
<CommandInput placeholder="Suchen …" /> <CommandInput placeholder="Suchen …" />
<CommandList> <CommandList>
<CommandEmpty>Keine Treffer.</CommandEmpty> <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 /> <CommandSeparator />
<CommandGroup heading="Erscheinungsbild"> <CommandGroup heading="Erscheinungsbild">
<CommandItem <CommandItem

View File

@@ -1,8 +1,11 @@
"use client"; "use client";
import { useState } from "react";
import { import {
Bot, Bot,
ClipboardList, ClipboardList,
ChevronDown,
ChevronRight,
Crop, Crop,
FolderOpen, FolderOpen,
Frame, Frame,
@@ -55,6 +58,11 @@ const CATALOG_ICONS: Partial<Record<string, LucideIcon>> = {
upscale: Wand2, upscale: Wand2,
"style-transfer": Wand2, "style-transfer": Wand2,
"face-restore": Sparkles, "face-restore": Sparkles,
curves: Sparkles,
"color-adjust": Palette,
"light-adjust": Sparkles,
"detail-adjust": Wand2,
render: Image,
splitter: Split, splitter: Split,
loop: Repeat, loop: Repeat,
agent: Bot, agent: Bot,
@@ -111,6 +119,13 @@ type CanvasSidebarProps = {
export default function CanvasSidebar({ canvasId }: CanvasSidebarProps) { export default function CanvasSidebar({ canvasId }: CanvasSidebarProps) {
const canvas = useAuthQuery(api.canvases.get, { canvasId }); const canvas = useAuthQuery(api.canvases.get, { canvasId });
const byCategory = catalogEntriesByCategory(); 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 ( return (
<aside className="flex w-60 shrink-0 flex-col border-r border-border/80 bg-background"> <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) ?? []; const entries = byCategory.get(categoryId) ?? [];
if (entries.length === 0) return null; if (entries.length === 0) return null;
const { label } = NODE_CATEGORY_META[categoryId]; const { label } = NODE_CATEGORY_META[categoryId];
const isCollapsed = collapsedByCategory[categoryId] ?? categoryId !== "source";
return ( return (
<div key={categoryId} className="mb-4 last:mb-0"> <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"> <button
{label} type="button"
</h2> onClick={() =>
<div className="flex flex-col gap-1.5"> 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) => ( {entries.map((entry) => (
<SidebarRow key={entry.type} entry={entry} /> <SidebarRow key={entry.type} entry={entry} />
))} ))}
</div> </div>
) : null}
</div> </div>
); );
})} })}

View File

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

View File

@@ -37,6 +37,7 @@ import { toast } from "@/lib/toast";
import { msg, type CanvasNodeDeleteBlockReason } from "@/lib/toast-messages"; import { msg, type CanvasNodeDeleteBlockReason } from "@/lib/toast-messages";
import { import {
enqueueCanvasOp, enqueueCanvasOp,
readCanvasOps,
readCanvasSnapshot, readCanvasSnapshot,
resolveCanvasOp, resolveCanvasOp,
writeCanvasSnapshot, 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( function mergeNodesPreservingLocalState(
previousNodes: RFNode[], previousNodes: RFNode[],
incomingNodes: RFNode[], incomingNodes: RFNode[],
@@ -1403,6 +1495,13 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
useLayoutEffect(() => { useLayoutEffect(() => {
if (!convexNodes || isResizing.current) return; if (!convexNodes || isResizing.current) return;
setNodes((previousNodes) => { 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“). */ /** 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) => const anyRfNodeDragging = previousNodes.some((n) =>
Boolean((n as { dragging?: boolean }).dragging), Boolean((n as { dragging?: boolean }).dragging),
@@ -1447,11 +1546,15 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
), ),
pendingLocalPositionUntilConvexMatchesRef.current, 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. */ /** 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( const incomingById = new Map(
filteredIncoming.map((n) => [n.id, n]), filteredIncoming.map((n) => [n.id, n]),
); );
for (const n of merged) { for (const n of mergedWithOpPins) {
if (!preferLocalPositionNodeIdsRef.current.has(n.id)) continue; if (!preferLocalPositionNodeIdsRef.current.has(n.id)) continue;
const inc = incomingById.get(n.id); const inc = incomingById.get(n.id);
if (!inc) continue; if (!inc) continue;
@@ -1464,9 +1567,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
preferLocalPositionNodeIdsRef.current.delete(n.id); preferLocalPositionNodeIdsRef.current.delete(n.id);
} }
} }
return merged; return mergedWithOpPins;
}); });
}, [convexNodes, edges, storageUrlsById]); }, [canvasId, convexNodes, edges, storageUrlsById]);
useEffect(() => { useEffect(() => {
if (isDragging.current) return; if (isDragging.current) return;

View File

@@ -48,6 +48,12 @@ const nodeType = v.union(
// Transformation (Phase 3) // Transformation (Phase 3)
v.literal("style-transfer"), v.literal("style-transfer"),
v.literal("face-restore"), v.literal("face-restore"),
// Bildbearbeitung (Phase 2)
v.literal("curves"),
v.literal("color-adjust"),
v.literal("light-adjust"),
v.literal("detail-adjust"),
v.literal("render"),
// Steuerung (Phase 2) // Steuerung (Phase 2)
v.literal("splitter"), v.literal("splitter"),
v.literal("loop"), v.literal("loop"),

View File

@@ -165,3 +165,7 @@ export function resolveCanvasOp(canvasId: string, opId: string): void {
payload.updatedAt = Date.now(); payload.updatedAt = Date.now();
writePayload(opsKey(canvasId), payload); writePayload(opsKey(canvasId), payload);
} }
export function readCanvasOps(canvasId: string): CanvasPendingOp[] {
return readOpsPayload(canvasId).ops;
}

View File

@@ -10,6 +10,7 @@ export type NodeCategoryId =
| "source" | "source"
| "ai-output" | "ai-output"
| "transform" | "transform"
| "image-edit"
| "control" | "control"
| "layout"; | "layout";
@@ -20,8 +21,9 @@ export const NODE_CATEGORY_META: Record<
source: { label: "Quelle", order: 0 }, source: { label: "Quelle", order: 0 },
"ai-output": { label: "KI-Ausgabe", order: 1 }, "ai-output": { label: "KI-Ausgabe", order: 1 },
transform: { label: "Transformation", order: 2 }, transform: { label: "Transformation", order: 2 },
control: { label: "Steuerung & Flow", order: 3 }, "image-edit": { label: "Bildbearbeitung", order: 3 },
layout: { label: "Canvas & Layout", order: 4 }, control: { label: "Steuerung & Flow", order: 4 },
layout: { label: "Canvas & Layout", order: 5 },
}; };
export const NODE_CATEGORIES_ORDERED: NodeCategoryId[] = ( export const NODE_CATEGORIES_ORDERED: NodeCategoryId[] = (
@@ -161,6 +163,47 @@ export const NODE_CATALOG: readonly NodeCatalogEntry[] = [
implemented: false, implemented: false,
disabledHint: "Folgt in Phase 3", disabledHint: "Folgt in Phase 3",
}), }),
// Bildbearbeitung
entry({
type: "curves",
label: "Kurven",
category: "image-edit",
phase: 2,
implemented: false,
disabledHint: "Folgt in Phase 2",
}),
entry({
type: "color-adjust",
label: "Farbe",
category: "image-edit",
phase: 2,
implemented: false,
disabledHint: "Folgt in Phase 2",
}),
entry({
type: "light-adjust",
label: "Licht",
category: "image-edit",
phase: 2,
implemented: false,
disabledHint: "Folgt in Phase 2",
}),
entry({
type: "detail-adjust",
label: "Detail",
category: "image-edit",
phase: 2,
implemented: false,
disabledHint: "Folgt in Phase 2",
}),
entry({
type: "render",
label: "Render",
category: "image-edit",
phase: 2,
implemented: false,
disabledHint: "Folgt in Phase 2",
}),
// Steuerung & Flow // Steuerung & Flow
entry({ entry({
type: "splitter", type: "splitter",

View File

@@ -2,6 +2,9 @@ import { withSentryConfig } from "@sentry/nextjs";
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
turbopack: {
root: __dirname,
},
// Reduziert in der Entwicklung Strict-Mode-Doppel-Mounts (häufige Ursache für // Reduziert in der Entwicklung Strict-Mode-Doppel-Mounts (häufige Ursache für
// „Hydration“-Lärm). Echte Server/Client-Mismatches können weiterhin auftreten; // „Hydration“-Lärm). Echte Server/Client-Mismatches können weiterhin auftreten;
// dann `pnpm dev:strict` zum Debuggen oder Ursache beheben. // dann `pnpm dev:strict` zum Debuggen oder Ursache beheben.