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