Refactor canvas storage URL handling and integrate adjustment presets

- Introduced a new `CanvasPresetsProvider` to manage adjustment presets for nodes, enhancing state management and reducing reactivity.
- Updated storage URL resolution to utilize a mutation instead of a reactive query, improving performance and reducing unnecessary re-renders.
- Refactored adjustment nodes (color-adjust, curves, detail-adjust, light-adjust) to use the new preset context for fetching user presets.
- Improved overall canvas functionality by streamlining storage ID collection and URL resolution processes.
This commit is contained in:
2026-04-03 14:52:34 +02:00
parent ef98acd0de
commit 1fb8fd2863
14 changed files with 322 additions and 117 deletions

View File

@@ -29,6 +29,7 @@ app/(app)/canvas/[canvasId]/page.tsx
| Datei | Zweck |
|------|-------|
| `canvas-helpers.ts` | Shared Utility-Layer (Optimistic IDs, Node-Merge, Compare-Resolution, Edge/Hit-Helpers, Konstante Defaults) |
| `canvas-presets-context.tsx` | Shared Preset-Provider für Adjustment-Nodes; bündelt `presets.list` zu einer einzigen Query |
| `canvas-node-change-helpers.ts` | Dimensions-/Resize-Transformationen für `asset` und `ai-image` Nodes |
| `canvas-generation-failures.ts` | Hook für AI-Generation-Error-Tracking mit Schwellenwert-Toast |
| `canvas-scissors.ts` | Hook für Scherenmodus (K/Esc Toggle, Click-Cut, Stroke-Cut) |
@@ -53,7 +54,9 @@ Convex und React Flow verwenden unterschiedliche Datenmodelle. Das Mapping liegt
**Status-Injection:** `convexNodeToRF` schreibt `_status`, `_statusMessage` und `retryCount` in `data`, damit Node-Komponenten darauf zugreifen können ohne das Node-Dokument direkt zu kennen.
**URL-Caching:** Images mit `storageId` werden über einen batch-Storage-URL-Query aufgelöst (`urlByStorage`-Map). Die vorherige URL wird in `previousDataByNodeId` gecacht, um Flackern beim Reload zu vermeiden.
**URL-Caching:** Images mit `storageId` werden im Canvas nicht mehr über eine reaktive Query aufgelöst. `canvas.tsx` sammelt die aktuellen `storageId`s aus `nodes.list` und ruft `storage.batchGetUrlsForCanvas` gezielt per Mutation auf, nur wenn sich das Set ändert. Die vorherige URL wird in `previousDataByNodeId` gecacht, um Flackern beim Reload zu vermeiden.
**Load-Shedding-Hot-Path:** Der Canvas-Hot-Path soll so wenig Convex-Abhängigkeiten wie möglich haben. Direkt reaktiv bleiben nur die Kernmodelle (`nodes.list`, `edges.list`, `canvases.get`). Nebenpfade wie Storage-URL-Auflösung, Adjustment-Presets und Toolbar-Credits sind bewusst entkoppelt oder zusammengefasst.
---
@@ -146,7 +149,7 @@ Im **Light Mode** wird der eigentliche Edge-`stroke` ebenfalls aus dieser Akzent
| `asset-browser-panel.tsx` | Freepik/Stock-Asset-Browser |
| `video-browser-panel.tsx` | Video-Asset-Browser |
| `canvas-user-menu.tsx` | User-Avatar und Menü |
| `credit-display.tsx` | Credit-Balance Anzeige in der Toolbar |
| `credit-display.tsx` | Credit-Balance Anzeige in der Toolbar (nur `credits.getBalance`, kein Tier-Badge) |
| `export-button.tsx` | Export-Button mit Format-Auswahl |
| `connection-banner.tsx` | Offline-Banner bei Convex-Verbindungsverlust |
| `custom-connection-line.tsx` | Angepasste temporäre Verbindungslinie |
@@ -168,6 +171,7 @@ Im **Light Mode** wird der eigentliche Edge-`stroke` ebenfalls aus dieser Akzent
## Wichtige Gotchas
- **`data.url` vs `storageId`:** Node-Komponenten erhalten `data.url` (aufgelöste HTTP-URL), nicht `storageId` direkt. Die URL wird von `convexNodeDocWithMergedStorageUrl` injiziert. Bei neuen Node-Typen mit Bild immer diesen Flow prüfen.
- **Adjustment-Presets:** `curves`, `color-adjust`, `light-adjust` und `detail-adjust` dürfen keine eigene `presets.list`-Query feuern. Immer `CanvasPresetsProvider` + `useCanvasAdjustmentPresets(...)` verwenden.
- **Min-Zoom:** `CANVAS_MIN_ZOOM = 0.5 / 3` — dreimal weiter raus als React-Flow-Default.
- **Parent-Nodes:** `parentId` zeigt auf einen Group- oder Frame-Node. React Flow erwartet, dass Parent-Nodes vor Child-Nodes in der `nodes`-Array stehen.
- **Bridge-Edges:** Beim Löschen eines mittleren Nodes werden Kanten automatisch neu verbunden (`computeBridgeCreatesForDeletedNodes` aus `lib/canvas-utils.ts`).

