Implement local-first canvas sync and fix drag edge stability

This commit is contained in:
Matthias
2026-04-01 09:40:31 +02:00
parent c1d7a49bc3
commit 32bd188d89
19 changed files with 1095 additions and 283 deletions

View File

@@ -12,6 +12,7 @@ import { classifyError, type AiErrorCategory } from "@/lib/ai-errors";
import { DEFAULT_ASPECT_RATIO } from "@/lib/image-formats";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import {
Loader2,
AlertCircle,
@@ -60,6 +61,7 @@ export default function AiImageNode({
}: NodeProps<AiImageNode>) {
const nodeData = data as AiImageNodeData;
const { getEdges, getNode } = useReactFlow();
const { status: syncStatus } = useCanvasSync();
const router = useRouter();
const [isGenerating, setIsGenerating] = useState(false);
@@ -84,6 +86,13 @@ export default function AiImageNode({
const handleRegenerate = useCallback(async () => {
if (isLoading) return;
if (syncStatus.isOffline) {
toast.warning(
"Offline aktuell nicht unterstützt",
"KI-Generierung benötigt eine aktive Verbindung.",
);
return;
}
setLocalError(null);
setIsGenerating(true);
@@ -140,7 +149,7 @@ export default function AiImageNode({
} finally {
setIsGenerating(false);
}
}, [isLoading, nodeData, id, getEdges, getNode, generateImage]);
}, [isLoading, syncStatus.isOffline, nodeData, id, getEdges, getNode, generateImage]);
const modelName =
getModel(nodeData.model ?? DEFAULT_MODEL_ID)?.name ?? "AI";

View File

@@ -9,7 +9,6 @@ import {
type MouseEvent,
} from "react";
import { Handle, Position, useStore, type Node, type NodeProps } from "@xyflow/react";
import { useMutation } from "convex/react";
import { ExternalLink, ImageIcon } from "lucide-react";
import BaseNodeWrapper from "./base-node-wrapper";
import {
@@ -17,11 +16,11 @@ import {
useAssetBrowserTarget,
type AssetBrowserSessionState,
} from "@/components/canvas/asset-browser-panel";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { resolveMediaAspectRatio } from "@/lib/canvas-utils";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
type AssetNodeData = {
assetId?: number;
@@ -55,7 +54,7 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
page: 1,
totalPages: 1,
});
const resizeNode = useMutation(api.nodes.resize);
const { queueNodeResize } = useCanvasSync();
const edges = useStore((s) => s.edges);
const nodes = useStore((s) => s.nodes);
@@ -124,7 +123,7 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
}
hasAutoSizedRef.current = true;
void resizeNode({
void queueNodeResize({
nodeId: id as Id<"nodes">,
width: targetSize.width,
height: targetSize.height,
@@ -136,7 +135,7 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
hasAsset,
height,
id,
resizeNode,
queueNodeResize,
width,
]);

View File

@@ -2,7 +2,7 @@
import { useCallback, useState } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { useAction, useMutation } from "convex/react";
import { useAction } from "convex/react";
import { Download, Loader2 } from "lucide-react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
@@ -10,6 +10,7 @@ import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import BaseNodeWrapper from "./base-node-wrapper";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
interface FrameNodeData {
label?: string;
@@ -19,7 +20,7 @@ interface FrameNodeData {
export default function FrameNode({ id, data, selected, width, height }: NodeProps) {
const nodeData = data as FrameNodeData;
const updateData = useMutation(api.nodes.updateData);
const { queueNodeDataUpdate, status } = useCanvasSync();
const exportFrame = useAction(api.export.exportFrame);
const [label, setLabel] = useState(nodeData.label ?? "Frame");
@@ -27,7 +28,10 @@ export default function FrameNode({ id, data, selected, width, height }: NodePro
const [exportError, setExportError] = useState<string | null>(null);
const debouncedSave = useDebouncedCallback((value: string) => {
void updateData({ nodeId: id as Id<"nodes">, data: { ...nodeData, label: value } });
void queueNodeDataUpdate({
nodeId: id as Id<"nodes">,
data: { ...nodeData, label: value },
});
}, 500);
const handleLabelChange = useCallback(
@@ -40,6 +44,10 @@ export default function FrameNode({ id, data, selected, width, height }: NodePro
const handleExport = useCallback(async () => {
if (isExporting) return;
if (status.isOffline) {
toast.warning("Offline aktuell nicht unterstützt", "Export benötigt eine aktive Verbindung.");
return;
}
setIsExporting(true);
setExportError(null);
@@ -67,7 +75,7 @@ export default function FrameNode({ id, data, selected, width, height }: NodePro
} finally {
setIsExporting(false);
}
}, [exportFrame, id, isExporting, label]);
}, [exportFrame, id, isExporting, label, status.isOffline]);
const frameW = Math.round(width ?? 400);
const frameH = Math.round(height ?? 300);

View File

@@ -2,10 +2,9 @@
import { useState, useCallback, useEffect } from "react";
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
import { useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import BaseNodeWrapper from "./base-node-wrapper";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
type GroupNodeData = {
label?: string;
@@ -16,7 +15,7 @@ type GroupNodeData = {
export type GroupNode = Node<GroupNodeData, "group">;
export default function GroupNode({ id, data, selected }: NodeProps<GroupNode>) {
const updateData = useMutation(api.nodes.updateData);
const { queueNodeDataUpdate } = useCanvasSync();
const [label, setLabel] = useState(data.label ?? "Gruppe");
const [isEditing, setIsEditing] = useState(false);
@@ -30,7 +29,7 @@ export default function GroupNode({ id, data, selected }: NodeProps<GroupNode>)
const handleBlur = useCallback(() => {
setIsEditing(false);
if (label !== data.label) {
updateData({
void queueNodeDataUpdate({
nodeId: id as Id<"nodes">,
data: {
...data,
@@ -40,7 +39,7 @@ export default function GroupNode({ id, data, selected }: NodeProps<GroupNode>)
},
});
}
}, [label, data, id, updateData]);
}, [label, data, id, queueNodeDataUpdate]);
return (
<BaseNodeWrapper

View File

@@ -9,13 +9,14 @@ import {
type DragEvent,
} from "react";
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
import { useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import BaseNodeWrapper from "./base-node-wrapper";
import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages";
import { computeMediaNodeSize } from "@/lib/canvas-utils";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import { useMutation } from "convex/react";
const ALLOWED_IMAGE_TYPES = new Set([
"image/png",
@@ -73,8 +74,7 @@ export default function ImageNode({
height,
}: NodeProps<ImageNode>) {
const generateUploadUrl = useMutation(api.storage.generateUploadUrl);
const updateData = useMutation(api.nodes.updateData);
const resizeNode = useMutation(api.nodes.resize);
const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
@@ -111,12 +111,12 @@ export default function ImageNode({
}
hasAutoSizedRef.current = true;
void resizeNode({
void queueNodeResize({
nodeId: id as Id<"nodes">,
width: targetSize.width,
height: targetSize.height,
});
}, [data.height, data.width, height, id, resizeNode, width]);
}, [data.height, data.width, height, id, queueNodeResize, width]);
const uploadFile = useCallback(
async (file: File) => {
@@ -134,6 +134,13 @@ export default function ImageNode({
toast.error(title, desc);
return;
}
if (status.isOffline) {
toast.warning(
"Offline aktuell nicht unterstützt",
"Bild-Uploads benötigen eine aktive Verbindung.",
);
return;
}
setIsUploading(true);
@@ -158,7 +165,7 @@ export default function ImageNode({
const { storageId } = (await result.json()) as { storageId: string };
await updateData({
await queueNodeDataUpdate({
nodeId: id as Id<"nodes">,
data: {
storageId,
@@ -174,7 +181,7 @@ export default function ImageNode({
intrinsicHeight: dimensions.height,
});
await resizeNode({
await queueNodeResize({
nodeId: id as Id<"nodes">,
width: targetSize.width,
height: targetSize.height,
@@ -192,7 +199,7 @@ export default function ImageNode({
setIsUploading(false);
}
},
[id, generateUploadUrl, resizeNode, updateData]
[generateUploadUrl, id, queueNodeDataUpdate, queueNodeResize, status.isOffline]
);
const handleClick = useCallback(() => {

View File

@@ -2,11 +2,10 @@
import { useState, useCallback, useEffect } from "react";
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
import { useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import BaseNodeWrapper from "./base-node-wrapper";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
type NoteNodeData = {
content?: string;
@@ -17,7 +16,7 @@ type NoteNodeData = {
export type NoteNode = Node<NoteNodeData, "note">;
export default function NoteNode({ id, data, selected }: NodeProps<NoteNode>) {
const updateData = useMutation(api.nodes.updateData);
const { queueNodeDataUpdate } = useCanvasSync();
const [content, setContent] = useState(data.content ?? "");
const [isEditing, setIsEditing] = useState(false);
@@ -30,7 +29,7 @@ export default function NoteNode({ id, data, selected }: NodeProps<NoteNode>) {
const saveContent = useDebouncedCallback(
(newContent: string) => {
updateData({
void queueNodeDataUpdate({
nodeId: id as Id<"nodes">,
data: {
...data,

View File

@@ -9,12 +9,13 @@ import {
type NodeProps,
type Node,
} from "@xyflow/react";
import { useMutation, useAction } from "convex/react";
import { useAction } from "convex/react";
import { useAuthQuery } from "@/hooks/use-auth-query";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import BaseNodeWrapper from "./base-node-wrapper";
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { DEFAULT_MODEL_ID, getModel } from "@/lib/ai-models";
import {
@@ -118,7 +119,7 @@ export default function PromptNode({
const hasEnoughCredits =
availableCredits !== null && availableCredits >= creditCost;
const updateData = useMutation(api.nodes.updateData);
const { queueNodeDataUpdate, status } = useCanvasSync();
const generateImage = useAction(api.ai.generateImage);
const { createNodeConnectedFromSource } = useCanvasPlacement();
@@ -127,7 +128,7 @@ export default function PromptNode({
const { _status, _statusMessage, ...rest } = raw;
void _status;
void _statusMessage;
updateData({
void queueNodeDataUpdate({
nodeId: id as Id<"nodes">,
data: {
...rest,
@@ -156,6 +157,13 @@ export default function PromptNode({
const handleGenerate = useCallback(async () => {
if (!effectivePrompt.trim() || isGenerating) return;
if (status.isOffline) {
toast.warning(
"Offline aktuell nicht unterstützt",
"KI-Generierung benötigt eine aktive Verbindung.",
);
return;
}
if (availableCredits !== null && !hasEnoughCredits) {
const { title, desc } = msg.ai.insufficientCredits(
@@ -291,6 +299,7 @@ export default function PromptNode({
availableCredits,
hasEnoughCredits,
router,
status.isOffline,
]);
return (

View File

@@ -8,11 +8,10 @@ import {
type NodeProps,
type Node,
} from "@xyflow/react";
import { useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import BaseNodeWrapper from "./base-node-wrapper";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
type TextNodeData = {
content?: string;
@@ -24,7 +23,7 @@ export type TextNode = Node<TextNodeData, "text">;
export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
const { setNodes } = useReactFlow();
const updateData = useMutation(api.nodes.updateData);
const { queueNodeDataUpdate } = useCanvasSync();
const [content, setContent] = useState(data.content ?? "");
const [isEditing, setIsEditing] = useState(false);
@@ -39,7 +38,7 @@ export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
// Debounced Save — 500ms nach letztem Tastendruck
const saveContent = useDebouncedCallback(
(newContent: string) => {
updateData({
void queueNodeDataUpdate({
nodeId: id as Id<"nodes">,
data: {
...data,

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Handle, Position, useStore, type NodeProps } from "@xyflow/react";
import { useAction, useMutation } from "convex/react";
import { useAction } from "convex/react";
import { Play } from "lucide-react";
import BaseNodeWrapper from "./base-node-wrapper";
import {
@@ -11,6 +11,7 @@ import {
} from "@/components/canvas/video-browser-panel";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
type VideoNodeData = {
canvasId?: string;
@@ -50,8 +51,7 @@ export default function VideoNode({
page: 1,
totalPages: 1,
});
const resizeNode = useMutation(api.nodes.resize);
const updateData = useMutation(api.nodes.updateData);
const { queueNodeDataUpdate, queueNodeResize } = useCanvasSync();
const refreshPexelsPlayback = useAction(api.pexels.getVideoByPexelsId);
const edges = useStore((s) => s.edges);
@@ -95,7 +95,7 @@ export default function VideoNode({
void (async () => {
try {
const fresh = await refreshPexelsPlayback({ pexelsId });
await updateData({
await queueNodeDataUpdate({
nodeId: id as Id<"nodes">,
data: {
...d,
@@ -109,7 +109,7 @@ export default function VideoNode({
playbackRefreshAttempted.current = false;
}
})();
}, [d, id, refreshPexelsPlayback, updateData]);
}, [d, id, queueNodeDataUpdate, refreshPexelsPlayback]);
useEffect(() => {
if (!hasVideo) return;
@@ -134,12 +134,12 @@ export default function VideoNode({
const targetWidth = 320;
const targetHeight = Math.round(targetWidth / aspectRatio);
void resizeNode({
void queueNodeResize({
nodeId: id as Id<"nodes">,
width: targetWidth,
height: targetHeight,
});
}, [d.width, d.height, hasVideo, height, id, resizeNode, width]);
}, [d.width, d.height, hasVideo, height, id, queueNodeResize, width]);
const showPreview = hasVideo && d.thumbnailUrl;