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 [totalPages, setTotalPages] = useState(initialState?.totalPages ?? 1);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [selectingAssetKey, setSelectingAssetKey] = useState<string | null>(null);
|
||||||
|
|
||||||
const searchFreepik = useAction(api.freepik.search);
|
const searchFreepik = useAction(api.freepik.search);
|
||||||
const updateData = useMutation(api.nodes.updateData);
|
const updateData = useMutation(api.nodes.updateData);
|
||||||
const resizeNode = useMutation(api.nodes.resize);
|
const resizeNode = useMutation(api.nodes.resize);
|
||||||
const shouldSkipInitialSearchRef = useRef(Boolean(initialState?.results?.length));
|
const shouldSkipInitialSearchRef = useRef(Boolean(initialState?.results?.length));
|
||||||
|
const requestSequenceRef = useRef(0);
|
||||||
|
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const isSelecting = selectingAssetKey !== null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsMounted(true);
|
setIsMounted(true);
|
||||||
@@ -102,6 +106,7 @@ export function AssetBrowserPanel({
|
|||||||
const runSearch = useCallback(
|
const runSearch = useCallback(
|
||||||
async (searchTerm: string, type: AssetType, requestedPage: number) => {
|
async (searchTerm: string, type: AssetType, requestedPage: number) => {
|
||||||
const cleanedTerm = searchTerm.trim();
|
const cleanedTerm = searchTerm.trim();
|
||||||
|
const requestSequence = ++requestSequenceRef.current;
|
||||||
if (!cleanedTerm) {
|
if (!cleanedTerm) {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
@@ -121,17 +126,30 @@ export function AssetBrowserPanel({
|
|||||||
limit: 20,
|
limit: 20,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (requestSequence !== requestSequenceRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setResults(response.results);
|
setResults(response.results);
|
||||||
setTotalPages(response.totalPages);
|
setTotalPages(response.totalPages);
|
||||||
setPage(response.currentPage);
|
setPage(response.currentPage);
|
||||||
|
|
||||||
|
if (scrollAreaRef.current) {
|
||||||
|
scrollAreaRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestSequence !== requestSequenceRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error("Freepik search error", error);
|
console.error("Freepik search error", error);
|
||||||
setErrorMessage(
|
setErrorMessage(
|
||||||
error instanceof Error ? error.message : "Freepik search failed",
|
error instanceof Error ? error.message : "Freepik search failed",
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
if (requestSequence === requestSequenceRef.current) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[searchFreepik],
|
[searchFreepik],
|
||||||
);
|
);
|
||||||
@@ -147,6 +165,10 @@ export function AssetBrowserPanel({
|
|||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
async (asset: FreepikResult) => {
|
async (asset: FreepikResult) => {
|
||||||
|
if (isSelecting) return;
|
||||||
|
const assetKey = `${asset.assetType}-${asset.id}`;
|
||||||
|
setSelectingAssetKey(assetKey);
|
||||||
|
try {
|
||||||
await updateData({
|
await updateData({
|
||||||
nodeId: nodeId as Id<"nodes">,
|
nodeId: nodeId as Id<"nodes">,
|
||||||
data: {
|
data: {
|
||||||
@@ -177,8 +199,13 @@ export function AssetBrowserPanel({
|
|||||||
height: targetSize.height,
|
height: targetSize.height,
|
||||||
});
|
});
|
||||||
onClose();
|
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(() => {
|
const handlePreviousPage = useCallback(() => {
|
||||||
@@ -204,6 +231,9 @@ export function AssetBrowserPanel({
|
|||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
onWheelCapture={(event) => event.stopPropagation()}
|
onWheelCapture={(event) => event.stopPropagation()}
|
||||||
onPointerDownCapture={(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">
|
<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>
|
<h2 className="text-sm font-semibold">Browse Freepik Assets</h2>
|
||||||
@@ -244,6 +274,7 @@ export function AssetBrowserPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
ref={scrollAreaRef}
|
||||||
className="nowheel nodrag nopan flex-1 overflow-y-auto p-5"
|
className="nowheel nodrag nopan flex-1 overflow-y-auto p-5"
|
||||||
onWheelCapture={(event) => event.stopPropagation()}
|
onWheelCapture={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
@@ -258,6 +289,14 @@ export function AssetBrowserPanel({
|
|||||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||||
<p className="text-sm text-foreground">Search failed</p>
|
<p className="text-sm text-foreground">Search failed</p>
|
||||||
<p className="max-w-md text-xs">{errorMessage}</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>
|
</div>
|
||||||
) : results.length === 0 ? (
|
) : results.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center text-muted-foreground">
|
<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">
|
<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
|
<button
|
||||||
key={`${asset.assetType}-${asset.id}`}
|
key={assetKey}
|
||||||
onClick={() => void handleSelect(asset)}
|
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"
|
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}
|
title={asset.title}
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={isSelecting}
|
||||||
|
aria-busy={isSelectingThisAsset}
|
||||||
|
aria-label={`Select asset: ${asset.title}`}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
@@ -296,8 +342,15 @@ export function AssetBrowserPanel({
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/20" />
|
<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>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
@@ -306,7 +359,7 @@ export function AssetBrowserPanel({
|
|||||||
|
|
||||||
<div className="flex shrink-0 flex-col gap-3 border-t px-5 py-3">
|
<div className="flex shrink-0 flex-col gap-3 border-t px-5 py-3">
|
||||||
{results.length > 0 ? (
|
{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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -363,10 +416,14 @@ export function AssetBrowserPanel({
|
|||||||
handleNextPage,
|
handleNextPage,
|
||||||
handlePreviousPage,
|
handlePreviousPage,
|
||||||
handleSelect,
|
handleSelect,
|
||||||
|
debouncedTerm,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isSelecting,
|
||||||
onClose,
|
onClose,
|
||||||
page,
|
page,
|
||||||
results,
|
results,
|
||||||
|
runSearch,
|
||||||
|
selectingAssetKey,
|
||||||
term,
|
term,
|
||||||
totalPages,
|
totalPages,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -410,9 +410,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
if (change.type !== "dimensions") continue;
|
if (change.type !== "dimensions") continue;
|
||||||
if (change.resizing !== false || !change.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({
|
void resizeNode({
|
||||||
nodeId: change.id as Id<"nodes">,
|
nodeId: change.id as Id<"nodes">,
|
||||||
width: change.dimensions.width,
|
width: change.dimensions.width,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type MouseEvent,
|
type MouseEvent,
|
||||||
@@ -42,7 +41,8 @@ export type AssetNodeType = Node<AssetNodeData, "asset">;
|
|||||||
|
|
||||||
export default function AssetNode({ id, data, selected, width, height }: NodeProps<AssetNodeType>) {
|
export default function AssetNode({ id, data, selected, width, height }: NodeProps<AssetNodeType>) {
|
||||||
const [panelOpen, setPanelOpen] = useState(false);
|
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>({
|
const [browserState, setBrowserState] = useState<AssetBrowserSessionState>({
|
||||||
term: "",
|
term: "",
|
||||||
assetType: "photo",
|
assetType: "photo",
|
||||||
@@ -51,19 +51,25 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
|||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
});
|
});
|
||||||
const resizeNode = useMutation(api.nodes.resize);
|
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 hasAsset = typeof data.assetId === "number";
|
||||||
const previewUrl = data.url ?? data.previewUrl;
|
const previewUrl = data.url ?? data.previewUrl;
|
||||||
|
const isPreviewLoading = Boolean(
|
||||||
|
previewUrl && previewUrl !== loadedPreviewUrl && previewUrl !== failedPreviewUrl,
|
||||||
|
);
|
||||||
|
const previewLoadError = Boolean(previewUrl && previewUrl === failedPreviewUrl);
|
||||||
const aspectRatio = resolveMediaAspectRatio(
|
const aspectRatio = resolveMediaAspectRatio(
|
||||||
data.intrinsicWidth,
|
data.intrinsicWidth,
|
||||||
data.intrinsicHeight,
|
data.intrinsicHeight,
|
||||||
data.orientation,
|
data.orientation,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasAutoSizedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasAsset) return;
|
if (!hasAsset) return;
|
||||||
|
if (hasAutoSizedRef.current) return;
|
||||||
|
hasAutoSizedRef.current = true;
|
||||||
|
|
||||||
const targetSize = computeMediaNodeSize("asset", {
|
const targetSize = computeMediaNodeSize("asset", {
|
||||||
intrinsicWidth: data.intrinsicWidth,
|
intrinsicWidth: data.intrinsicWidth,
|
||||||
@@ -91,39 +97,6 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
|||||||
width,
|
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>) => {
|
const stopNodeClickPropagation = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
};
|
};
|
||||||
@@ -140,10 +113,9 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
|||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
className="h-3! w-3! border-2! border-background! bg-primary!"
|
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">
|
<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
|
||||||
@@ -162,16 +134,36 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
|||||||
{hasAsset && previewUrl ? (
|
{hasAsset && previewUrl ? (
|
||||||
<div className="flex flex-col gap-0">
|
<div className="flex flex-col gap-0">
|
||||||
<div
|
<div
|
||||||
ref={mediaRef}
|
|
||||||
className="relative overflow-hidden bg-muted/30"
|
className="relative overflow-hidden bg-muted/30"
|
||||||
style={{ aspectRatio }}
|
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 */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={previewUrl}
|
src={previewUrl}
|
||||||
alt={data.title ?? "Asset preview"}
|
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}
|
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]">
|
<Badge variant="secondary" className="absolute top-2 left-2 h-4 py-0 text-[10px]">
|
||||||
{data.assetType ?? "asset"}
|
{data.assetType ?? "asset"}
|
||||||
@@ -238,7 +230,6 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
|||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
className="h-3! w-3! border-2! border-background! bg-primary!"
|
className="h-3! w-3! border-2! border-background! bg-primary!"
|
||||||
style={{ top: hasAsset && handleTop ? `${handleTop}px` : "50%" }}
|
|
||||||
/>
|
/>
|
||||||
</BaseNodeWrapper>
|
</BaseNodeWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,37 @@
|
|||||||
"use client";
|
"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";
|
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 {
|
interface BaseNodeWrapperProps {
|
||||||
nodeType: string;
|
nodeType: string;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
@@ -20,6 +49,9 @@ export default function BaseNodeWrapper({
|
|||||||
children,
|
children,
|
||||||
className = "",
|
className = "",
|
||||||
}: BaseNodeWrapperProps) {
|
}: BaseNodeWrapperProps) {
|
||||||
|
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const config = RESIZE_CONFIGS[nodeType] ?? DEFAULT_CONFIG;
|
||||||
|
|
||||||
const statusStyles: Record<string, string> = {
|
const statusStyles: Record<string, string> = {
|
||||||
idle: "",
|
idle: "",
|
||||||
analyzing: "border-yellow-400 animate-pulse",
|
analyzing: "border-yellow-400 animate-pulse",
|
||||||
@@ -29,8 +61,35 @@ export default function BaseNodeWrapper({
|
|||||||
error: "border-red-500",
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={wrapperRef}
|
||||||
className={`
|
className={`
|
||||||
rounded-xl border bg-card shadow-sm transition-shadow
|
rounded-xl border bg-card shadow-sm transition-shadow
|
||||||
${selected ? "ring-2 ring-primary shadow-md" : ""}
|
${selected ? "ring-2 ring-primary shadow-md" : ""}
|
||||||
@@ -38,6 +97,61 @@ export default function BaseNodeWrapper({
|
|||||||
${className}
|
${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>
|
<NodeErrorBoundary nodeType={nodeType}>{children}</NodeErrorBoundary>
|
||||||
{status === "error" && statusMessage && (
|
{status === "error" && statusMessage && (
|
||||||
<div className="px-3 pb-2 text-xs text-red-500 truncate">
|
<div className="px-3 pb-2 text-xs text-red-500 truncate">
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default function CompareNode({ data, selected }: NodeProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
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>
|
<div className="px-3 py-2 text-xs font-medium text-muted-foreground">⚖️ Compare</div>
|
||||||
|
|
||||||
<Handle
|
<Handle
|
||||||
@@ -89,7 +89,8 @@ export default function CompareNode({ data, selected }: NodeProps) {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
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}
|
onMouseDown={handleMouseDown}
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
>
|
>
|
||||||
@@ -122,7 +123,7 @@ export default function CompareNode({ data, selected }: NodeProps) {
|
|||||||
src={nodeData.leftUrl}
|
src={nodeData.leftUrl}
|
||||||
alt={nodeData.leftLabel ?? "Left"}
|
alt={nodeData.leftLabel ?? "Left"}
|
||||||
className="absolute inset-0 h-full w-full object-contain"
|
className="absolute inset-0 h-full w-full object-contain"
|
||||||
style={{ width: "500px", maxWidth: "none" }}
|
style={{ width: "100%", maxWidth: "none" }}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
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 { useAction, useMutation } from "convex/react";
|
||||||
import { Download, Loader2 } from "lucide-react";
|
import { Download, Loader2 } from "lucide-react";
|
||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
@@ -78,8 +78,6 @@ export default function FrameNode({ id, data, selected, width, height }: NodePro
|
|||||||
selected={selected}
|
selected={selected}
|
||||||
className="relative h-full w-full border-2 border-dashed border-muted-foreground/40 !bg-transparent p-0 shadow-none"
|
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">
|
<div className="absolute -top-8 left-0 flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
value={label}
|
value={label}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
|
||||||
useRef,
|
useRef,
|
||||||
type ChangeEvent,
|
type ChangeEvent,
|
||||||
type DragEvent,
|
type DragEvent,
|
||||||
@@ -77,11 +76,9 @@ export default function ImageNode({
|
|||||||
const updateData = useMutation(api.nodes.updateData);
|
const updateData = useMutation(api.nodes.updateData);
|
||||||
const resizeNode = useMutation(api.nodes.resize);
|
const resizeNode = useMutation(api.nodes.resize);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const mediaRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [isDragOver, setIsDragOver] = 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);
|
const aspectRatio = resolveMediaAspectRatio(data.width, data.height);
|
||||||
|
|
||||||
@@ -90,6 +87,9 @@ export default function ImageNode({
|
|||||||
return;
|
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,
|
||||||
@@ -106,39 +106,6 @@ export default function ImageNode({
|
|||||||
});
|
});
|
||||||
}, [data.height, data.width, height, id, resizeNode, width]);
|
}, [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(
|
const uploadFile = useCallback(
|
||||||
async (file: File) => {
|
async (file: File) => {
|
||||||
if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
|
if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
|
||||||
@@ -274,10 +241,9 @@ export default function ImageNode({
|
|||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
className="h-3! w-3! bg-primary! border-2! border-background!"
|
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="mb-1 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 && (
|
||||||
@@ -290,7 +256,7 @@ export default function ImageNode({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 ? (
|
{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">
|
||||||
@@ -349,7 +315,6 @@ export default function ImageNode({
|
|||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
className="h-3! w-3! bg-primary! border-2! border-background!"
|
className="h-3! w-3! bg-primary! border-2! border-background!"
|
||||||
style={{ top: handleTop ? `${handleTop}px` : "50%" }}
|
|
||||||
/>
|
/>
|
||||||
</BaseNodeWrapper>
|
</BaseNodeWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export default function NoteNode({ id, data, selected }: NodeProps<NoteNode>) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseNodeWrapper nodeType="note" selected={selected} className="w-52 p-3">
|
<BaseNodeWrapper nodeType="note" selected={selected} className="p-3">
|
||||||
<Handle
|
<Handle
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
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"
|
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">
|
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||||
📝 Text
|
📝 Text
|
||||||
</div>
|
</div>
|
||||||
@@ -97,14 +97,14 @@ export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onBlur={() => setIsEditing(false)}
|
onBlur={() => setIsEditing(false)}
|
||||||
autoFocus
|
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…"
|
placeholder="Text eingeben…"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
onDoubleClick={() => setIsEditing(true)}
|
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 || (
|
{content || (
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
|
|||||||
Reference in New Issue
Block a user