View File

@@ -0,0 +1,56 @@
"use client";
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { api } from "@/convex/_generated/api";
import type { Doc } from "@/convex/_generated/dataModel";
import { useAuthQuery } from "@/hooks/use-auth-query";
type AdjustmentPresetDoc = Doc<"adjustmentPresets">;
type PresetsByNodeType = Map<AdjustmentPresetDoc["nodeType"], AdjustmentPresetDoc[]>;
const CanvasPresetsContext = createContext<PresetsByNodeType | null>(null);
type CanvasPresetsProviderProps = {
enabled?: boolean;
children: ReactNode;
};
export function CanvasPresetsProvider({
enabled = true,
children,
}: CanvasPresetsProviderProps) {
const rawPresets = useAuthQuery(api.presets.list, enabled ? {} : "skip");
const presetsByNodeType = useMemo<PresetsByNodeType>(() => {
const next = new Map<AdjustmentPresetDoc["nodeType"], AdjustmentPresetDoc[]>();
for (const preset of (rawPresets ?? []) as AdjustmentPresetDoc[]) {
const existing = next.get(preset.nodeType);
if (existing) {
existing.push(preset);
} else {
next.set(preset.nodeType, [preset]);
}
}
return next;
}, [rawPresets]);
return (
<CanvasPresetsContext.Provider value={presetsByNodeType}>
{children}
</CanvasPresetsContext.Provider>
);
}
export function useCanvasAdjustmentPresets(
nodeType: AdjustmentPresetDoc["nodeType"],
): AdjustmentPresetDoc[] {
const context = useContext(CanvasPresetsContext);
if (context === null) {
return [];
}
return context.get(nodeType) ?? [];
}

View File

