Files
infinite-canvas/web/src/app/(user)/canvas/components/canvas-node.tsx
T
HouYunFei 030541b99d feat(canvas): 添加画布图片信息显示功能
- 在画布项目状态中添加 showImageInfo 属性
- 实现图片信息栏组件显示图片尺寸和文件大小
- 添加画布工具栏开关控制图片信息显示
- 将图片信息状态集成到项目历史记录和持久化
- 更新画布节点组件支持图片信息显示功能
2026-05-25 15:51:14 +08:00

622 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { ChevronRight, Image as ImageIcon, RefreshCw, Star, Video } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { formatBytes } from "@/lib/image-utils";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData, type Position } from "../types";
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
const selectionBlue = "#2f80ff";
type CanvasNodeProps = {
data: CanvasNodeData;
scale: number;
isSelected: boolean;
isRelated: boolean;
isFocusRelated: boolean;
isConnectionTarget: boolean;
isConnecting: boolean;
editRequestNonce?: number;
showPanel: boolean;
showImageInfo: boolean;
renderPanel?: (node: CanvasNodeData) => ReactNode;
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
batchCount?: number;
batchExpanded?: boolean;
batchClosing?: boolean;
batchOpening?: boolean;
batchRecovering?: boolean;
batchMotion?: { x: number; y: number; index: number };
onMouseDown: (event: React.MouseEvent, nodeId: string) => void;
onHoverStart: (nodeId: string) => void;
onHoverEnd: (nodeId: string) => void;
onConnectStart: (event: React.MouseEvent, nodeId: string, handleType: "source" | "target") => void;
onResize: (nodeId: string, width: number, height: number, position?: Position) => void;
onContentChange: (nodeId: string, content: string) => void;
onToggleBatch?: (nodeId: string) => void;
onSetBatchPrimary?: (node: CanvasNodeData) => void;
onRetry?: (node: CanvasNodeData) => void;
onGenerateImage?: (node: CanvasNodeData) => void;
onContextMenu: (event: React.MouseEvent, nodeId: string) => void;
};
type NodeContentRendererProps = {
node: CanvasNodeData;
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
isEditingContent: boolean;
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
isBatchRoot: boolean;
batchCount: number;
batchExpanded: boolean;
batchOpening: boolean;
batchRecovering: boolean;
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
onContentChange: (nodeId: string, content: string) => void;
onStopEditing: () => void;
onRetry?: (node: CanvasNodeData) => void;
onGenerateImage?: (node: CanvasNodeData) => void;
onToggleBatch?: () => void;
onSetBatchPrimary?: () => void;
};
export const CanvasNode = React.memo(function CanvasNode({
data,
scale,
isSelected,
isRelated,
isFocusRelated,
isConnectionTarget,
isConnecting,
editRequestNonce = 0,
showPanel,
showImageInfo,
renderPanel,
renderNodeContent,
batchCount = 0,
batchExpanded = false,
batchClosing = false,
batchOpening = false,
batchRecovering = false,
batchMotion,
onMouseDown,
onHoverStart,
onHoverEnd,
onConnectStart,
onResize,
onContentChange,
onToggleBatch,
onSetBatchPrimary,
onRetry,
onGenerateImage,
onContextMenu,
}: CanvasNodeProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [hovered, setHovered] = useState(false);
const [isEditingContent, setIsEditingContent] = useState(false);
const hasImageContent = data.type === CanvasNodeType.Image && Boolean(data.metadata?.content);
const hasVideoContent = data.type === CanvasNodeType.Video && Boolean(data.metadata?.content);
const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1;
const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId);
const isActive = isConnectionTarget || isSelected || isFocusRelated;
const imageBorderColor = isActive ? selectionBlue : isRelated && !isBatchChild ? theme.node.muted : "transparent";
const textareaRef = useRef<HTMLTextAreaElement>(null);
const resizeRef = useRef({
isResizing: false,
corner: "bottom-right" as ResizeCorner,
startX: 0,
startY: 0,
startLeft: 0,
startTop: 0,
startWidth: 0,
startHeight: 0,
keepRatio: false,
ratio: 1,
});
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const handleWheel = (event: WheelEvent) => event.stopPropagation();
textarea.addEventListener("wheel", handleWheel, { passive: false });
return () => textarea.removeEventListener("wheel", handleWheel);
}, [data.type, isEditingContent]);
useEffect(() => {
if (!isEditingContent) return;
const textarea = textareaRef.current;
textarea?.focus();
textarea?.setSelectionRange(textarea.value.length, textarea.value.length);
}, [isEditingContent]);
useEffect(() => {
if (!editRequestNonce || data.type !== CanvasNodeType.Text) return;
setIsEditingContent(true);
}, [data.type, editRequestNonce]);
useEffect(() => {
if (!isEditingContent) return;
const handleOutsidePointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (isEditingContent && textareaRef.current?.contains(target)) return;
setIsEditingContent(false);
};
window.addEventListener("pointerdown", handleOutsidePointerDown, true);
return () => window.removeEventListener("pointerdown", handleOutsidePointerDown, true);
}, [isEditingContent]);
const handleResizeMove = useCallback(
(event: MouseEvent) => {
if (!resizeRef.current.isResizing) return;
const dx = (event.clientX - resizeRef.current.startX) / scale;
const dy = (event.clientY - resizeRef.current.startY) / scale;
const minWidth = 220;
const minHeight = 160;
const startRight = resizeRef.current.startLeft + resizeRef.current.startWidth;
const startBottom = resizeRef.current.startTop + resizeRef.current.startHeight;
const fromLeft = resizeRef.current.corner.includes("left");
const fromTop = resizeRef.current.corner.includes("top");
const rawWidth = Math.max(minWidth, resizeRef.current.startWidth + (fromLeft ? -dx : dx));
const rawHeight = Math.max(minHeight, resizeRef.current.startHeight + (fromTop ? -dy : dy));
let width = rawWidth;
let height = rawHeight;
if (resizeRef.current.keepRatio) {
const ratio = resizeRef.current.ratio;
if (Math.abs(dx) >= Math.abs(dy)) {
height = width / ratio;
} else {
width = height * ratio;
}
if (height < minHeight) {
height = minHeight;
width = height * ratio;
}
if (width < minWidth) {
width = minWidth;
height = width / ratio;
}
}
onResize(data.id, width, height, {
x: fromLeft ? startRight - width : resizeRef.current.startLeft,
y: fromTop ? startBottom - height : resizeRef.current.startTop,
});
},
[data.id, onResize, scale],
);
const handleResizeUp = useCallback(() => {
resizeRef.current.isResizing = false;
window.removeEventListener("mousemove", handleResizeMove);
window.removeEventListener("mouseup", handleResizeUp);
}, [handleResizeMove]);
const handleResizeMouseDown = (event: React.MouseEvent, corner: ResizeCorner) => {
event.stopPropagation();
event.preventDefault();
resizeRef.current = {
isResizing: true,
corner,
startX: event.clientX,
startY: event.clientY,
startLeft: data.position.x,
startTop: data.position.y,
startWidth: data.width,
startHeight: data.height,
keepRatio: (data.type === CanvasNodeType.Image && !data.metadata?.freeResize) || data.type === CanvasNodeType.Video,
ratio: (data.metadata?.naturalWidth || data.width) / (data.metadata?.naturalHeight || data.height || 1),
};
window.addEventListener("mousemove", handleResizeMove);
window.addEventListener("mouseup", handleResizeUp);
};
useEffect(() => {
return () => {
window.removeEventListener("mousemove", handleResizeMove);
window.removeEventListener("mouseup", handleResizeUp);
};
}, [handleResizeMove, handleResizeUp]);
return (
<div
data-node-id={data.id}
className={`node-element absolute flex select-none flex-col transition-shadow duration-200 ${isSelected ? "z-50" : "z-10"}`}
style={{
transform: `translate(${data.position.x}px, ${data.position.y}px)`,
width: data.width,
height: data.height,
transition: "box-shadow 200ms ease",
contain: "layout style",
}}
onMouseEnter={() => {
setHovered(true);
onHoverStart(data.id);
}}
onMouseLeave={() => {
setHovered(false);
onHoverEnd(data.id);
}}
onContextMenu={(event) => onContextMenu(event, data.id)}
>
<div
className="relative h-full w-full overflow-visible rounded-3xl border-2"
style={{
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
borderColor: hasImageContent ? imageBorderColor : isActive ? selectionBlue : isRelated ? theme.node.muted : theme.node.stroke,
boxShadow: isActive ? `0 0 0 1px ${selectionBlue}55` : isRelated && !isBatchChild ? `0 0 0 1px ${theme.node.muted}55, 0 18px 48px rgba(0,0,0,.14)` : undefined,
}}
onMouseDown={(event) => onMouseDown(event, data.id)}
onDoubleClick={(event) => {
if (isBatchRoot) {
event.stopPropagation();
onToggleBatch?.(data.id);
return;
}
if (data.type !== CanvasNodeType.Text) return;
event.stopPropagation();
setIsEditingContent(true);
}}
>
<div
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot ? "overflow-visible" : "overflow-hidden"}`}
style={
{
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
"--batch-from-x": `${batchMotion?.x || 0}px`,
"--batch-from-y": `${batchMotion?.y || 0}px`,
"--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`,
animation: data.metadata?.batchRootId ? (batchClosing ? "canvas-batch-child-out 260ms cubic-bezier(.4,0,.2,1) both" : "canvas-batch-child-in 340ms cubic-bezier(.2,.85,.18,1) both") : undefined,
animationDelay: data.metadata?.batchRootId ? `${batchClosing ? 0 : 45 + (batchMotion?.index || 0) * 24}ms` : undefined,
} as React.CSSProperties
}
>
<NodeContent
node={data}
theme={theme}
isEditingContent={isEditingContent}
textareaRef={textareaRef}
isBatchRoot={isBatchRoot}
batchCount={batchCount}
batchExpanded={batchExpanded}
batchOpening={batchOpening}
batchRecovering={batchRecovering}
renderNodeContent={renderNodeContent}
onContentChange={onContentChange}
onStopEditing={() => setIsEditingContent(false)}
onRetry={onRetry}
onGenerateImage={onGenerateImage}
onToggleBatch={() => onToggleBatch?.(data.id)}
onSetBatchPrimary={() => onSetBatchPrimary?.(data)}
/>
</div>
{showImageInfo && hasImageContent ? <ImageInfoBar node={data} /> : null}
{!hasImageContent && !hasVideoContent ? <div className="pointer-events-none absolute inset-x-0 bottom-0 h-12" style={{ background: `linear-gradient(to top, ${theme.canvas.background}66, transparent)` }} /> : null}
<ResizeHandle corner="top-left" onMouseDown={handleResizeMouseDown} />
<ResizeHandle corner="top-right" onMouseDown={handleResizeMouseDown} />
<ResizeHandle corner="bottom-left" onMouseDown={handleResizeMouseDown} />
<ResizeHandle corner="bottom-right" onMouseDown={handleResizeMouseDown} />
</div>
<ConnectionHandleDot side="left" visible={hovered || isSelected || isConnecting} onMouseDown={(event) => onConnectStart(event, data.id, "target")} />
<ConnectionHandleDot side="right" visible={data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} />
{showPanel && renderPanel && data.type !== CanvasNodeType.Config ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
</div>
);
});
function NodeContent(props: NodeContentRendererProps) {
if (props.node.type === CanvasNodeType.Config && props.renderNodeContent) return props.renderNodeContent(props.node);
if (props.isBatchRoot) return <ImageNodeContent {...props} />;
if (props.node.metadata?.status === "loading") return <LoadingContent theme={props.theme} />;
if (props.node.metadata?.status === "error") return <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />;
const Renderer = nodeContentRenderers[props.node.type];
return <Renderer {...props} />;
}
const nodeContentRenderers = {
[CanvasNodeType.Text]: TextContent,
[CanvasNodeType.Image]: ImageNodeContent,
[CanvasNodeType.Config]: EmptyImageContent,
[CanvasNodeType.Video]: VideoNodeContent,
} satisfies Record<CanvasNodeType, (props: NodeContentRendererProps) => ReactNode>;
function LoadingContent({ theme }: Pick<NodeContentRendererProps, "theme">) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.activeStroke }}>
<div className="size-10 animate-spin rounded-full border-2" style={{ borderColor: theme.node.stroke, borderTopColor: theme.node.activeStroke }} />
<span className="text-[10px] tracking-[0.2em]"></span>
</div>
);
}
function ErrorContent({ node, theme, onRetry }: Pick<NodeContentRendererProps, "node" | "theme" | "onRetry">) {
return (
<div className="flex max-w-[260px] flex-col items-center gap-3 px-5 text-center">
<div className="text-xs leading-5 text-red-300">{node.metadata?.errorDetails || "生成失败"}</div>
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-xs font-medium transition hover:scale-[1.02]"
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
onClick={(event) => {
event.stopPropagation();
onRetry?.(node);
}}
onMouseDown={(event) => event.stopPropagation()}
>
<RefreshCw className="size-3.5" />
</button>
</div>
);
}
function TextContent({ node, theme, isEditingContent, textareaRef, onContentChange, onStopEditing, onGenerateImage }: NodeContentRendererProps) {
return (
<div className="flex h-full w-full flex-col overflow-hidden pt-8">
<button
type="button"
className="absolute right-3 top-3 z-20 inline-flex h-8 items-center gap-1 rounded-full border px-2.5 text-xs font-medium opacity-85 backdrop-blur-md transition hover:scale-[1.02] hover:opacity-100"
style={{ background: `${theme.toolbar.panel}dd`, borderColor: theme.node.stroke, color: theme.node.text }}
onClick={(event) => {
event.stopPropagation();
onGenerateImage?.(node);
}}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
title="用文本生图"
aria-label="用文本生图"
>
<ImageIcon className="size-3.5" />
</button>
{isEditingContent ? (
<textarea
ref={textareaRef}
className="thin-scrollbar block h-full w-full resize-none overflow-y-auto whitespace-pre-wrap break-words border-none bg-transparent pl-4 pr-14 pt-0 pb-4 m-0 font-mono leading-relaxed outline-none select-text appearance-none"
style={{ fontSize: `${node.metadata?.fontSize || 14}px`, color: theme.node.text }}
value={node.metadata?.content || ""}
onChange={(event) => onContentChange(node.id, event.target.value)}
onBlur={onStopEditing}
onKeyDown={(event) => {
if (event.key === "Escape") onStopEditing();
}}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
/>
) : (
<div
className="thin-scrollbar block h-full w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent pl-4 pr-14 pt-0 pb-4 font-mono leading-relaxed"
style={{ fontSize: `${node.metadata?.fontSize || 14}px`, color: theme.node.text }}
onWheel={(event) => event.stopPropagation()}
>
{node.metadata?.content || <span style={{ color: theme.node.placeholder }}></span>}
</div>
)}
</div>
);
}
function ImageNodeContent(props: NodeContentRendererProps) {
if (!props.node.metadata?.content && props.isBatchRoot) {
const content =
props.node.metadata?.status === "loading" ? (
<LoadingContent theme={props.theme} />
) : props.node.metadata?.status === "error" ? (
<ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />
) : (
<EmptyImageContent {...props} isBatchRoot={false} />
);
return (
<BatchFrame batchCount={props.batchCount} batchExpanded={props.batchExpanded} batchOpening={props.batchOpening} batchRecovering={props.batchRecovering} onToggleBatch={props.onToggleBatch}>
{content}
</BatchFrame>
);
}
if (!props.node.metadata?.content) return <EmptyImageContent {...props} />;
return (
<ImageContent
node={props.node}
isBatchRoot={props.isBatchRoot}
batchCount={props.batchCount}
batchExpanded={props.batchExpanded}
batchOpening={props.batchOpening}
batchRecovering={props.batchRecovering}
onToggleBatch={props.onToggleBatch}
onSetBatchPrimary={props.onSetBatchPrimary}
/>
);
}
function EmptyImageContent({ theme, isBatchRoot, batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch }: NodeContentRendererProps) {
const content = (
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.placeholder }}>
<div className="flex size-14 items-center justify-center rounded-2xl" style={{ background: theme.toolbar.activeBg }}>
<ImageIcon className="size-6 opacity-30" />
</div>
<span className="text-[10px] tracking-[0.18em] opacity-50"></span>
</div>
);
if (isBatchRoot)
return (
<BatchFrame batchCount={batchCount} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} onToggleBatch={onToggleBatch}>
{content}
</BatchFrame>
);
return content;
}
function VideoNodeContent({ node, theme }: NodeContentRendererProps) {
if (!node.metadata?.content)
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.placeholder }}>
<Video className="size-7 opacity-35" />
<span className="text-sm">(使)</span>
</div>
);
return <video src={node.metadata.content} controls className="h-full w-full rounded-[18px] bg-black object-contain" data-canvas-no-zoom />;
}
function ImageContent({
node,
isBatchRoot,
batchCount,
batchExpanded,
batchOpening,
batchRecovering,
onToggleBatch,
onSetBatchPrimary,
}: {
node: CanvasNodeData;
isBatchRoot: boolean;
batchCount: number;
batchExpanded: boolean;
batchOpening: boolean;
batchRecovering: boolean;
onToggleBatch?: () => void;
onSetBatchPrimary?: () => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const isBatchChild = Boolean(node.metadata?.batchRootId);
return (
<BatchFrame batchCount={isBatchRoot ? batchCount : 0} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} onToggleBatch={onToggleBatch}>
<div className="h-full w-full overflow-hidden rounded-3xl">
<img
src={node.metadata!.content!}
alt={node.title}
draggable={false}
onDragStart={(event) => event.preventDefault()}
className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`}
/>
</div>
{isBatchRoot ? (
<button
type="button"
className="absolute right-2.5 top-2.5 z-30 flex h-8 items-center justify-center gap-1 rounded-full border px-2.5 text-xs font-semibold shadow-[0_6px_18px_rgba(15,23,42,.10)] backdrop-blur-md transition hover:scale-[1.02]"
style={{ background: `${theme.toolbar.panel}d9`, borderColor: `${theme.toolbar.border}cc`, color: theme.node.text }}
aria-label={batchExpanded ? "图片组已展开" : "图片组已收起"}
onClick={(event) => {
event.stopPropagation();
onToggleBatch?.();
}}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<span className="leading-none text-[#2f80ff]">{batchCount}</span>
<ChevronRight className={`size-3.5 opacity-55 transition-transform ${batchExpanded ? "rotate-90" : ""}`} />
</button>
) : null}
{isBatchChild ? (
<button
type="button"
className="absolute right-3 top-3 z-30 flex h-9 items-center gap-1.5 rounded-xl border px-2.5 text-xs font-medium opacity-0 shadow-[0_8px_20px_rgba(68,64,60,.13)] backdrop-blur-md transition group-hover/batch:opacity-100 hover:scale-[1.02]"
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
onClick={(event) => {
event.stopPropagation();
onSetBatchPrimary?.();
}}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<Star className="size-3.5 text-[#2f80ff]" />
</button>
) : null}
</BatchFrame>
);
}
function ImageInfoBar({ node }: { node: CanvasNodeData }) {
const width = Math.round(node.metadata?.naturalWidth || node.width);
const height = Math.round(node.metadata?.naturalHeight || node.height);
const size = formatBytes(node.metadata?.bytes || 0);
return (
<div className="pointer-events-none absolute bottom-3 right-3 z-40 max-w-[calc(100%-24px)]">
<span className="max-w-full truncate rounded-md bg-black/55 px-2 py-1 text-[11px] font-medium leading-none text-white backdrop-blur-sm">
{width} x {height}
{size ? ` · ${size}` : ""}
</span>
</div>
);
}
function BatchFrame({ batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch, children }: { batchCount: number; batchExpanded: boolean; batchOpening: boolean; batchRecovering: boolean; onToggleBatch?: () => void; children: ReactNode }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const isBatchRoot = batchCount > 1;
return (
<div
className="group/batch relative h-full w-full overflow-visible"
onDoubleClick={
isBatchRoot
? (event) => {
event.stopPropagation();
onToggleBatch?.();
}
: undefined
}
>
{isBatchRoot ? (
<div className="pointer-events-none absolute inset-0 overflow-visible">
{Array.from({ length: Math.min(batchCount - 1, 5) }).map((_, index) => (
<div
key={index}
className="absolute rounded-[inherit] border shadow-[0_14px_34px_rgba(68,64,60,.16)] transition-all duration-300 group-hover/batch:translate-x-2"
style={{
inset: 0,
background: `linear-gradient(135deg, ${theme.node.panel}, ${theme.node.fill})`,
borderColor: theme.node.stroke,
opacity: batchExpanded && !batchOpening ? 0.34 : 1,
transform:
batchOpening || batchRecovering ? `translate(${54 + index * 22}px, ${20 + index * 12}px) rotate(${8 + index * 5}deg) scale(.98)` : `translate(${34 + index * 18}px, ${14 + index * 10}px) rotate(${6 + index * 4}deg)`,
zIndex: -index - 1,
}}
/>
))}
</div>
) : null}
{children}
</div>
);
}
function ResizeHandle({ corner, onMouseDown }: { corner: ResizeCorner; onMouseDown: (event: React.MouseEvent, corner: ResizeCorner) => void }) {
const positionClass = {
"top-left": "-left-[14px] -top-[14px] cursor-nwse-resize",
"top-right": "-right-[14px] -top-[14px] cursor-nesw-resize",
"bottom-left": "-bottom-[14px] -left-[14px] cursor-nesw-resize",
"bottom-right": "-bottom-[14px] -right-[14px] cursor-nwse-resize",
}[corner];
return <div className={`absolute z-50 size-7 ${positionClass}`} onMouseDown={(event) => onMouseDown(event, corner)} />;
}
function ConnectionHandleDot({ side, visible, onMouseDown }: { side: "left" | "right"; visible: boolean; onMouseDown: (event: React.MouseEvent) => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
return (
<div
className={`absolute top-1/2 z-30 flex size-12 -translate-y-1/2 cursor-crosshair items-center justify-center transition-opacity duration-150 ${
side === "left" ? "-left-6" : "-right-6"
} ${visible ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`}
onMouseDown={onMouseDown}
>
<div className="size-3 rounded-full border-2 transition-all hover:scale-125" style={{ background: theme.node.panel, borderColor: theme.node.muted }} />
</div>
);
}