feat: enhance asset browser panel with improved asset selection and loading states
- Added state management for asset selection to prevent multiple simultaneous selections. - Implemented request sequence tracking to ensure accurate loading state handling during asset searches. - Enhanced error handling and user feedback for asset loading failures. - Updated UI elements to improve accessibility and user experience during asset browsing.
This commit is contained in:
@@ -59,11 +59,15 @@ export function AssetBrowserPanel({
|
||||
const [totalPages, setTotalPages] = useState(initialState?.totalPages ?? 1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [selectingAssetKey, setSelectingAssetKey] = useState<string | null>(null);
|
||||
|
||||
const searchFreepik = useAction(api.freepik.search);
|
||||
const updateData = useMutation(api.nodes.updateData);
|
||||
const resizeNode = useMutation(api.nodes.resize);
|
||||
const shouldSkipInitialSearchRef = useRef(Boolean(initialState?.results?.length));
|
||||
const requestSequenceRef = useRef(0);
|
||||
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
|
||||
const isSelecting = selectingAssetKey !== null;
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
@@ -102,6 +106,7 @@ export function AssetBrowserPanel({
|
||||
const runSearch = useCallback(
|
||||
async (searchTerm: string, type: AssetType, requestedPage: number) => {
|
||||
const cleanedTerm = searchTerm.trim();
|
||||
const requestSequence = ++requestSequenceRef.current;
|
||||
if (!cleanedTerm) {
|
||||
setResults([]);
|
||||
setErrorMessage(null);
|
||||
@@ -121,17 +126,30 @@ export function AssetBrowserPanel({
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
if (requestSequence !== requestSequenceRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setResults(response.results);
|
||||
setTotalPages(response.totalPages);
|
||||
setPage(response.currentPage);
|
||||
|
||||
if (scrollAreaRef.current) {
|
||||
scrollAreaRef.current.scrollTop = 0;
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestSequence !== requestSequenceRef.current) {
|
||||
return;
|
||||
}
|
||||
console.error("Freepik search error", error);
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : "Freepik search failed",
|
||||
);
|
||||
} finally {
|
||||
if (requestSequence === requestSequenceRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[searchFreepik],
|
||||
);
|
||||
@@ -147,6 +165,10 @@ export function AssetBrowserPanel({
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (asset: FreepikResult) => {
|
||||
if (isSelecting) return;
|
||||
const assetKey = `${asset.assetType}-${asset.id}`;
|
||||
setSelectingAssetKey(assetKey);
|
||||
try {
|
||||
await updateData({
|
||||
nodeId: nodeId as Id<"nodes">,
|
||||
data: {
|
||||
@@ -177,8 +199,13 @@ export function AssetBrowserPanel({
|
||||
height: targetSize.height,
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to select asset", error);
|
||||
} finally {
|
||||
setSelectingAssetKey(null);
|
||||
}
|
||||
},
|
||||
[canvasId, nodeId, onClose, resizeNode, updateData],
|
||||
[canvasId, isSelecting, nodeId, onClose, resizeNode, updateData],
|
||||
);
|
||||
|
||||
const handlePreviousPage = useCallback(() => {
|
||||
@@ -204,6 +231,9 @@ export function AssetBrowserPanel({
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onWheelCapture={(event) => event.stopPropagation()}
|
||||
onPointerDownCapture={(event) => event.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Browse Freepik assets"
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between border-b px-5 py-4">
|
||||
<h2 className="text-sm font-semibold">Browse Freepik Assets</h2>
|
||||
@@ -244,6 +274,7 @@ export function AssetBrowserPanel({
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollAreaRef}
|
||||
className="nowheel nodrag nopan flex-1 overflow-y-auto p-5"
|
||||
onWheelCapture={(event) => event.stopPropagation()}
|
||||
>
|
||||
@@ -258,6 +289,14 @@ export function AssetBrowserPanel({
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
<p className="text-sm text-foreground">Search failed</p>
|
||||
<p className="max-w-md text-xs">{errorMessage}</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void runSearch(debouncedTerm, assetType, page)}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center text-muted-foreground">
|
||||
@@ -269,13 +308,20 @@ export function AssetBrowserPanel({
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{results.map((asset) => (
|
||||
{results.map((asset) => {
|
||||
const assetKey = `${asset.assetType}-${asset.id}`;
|
||||
const isSelectingThisAsset = selectingAssetKey === assetKey;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${asset.assetType}-${asset.id}`}
|
||||
key={assetKey}
|
||||
onClick={() => void handleSelect(asset)}
|
||||
className="group relative aspect-square overflow-hidden rounded-lg border-2 border-transparent bg-muted transition-all hover:border-primary focus:border-primary focus:outline-none"
|
||||
title={asset.title}
|
||||
type="button"
|
||||
disabled={isSelecting}
|
||||
aria-busy={isSelectingThisAsset}
|
||||
aria-label={`Select asset: ${asset.title}`}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
@@ -296,8 +342,15 @@ export function AssetBrowserPanel({
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/20" />
|
||||
{isSelectingThisAsset ? (
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-1 bg-black/55 text-[11px] text-white">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Applying...
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
</>
|
||||
@@ -306,7 +359,7 @@ export function AssetBrowserPanel({
|
||||
|
||||
<div className="flex shrink-0 flex-col gap-3 border-t px-5 py-3">
|
||||
{results.length > 0 ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="flex items-center justify-center gap-2" aria-live="polite">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -363,10 +416,14 @@ export function AssetBrowserPanel({
|
||||
handleNextPage,
|
||||
handlePreviousPage,
|
||||
handleSelect,
|
||||
debouncedTerm,
|
||||
isLoading,
|
||||
isSelecting,
|
||||
onClose,
|
||||
page,
|
||||
results,
|
||||
runSearch,
|
||||
selectingAssetKey,
|
||||
term,
|
||||
totalPages,
|
||||
],
|
||||
|
||||
@@ -410,9 +410,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
if (change.type !== "dimensions") continue;
|
||||
if (change.resizing !== false || !change.dimensions) continue;
|
||||
|
||||
const resizedNode = nextNodes.find((node) => node.id === change.id);
|
||||
if (resizedNode?.type !== "frame") continue;
|
||||
|
||||
void resizeNode({
|
||||
nodeId: change.id as Id<"nodes">,
|
||||
width: change.dimensions.width,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent,
|
||||
@@ -42,7 +41,8 @@ export type AssetNodeType = Node<AssetNodeData, "asset">;
|
||||
|
||||
export default function AssetNode({ id, data, selected, width, height }: NodeProps<AssetNodeType>) {
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [handleTop, setHandleTop] = useState<number | undefined>(undefined);
|
||||
const [loadedPreviewUrl, setLoadedPreviewUrl] = useState<string | null>(null);
|
||||
const [failedPreviewUrl, setFailedPreviewUrl] = useState<string | null>(null);
|
||||
const [browserState, setBrowserState] = useState<AssetBrowserSessionState>({
|
||||
term: "",
|
||||
assetType: "photo",
|
||||
@@ -51,19 +51,25 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
||||
totalPages: 1,
|
||||
});
|
||||
const resizeNode = useMutation(api.nodes.resize);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const mediaRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const hasAsset = typeof data.assetId === "number";
|
||||
const previewUrl = data.url ?? data.previewUrl;
|
||||
const isPreviewLoading = Boolean(
|
||||
previewUrl && previewUrl !== loadedPreviewUrl && previewUrl !== failedPreviewUrl,
|
||||
);
|
||||
const previewLoadError = Boolean(previewUrl && previewUrl === failedPreviewUrl);
|
||||
const aspectRatio = resolveMediaAspectRatio(
|
||||
data.intrinsicWidth,
|
||||
data.intrinsicHeight,
|
||||
data.orientation,
|
||||
);
|
||||
|
||||
const hasAutoSizedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAsset) return;
|
||||
if (hasAutoSizedRef.current) return;
|
||||
hasAutoSizedRef.current = true;
|
||||
|
||||
const targetSize = computeMediaNodeSize("asset", {
|
||||
intrinsicWidth: data.intrinsicWidth,
|
||||
@@ -91,39 +97,6 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
||||
width,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!hasAsset || !contentRef.current || !mediaRef.current) return;
|
||||
|
||||
const contentEl = contentRef.current;
|
||||
const mediaEl = mediaRef.current;
|
||||
let frameId: number | undefined;
|
||||
|
||||
const updateHandleTop = () => {
|
||||
if (frameId !== undefined) {
|
||||
cancelAnimationFrame(frameId);
|
||||
}
|
||||
frameId = requestAnimationFrame(() => {
|
||||
const contentRect = contentEl.getBoundingClientRect();
|
||||
const mediaRect = mediaEl.getBoundingClientRect();
|
||||
const nextTop = mediaRect.top - contentRect.top + mediaRect.height / 2;
|
||||
setHandleTop(nextTop);
|
||||
});
|
||||
};
|
||||
|
||||
updateHandleTop();
|
||||
|
||||
const observer = new ResizeObserver(updateHandleTop);
|
||||
observer.observe(contentEl);
|
||||
observer.observe(mediaEl);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (frameId !== undefined) {
|
||||
cancelAnimationFrame(frameId);
|
||||
}
|
||||
};
|
||||
}, [aspectRatio, hasAsset]);
|
||||
|
||||
const stopNodeClickPropagation = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
@@ -140,10 +113,9 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="h-3! w-3! border-2! border-background! bg-primary!"
|
||||
style={{ top: hasAsset && handleTop ? `${handleTop}px` : "50%" }}
|
||||
/>
|
||||
|
||||
<div ref={contentRef} className="w-full">
|
||||
<div className="w-full">
|
||||
<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">
|
||||
Asset
|
||||
@@ -162,16 +134,36 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
||||
{hasAsset && previewUrl ? (
|
||||
<div className="flex flex-col gap-0">
|
||||
<div
|
||||
ref={mediaRef}
|
||||
className="relative overflow-hidden bg-muted/30"
|
||||
style={{ aspectRatio }}
|
||||
>
|
||||
{isPreviewLoading ? (
|
||||
<div className="absolute inset-0 z-10 flex animate-pulse items-center justify-center bg-muted/60 text-[11px] text-muted-foreground">
|
||||
Loading preview...
|
||||
</div>
|
||||
) : null}
|
||||
{previewLoadError ? (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-muted/70 text-[11px] text-muted-foreground">
|
||||
Preview unavailable
|
||||
</div>
|
||||
) : null}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={data.title ?? "Asset preview"}
|
||||
className="h-full w-full object-contain"
|
||||
className={`h-full w-full object-contain transition-opacity ${
|
||||
isPreviewLoading ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
draggable={false}
|
||||
onLoad={() => {
|
||||
setLoadedPreviewUrl(previewUrl ?? null);
|
||||
setFailedPreviewUrl((current) =>
|
||||
current === (previewUrl ?? null) ? null : current,
|
||||
);
|
||||
}}
|
||||
onError={() => {
|
||||
setFailedPreviewUrl(previewUrl ?? null);
|
||||
}}
|
||||
/>
|
||||
<Badge variant="secondary" className="absolute top-2 left-2 h-4 py-0 text-[10px]">
|
||||
{data.assetType ?? "asset"}
|
||||
@@ -238,7 +230,6 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="h-3! w-3! border-2! border-background! bg-primary!"
|
||||
style={{ top: hasAsset && handleTop ? `${handleTop}px` : "50%" }}
|
||||
/>
|
||||
</BaseNodeWrapper>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useRef, type ReactNode } from "react";
|
||||
import { NodeResizeControl, type ShouldResize } from "@xyflow/react";
|
||||
import { NodeErrorBoundary } from "./node-error-boundary";
|
||||
|
||||
interface ResizeConfig {
|
||||
minWidth: number;
|
||||
minHeight: number;
|
||||
keepAspectRatio?: boolean;
|
||||
contentAware?: boolean;
|
||||
}
|
||||
|
||||
const RESIZE_CONFIGS: Record<string, ResizeConfig> = {
|
||||
frame: { minWidth: 200, minHeight: 150 },
|
||||
group: { minWidth: 150, minHeight: 100 },
|
||||
image: { minWidth: 100, minHeight: 80, keepAspectRatio: true },
|
||||
asset: { minWidth: 100, minHeight: 80, keepAspectRatio: true },
|
||||
"ai-image": { minWidth: 200, minHeight: 200 },
|
||||
compare: { minWidth: 300, minHeight: 200 },
|
||||
prompt: { minWidth: 240, minHeight: 200, contentAware: true },
|
||||
text: { minWidth: 180, minHeight: 80, contentAware: true },
|
||||
note: { minWidth: 160, minHeight: 80, contentAware: true },
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG: ResizeConfig = { minWidth: 80, minHeight: 50, contentAware: true };
|
||||
|
||||
const CORNERS = [
|
||||
"top-left",
|
||||
"top-right",
|
||||
"bottom-left",
|
||||
"bottom-right",
|
||||
] as const;
|
||||
|
||||
interface BaseNodeWrapperProps {
|
||||
nodeType: string;
|
||||
selected?: boolean;
|
||||
@@ -20,6 +49,9 @@ export default function BaseNodeWrapper({
|
||||
children,
|
||||
className = "",
|
||||
}: BaseNodeWrapperProps) {
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const config = RESIZE_CONFIGS[nodeType] ?? DEFAULT_CONFIG;
|
||||
|
||||
const statusStyles: Record<string, string> = {
|
||||
idle: "",
|
||||
analyzing: "border-yellow-400 animate-pulse",
|
||||
@@ -29,8 +61,35 @@ export default function BaseNodeWrapper({
|
||||
error: "border-red-500",
|
||||
};
|
||||
|
||||
const shouldResize: ShouldResize = useCallback(
|
||||
(event, params) => {
|
||||
if (!wrapperRef.current || !config.contentAware) return true;
|
||||
|
||||
const contentEl = wrapperRef.current;
|
||||
const paddingX =
|
||||
parseFloat(getComputedStyle(contentEl).paddingLeft) +
|
||||
parseFloat(getComputedStyle(contentEl).paddingRight);
|
||||
const paddingY =
|
||||
parseFloat(getComputedStyle(contentEl).paddingTop) +
|
||||
parseFloat(getComputedStyle(contentEl).paddingBottom);
|
||||
|
||||
const minW = Math.max(
|
||||
config.minWidth,
|
||||
contentEl.scrollWidth - paddingX + paddingX * 0.5,
|
||||
);
|
||||
const minH = Math.max(
|
||||
config.minHeight,
|
||||
contentEl.scrollHeight - paddingY + paddingY * 0.5,
|
||||
);
|
||||
|
||||
return params.width >= minW && params.height >= minH;
|
||||
},
|
||||
[config],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={`
|
||||
rounded-xl border bg-card shadow-sm transition-shadow
|
||||
${selected ? "ring-2 ring-primary shadow-md" : ""}
|
||||
@@ -38,6 +97,61 @@ export default function BaseNodeWrapper({
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{selected &&
|
||||
CORNERS.map((corner) => (
|
||||
<NodeResizeControl
|
||||
key={corner}
|
||||
position={corner}
|
||||
minWidth={config.minWidth}
|
||||
minHeight={config.minHeight}
|
||||
keepAspectRatio={config.keepAspectRatio}
|
||||
shouldResize={shouldResize}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
width: 12,
|
||||
height: 12,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
className="text-primary/70"
|
||||
>
|
||||
<path
|
||||
d={
|
||||
corner === "bottom-right"
|
||||
? "M11 5V11H5"
|
||||
: corner === "bottom-left"
|
||||
? "M1 5V11H7"
|
||||
: corner === "top-right"
|
||||
? "M11 7V1H5"
|
||||
: "M1 7V1H7"
|
||||
}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle
|
||||
cx={
|
||||
corner === "bottom-right" || corner === "top-right"
|
||||
? "11"
|
||||
: "1"
|
||||
}
|
||||
cy={
|
||||
corner === "bottom-right" || corner === "bottom-left"
|
||||
? "11"
|
||||
: "1"
|
||||
}
|
||||
r="1.5"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</NodeResizeControl>
|
||||
))}
|
||||
<NodeErrorBoundary nodeType={nodeType}>{children}</NodeErrorBoundary>
|
||||
{status === "error" && statusMessage && (
|
||||
<div className="px-3 pb-2 text-xs text-red-500 truncate">
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function CompareNode({ data, selected }: NodeProps) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BaseNodeWrapper nodeType="compare" selected={selected} className="w-[500px] 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
|
||||
@@ -89,7 +89,8 @@ export default function CompareNode({ data, selected }: NodeProps) {
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="nodrag relative h-[320px] w-[500px] select-none overflow-hidden rounded-b-xl bg-muted"
|
||||
className="nodrag relative w-full select-none overflow-hidden rounded-b-xl bg-muted"
|
||||
style={{ height: "100%" }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
>
|
||||
@@ -122,7 +123,7 @@ export default function CompareNode({ data, selected }: NodeProps) {
|
||||
src={nodeData.leftUrl}
|
||||
alt={nodeData.leftLabel ?? "Left"}
|
||||
className="absolute inset-0 h-full w-full object-contain"
|
||||
style={{ width: "500px", maxWidth: "none" }}
|
||||
style={{ width: "100%", maxWidth: "none" }}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { Handle, NodeResizer, Position, type NodeProps } from "@xyflow/react";
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import { useAction, useMutation } from "convex/react";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
@@ -78,8 +78,6 @@ export default function FrameNode({ id, data, selected, width, height }: NodePro
|
||||
selected={selected}
|
||||
className="relative h-full w-full border-2 border-dashed border-muted-foreground/40 !bg-transparent p-0 shadow-none"
|
||||
>
|
||||
<NodeResizer isVisible={selected} minWidth={200} minHeight={150} />
|
||||
|
||||
<div className="absolute -top-8 left-0 flex items-center gap-2">
|
||||
<input
|
||||
value={label}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
type ChangeEvent,
|
||||
type DragEvent,
|
||||
@@ -77,11 +76,9 @@ export default function ImageNode({
|
||||
const updateData = useMutation(api.nodes.updateData);
|
||||
const resizeNode = useMutation(api.nodes.resize);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const mediaRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [handleTop, setHandleTop] = useState<number | undefined>(undefined);
|
||||
const hasAutoSizedRef = useRef(false);
|
||||
|
||||
const aspectRatio = resolveMediaAspectRatio(data.width, data.height);
|
||||
|
||||
@@ -90,6 +87,9 @@ export default function ImageNode({
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasAutoSizedRef.current) return;
|
||||
hasAutoSizedRef.current = true;
|
||||
|
||||
const targetSize = computeMediaNodeSize("image", {
|
||||
intrinsicWidth: data.width,
|
||||
intrinsicHeight: data.height,
|
||||
@@ -106,39 +106,6 @@ export default function ImageNode({
|
||||
});
|
||||
}, [data.height, data.width, height, id, resizeNode, width]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!contentRef.current || !mediaRef.current) return;
|
||||
|
||||
const contentEl = contentRef.current;
|
||||
const mediaEl = mediaRef.current;
|
||||
let frameId: number | undefined;
|
||||
|
||||
const updateHandleTop = () => {
|
||||
if (frameId !== undefined) {
|
||||
cancelAnimationFrame(frameId);
|
||||
}
|
||||
frameId = requestAnimationFrame(() => {
|
||||
const contentRect = contentEl.getBoundingClientRect();
|
||||
const mediaRect = mediaEl.getBoundingClientRect();
|
||||
const nextTop = mediaRect.top - contentRect.top + mediaRect.height / 2;
|
||||
setHandleTop(nextTop);
|
||||
});
|
||||
};
|
||||
|
||||
updateHandleTop();
|
||||
|
||||
const observer = new ResizeObserver(updateHandleTop);
|
||||
observer.observe(contentEl);
|
||||
observer.observe(mediaEl);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (frameId !== undefined) {
|
||||
cancelAnimationFrame(frameId);
|
||||
}
|
||||
};
|
||||
}, [aspectRatio, data.filename, data.url, isDragOver, isUploading]);
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
|
||||
@@ -274,10 +241,9 @@ export default function ImageNode({
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="h-3! w-3! bg-primary! border-2! border-background!"
|
||||
style={{ top: handleTop ? `${handleTop}px` : "50%" }}
|
||||
/>
|
||||
|
||||
<div ref={contentRef} className="p-2">
|
||||
<div className="p-2">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-muted-foreground">🖼️ Bild</div>
|
||||
{data.url && (
|
||||
@@ -290,7 +256,7 @@ export default function ImageNode({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div ref={mediaRef} className="relative w-full overflow-hidden rounded-lg" style={{ aspectRatio }}>
|
||||
<div className="relative w-full overflow-hidden rounded-lg" style={{ aspectRatio }}>
|
||||
{isUploading ? (
|
||||
<div className="flex h-full w-full items-center justify-center bg-muted">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
@@ -349,7 +315,6 @@ export default function ImageNode({
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="h-3! w-3! bg-primary! border-2! border-background!"
|
||||
style={{ top: handleTop ? `${handleTop}px` : "50%" }}
|
||||
/>
|
||||
</BaseNodeWrapper>
|
||||
);
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function NoteNode({ id, data, selected }: NodeProps<NoteNode>) {
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseNodeWrapper nodeType="note" selected={selected} className="w-52 p-3">
|
||||
<BaseNodeWrapper nodeType="note" selected={selected} className="p-3">
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
|
||||
@@ -87,7 +87,7 @@ export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
|
||||
className="!h-3 !w-3 !bg-primary !border-2 !border-background"
|
||||
/>
|
||||
|
||||
<div className="w-64 p-3">
|
||||
<div className="w-full p-3">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
📝 Text
|
||||
</div>
|
||||
@@ -97,14 +97,14 @@ export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
|
||||
onChange={handleChange}
|
||||
onBlur={() => setIsEditing(false)}
|
||||
autoFocus
|
||||
className="nodrag nowheel w-full resize-none rounded-md border-0 bg-transparent p-0 text-sm outline-none focus:ring-0 min-h-[3rem]"
|
||||
className="nodrag nowheel w-full resize-none rounded-md border-0 bg-transparent p-0 text-sm outline-none focus:ring-0 min-h-[3rem] overflow-hidden"
|
||||
placeholder="Text eingeben…"
|
||||
rows={3}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
onDoubleClick={() => setIsEditing(true)}
|
||||
className="min-h-[2rem] cursor-text text-sm whitespace-pre-wrap"
|
||||
className="min-h-[2rem] cursor-text text-sm whitespace-pre-wrap overflow-wrap-break-word"
|
||||
>
|
||||
{content || (
|
||||
<span className="text-muted-foreground">
|
||||
|
||||
Reference in New Issue
Block a user