@@ -72,6 +72,7 @@ import { api } from "@/convex/_generated/api";
import type { Doc, Id } from "@/convex/_generated/dataModel";
import { authClient } from "@/lib/auth-client";
import {
isAdjustmentPresetNodeType,
isCanvasNodeType,
type CanvasNodeType,
} from "@/lib/canvas-node-types";
@@ -95,6 +96,7 @@ import {
type ConnectionDropMenuState,
} from "@/components/canvas/canvas-connection-drop-menu";
import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context";
import { CanvasPresetsProvider } from "@/components/canvas/canvas-presets-context";
import {
AssetBrowserTargetContext,
type AssetBrowserTargetApi,
@@ -203,11 +205,6 @@ function summarizeResizePayload(payload: unknown): Record<string, unknown> {
};
}
function hasStorageId(node: Doc<"nodes">): boolean {
const data = node.data as Record<string, unknown> | undefined;
return typeof data?.storageId === "string" && data.storageId.length > 0;
}
function validateCanvasConnection(
connection: Connection,
nodes: RFNode[],
@@ -261,10 +258,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const { isLoading: isAuthLoading, isAuthenticated } = useConvexAuth();
const shouldSkipCanvasQueries =
isSessionPending || isAuthLoading || !isAuthenticated;
const convexAuthUserProbe = useQuery(
api.auth.safeGetAuthUser,
shouldSkipCanvasQueries ? "skip" : {},
);
useEffect(() => {
if (process.env.NODE_ENV === "production") return;
@@ -282,8 +275,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
convex: {
isAuthenticated,
shouldSkipCanvasQueries,
probeUserId: convexAuthUserProbe?.userId ?? null,
probeRecordId: convexAuthUserProbe?._id ?? null,
},
session: {
hasUser: Boolean(session?.user),
@@ -292,8 +283,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
});
}, [
canvasId,
convexAuthUserProbe?._id,
convexAuthUserProbe?.userId,
isAuthLoading,
isAuthenticated,
isSessionPending,
@@ -310,20 +299,71 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
api.edges.list,
shouldSkipCanvasQueries ? "skip" : { canvasId },
);
const shouldSkipStorageUrlQuery = useMemo(() => {
if (shouldSkipCanvasQueries) return true;
if (convexNodes === undefined) return true;
return !convexNodes.some(hasStorageId);
}, [convexNodes, shouldSkipCanvasQueries]);
const storageUrlsById = useQuery(
api.storage.batchGetUrlsForCanvas,
shouldSkipStorageUrlQuery ? "skip" : { canvasId },
);
const storageIdsForCanvas = useMemo(() => {
if (!convexNodes) {
return [] as Id<"_storage">[];
}
return [...new Set(
convexNodes.flatMap((node) => {
const data = node.data as Record<string, unknown> | undefined;
return typeof data?.storageId === "string" && data.storageId.length > 0
? [data.storageId as Id<"_storage">]
: [];
}),
)].sort();
}, [convexNodes]);
const storageIdsForCanvasKey = storageIdsForCanvas.join(",");
const stableStorageIdsForCanvasRef = useRef(storageIdsForCanvas);
if (stableStorageIdsForCanvasRef.current.join(",") !== storageIdsForCanvasKey) {
stableStorageIdsForCanvasRef.current = storageIdsForCanvas;
}
const resolveStorageUrlsForCanvas = useMutation(api.storage.batchGetUrlsForCanvas);
const [storageUrlsById, setStorageUrlsById] = useState<Record<string, string | undefined>>();
const canvas = useQuery(
api.canvases.get,
shouldSkipCanvasQueries ? "skip" : { canvasId },
);
useEffect(() => {
const requestedStorageIds = stableStorageIdsForCanvasRef.current;
if (shouldSkipCanvasQueries || requestedStorageIds.length === 0) {
setStorageUrlsById(undefined);
return;
}
let cancelled = false;
void resolveStorageUrlsForCanvas({
canvasId,
storageIds: requestedStorageIds,
})
.then((result) => {
if (!cancelled) {
setStorageUrlsById(result);
}
})
.catch((error: unknown) => {
if (!cancelled) {
console.warn("[Canvas] failed to resolve storage URLs", {
canvasId,
storageIdCount: requestedStorageIds.length,
message: error instanceof Error ? error.message : String(error),
});
}
});
return () => {
cancelled = true;
};
}, [
canvasId,
resolveStorageUrlsForCanvas,
shouldSkipCanvasQueries,
storageIdsForCanvasKey,
]);
// ─── Convex Mutations (exakte Signaturen aus nodes.ts / edges.ts) ──
const moveNode = useMutation(api.nodes.move);
const resizeNode = useMutation(api.nodes.resize);
@@ -571,6 +611,12 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const [nodes, setNodes] = useState<RFNode[]>([]);
const [edges, setEdges] = useState<RFEdge[]>([]);
const hasPresetAwareNodes = useMemo(
() =>
nodes.some((node) => isAdjustmentPresetNodeType(node.type ?? "")) ||
(convexNodes ?? []).some((node) => isAdjustmentPresetNodeType(node.type)),
[convexNodes, nodes],
);
const edgesRef = useRef(edges);
edgesRef.current = edges;
const [pendingSyncCount, setPendingSyncCount] = useState(0);
@@ -2970,24 +3016,25 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
return (
<CanvasSyncProvider value={canvasSyncContextValue}>
<CanvasPlacementProvider
canvasId={canvasId}
createNode={runCreateNodeOnlineOnly}
createNodeWithEdgeSplit={runCreateNodeWithEdgeSplitOnlineOnly}
createNodeWithEdgeFromSource={runCreateNodeWithEdgeFromSourceOnlineOnly}
createNodeWithEdgeToTarget={runCreateNodeWithEdgeToTargetOnlineOnly}
onCreateNodeSettled={({ clientRequestId, realId }) => {
void syncPendingMoveForClientRequest(clientRequestId, realId).catch(
(error: unknown) => {
console.error(
"[Canvas] onCreateNodeSettled syncPendingMove failed",
error,
);
},
);
}}
>
<AssetBrowserTargetContext.Provider value={assetBrowserTargetApi}>
<CanvasPresetsProvider enabled={hasPresetAwareNodes}>
<CanvasPlacementProvider
canvasId={canvasId}
createNode={runCreateNodeOnlineOnly}
createNodeWithEdgeSplit={runCreateNodeWithEdgeSplitOnlineOnly}
createNodeWithEdgeFromSource={runCreateNodeWithEdgeFromSourceOnlineOnly}
createNodeWithEdgeToTarget={runCreateNodeWithEdgeToTargetOnlineOnly}
onCreateNodeSettled={({ clientRequestId, realId }) => {
void syncPendingMoveForClientRequest(clientRequestId, realId).catch(
(error: unknown) => {
console.error(
"[Canvas] onCreateNodeSettled syncPendingMove failed",
error,
);
},
);
}}
>
<AssetBrowserTargetContext.Provider value={assetBrowserTargetApi}>
<div className="relative h-full w-full">
<CanvasToolbar
canvasName={canvas?.name ?? "canvas"}
@@ -3079,8 +3126,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
</ReactFlow>
</div>
</div>
</AssetBrowserTargetContext.Provider>
</CanvasPlacementProvider>
</AssetBrowserTargetContext.Provider>
</CanvasPlacementProvider>
</CanvasPresetsProvider>
</CanvasSyncProvider>
);
}

