feat: implement node deletion handling and geometry synchronization in canvas
- Added functionality to block node deletion based on synchronization status with Convex, providing user feedback through toast notifications. - Introduced helper functions to determine reasons for blocking deletions, enhancing user experience during canvas interactions. - Updated asset node styling to improve visual consistency and adjusted minimum dimensions for asset nodes to ensure better layout management.
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
|||||||
applyEdgeChanges,
|
applyEdgeChanges,
|
||||||
useReactFlow,
|
useReactFlow,
|
||||||
reconnectEdge,
|
reconnectEdge,
|
||||||
|
getConnectedEdges,
|
||||||
type Node as RFNode,
|
type Node as RFNode,
|
||||||
type Edge as RFEdge,
|
type Edge as RFEdge,
|
||||||
type NodeChange,
|
type NodeChange,
|
||||||
@@ -32,7 +33,7 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import "@xyflow/react/dist/style.css";
|
import "@xyflow/react/dist/style.css";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import { msg } from "@/lib/toast-messages";
|
import { msg, type CanvasNodeDeleteBlockReason } from "@/lib/toast-messages";
|
||||||
|
|
||||||
import { useConvexAuth, useMutation, useQuery } from "convex/react";
|
import { useConvexAuth, useMutation, useQuery } from "convex/react";
|
||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
@@ -97,6 +98,33 @@ function clientRequestIdFromOptimisticNodeId(id: string): string | null {
|
|||||||
return suffix.length > 0 ? suffix : null;
|
return suffix.length > 0 ? suffix : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isNodeGeometrySyncedWithConvex(
|
||||||
|
node: RFNode,
|
||||||
|
doc: Doc<"nodes">,
|
||||||
|
): boolean {
|
||||||
|
const styleW = node.style?.width;
|
||||||
|
const styleH = node.style?.height;
|
||||||
|
const w = typeof styleW === "number" ? styleW : doc.width;
|
||||||
|
const h = typeof styleH === "number" ? styleH : doc.height;
|
||||||
|
return (
|
||||||
|
node.position.x === doc.positionX &&
|
||||||
|
node.position.y === doc.positionY &&
|
||||||
|
w === doc.width &&
|
||||||
|
h === doc.height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeDeleteBlockReason(
|
||||||
|
node: RFNode,
|
||||||
|
convexById: Map<string, Doc<"nodes">>,
|
||||||
|
): CanvasNodeDeleteBlockReason | null {
|
||||||
|
if (isOptimisticNodeId(node.id)) return "optimistic";
|
||||||
|
const doc = convexById.get(node.id);
|
||||||
|
if (!doc) return "missingInConvex";
|
||||||
|
if (!isNodeGeometrySyncedWithConvex(node, doc)) return "geometryPending";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function getConnectEndClientPoint(
|
function getConnectEndClientPoint(
|
||||||
event: MouseEvent | TouchEvent,
|
event: MouseEvent | TouchEvent,
|
||||||
): { x: number; y: number } | null {
|
): { x: number; y: number } | null {
|
||||||
@@ -1167,28 +1195,38 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
let constrainedHeight = change.dimensions.height;
|
let constrainedHeight = change.dimensions.height;
|
||||||
|
|
||||||
// Axis with larger delta drives resize; the other axis is ratio-locked.
|
// Axis with larger delta drives resize; the other axis is ratio-locked.
|
||||||
|
// Chrome must be subtracted before ratio math, then re-added.
|
||||||
|
const assetChromeHeight = 88;
|
||||||
|
const assetMinPreviewHeight = 150;
|
||||||
|
const assetMinNodeHeight = assetChromeHeight + assetMinPreviewHeight;
|
||||||
|
const assetMinNodeWidth = 200;
|
||||||
|
|
||||||
if (heightDelta > widthDelta) {
|
if (heightDelta > widthDelta) {
|
||||||
constrainedWidth = constrainedHeight * targetRatio;
|
const previewHeight = Math.max(1, constrainedHeight - assetChromeHeight);
|
||||||
|
constrainedWidth = previewHeight * targetRatio;
|
||||||
|
constrainedHeight = assetChromeHeight + previewHeight;
|
||||||
} else {
|
} else {
|
||||||
constrainedHeight = constrainedWidth / targetRatio;
|
const previewHeight = constrainedWidth / targetRatio;
|
||||||
|
constrainedHeight = assetChromeHeight + previewHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetChromeHeight = 88;
|
const minWidthFromPreview = assetMinPreviewHeight * targetRatio;
|
||||||
const assetMinPreviewHeight = 120;
|
const minimumAllowedWidth = Math.max(assetMinNodeWidth, minWidthFromPreview);
|
||||||
const assetMinNodeHeight = assetChromeHeight + assetMinPreviewHeight;
|
const minPreviewFromWidth = minimumAllowedWidth / targetRatio;
|
||||||
const assetMinNodeWidth = 140;
|
const minimumAllowedHeight = Math.max(
|
||||||
|
|
||||||
const minWidthFromHeight = assetMinNodeHeight * targetRatio;
|
|
||||||
const minimumAllowedWidth = Math.max(assetMinNodeWidth, minWidthFromHeight);
|
|
||||||
const minimumAllowedHeight = minimumAllowedWidth / targetRatio;
|
|
||||||
|
|
||||||
const enforcedWidth = Math.max(constrainedWidth, minimumAllowedWidth);
|
|
||||||
const enforcedHeight = Math.max(
|
|
||||||
constrainedHeight,
|
|
||||||
minimumAllowedHeight,
|
|
||||||
assetMinNodeHeight,
|
assetMinNodeHeight,
|
||||||
|
assetChromeHeight + minPreviewFromWidth,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let enforcedWidth = Math.max(constrainedWidth, minimumAllowedWidth);
|
||||||
|
let enforcedHeight = assetChromeHeight + enforcedWidth / targetRatio;
|
||||||
|
if (enforcedHeight < minimumAllowedHeight) {
|
||||||
|
enforcedHeight = minimumAllowedHeight;
|
||||||
|
enforcedWidth = (enforcedHeight - assetChromeHeight) * targetRatio;
|
||||||
|
}
|
||||||
|
enforcedWidth = Math.max(enforcedWidth, minimumAllowedWidth);
|
||||||
|
enforcedHeight = assetChromeHeight + enforcedWidth / targetRatio;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...change,
|
...change,
|
||||||
dimensions: {
|
dimensions: {
|
||||||
@@ -1759,6 +1797,58 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onBeforeDelete = useCallback(
|
||||||
|
async ({
|
||||||
|
nodes: matchingNodes,
|
||||||
|
edges: matchingEdges,
|
||||||
|
}: {
|
||||||
|
nodes: RFNode[];
|
||||||
|
edges: RFEdge[];
|
||||||
|
}) => {
|
||||||
|
if (matchingNodes.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const convexById = new Map<string, Doc<"nodes">>(
|
||||||
|
(convexNodes ?? []).map((n) => [n._id as string, n]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const allowed: RFNode[] = [];
|
||||||
|
const blocked: RFNode[] = [];
|
||||||
|
const blockedReasons = new Set<CanvasNodeDeleteBlockReason>();
|
||||||
|
for (const node of matchingNodes) {
|
||||||
|
const reason = getNodeDeleteBlockReason(node, convexById);
|
||||||
|
if (reason !== null) {
|
||||||
|
blocked.push(node);
|
||||||
|
blockedReasons.add(reason);
|
||||||
|
} else {
|
||||||
|
allowed.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowed.length === 0) {
|
||||||
|
const { title, desc } = msg.canvas.nodeDeleteBlockedExplain(blockedReasons);
|
||||||
|
toast.warning(title, desc);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blocked.length > 0) {
|
||||||
|
const { title, desc } = msg.canvas.nodeDeleteBlockedPartial(
|
||||||
|
blocked.length,
|
||||||
|
blockedReasons,
|
||||||
|
);
|
||||||
|
toast.warning(title, desc);
|
||||||
|
return {
|
||||||
|
nodes: allowed,
|
||||||
|
edges: getConnectedEdges(allowed, matchingEdges),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[convexNodes],
|
||||||
|
);
|
||||||
|
|
||||||
// ─── Node löschen → Convex ────────────────────────────────────
|
// ─── Node löschen → Convex ────────────────────────────────────
|
||||||
const onNodesDelete = useCallback(
|
const onNodesDelete = useCallback(
|
||||||
(deletedNodes: RFNode[]) => {
|
(deletedNodes: RFNode[]) => {
|
||||||
@@ -2095,6 +2185,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
onReconnect={onReconnect}
|
onReconnect={onReconnect}
|
||||||
onReconnectStart={onReconnectStart}
|
onReconnectStart={onReconnectStart}
|
||||||
onReconnectEnd={onReconnectEnd}
|
onReconnectEnd={onReconnectEnd}
|
||||||
|
onBeforeDelete={onBeforeDelete}
|
||||||
onNodesDelete={onNodesDelete}
|
onNodesDelete={onNodesDelete}
|
||||||
onEdgesDelete={onEdgesDelete}
|
onEdgesDelete={onEdgesDelete}
|
||||||
onEdgeClick={scissorsMode ? onEdgeClickScissors : undefined}
|
onEdgeClick={scissorsMode ? onEdgeClickScissors : undefined}
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
|||||||
<img
|
<img
|
||||||
src={previewUrl}
|
src={previewUrl}
|
||||||
alt={data.title ?? "FreePik-Vorschau"}
|
alt={data.title ?? "FreePik-Vorschau"}
|
||||||
className={`h-full w-full object-cover object-right transition-opacity ${
|
className={`h-full w-full object-contain transition-opacity ${
|
||||||
isPreviewLoading ? "opacity-0" : "opacity-100"
|
isPreviewLoading ? "opacity-0" : "opacity-100"
|
||||||
}`}
|
}`}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const RESIZE_CONFIGS: Record<string, ResizeConfig> = {
|
|||||||
frame: { minWidth: 200, minHeight: 150 },
|
frame: { minWidth: 200, minHeight: 150 },
|
||||||
group: { minWidth: 150, minHeight: 100 },
|
group: { minWidth: 150, minHeight: 100 },
|
||||||
image: { minWidth: 140, minHeight: 120, keepAspectRatio: true },
|
image: { minWidth: 140, minHeight: 120, keepAspectRatio: true },
|
||||||
asset: { minWidth: 140, minHeight: 208, keepAspectRatio: false },
|
asset: { minWidth: 200, minHeight: 240, keepAspectRatio: false },
|
||||||
video: { minWidth: 200, minHeight: 120, keepAspectRatio: true },
|
video: { minWidth: 200, minHeight: 120, keepAspectRatio: true },
|
||||||
// Chrome 88 + min. Viewport 120 → äußere Mindesthöhe 208 (siehe canvas onNodesChange)
|
// Chrome 88 + min. Viewport 120 → äußere Mindesthöhe 208 (siehe canvas onNodesChange)
|
||||||
"ai-image": { minWidth: 200, minHeight: 208, keepAspectRatio: false },
|
"ai-image": { minWidth: 200, minHeight: 208, keepAspectRatio: false },
|
||||||
|
|||||||
@@ -1,6 +1,46 @@
|
|||||||
// Zentrales Dictionary für alle Toast-Strings.
|
// Zentrales Dictionary für alle Toast-Strings.
|
||||||
// Spätere i18n: diese Datei gegen Framework-Lookup ersetzen.
|
// Spätere i18n: diese Datei gegen Framework-Lookup ersetzen.
|
||||||
|
|
||||||
|
/** Grund, warum ein Node-Löschen bis zur Convex-Synchronisierung blockiert ist. */
|
||||||
|
export type CanvasNodeDeleteBlockReason =
|
||||||
|
| "optimistic"
|
||||||
|
| "missingInConvex"
|
||||||
|
| "geometryPending";
|
||||||
|
|
||||||
|
function canvasNodeDeleteWhy(
|
||||||
|
reasons: Set<CanvasNodeDeleteBlockReason>,
|
||||||
|
): { title: string; desc: string } {
|
||||||
|
if (reasons.size === 0) {
|
||||||
|
return {
|
||||||
|
title: "Löschen momentan nicht möglich",
|
||||||
|
desc: "Bitte kurz warten und erneut versuchen.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (reasons.size === 1) {
|
||||||
|
const only = [...reasons][0]!;
|
||||||
|
if (only === "optimistic") {
|
||||||
|
return {
|
||||||
|
title: "Element wird noch angelegt",
|
||||||
|
desc: "Dieses Element ist noch nicht vollständig auf dem Server gespeichert. Sobald die Synchronisierung fertig ist, kannst du es löschen.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (only === "missingInConvex") {
|
||||||
|
return {
|
||||||
|
title: "Element noch nicht verfügbar",
|
||||||
|
desc: "Dieses Element ist in der Datenbank noch nicht sichtbar. Warte einen Moment und versuche das Löschen erneut.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: "Änderungen werden gespeichert",
|
||||||
|
desc: "Position oder Größe ist noch nicht mit dem Server abgeglichen — zum Beispiel direkt nach Verschieben oder nach dem Ziehen an der Größe. Bitte kurz warten.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: "Löschen momentan nicht möglich",
|
||||||
|
desc: "Mindestens ein Element wird noch angelegt, oder Position bzw. Größe wird noch gespeichert. Bitte kurz warten und erneut versuchen.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const msg = {
|
export const msg = {
|
||||||
canvas: {
|
canvas: {
|
||||||
imageUploaded: { title: "Bild hochgeladen" },
|
imageUploaded: { title: "Bild hochgeladen" },
|
||||||
@@ -17,6 +57,22 @@ export const msg = {
|
|||||||
nodesRemoved: (count: number) => ({
|
nodesRemoved: (count: number) => ({
|
||||||
title: count === 1 ? "Element entfernt" : `${count} Elemente entfernt`,
|
title: count === 1 ? "Element entfernt" : `${count} Elemente entfernt`,
|
||||||
}),
|
}),
|
||||||
|
/** Warum gerade kein (vollständiges) Löschen möglich ist — aus den gesammelten Gründen der blockierten Nodes. */
|
||||||
|
nodeDeleteBlockedExplain: canvasNodeDeleteWhy,
|
||||||
|
nodeDeleteBlockedPartial: (
|
||||||
|
blockedCount: number,
|
||||||
|
reasons: Set<CanvasNodeDeleteBlockReason>,
|
||||||
|
) => {
|
||||||
|
const why = canvasNodeDeleteWhy(reasons);
|
||||||
|
const suffix =
|
||||||
|
blockedCount === 1
|
||||||
|
? "Ein Element wurde deshalb nicht gelöscht; die übrige Auswahl wurde entfernt."
|
||||||
|
: `${blockedCount} Elemente wurden deshalb nicht gelöscht; die übrige Auswahl wurde entfernt.`;
|
||||||
|
return {
|
||||||
|
title: "Nicht alle Elemente entfernt",
|
||||||
|
desc: `${why.desc} ${suffix}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
ai: {
|
ai: {
|
||||||
|
|||||||
Reference in New Issue
Block a user