Implement local-first canvas sync and fix drag edge stability
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user