View File

@@ -7,22 +7,6 @@ import { api } from "@/convex/_generated/api";
import { Coins } from "lucide-react";
import { toast } from "@/lib/toast";
const TIER_LABELS: Record<string, string> = {
free: "Free",
starter: "Starter",
pro: "Pro",
max: "Max",
business: "Business",
};
const TIER_COLORS: Record<string, string> = {
free: "text-muted-foreground",
starter: "text-blue-500",
pro: "text-purple-500",
max: "text-amber-500",
business: "text-amber-500",
};
const showTestCreditGrant =
typeof process.env.NEXT_PUBLIC_ALLOW_TEST_CREDIT_GRANT === "string" &&
process.env.NEXT_PUBLIC_ALLOW_TEST_CREDIT_GRANT === "true";
@@ -30,10 +14,9 @@ const showTestCreditGrant =
export function CreditDisplay() {
const t = useTranslations('toasts');
const balance = useAuthQuery(api.credits.getBalance);
const subscription = useAuthQuery(api.credits.getSubscription);
const grantTestCredits = useMutation(api.credits.grantTestCredits);
if (balance === undefined || subscription === undefined) {
if (balance === undefined) {
return (
<div className="flex items-center gap-2 rounded-lg bg-muted/50 px-3 py-1.5 animate-pulse">
<Coins className="h-4 w-4 text-muted-foreground" />
@@ -43,9 +26,6 @@ export function CreditDisplay() {
}
const available = balance.balance - balance.reserved;
const tier = subscription.tier;
const tierLabel = TIER_LABELS[tier] ?? tier;
const tierColor = TIER_COLORS[tier] ?? "text-muted-foreground";
const isLow = available < 10;
const isEmpty = available <= 0;
@@ -82,8 +62,6 @@ export function CreditDisplay() {
({balance.reserved} reserved)
</span>
)}
<span className="text-xs text-muted-foreground/70">·</span>
<span className={`text-xs font-medium ${tierColor}`}>{tierLabel}</span>
</div>
{showTestCreditGrant && (
<button

View File

@@ -8,7 +8,7 @@ import { Palette } from "lucide-react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import { useAuthQuery } from "@/hooks/use-auth-query";
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
@@ -47,7 +47,7 @@ export default function ColorAdjustNode({ id, data, selected, width }: NodeProps
const tToasts = useTranslations("toasts");
const { queueNodeDataUpdate } = useCanvasSync();
const savePreset = useMutation(api.presets.save);
const userPresets = (useAuthQuery(api.presets.list, { nodeType: "color-adjust" }) ?? []) as PresetDoc[];
const userPresets = useCanvasAdjustmentPresets("color-adjust") as PresetDoc[];
const [localData, setLocalData] = useState<ColorAdjustData>(() =>
normalizeColorAdjustData({ ...cloneAdjustmentData(DEFAULT_COLOR_ADJUST_DATA), ...data }),

View File

@@ -8,7 +8,7 @@ import { TrendingUp } from "lucide-react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import { useAuthQuery } from "@/hooks/use-auth-query";
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
@@ -47,7 +47,7 @@ export default function CurvesNode({ id, data, selected, width }: NodeProps<Curv
const tToasts = useTranslations("toasts");
const { queueNodeDataUpdate } = useCanvasSync();
const savePreset = useMutation(api.presets.save);
const userPresets = (useAuthQuery(api.presets.list, { nodeType: "curves" }) ?? []) as PresetDoc[];
const userPresets = useCanvasAdjustmentPresets("curves") as PresetDoc[];
const [localData, setLocalData] = useState<CurvesData>(() =>
normalizeCurvesData({ ...cloneAdjustmentData(DEFAULT_CURVES_DATA), ...data }),

View File

@@ -8,7 +8,7 @@ import { Focus } from "lucide-react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import { useAuthQuery } from "@/hooks/use-auth-query";
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
@@ -47,7 +47,7 @@ export default function DetailAdjustNode({ id, data, selected, width }: NodeProp
const tToasts = useTranslations("toasts");
const { queueNodeDataUpdate } = useCanvasSync();
const savePreset = useMutation(api.presets.save);
const userPresets = (useAuthQuery(api.presets.list, { nodeType: "detail-adjust" }) ?? []) as PresetDoc[];
const userPresets = useCanvasAdjustmentPresets("detail-adjust") as PresetDoc[];
const [localData, setLocalData] = useState<DetailAdjustData>(() =>
normalizeDetailAdjustData({ ...cloneAdjustmentData(DEFAULT_DETAIL_ADJUST_DATA), ...data }),

View File

@@ -8,7 +8,7 @@ import { Sun } from "lucide-react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import { useAuthQuery } from "@/hooks/use-auth-query";
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
@@ -47,7 +47,7 @@ export default function LightAdjustNode({ id, data, selected, width }: NodeProps
const tToasts = useTranslations("toasts");
const { queueNodeDataUpdate } = useCanvasSync();
const savePreset = useMutation(api.presets.save);
const userPresets = (useAuthQuery(api.presets.list, { nodeType: "light-adjust" }) ?? []) as PresetDoc[];
const userPresets = useCanvasAdjustmentPresets("light-adjust") as PresetDoc[];
const [localData, setLocalData] = useState<LightAdjustData>(() =>
normalizeLightAdjustData({ ...cloneAdjustmentData(DEFAULT_LIGHT_ADJUST_DATA), ...data }),