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:
@@ -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`).
|
||||
|
||||
56
components/canvas/canvas-presets-context.tsx
Normal file
56
components/canvas/canvas-presets-context.tsx
Normal 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) ?? [];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -20,7 +20,7 @@ Convex ist das vollständige Backend von LemonSpace: Datenbank, Realtime-Subscri
|
||||
| `polar.ts` | Polar.sh Webhook-Handler (Subscriptions) |
|
||||
| `pexels.ts` | Pexels Stock-Bilder API |
|
||||
| `freepik.ts` | Freepik Asset-Browser API |
|
||||
| `storage.ts` | Convex File Storage Helpers |
|
||||
| `storage.ts` | Convex File Storage Helpers + gebündelte Canvas-URL-Auflösung |
|
||||
| `export.ts` | Canvas-Export-Logik |
|
||||
| `http.ts` | HTTP-Endpunkte (Webhooks) |
|
||||
|
||||
@@ -148,6 +148,16 @@ Wirft bei unauthentifiziertem Zugriff. Wird von allen Queries und Mutations genu
|
||||
|
||||
---
|
||||
|
||||
## Storage (`storage.ts`)
|
||||
|
||||
- `generateUploadUrl` bleibt eine normale Mutation für Upload-Start im Client.
|
||||
- `batchGetUrlsForCanvas` ist absichtlich **keine reaktive Query** mehr, sondern eine Mutation. Der Canvas ruft sie gezielt an, wenn sich das aktuelle Set von `storageId`s geändert hat.
|
||||
- Eingabe: `canvasId` + client-seitig ermittelte `storageIds`.
|
||||
- Server-seitig werden die angefragten IDs gegen die aktuellen Nodes des Canvas verifiziert, bevor `ctx.storage.getUrl(...)` aufgerufen wird.
|
||||
- Ziel der Änderung: weniger Query-Fanout und weniger Canvas-weite Requery-Last bei jedem Node-/Edge-Update.
|
||||
|
||||
---
|
||||
|
||||
## Konventionen
|
||||
|
||||
- `internalMutation` / `internalAction` — Nur von anderen Convex-Funktionen aufrufbar, nicht direkt vom Client.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mutation, query, type QueryCtx } from "./_generated/server";
|
||||
import { mutation, type MutationCtx, type QueryCtx } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { requireAuth } from "./helpers";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
@@ -31,7 +31,7 @@ type StorageUrlResult =
|
||||
};
|
||||
|
||||
async function assertCanvasOwner(
|
||||
ctx: QueryCtx,
|
||||
ctx: QueryCtx | MutationCtx,
|
||||
canvasId: Id<"canvases">,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
@@ -41,29 +41,6 @@ async function assertCanvasOwner(
|
||||
}
|
||||
}
|
||||
|
||||
async function listNodesForCanvas(ctx: QueryCtx, canvasId: Id<"canvases">) {
|
||||
return await ctx.db
|
||||
.query("nodes")
|
||||
.withIndex("by_canvas", (q) => q.eq("canvasId", canvasId))
|
||||
.collect();
|
||||
}
|
||||
|
||||
function collectStorageIds(
|
||||
nodes: Array<{ data: unknown }>,
|
||||
): Array<Id<"_storage">> {
|
||||
const ids = new Set<Id<"_storage">>();
|
||||
|
||||
for (const node of nodes) {
|
||||
const data = node.data as Record<string, unknown> | undefined;
|
||||
const storageId = data?.storageId;
|
||||
if (typeof storageId === "string" && storageId.length > 0) {
|
||||
ids.add(storageId as Id<"_storage">);
|
||||
}
|
||||
}
|
||||
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
async function resolveStorageUrls(
|
||||
ctx: QueryCtx,
|
||||
storageIds: Array<Id<"_storage">>,
|
||||
@@ -141,33 +118,67 @@ export const generateUploadUrl = mutation({
|
||||
* Signierte URLs für alle Storage-Assets eines Canvas (gebündelt).
|
||||
* `nodes.list` liefert keine URLs mehr, damit Node-Liste schnell bleibt.
|
||||
*/
|
||||
export const batchGetUrlsForCanvas = query({
|
||||
args: { canvasId: v.id("canvases") },
|
||||
handler: async (ctx, { canvasId }) => {
|
||||
export const batchGetUrlsForCanvas = mutation({
|
||||
args: {
|
||||
canvasId: v.id("canvases"),
|
||||
storageIds: v.array(v.id("_storage")),
|
||||
},
|
||||
handler: async (ctx, { canvasId, storageIds }) => {
|
||||
const startedAt = Date.now();
|
||||
const user = await requireAuth(ctx);
|
||||
await assertCanvasOwner(ctx, canvasId, user.userId);
|
||||
|
||||
const uniqueSortedStorageIds = [...new Set(storageIds)].sort();
|
||||
if (uniqueSortedStorageIds.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const nodes = await listNodesForCanvas(ctx, canvasId);
|
||||
const nodeCount = nodes.length;
|
||||
const storageIds = collectStorageIds(nodes);
|
||||
const collectTimeMs = Date.now() - startedAt;
|
||||
if (collectTimeMs >= PERFORMANCE_LOG_THRESHOLD_MS) {
|
||||
console.warn("[storage.batchGetUrlsForCanvas] slow node scan", {
|
||||
const allowedStorageIds = new Set(collectStorageIds(nodes));
|
||||
const verifiedStorageIds = uniqueSortedStorageIds.filter((storageId) =>
|
||||
allowedStorageIds.has(storageId),
|
||||
);
|
||||
const rejectedStorageIds = uniqueSortedStorageIds.length - verifiedStorageIds.length;
|
||||
if (rejectedStorageIds > 0) {
|
||||
console.warn("[storage.batchGetUrlsForCanvas] rejected unowned storage ids", {
|
||||
canvasId,
|
||||
nodeCount,
|
||||
storageIdCount: storageIds.length,
|
||||
durationMs: collectTimeMs,
|
||||
requestedCount: uniqueSortedStorageIds.length,
|
||||
rejectedStorageIds,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await resolveStorageUrls(ctx, storageIds);
|
||||
const result = await resolveStorageUrls(ctx, verifiedStorageIds);
|
||||
logSlowQuery("batchGetUrlsForCanvas::total", startedAt, {
|
||||
canvasId,
|
||||
nodeCount,
|
||||
storageIdCount: storageIds.length,
|
||||
storageIdCount: verifiedStorageIds.length,
|
||||
rejectedStorageIds,
|
||||
resolvedCount: Object.keys(result).length,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
});
|
||||
async function listNodesForCanvas(
|
||||
ctx: QueryCtx | MutationCtx,
|
||||
canvasId: Id<"canvases">,
|
||||
) {
|
||||
return await ctx.db
|
||||
.query("nodes")
|
||||
.withIndex("by_canvas", (q) => q.eq("canvasId", canvasId))
|
||||
.collect();
|
||||
}
|
||||
|
||||
function collectStorageIds(
|
||||
nodes: Array<{ data: unknown }>,
|
||||
): Array<Id<"_storage">> {
|
||||
const ids = new Set<Id<"_storage">>();
|
||||
|
||||
for (const node of nodes) {
|
||||
const data = node.data as Record<string, unknown> | undefined;
|
||||
const storageId = data?.storageId;
|
||||
if (typeof storageId === "string" && storageId.length > 0) {
|
||||
ids.add(storageId as Id<"_storage">);
|
||||
}
|
||||
}
|
||||
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
33
docs/plans/2026-04-03-canvas-convex-load-shedding-design.md
Normal file
33
docs/plans/2026-04-03-canvas-convex-load-shedding-design.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Canvas Convex Load Shedding Design
|
||||
|
||||
## Goal
|
||||
|
||||
Reduce Convex query fanout and hot-path load on the canvas page so node placement and canvas interaction no longer collapse when the local Convex runtime is under pressure.
|
||||
|
||||
## Findings
|
||||
|
||||
- `nodes:create` itself is fast when the runtime is healthy.
|
||||
- The canvas page issues multiple concurrent reactive queries: `nodes:list`, `edges:list`, `canvases:get`, `credits:getBalance`, `credits:getSubscription`, `storage:batchGetUrlsForCanvas`, and several `presets:list` variants.
|
||||
- During failure windows, many unrelated queries time out together, which points to runtime saturation rather than a single broken query.
|
||||
|
||||
## Chosen Approach
|
||||
|
||||
Use a broad load-shedding refactor on the canvas hot path:
|
||||
|
||||
1. Remove debug-only and non-essential queries from the core canvas render path.
|
||||
2. Eliminate the server-side batch storage URL query by deriving stable fallback URLs client-side from `storageId`.
|
||||
3. Collapse multiple adjustment preset queries into one shared presets query and distribute the data through React context.
|
||||
4. Drop the subscription-tier query from the canvas toolbar and keep the toolbar credit widget on the cheaper balance path only.
|
||||
|
||||
## Expected Impact
|
||||
|
||||
- Fewer unique Convex subscriptions on the canvas page.
|
||||
- Less full-canvas invalidation work after every node or edge mutation.
|
||||
- Lower pressure on `auth:safeGetAuthUser` because fewer canvas-adjacent queries depend on it.
|
||||
- Better resilience when Convex dev runtime is temporarily slow.
|
||||
|
||||
## Risks
|
||||
|
||||
- Client-side fallback storage URLs must continue to work with the current Convex deployment setup.
|
||||
- The toolbar will no longer show the subscription tier on the canvas page.
|
||||
- Shared preset context must preserve current node behavior and save/remove flows.
|
||||
63
docs/plans/2026-04-03-canvas-convex-load-shedding.md
Normal file
63
docs/plans/2026-04-03-canvas-convex-load-shedding.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Canvas Convex Load Shedding Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Reduce Convex canvas-page load by removing non-essential hot-path queries and collapsing redundant read models.
|
||||
|
||||
**Architecture:** The canvas keeps `nodes:list`, `edges:list`, and `canvases:get` as the primary real-time model while shedding secondary reads. Storage URLs move to a client-derived fallback path, presets move to a shared context backed by one query, and the toolbar stops depending on the subscription query.
|
||||
|
||||
**Tech Stack:** Next.js 16, React 19, Convex, React Flow, TypeScript
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Remove unnecessary canvas hot-path queries
|
||||
|
||||
**Files:**
|
||||
- Modify: `components/canvas/canvas.tsx`
|
||||
- Modify: `components/canvas/credit-display.tsx`
|
||||
- Modify: `components/canvas/canvas-toolbar.tsx`
|
||||
|
||||
**Step 1:** Remove the debug-only `api.auth.safeGetAuthUser` query from `components/canvas/canvas.tsx`.
|
||||
|
||||
**Step 2:** Remove `api.credits.getSubscription` dependency from `components/canvas/credit-display.tsx` and keep the widget on the balance query only.
|
||||
|
||||
**Step 3:** Update toolbar rendering only as needed to match the leaner credit widget.
|
||||
|
||||
### Task 2: Remove server-side storage URL batching from the canvas hot path
|
||||
|
||||
**Files:**
|
||||
- Modify: `components/canvas/canvas.tsx`
|
||||
- Modify: `lib/canvas-utils.ts`
|
||||
|
||||
**Step 1:** Stop subscribing to `api.storage.batchGetUrlsForCanvas` from the canvas page.
|
||||
|
||||
**Step 2:** Extend `convexNodeDocWithMergedStorageUrl` to synthesize a stable fallback URL from `storageId` when no cached URL exists.
|
||||
|
||||
**Step 3:** Preserve previous merged URLs when available so existing anti-flicker behavior remains intact.
|
||||
|
||||
### Task 3: Collapse preset reads into one shared query
|
||||
|
||||
**Files:**
|
||||
- Create: `components/canvas/canvas-presets-context.tsx`
|
||||
- Modify: `components/canvas/canvas.tsx`
|
||||
- Modify: `components/canvas/nodes/curves-node.tsx`
|
||||
- Modify: `components/canvas/nodes/color-adjust-node.tsx`
|
||||
- Modify: `components/canvas/nodes/light-adjust-node.tsx`
|
||||
- Modify: `components/canvas/nodes/detail-adjust-node.tsx`
|
||||
|
||||
**Step 1:** Add a shared presets provider that executes one `api.presets.list` query.
|
||||
|
||||
**Step 2:** Filter presets client-side by node type via a small hook/helper.
|
||||
|
||||
**Step 3:** Replace per-node `useAuthQuery(api.presets.list, { nodeType })` calls with the shared presets hook.
|
||||
|
||||
### Task 4: Verify the refactor
|
||||
|
||||
**Files:**
|
||||
- Verify only
|
||||
|
||||
**Step 1:** Run lint on touched files.
|
||||
|
||||
**Step 2:** Run targeted type-check or full type-check if feasible.
|
||||
|
||||
**Step 3:** Reproduce canvas placement flow and confirm Convex hot-path query count is reduced.
|
||||
@@ -37,12 +37,14 @@ Alle Adapter-Funktionen zwischen Convex-Datenmodell und React Flow. Details in `
|
||||
|
||||
**Kritische Exports:**
|
||||
- `convexNodeToRF`, `convexEdgeToRF`, `convexEdgeToRFWithSourceGlow`
|
||||
- `convexNodeDocWithMergedStorageUrl` — URL-Injection für Storage-Bilder
|
||||
- `convexNodeDocWithMergedStorageUrl` — URL-Injection für Storage-Bilder aus serverseitig aufgelöster URL-Map oder gecachtem Vorgängerzustand
|
||||
- `NODE_DEFAULTS` — Default-Größen und Daten per Node-Typ
|
||||
- `NODE_HANDLE_MAP` — Handle-IDs pro Node-Typ
|
||||
- `computeBridgeCreatesForDeletedNodes` — Kanten-Reconnect nach Node-Löschung
|
||||
- `computeMediaNodeSize` — Dynamische Node-Größe basierend auf Bild-Dimensionen
|
||||
|
||||
**Wichtig:** `canvas-utils.ts` erzeugt keine Storage-Fallback-URLs mehr selbst. Die URL-Auflösung kommt aus dem Canvas-Layer (`storage.batchGetUrlsForCanvas`) und wird hier nur noch gemerged/cached.
|
||||
|
||||
---
|
||||
|
||||
## `canvas-node-catalog.ts` — Node-Taxonomie
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
*/
|
||||
/**
|
||||
* Reichert Node-Dokumente mit `data.url` an (aus gebündelter Storage-URL-Map).
|
||||
* Behält eine zuvor gemappte URL bei, solange die Batch-Query noch lädt.
|
||||
* Behält eine zuvor gemappte URL bei, solange die URL-Auflösung noch lädt.
|
||||
*/
|
||||
export function convexNodeDocWithMergedStorageUrl(
|
||||
node: Doc<"nodes">,
|
||||
|
||||
Reference in New Issue
Block a user