feat: enhance dashboard and canvas components with improved state management and resizing logic
- Added client mount state to the dashboard to prevent premature interactions before the component is fully loaded. - Updated button disabling logic to ensure it reflects the component's readiness and user session state. - Introduced zIndex handling in canvas placement context for better node layering. - Enhanced asset and image nodes with improved resizing logic to maintain aspect ratios during adjustments. - Refactored node components to streamline rendering and improve performance during dynamic updates.
This commit is contained in:
@@ -61,6 +61,11 @@ export default function DashboardPage() {
|
|||||||
);
|
);
|
||||||
const createCanvas = useMutation(api.canvases.create);
|
const createCanvas = useMutation(api.canvases.create);
|
||||||
const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false);
|
const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false);
|
||||||
|
const [hasClientMounted, setHasClientMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHasClientMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const displayName = session?.user.name?.trim() || session?.user.email || "Nutzer";
|
const displayName = session?.user.name?.trim() || session?.user.email || "Nutzer";
|
||||||
const initials = getInitials(displayName);
|
const initials = getInitials(displayName);
|
||||||
@@ -207,7 +212,12 @@ export default function DashboardPage() {
|
|||||||
className="cursor-pointer text-muted-foreground"
|
className="cursor-pointer text-muted-foreground"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCreateWorkspace}
|
onClick={handleCreateWorkspace}
|
||||||
disabled={isCreatingWorkspace || isSessionPending || !session?.user}
|
disabled={
|
||||||
|
isCreatingWorkspace ||
|
||||||
|
!hasClientMounted ||
|
||||||
|
isSessionPending ||
|
||||||
|
!session?.user
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isCreatingWorkspace ? "Erstelle..." : "Neuen Arbeitsbereich"}
|
{isCreatingWorkspace ? "Erstelle..." : "Neuen Arbeitsbereich"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type CreateNodeWithIntersectionInput = {
|
|||||||
height?: number;
|
height?: number;
|
||||||
data?: Record<string, unknown>;
|
data?: Record<string, unknown>;
|
||||||
clientPosition?: FlowPoint;
|
clientPosition?: FlowPoint;
|
||||||
|
zIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CanvasPlacementContextValue = {
|
type CanvasPlacementContextValue = {
|
||||||
@@ -107,6 +108,7 @@ export function CanvasPlacementProvider({
|
|||||||
height,
|
height,
|
||||||
data,
|
data,
|
||||||
clientPosition,
|
clientPosition,
|
||||||
|
zIndex,
|
||||||
}: CreateNodeWithIntersectionInput) => {
|
}: CreateNodeWithIntersectionInput) => {
|
||||||
const defaults = NODE_DEFAULTS[type] ?? {
|
const defaults = NODE_DEFAULTS[type] ?? {
|
||||||
width: 200,
|
width: 200,
|
||||||
@@ -140,6 +142,7 @@ export function CanvasPlacementProvider({
|
|||||||
...(data ?? {}),
|
...(data ?? {}),
|
||||||
canvasId,
|
canvasId,
|
||||||
},
|
},
|
||||||
|
...(zIndex !== undefined ? { zIndex } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!hitEdge) {
|
if (!hitEdge) {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
applyNodeChanges,
|
applyNodeChanges,
|
||||||
applyEdgeChanges,
|
applyEdgeChanges,
|
||||||
useReactFlow,
|
useReactFlow,
|
||||||
useStoreApi,
|
|
||||||
reconnectEdge,
|
reconnectEdge,
|
||||||
type Node as RFNode,
|
type Node as RFNode,
|
||||||
type Edge as RFEdge,
|
type Edge as RFEdge,
|
||||||
@@ -36,6 +35,7 @@ import {
|
|||||||
convexEdgeToRF,
|
convexEdgeToRF,
|
||||||
NODE_DEFAULTS,
|
NODE_DEFAULTS,
|
||||||
NODE_HANDLE_MAP,
|
NODE_HANDLE_MAP,
|
||||||
|
resolveMediaAspectRatio,
|
||||||
} from "@/lib/canvas-utils";
|
} from "@/lib/canvas-utils";
|
||||||
import CanvasToolbar from "@/components/canvas/canvas-toolbar";
|
import CanvasToolbar from "@/components/canvas/canvas-toolbar";
|
||||||
import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context";
|
import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context";
|
||||||
@@ -221,6 +221,58 @@ function mergeNodesPreservingLocalState(
|
|||||||
return previousNode;
|
return previousNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (incomingNode.type === "prompt") {
|
||||||
|
const prevW = typeof previousNode.style?.width === "number" ? previousNode.style.width : null;
|
||||||
|
const prevH = typeof previousNode.style?.height === "number" ? previousNode.style.height : null;
|
||||||
|
const inW = typeof incomingNode.style?.width === "number" ? incomingNode.style.width : null;
|
||||||
|
const inH = typeof incomingNode.style?.height === "number" ? incomingNode.style.height : null;
|
||||||
|
void prevW;
|
||||||
|
void prevH;
|
||||||
|
void inW;
|
||||||
|
void inH;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousResizing =
|
||||||
|
typeof (previousNode as { resizing?: boolean }).resizing === "boolean"
|
||||||
|
? (previousNode as { resizing?: boolean }).resizing
|
||||||
|
: false;
|
||||||
|
const isMediaNode = incomingNode.type === "asset" || incomingNode.type === "image";
|
||||||
|
const shouldPreserveInteractivePosition =
|
||||||
|
isMediaNode && (Boolean(previousNode.selected) || Boolean(previousNode.dragging) || previousResizing);
|
||||||
|
const shouldPreserveInteractiveSize =
|
||||||
|
isMediaNode && (Boolean(previousNode.dragging) || previousResizing);
|
||||||
|
|
||||||
|
const previousStyleWidth = typeof previousNode.style?.width === "number" ? previousNode.style.width : null;
|
||||||
|
const previousStyleHeight = typeof previousNode.style?.height === "number" ? previousNode.style.height : null;
|
||||||
|
const incomingStyleWidth = typeof incomingNode.style?.width === "number" ? incomingNode.style.width : null;
|
||||||
|
const incomingStyleHeight = typeof incomingNode.style?.height === "number" ? incomingNode.style.height : null;
|
||||||
|
const isAssetSeedSize = previousStyleWidth === 260 && previousStyleHeight === 240;
|
||||||
|
const isImageSeedSize = previousStyleWidth === 280 && previousStyleHeight === 200;
|
||||||
|
const canApplySeedSizeCorrection =
|
||||||
|
isMediaNode &&
|
||||||
|
Boolean(previousNode.selected) &&
|
||||||
|
!previousNode.dragging &&
|
||||||
|
!previousResizing &&
|
||||||
|
((incomingNode.type === "asset" && isAssetSeedSize) ||
|
||||||
|
(incomingNode.type === "image" && isImageSeedSize)) &&
|
||||||
|
incomingStyleWidth !== null &&
|
||||||
|
incomingStyleHeight !== null &&
|
||||||
|
(incomingStyleWidth !== previousStyleWidth || incomingStyleHeight !== previousStyleHeight);
|
||||||
|
|
||||||
|
if (shouldPreserveInteractivePosition) {
|
||||||
|
const nextStyle = shouldPreserveInteractiveSize || !canApplySeedSizeCorrection
|
||||||
|
? previousNode.style
|
||||||
|
: incomingNode.style;
|
||||||
|
return {
|
||||||
|
...previousNode,
|
||||||
|
...incomingNode,
|
||||||
|
position: previousNode.position,
|
||||||
|
style: nextStyle,
|
||||||
|
selected: previousNode.selected,
|
||||||
|
dragging: previousNode.dragging,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...previousNode,
|
...previousNode,
|
||||||
...incomingNode,
|
...incomingNode,
|
||||||
@@ -232,7 +284,6 @@ function mergeNodesPreservingLocalState(
|
|||||||
|
|
||||||
function CanvasInner({ canvasId }: CanvasInnerProps) {
|
function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||||
const { screenToFlowPosition } = useReactFlow();
|
const { screenToFlowPosition } = useReactFlow();
|
||||||
const storeApi = useStoreApi();
|
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
const { data: session, isPending: isSessionPending } = authClient.useSession();
|
const { data: session, isPending: isSessionPending } = authClient.useSession();
|
||||||
const { isLoading: isAuthLoading, isAuthenticated } = useConvexAuth();
|
const { isLoading: isAuthLoading, isAuthenticated } = useConvexAuth();
|
||||||
@@ -395,9 +446,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
setEdges((prev) => {
|
setEdges((prev) => {
|
||||||
const tempEdges = prev.filter((e) => e.className === "temp");
|
const tempEdges = prev.filter((e) => e.className === "temp");
|
||||||
const mapped = convexEdges.map(convexEdgeToRF);
|
const mapped = convexEdges.map(convexEdgeToRF);
|
||||||
// #region agent log
|
|
||||||
fetch('http://127.0.0.1:7733/ingest/db1ec129-24cb-483b-98e2-3e7beef6d9cd',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'594b9f'},body:JSON.stringify({sessionId:'594b9f',runId:'run1',hypothesisId:'H1-H2',location:'canvas.tsx:edgeSyncEffect',message:'edges passed to ReactFlow',data:{edgeCount:mapped.length,edges:mapped.map(e=>({id:e.id,source:e.source,target:e.target,sourceHandle:e.sourceHandle,targetHandle:e.targetHandle,typeofTH:typeof e.targetHandle,isNullTH:e.targetHandle===null}))},timestamp:Date.now()})}).catch(()=>{});
|
|
||||||
// #endregion
|
|
||||||
return [...mapped, ...tempEdges];
|
return [...mapped, ...tempEdges];
|
||||||
});
|
});
|
||||||
}, [convexEdges]);
|
}, [convexEdges]);
|
||||||
@@ -418,12 +466,107 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setNodes((nds) => {
|
setNodes((nds) => {
|
||||||
const nextNodes = applyNodeChanges(changes, nds);
|
const adjustedChanges = changes
|
||||||
|
.map((change) => {
|
||||||
|
if (change.type !== "dimensions" || !change.dimensions) {
|
||||||
|
return change;
|
||||||
|
}
|
||||||
|
|
||||||
for (const change of changes) {
|
const node = nds.find((candidate) => candidate.id === change.id);
|
||||||
|
if (!node || node.type !== "asset") {
|
||||||
|
return change;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActiveResize =
|
||||||
|
change.resizing === true || change.resizing === false;
|
||||||
|
if (!isActiveResize) {
|
||||||
|
return change;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeData = node.data as {
|
||||||
|
intrinsicWidth?: number;
|
||||||
|
intrinsicHeight?: number;
|
||||||
|
orientation?: string;
|
||||||
|
};
|
||||||
|
const hasIntrinsicRatioInput =
|
||||||
|
typeof nodeData.intrinsicWidth === "number" &&
|
||||||
|
nodeData.intrinsicWidth > 0 &&
|
||||||
|
typeof nodeData.intrinsicHeight === "number" &&
|
||||||
|
nodeData.intrinsicHeight > 0;
|
||||||
|
if (!hasIntrinsicRatioInput) {
|
||||||
|
return change;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRatio = resolveMediaAspectRatio(
|
||||||
|
nodeData.intrinsicWidth,
|
||||||
|
nodeData.intrinsicHeight,
|
||||||
|
nodeData.orientation,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!Number.isFinite(targetRatio) || targetRatio <= 0) {
|
||||||
|
return change;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousWidth =
|
||||||
|
typeof node.style?.width === "number"
|
||||||
|
? node.style.width
|
||||||
|
: change.dimensions.width;
|
||||||
|
const previousHeight =
|
||||||
|
typeof node.style?.height === "number"
|
||||||
|
? node.style.height
|
||||||
|
: change.dimensions.height;
|
||||||
|
|
||||||
|
const widthDelta = Math.abs(change.dimensions.width - previousWidth);
|
||||||
|
const heightDelta = Math.abs(change.dimensions.height - previousHeight);
|
||||||
|
|
||||||
|
let constrainedWidth = change.dimensions.width;
|
||||||
|
let constrainedHeight = change.dimensions.height;
|
||||||
|
|
||||||
|
// Axis with larger delta drives resize; the other axis is ratio-locked.
|
||||||
|
if (heightDelta > widthDelta) {
|
||||||
|
constrainedWidth = constrainedHeight * targetRatio;
|
||||||
|
} else {
|
||||||
|
constrainedHeight = constrainedWidth / targetRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetChromeHeight = 88;
|
||||||
|
const assetMinPreviewHeight = 120;
|
||||||
|
const assetMinNodeHeight = assetChromeHeight + assetMinPreviewHeight;
|
||||||
|
const assetMinNodeWidth = 140;
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...change,
|
||||||
|
dimensions: {
|
||||||
|
...change.dimensions,
|
||||||
|
width: enforcedWidth,
|
||||||
|
height: enforcedHeight,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((change): change is NodeChange => change !== null);
|
||||||
|
|
||||||
|
const nextNodes = applyNodeChanges(adjustedChanges, nds);
|
||||||
|
|
||||||
|
for (const change of adjustedChanges) {
|
||||||
if (change.type !== "dimensions") continue;
|
if (change.type !== "dimensions") continue;
|
||||||
if (change.resizing !== false || !change.dimensions) continue;
|
if (!change.dimensions) continue;
|
||||||
if (removedIds.has(change.id)) continue;
|
if (removedIds.has(change.id)) continue;
|
||||||
|
const prevNode = nds.find((node) => node.id === change.id);
|
||||||
|
const nextNode = nextNodes.find((node) => node.id === change.id);
|
||||||
|
void prevNode;
|
||||||
|
void nextNode;
|
||||||
|
if (change.resizing !== false) continue;
|
||||||
|
|
||||||
void resizeNode({
|
void resizeNode({
|
||||||
nodeId: change.id as Id<"nodes">,
|
nodeId: change.id as Id<"nodes">,
|
||||||
@@ -446,55 +589,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
setEdges((eds) => applyEdgeChanges(changes, eds));
|
setEdges((eds) => applyEdgeChanges(changes, eds));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onFlowError = useCallback(
|
const onFlowError = useCallback((code: string, message: string) => {
|
||||||
(code: string, message: string) => {
|
if (process.env.NODE_ENV === "production") return;
|
||||||
if (process.env.NODE_ENV === "production") return;
|
console.error("[ReactFlow error]", { canvasId, code, message });
|
||||||
|
}, [canvasId]);
|
||||||
if (code !== "015") {
|
|
||||||
console.error("[ReactFlow error]", { canvasId, code, message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = storeApi.getState() as {
|
|
||||||
nodeLookup?: Map<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
id: string;
|
|
||||||
selected?: boolean;
|
|
||||||
type?: string;
|
|
||||||
measured?: { width?: number; height?: number };
|
|
||||||
internals?: { positionAbsolute?: { x: number; y: number } };
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const uninitializedNodes = Array.from(state.nodeLookup?.values() ?? [])
|
|
||||||
.filter(
|
|
||||||
(node) =>
|
|
||||||
node.measured?.width === undefined ||
|
|
||||||
node.measured?.height === undefined,
|
|
||||||
)
|
|
||||||
.map((node) => ({
|
|
||||||
id: node.id,
|
|
||||||
type: node.type ?? null,
|
|
||||||
selected: Boolean(node.selected),
|
|
||||||
measuredWidth: node.measured?.width,
|
|
||||||
measuredHeight: node.measured?.height,
|
|
||||||
positionAbsolute: node.internals?.positionAbsolute ?? null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
console.error("[ReactFlow error 015 diagnostics]", {
|
|
||||||
canvasId,
|
|
||||||
message,
|
|
||||||
localNodeCount: nodes.length,
|
|
||||||
localSelectedNodeIds: nodes.filter((n) => n.selected).map((n) => n.id),
|
|
||||||
isDragging: isDragging.current,
|
|
||||||
uninitializedNodeCount: uninitializedNodes.length,
|
|
||||||
uninitializedNodes,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[canvasId, nodes, storeApi],
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── Delete Edge on Drop ──────────────────────────────────────
|
// ─── Delete Edge on Drop ──────────────────────────────────────
|
||||||
const onReconnectStart = useCallback(() => {
|
const onReconnectStart = useCallback(() => {
|
||||||
|
|||||||
@@ -104,7 +104,8 @@ export default function ConnectionBanner() {
|
|||||||
return showReconnected ? "reconnected" : "hidden";
|
return showReconnected ? "reconnected" : "hidden";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isBrowserOnline) {
|
// Streng `=== false`, damit kein undefined/SSR-Artefakt wie „offline“ wird.
|
||||||
|
if (isBrowserOnline === false) {
|
||||||
return "disconnected";
|
return "disconnected";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +122,12 @@ export default function ConnectionBanner() {
|
|||||||
showReconnected,
|
showReconnected,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// WebSocket/Convex-Verbindung gibt es im Browser; SSR soll keinen Banner rendern,
|
||||||
|
// sonst weicht die Geschwister-Reihenfolge vom ersten Client-Render ab (Hydration).
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (bannerState === "hidden") {
|
if (bannerState === "hidden") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export default function AiImageNode({
|
|||||||
<BaseNodeWrapper
|
<BaseNodeWrapper
|
||||||
nodeType="ai-image"
|
nodeType="ai-image"
|
||||||
selected={selected}
|
selected={selected}
|
||||||
className="flex h-full w-full min-h-0 min-w-0 flex-col overflow-hidden"
|
className="flex h-full w-full min-h-0 min-w-0 flex-col"
|
||||||
>
|
>
|
||||||
<Handle
|
<Handle
|
||||||
type="target"
|
type="target"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { api } from "@/convex/_generated/api";
|
|||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { computeMediaNodeSize } from "@/lib/canvas-utils";
|
import { resolveMediaAspectRatio } from "@/lib/canvas-utils";
|
||||||
|
|
||||||
type AssetNodeData = {
|
type AssetNodeData = {
|
||||||
assetId?: number;
|
assetId?: number;
|
||||||
@@ -60,28 +60,40 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
|||||||
const previewLoadError = Boolean(previewUrl && previewUrl === failedPreviewUrl);
|
const previewLoadError = Boolean(previewUrl && previewUrl === failedPreviewUrl);
|
||||||
|
|
||||||
const hasAutoSizedRef = useRef(false);
|
const hasAutoSizedRef = useRef(false);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
|
||||||
const headerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const previewRef = useRef<HTMLDivElement>(null);
|
|
||||||
const imageRef = useRef<HTMLImageElement>(null);
|
|
||||||
const footerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const lastMetricsRef = useRef<string>("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasAsset) return;
|
if (!hasAsset) return;
|
||||||
if (hasAutoSizedRef.current) return;
|
if (hasAutoSizedRef.current) return;
|
||||||
hasAutoSizedRef.current = true;
|
const targetAspectRatio = resolveMediaAspectRatio(
|
||||||
|
data.intrinsicWidth,
|
||||||
const targetSize = computeMediaNodeSize("asset", {
|
data.intrinsicHeight,
|
||||||
intrinsicWidth: data.intrinsicWidth,
|
data.orientation,
|
||||||
intrinsicHeight: data.intrinsicHeight,
|
);
|
||||||
orientation: data.orientation,
|
const minimumNodeHeight = 208;
|
||||||
});
|
const baseNodeWidth = 260;
|
||||||
|
const targetWidth = Math.max(baseNodeWidth, Math.round(minimumNodeHeight * targetAspectRatio));
|
||||||
if (width === targetSize.width && height === targetSize.height) {
|
const targetHeight = Math.round(targetWidth / targetAspectRatio);
|
||||||
|
const targetSize = {
|
||||||
|
width: targetWidth,
|
||||||
|
height: targetHeight,
|
||||||
|
};
|
||||||
|
const currentWidth = typeof width === "number" ? width : 0;
|
||||||
|
const currentHeight = typeof height === "number" ? height : 0;
|
||||||
|
const hasMeasuredSize = currentWidth > 0 && currentHeight > 0;
|
||||||
|
if (!hasMeasuredSize) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isAtTargetSize = currentWidth === targetSize.width && currentHeight === targetSize.height;
|
||||||
|
const isAtDefaultSeedSize = currentWidth === 260 && currentHeight === 240;
|
||||||
|
const shouldRunInitialAutoSize = isAtDefaultSeedSize && !isAtTargetSize;
|
||||||
|
|
||||||
|
if (!shouldRunInitialAutoSize) {
|
||||||
|
hasAutoSizedRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAutoSizedRef.current = true;
|
||||||
void resizeNode({
|
void resizeNode({
|
||||||
nodeId: id as Id<"nodes">,
|
nodeId: id as Id<"nodes">,
|
||||||
width: targetSize.width,
|
width: targetSize.width,
|
||||||
@@ -104,61 +116,12 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
|||||||
|
|
||||||
const showPreview = Boolean(hasAsset && previewUrl);
|
const showPreview = Boolean(hasAsset && previewUrl);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selected) return;
|
|
||||||
const rootEl = rootRef.current;
|
|
||||||
const headerEl = headerRef.current;
|
|
||||||
if (!rootEl || !headerEl) return;
|
|
||||||
|
|
||||||
const rootHeight = rootEl.getBoundingClientRect().height;
|
|
||||||
const headerHeight = headerEl.getBoundingClientRect().height;
|
|
||||||
const previewHeight = previewRef.current?.getBoundingClientRect().height ?? null;
|
|
||||||
const footerHeight = footerRef.current?.getBoundingClientRect().height ?? null;
|
|
||||||
const imageEl = imageRef.current;
|
|
||||||
const rootStyles = window.getComputedStyle(rootEl);
|
|
||||||
const imageStyles = imageEl ? window.getComputedStyle(imageEl) : null;
|
|
||||||
const rows = rootStyles.gridTemplateRows;
|
|
||||||
const imageRect = imageEl?.getBoundingClientRect();
|
|
||||||
const previewRect = previewRef.current?.getBoundingClientRect();
|
|
||||||
const naturalRatio =
|
|
||||||
imageEl && imageEl.naturalWidth > 0 && imageEl.naturalHeight > 0
|
|
||||||
? imageEl.naturalWidth / imageEl.naturalHeight
|
|
||||||
: null;
|
|
||||||
const previewRatio =
|
|
||||||
previewRect && previewRect.width > 0 && previewRect.height > 0
|
|
||||||
? previewRect.width / previewRect.height
|
|
||||||
: null;
|
|
||||||
let expectedContainWidth: number | null = null;
|
|
||||||
let expectedContainHeight: number | null = null;
|
|
||||||
if (previewRect && naturalRatio) {
|
|
||||||
const fitByWidthHeight = previewRect.width / naturalRatio;
|
|
||||||
if (fitByWidthHeight <= previewRect.height) {
|
|
||||||
expectedContainWidth = previewRect.width;
|
|
||||||
expectedContainHeight = fitByWidthHeight;
|
|
||||||
} else {
|
|
||||||
expectedContainHeight = previewRect.height;
|
|
||||||
expectedContainWidth = previewRect.height * naturalRatio;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const signature = `${width}|${height}|${Math.round(rootHeight)}|${Math.round(headerHeight)}|${Math.round(previewHeight ?? -1)}|${Math.round(footerHeight ?? -1)}|${Math.round(imageRect?.height ?? -1)}|${rows}|${showPreview}`;
|
|
||||||
|
|
||||||
if (lastMetricsRef.current === signature) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastMetricsRef.current = signature;
|
|
||||||
|
|
||||||
// #region agent log
|
|
||||||
fetch('http://127.0.0.1:7733/ingest/db1ec129-24cb-483b-98e2-3e7beef6d9cd',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'d48a18'},body:JSON.stringify({sessionId:'d48a18',runId:'run4',hypothesisId:'H13-H14',location:'asset-node.tsx:metricsEffect',message:'asset contain-fit diagnostics',data:{nodeId:id,width,height,rootHeight,previewWidth:previewRect?.width ?? null,previewHeight,previewRatio,naturalRatio,headerHeight,footerHeight,imageRenderWidth:imageRect?.width ?? null,imageRenderHeight:imageRect?.height ?? null,expectedContainWidth,expectedContainHeight,imageNaturalWidth:imageEl?.naturalWidth ?? null,imageNaturalHeight:imageEl?.naturalHeight ?? null,imageObjectFit:imageStyles?.objectFit ?? null,imageObjectPosition:imageStyles?.objectPosition ?? null,rows,showPreview},timestamp:Date.now()})}).catch(()=>{});
|
|
||||||
// #endregion
|
|
||||||
}, [height, id, selected, showPreview, width]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseNodeWrapper
|
<BaseNodeWrapper
|
||||||
nodeType="asset"
|
nodeType="asset"
|
||||||
selected={selected}
|
selected={selected}
|
||||||
status={data._status}
|
status={data._status}
|
||||||
statusMessage={data._statusMessage}
|
statusMessage={data._statusMessage}
|
||||||
className="overflow-hidden"
|
|
||||||
>
|
>
|
||||||
<Handle
|
<Handle
|
||||||
type="target"
|
type="target"
|
||||||
@@ -167,14 +130,13 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
|
||||||
className={`grid h-full min-h-0 w-full ${
|
className={`grid h-full min-h-0 w-full ${
|
||||||
showPreview
|
showPreview
|
||||||
? "grid-rows-[auto_minmax(0,1fr)_auto]"
|
? "grid-rows-[auto_minmax(0,1fr)_auto]"
|
||||||
: "grid-rows-[auto_minmax(0,1fr)]"
|
: "grid-rows-[auto_minmax(0,1fr)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div ref={headerRef} className="flex items-center justify-between border-b px-3 py-2">
|
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||||
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||||
Asset
|
Asset
|
||||||
</span>
|
</span>
|
||||||
@@ -191,7 +153,7 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
|||||||
|
|
||||||
{showPreview ? (
|
{showPreview ? (
|
||||||
<>
|
<>
|
||||||
<div ref={previewRef} className="relative min-h-0 overflow-hidden bg-muted/30">
|
<div className="relative min-h-0 overflow-hidden bg-muted/30">
|
||||||
{isPreviewLoading ? (
|
{isPreviewLoading ? (
|
||||||
<div className="absolute inset-0 z-10 flex animate-pulse items-center justify-center bg-muted/60 text-[11px] text-muted-foreground">
|
<div className="absolute inset-0 z-10 flex animate-pulse items-center justify-center bg-muted/60 text-[11px] text-muted-foreground">
|
||||||
Loading preview...
|
Loading preview...
|
||||||
@@ -204,10 +166,9 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
|||||||
) : null}
|
) : null}
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
ref={imageRef}
|
|
||||||
src={previewUrl}
|
src={previewUrl}
|
||||||
alt={data.title ?? "Asset preview"}
|
alt={data.title ?? "Asset preview"}
|
||||||
className={`h-full w-full object-cover object-center transition-opacity ${
|
className={`h-full w-full object-cover object-right transition-opacity ${
|
||||||
isPreviewLoading ? "opacity-0" : "opacity-100"
|
isPreviewLoading ? "opacity-0" : "opacity-100"
|
||||||
}`}
|
}`}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
@@ -234,7 +195,7 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={footerRef} className="flex flex-col gap-1 px-3 py-2">
|
<div className="flex flex-col gap-1 px-3 py-2">
|
||||||
<p className="truncate text-xs font-medium" title={data.title ?? "Untitled"}>
|
<p className="truncate text-xs font-medium" title={data.title ?? "Untitled"}>
|
||||||
{data.title ?? "Untitled"}
|
{data.title ?? "Untitled"}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { NodeResizeControl } from "@xyflow/react";
|
import { NodeResizeControl, NodeToolbar, Position, useNodeId, useReactFlow } from "@xyflow/react";
|
||||||
|
import { Trash2, Copy } from "lucide-react";
|
||||||
|
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
|
||||||
import { NodeErrorBoundary } from "./node-error-boundary";
|
import { NodeErrorBoundary } from "./node-error-boundary";
|
||||||
|
|
||||||
interface ResizeConfig {
|
interface ResizeConfig {
|
||||||
@@ -14,10 +16,10 @@ 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: 120, keepAspectRatio: true },
|
asset: { minWidth: 140, minHeight: 208, keepAspectRatio: false },
|
||||||
"ai-image": { minWidth: 200, minHeight: 200 },
|
"ai-image": { minWidth: 200, minHeight: 200 },
|
||||||
compare: { minWidth: 300, minHeight: 200 },
|
compare: { minWidth: 300, minHeight: 200 },
|
||||||
prompt: { minWidth: 260, minHeight: 200 },
|
prompt: { minWidth: 260, minHeight: 220 },
|
||||||
text: { minWidth: 220, minHeight: 90 },
|
text: { minWidth: 220, minHeight: 90 },
|
||||||
note: { minWidth: 200, minHeight: 90 },
|
note: { minWidth: 200, minHeight: 90 },
|
||||||
};
|
};
|
||||||
@@ -31,6 +33,117 @@ const CORNERS = [
|
|||||||
"bottom-right",
|
"bottom-right",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/** Internal fields to strip when duplicating a node */
|
||||||
|
const INTERNAL_FIELDS = new Set([
|
||||||
|
"_status",
|
||||||
|
"_statusMessage",
|
||||||
|
"retryCount",
|
||||||
|
"url",
|
||||||
|
"canvasId",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function NodeToolbarActions() {
|
||||||
|
const nodeId = useNodeId();
|
||||||
|
const { deleteElements, getNode, getNodes, setNodes } = useReactFlow();
|
||||||
|
const { createNodeWithIntersection } = useCanvasPlacement();
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (!nodeId) return;
|
||||||
|
void deleteElements({ nodes: [{ id: nodeId }] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDuplicate = async () => {
|
||||||
|
if (!nodeId) return;
|
||||||
|
const node = getNode(nodeId);
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
// Strip internal/runtime fields, keep only user content
|
||||||
|
const originalData = (node.data ?? {}) as Record<string, unknown>;
|
||||||
|
const cleanedData: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(originalData)) {
|
||||||
|
if (!INTERNAL_FIELDS.has(key)) {
|
||||||
|
cleanedData[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalPosition = node.position ?? { x: 0, y: 0 };
|
||||||
|
const width = typeof node.style?.width === "number" ? node.style.width : undefined;
|
||||||
|
const height = typeof node.style?.height === "number" ? node.style.height : undefined;
|
||||||
|
|
||||||
|
// Find the highest zIndex across all nodes to ensure the duplicate renders on top
|
||||||
|
const allNodes = getNodes();
|
||||||
|
const maxZIndex = allNodes.reduce(
|
||||||
|
(max, n) => Math.max(max, n.zIndex ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const createdNodeId = await createNodeWithIntersection({
|
||||||
|
type: node.type ?? "text",
|
||||||
|
position: {
|
||||||
|
x: originalPosition.x + 50,
|
||||||
|
y: originalPosition.y + 50,
|
||||||
|
},
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
data: cleanedData,
|
||||||
|
zIndex: maxZIndex + 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectCreatedNode = (attempt = 0) => {
|
||||||
|
const createdNode = getNode(createdNodeId);
|
||||||
|
if (!createdNode) {
|
||||||
|
if (attempt < 10) {
|
||||||
|
requestAnimationFrame(() => selectCreatedNode(attempt + 1));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNodes((nodes) =>
|
||||||
|
nodes.map((n) => {
|
||||||
|
if (n.id === nodeId) {
|
||||||
|
return { ...n, selected: false };
|
||||||
|
}
|
||||||
|
if (n.id === createdNodeId) {
|
||||||
|
return { ...n, selected: true };
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
selectCreatedNode();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopPropagation = (e: React.MouseEvent | React.PointerEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeToolbar position={Position.Top} offset={8}>
|
||||||
|
<div className="flex items-center gap-1 rounded-lg border bg-card p-1 shadow-md">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { stopPropagation(e); handleDuplicate(); }}
|
||||||
|
onPointerDown={stopPropagation}
|
||||||
|
title="Duplicate"
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Copy size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { stopPropagation(e); handleDelete(); }}
|
||||||
|
onPointerDown={stopPropagation}
|
||||||
|
title="Delete"
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</NodeToolbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface BaseNodeWrapperProps {
|
interface BaseNodeWrapperProps {
|
||||||
nodeType: string;
|
nodeType: string;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
@@ -128,6 +241,7 @@ export default function BaseNodeWrapper({
|
|||||||
{statusMessage}
|
{statusMessage}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<NodeToolbarActions />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,8 +64,6 @@ export default function CompareNode({ data, selected }: NodeProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseNodeWrapper nodeType="compare" selected={selected} className="p-0">
|
<BaseNodeWrapper nodeType="compare" selected={selected} className="p-0">
|
||||||
<div className="px-3 py-2 text-xs font-medium text-muted-foreground">⚖️ Compare</div>
|
|
||||||
|
|
||||||
<Handle
|
<Handle
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
@@ -87,13 +85,15 @@ export default function CompareNode({ data, selected }: NodeProps) {
|
|||||||
className="!h-3 !w-3 !border-2 !border-background !bg-muted-foreground"
|
className="!h-3 !w-3 !border-2 !border-background !bg-muted-foreground"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div className="grid h-full min-h-0 w-full grid-cols-1 grid-rows-[auto_minmax(0,1fr)]">
|
||||||
ref={containerRef}
|
<div className="px-3 py-2 text-xs font-medium text-muted-foreground">⚖️ Compare</div>
|
||||||
className="nodrag relative w-full select-none overflow-hidden rounded-b-xl bg-muted"
|
|
||||||
style={{ height: "100%" }}
|
<div
|
||||||
onMouseDown={handleMouseDown}
|
ref={containerRef}
|
||||||
onTouchStart={handleTouchStart}
|
className="nodrag relative min-h-0 w-full select-none overflow-hidden rounded-b-xl bg-muted"
|
||||||
>
|
onMouseDown={handleMouseDown}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
>
|
||||||
{!hasLeft && !hasRight && (
|
{!hasLeft && !hasRight && (
|
||||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||||
<ImageIcon className="h-10 w-10 opacity-30" />
|
<ImageIcon className="h-10 w-10 opacity-30" />
|
||||||
@@ -169,6 +169,7 @@ export default function CompareNode({ data, selected }: NodeProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</BaseNodeWrapper>
|
</BaseNodeWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -78,12 +78,6 @@ export default function ImageNode({
|
|||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const hasAutoSizedRef = useRef(false);
|
const hasAutoSizedRef = useRef(false);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
|
||||||
const headerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const previewRef = useRef<HTMLDivElement>(null);
|
|
||||||
const imageRef = useRef<HTMLImageElement>(null);
|
|
||||||
const footerRef = useRef<HTMLParagraphElement>(null);
|
|
||||||
const lastMetricsRef = useRef<string>("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof data.width !== "number" || typeof data.height !== "number") {
|
if (typeof data.width !== "number" || typeof data.height !== "number") {
|
||||||
@@ -91,17 +85,27 @@ export default function ImageNode({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasAutoSizedRef.current) return;
|
if (hasAutoSizedRef.current) return;
|
||||||
hasAutoSizedRef.current = true;
|
|
||||||
|
|
||||||
const targetSize = computeMediaNodeSize("image", {
|
const targetSize = computeMediaNodeSize("image", {
|
||||||
intrinsicWidth: data.width,
|
intrinsicWidth: data.width,
|
||||||
intrinsicHeight: data.height,
|
intrinsicHeight: data.height,
|
||||||
});
|
});
|
||||||
|
const currentWidth = typeof width === "number" ? width : 0;
|
||||||
if (width === targetSize.width && height === targetSize.height) {
|
const currentHeight = typeof height === "number" ? height : 0;
|
||||||
|
const hasMeasuredSize = currentWidth > 0 && currentHeight > 0;
|
||||||
|
if (!hasMeasuredSize) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isAtTargetSize = currentWidth === targetSize.width && currentHeight === targetSize.height;
|
||||||
|
const isAtDefaultSeedSize = currentWidth === 280 && currentHeight === 200;
|
||||||
|
const shouldRunInitialAutoSize = isAtDefaultSeedSize && !isAtTargetSize;
|
||||||
|
|
||||||
|
if (!shouldRunInitialAutoSize) {
|
||||||
|
hasAutoSizedRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAutoSizedRef.current = true;
|
||||||
void resizeNode({
|
void resizeNode({
|
||||||
nodeId: id as Id<"nodes">,
|
nodeId: id as Id<"nodes">,
|
||||||
width: targetSize.width,
|
width: targetSize.width,
|
||||||
@@ -235,61 +239,11 @@ export default function ImageNode({
|
|||||||
|
|
||||||
const showFilename = Boolean(data.filename && data.url);
|
const showFilename = Boolean(data.filename && data.url);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selected) return;
|
|
||||||
const rootEl = rootRef.current;
|
|
||||||
const headerEl = headerRef.current;
|
|
||||||
const previewEl = previewRef.current;
|
|
||||||
if (!rootEl || !headerEl || !previewEl) return;
|
|
||||||
|
|
||||||
const rootHeight = rootEl.getBoundingClientRect().height;
|
|
||||||
const headerHeight = headerEl.getBoundingClientRect().height;
|
|
||||||
const previewHeight = previewEl.getBoundingClientRect().height;
|
|
||||||
const footerHeight = footerRef.current?.getBoundingClientRect().height ?? null;
|
|
||||||
const imageEl = imageRef.current;
|
|
||||||
const rootStyles = window.getComputedStyle(rootEl);
|
|
||||||
const imageStyles = imageEl ? window.getComputedStyle(imageEl) : null;
|
|
||||||
const rows = rootStyles.gridTemplateRows;
|
|
||||||
const imageRect = imageEl?.getBoundingClientRect();
|
|
||||||
const previewRect = previewEl.getBoundingClientRect();
|
|
||||||
const naturalRatio =
|
|
||||||
imageEl && imageEl.naturalWidth > 0 && imageEl.naturalHeight > 0
|
|
||||||
? imageEl.naturalWidth / imageEl.naturalHeight
|
|
||||||
: null;
|
|
||||||
const previewRatio =
|
|
||||||
previewRect.width > 0 && previewRect.height > 0
|
|
||||||
? previewRect.width / previewRect.height
|
|
||||||
: null;
|
|
||||||
let expectedContainWidth: number | null = null;
|
|
||||||
let expectedContainHeight: number | null = null;
|
|
||||||
if (naturalRatio) {
|
|
||||||
const fitByWidthHeight = previewRect.width / naturalRatio;
|
|
||||||
if (fitByWidthHeight <= previewRect.height) {
|
|
||||||
expectedContainWidth = previewRect.width;
|
|
||||||
expectedContainHeight = fitByWidthHeight;
|
|
||||||
} else {
|
|
||||||
expectedContainHeight = previewRect.height;
|
|
||||||
expectedContainWidth = previewRect.height * naturalRatio;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const signature = `${width}|${height}|${Math.round(rootHeight)}|${Math.round(headerHeight)}|${Math.round(previewHeight)}|${Math.round(footerHeight ?? -1)}|${Math.round(imageRect?.height ?? -1)}|${rows}|${showFilename}`;
|
|
||||||
|
|
||||||
if (lastMetricsRef.current === signature) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastMetricsRef.current = signature;
|
|
||||||
|
|
||||||
// #region agent log
|
|
||||||
fetch('http://127.0.0.1:7733/ingest/db1ec129-24cb-483b-98e2-3e7beef6d9cd',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'d48a18'},body:JSON.stringify({sessionId:'d48a18',runId:'run4',hypothesisId:'H15-H16',location:'image-node.tsx:metricsEffect',message:'image contain-fit diagnostics',data:{nodeId:id,width,height,rootHeight,previewWidth:previewRect.width,previewHeight,previewRatio,naturalRatio,headerHeight,footerHeight,imageRenderWidth:imageRect?.width ?? null,imageRenderHeight:imageRect?.height ?? null,expectedContainWidth,expectedContainHeight,imageNaturalWidth:imageEl?.naturalWidth ?? null,imageNaturalHeight:imageEl?.naturalHeight ?? null,imageObjectFit:imageStyles?.objectFit ?? null,imageObjectPosition:imageStyles?.objectPosition ?? null,rows,showFilename},timestamp:Date.now()})}).catch(()=>{});
|
|
||||||
// #endregion
|
|
||||||
}, [height, id, selected, showFilename, width]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseNodeWrapper
|
<BaseNodeWrapper
|
||||||
nodeType="image"
|
nodeType="image"
|
||||||
selected={selected}
|
selected={selected}
|
||||||
status={data._status}
|
status={data._status}
|
||||||
className="overflow-hidden"
|
|
||||||
>
|
>
|
||||||
<Handle
|
<Handle
|
||||||
type="target"
|
type="target"
|
||||||
@@ -298,14 +252,13 @@ export default function ImageNode({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
|
||||||
className={`grid h-full min-h-0 w-full grid-cols-1 gap-y-1 p-2 ${
|
className={`grid h-full min-h-0 w-full grid-cols-1 gap-y-1 p-2 ${
|
||||||
showFilename
|
showFilename
|
||||||
? "grid-rows-[auto_minmax(0,1fr)_auto]"
|
? "grid-rows-[auto_minmax(0,1fr)_auto]"
|
||||||
: "grid-rows-[auto_minmax(0,1fr)]"
|
: "grid-rows-[auto_minmax(0,1fr)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div ref={headerRef} className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-xs font-medium text-muted-foreground">🖼️ Bild</div>
|
<div className="text-xs font-medium text-muted-foreground">🖼️ Bild</div>
|
||||||
{data.url && (
|
{data.url && (
|
||||||
<button
|
<button
|
||||||
@@ -317,7 +270,7 @@ export default function ImageNode({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={previewRef} className="relative min-h-0 overflow-hidden rounded-lg bg-muted/30">
|
<div className="relative min-h-0 overflow-hidden rounded-lg bg-muted/30">
|
||||||
{isUploading ? (
|
{isUploading ? (
|
||||||
<div className="flex h-full w-full items-center justify-center bg-muted">
|
<div className="flex h-full w-full items-center justify-center bg-muted">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
@@ -328,7 +281,6 @@ export default function ImageNode({
|
|||||||
) : data.url ? (
|
) : data.url ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element -- Convex storage URL, volle Auflösung wie Asset-Node
|
// eslint-disable-next-line @next/next/no-img-element -- Convex storage URL, volle Auflösung wie Asset-Node
|
||||||
<img
|
<img
|
||||||
ref={imageRef}
|
|
||||||
src={data.url}
|
src={data.url}
|
||||||
alt={data.filename ?? "Bild"}
|
alt={data.filename ?? "Bild"}
|
||||||
className="h-full w-full object-cover object-center"
|
className="h-full w-full object-cover object-center"
|
||||||
@@ -358,7 +310,7 @@ export default function ImageNode({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showFilename ? (
|
{showFilename ? (
|
||||||
<p ref={footerRef} className="min-h-0 truncate text-xs text-muted-foreground">{data.filename}</p>
|
<p className="min-h-0 truncate text-xs text-muted-foreground">{data.filename}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -297,13 +297,13 @@ export default function PromptNode({
|
|||||||
className="!h-3 !w-3 !bg-violet-500 !border-2 !border-background"
|
className="!h-3 !w-3 !bg-violet-500 !border-2 !border-background"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 p-3">
|
<div className="flex h-full flex-col gap-2 p-3">
|
||||||
<div className="flex items-center gap-1.5 text-xs font-medium text-violet-600 dark:text-violet-400">
|
<div className="flex items-center gap-1.5 text-xs font-medium text-violet-600 dark:text-violet-400">
|
||||||
<Sparkles className="h-3.5 w-3.5" />
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
Eingabe
|
Eingabe
|
||||||
</div>
|
</div>
|
||||||
{inputMeta.hasTextInput ? (
|
{inputMeta.hasTextInput ? (
|
||||||
<div className="rounded-md border border-violet-500/30 bg-violet-500/5 px-3 py-2">
|
<div className="flex-1 overflow-auto rounded-md border border-violet-500/30 bg-violet-500/5 px-3 py-2">
|
||||||
<p className="text-[11px] font-medium text-violet-700 dark:text-violet-300">
|
<p className="text-[11px] font-medium text-violet-700 dark:text-violet-300">
|
||||||
Prompt aus verbundener Text-Node
|
Prompt aus verbundener Text-Node
|
||||||
</p>
|
</p>
|
||||||
@@ -316,8 +316,7 @@ export default function PromptNode({
|
|||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={handlePromptChange}
|
onChange={handlePromptChange}
|
||||||
placeholder="Beschreibe, was du generieren willst…"
|
placeholder="Beschreibe, was du generieren willst…"
|
||||||
rows={4}
|
className="nodrag nowheel min-h-[72px] w-full flex-1 resize-none rounded-md border border-border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||||
className="nodrag nowheel w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-violet-500"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,6 @@ export function convexNodeToRF(node: Doc<"nodes">): RFNode {
|
|||||||
export function convexEdgeToRF(edge: Doc<"edges">): RFEdge {
|
export function convexEdgeToRF(edge: Doc<"edges">): RFEdge {
|
||||||
const sanitize = (h: string | undefined): string | undefined =>
|
const sanitize = (h: string | undefined): string | undefined =>
|
||||||
h === undefined || h === "null" ? undefined : h;
|
h === undefined || h === "null" ? undefined : h;
|
||||||
// #region agent log
|
|
||||||
fetch('http://127.0.0.1:7733/ingest/db1ec129-24cb-483b-98e2-3e7beef6d9cd',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'594b9f'},body:JSON.stringify({sessionId:'594b9f',runId:'run1',hypothesisId:'H1-H3-H4',location:'canvas-utils.ts:convexEdgeToRF',message:'raw edge from convex',data:{edgeId:edge._id,sourceNodeId:edge.sourceNodeId,targetNodeId:edge.targetNodeId,rawSourceHandle:edge.sourceHandle,rawTargetHandle:edge.targetHandle,typeofSourceHandle:typeof edge.sourceHandle,typeofTargetHandle:typeof edge.targetHandle,isNullSH:edge.sourceHandle===null,isNullTH:edge.targetHandle===null,isUndefinedSH:edge.sourceHandle===undefined,isUndefinedTH:edge.targetHandle===undefined,isStringNullSH:edge.sourceHandle==='null',isStringNullTH:edge.targetHandle==='null',sanitizedSH:sanitize(edge.sourceHandle),sanitizedTH:sanitize(edge.targetHandle)},timestamp:Date.now()})}).catch(()=>{});
|
|
||||||
// #endregion
|
|
||||||
return {
|
return {
|
||||||
id: edge._id,
|
id: edge._id,
|
||||||
source: edge.sourceNodeId,
|
source: edge.sourceNodeId,
|
||||||
|
|||||||
Reference in New Issue
Block a user