682 lines
37 KiB
TypeScript
682 lines
37 KiB
TypeScript
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||
import type { ReactNode } from "react";
|
||
import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Image as ImageIcon, Lock, Maximize2, Music2, Pencil, Plus, Replace, Settings2, Star, Type, Upload, Video } from "lucide-react";
|
||
|
||
import { canvasThemes } from "@/lib/canvas-theme";
|
||
import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } from "@/lib/canvas/resource-storage-status";
|
||
import { useThemeStore } from "@/stores/use-theme-store";
|
||
import { storyboardMinNodeHeight } from "./canvas-script-node";
|
||
import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas";
|
||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||
import { MEDIA_NODE_MIN_SIZE } from "@/lib/canvas/canvas-node-size";
|
||
import { CanvasNodeContent, CanvasNodeImageInfo } from "./canvas-node-content";
|
||
|
||
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||
type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
|
||
|
||
type CanvasNodeProps = {
|
||
data: CanvasNodeData;
|
||
dragOffset?: Position;
|
||
scale: number;
|
||
isSelected: boolean;
|
||
isRelated: boolean;
|
||
isFocusRelated: boolean;
|
||
isConnectionTarget: boolean;
|
||
isConnecting: boolean;
|
||
forceInputVisible?: boolean;
|
||
showImageInfo: boolean;
|
||
reduceMediaEffects?: boolean;
|
||
readOnly?: boolean;
|
||
resourceLabel?: CanvasResourceReference;
|
||
mentionReferences?: CanvasResourceReference[];
|
||
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
|
||
drawingProjectId?: string;
|
||
batchCount?: number;
|
||
batchExpanded?: boolean;
|
||
batchClosing?: boolean;
|
||
batchOpening?: boolean;
|
||
batchRecovering?: boolean;
|
||
batchPrimary?: 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.PointerEvent, nodeId: string, handleType: "source" | "target", handleId?: string, anchorRatio?: number) => void;
|
||
onResize: (nodeId: string, width: number, height: number, position?: Position) => void;
|
||
onTitleChange?: (nodeId: string, title: string) => void;
|
||
onContentChange: (nodeId: string, content: string) => void;
|
||
onToggleBatch?: (nodeId: string) => void;
|
||
onSetBatchPrimary?: (node: CanvasNodeData) => void;
|
||
onRetry?: (node: CanvasNodeData) => void;
|
||
onCancelTask?: (node: CanvasNodeData) => void;
|
||
onOpenTaskDetails?: (node: CanvasNodeData) => void;
|
||
onOpenVersions?: (node: CanvasNodeData) => void;
|
||
onViewImage?: (node: CanvasNodeData) => void;
|
||
onReplaceMedia?: (node: CanvasNodeData) => void;
|
||
onOpenTextEditor?: (node: CanvasNodeData) => void;
|
||
onOpenDirector?: (node: CanvasNodeData) => void;
|
||
onOpenDrawing?: (node: CanvasNodeData) => void;
|
||
onContextMenu: (event: React.MouseEvent, nodeId: string) => void;
|
||
};
|
||
|
||
export const CanvasNode = React.memo(function CanvasNode({
|
||
data,
|
||
dragOffset,
|
||
scale,
|
||
isSelected,
|
||
isRelated,
|
||
isFocusRelated,
|
||
isConnectionTarget,
|
||
forceInputVisible = false,
|
||
showImageInfo,
|
||
reduceMediaEffects = false,
|
||
readOnly = false,
|
||
resourceLabel,
|
||
mentionReferences = [],
|
||
renderNodeContent,
|
||
drawingProjectId,
|
||
batchCount = 0,
|
||
batchExpanded = false,
|
||
batchClosing = false,
|
||
batchOpening = false,
|
||
batchRecovering = false,
|
||
batchPrimary = false,
|
||
batchMotion,
|
||
onMouseDown,
|
||
onHoverStart,
|
||
onHoverEnd,
|
||
onConnectStart,
|
||
onResize,
|
||
onTitleChange,
|
||
onContentChange,
|
||
onToggleBatch,
|
||
onSetBatchPrimary,
|
||
onRetry,
|
||
onCancelTask,
|
||
onOpenTaskDetails,
|
||
onOpenVersions,
|
||
onViewImage,
|
||
onReplaceMedia,
|
||
onOpenTextEditor,
|
||
onOpenDirector,
|
||
onOpenDrawing,
|
||
onContextMenu,
|
||
}: CanvasNodeProps) {
|
||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||
const [hovered, setHovered] = useState(false);
|
||
const [isEditingContent, setIsEditingContent] = useState(false);
|
||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||
const [titleDraft, setTitleDraft] = useState(data.title);
|
||
const hasImageContent = data.type === CanvasNodeType.Image && Boolean(data.metadata?.content);
|
||
const hasVideoContent = data.type === CanvasNodeType.Video && Boolean(data.metadata?.content);
|
||
const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content);
|
||
const hasMediaContent = hasImageContent || hasVideoContent || hasAudioContent;
|
||
const hasReplaceAction = !readOnly && (data.type === CanvasNodeType.Image || data.type === CanvasNodeType.Video);
|
||
const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1;
|
||
const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId);
|
||
const showStatusTrack = Boolean(resourceLabel || data.metadata?.locked || isBatchRoot || (isBatchChild && !readOnly) || (hasMediaContent && !readOnly));
|
||
const isActive = isConnectionTarget || isSelected || isFocusRelated;
|
||
const nodeState = isFocusRelated ? "focus" : isConnectionTarget ? "target" : isSelected ? "selected" : isRelated && !isBatchChild ? "related" : "idle";
|
||
const assetTags = data.metadata?.assetTags?.filter((tag) => tag.trim()) || [];
|
||
const scriptMinHeight = data.type === CanvasNodeType.Script ? storyboardMinNodeHeight(data.metadata?.storyboardComposerHeight) : null;
|
||
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 (!isEditingTitle) setTitleDraft(data.title);
|
||
}, [data.title, isEditingTitle]);
|
||
|
||
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 isMediaNode = data.type === CanvasNodeType.Image || data.type === CanvasNodeType.Video;
|
||
const minWidth = data.type === CanvasNodeType.Script ? 800 : isMediaNode ? MEDIA_NODE_MIN_SIZE.width : 220;
|
||
const minHeight = scriptMinHeight || (isMediaNode ? MEDIA_NODE_MIN_SIZE.height : 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, data.type, onResize, scale, scriptMinHeight],
|
||
);
|
||
|
||
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]);
|
||
|
||
const commitTitle = () => {
|
||
const next = titleDraft.trim();
|
||
setIsEditingTitle(false);
|
||
if (!next) {
|
||
setTitleDraft(data.title);
|
||
return;
|
||
}
|
||
if (next !== data.title) onTitleChange?.(data.id, next);
|
||
};
|
||
|
||
return (
|
||
<div
|
||
data-node-id={data.id}
|
||
className={`node-element absolute flex select-none flex-col ${dragOffset ? "cursor-grabbing" : data.type === CanvasNodeType.Drawing ? "cursor-pointer" : "cursor-default"} ${isSelected ? "z-[var(--z-node-active)]" : "z-[var(--z-node)]"}`}
|
||
style={{
|
||
transform: `translate(${data.position.x + (dragOffset?.x || 0)}px, ${data.position.y + (dragOffset?.y || 0)}px)`,
|
||
width: data.width,
|
||
height: data.height,
|
||
contain: "layout style",
|
||
}}
|
||
onMouseEnter={() => {
|
||
setHovered(true);
|
||
onHoverStart(data.id);
|
||
}}
|
||
onMouseLeave={() => {
|
||
setHovered(false);
|
||
onHoverEnd(data.id);
|
||
}}
|
||
onContextMenu={(event) => onContextMenu(event, data.id)}
|
||
>
|
||
<NodeExternalHeader
|
||
node={data}
|
||
scale={scale}
|
||
active={hovered || isSelected || isFocusRelated}
|
||
editable={!readOnly && !data.metadata?.locked && Boolean(onTitleChange)}
|
||
editing={isEditingTitle}
|
||
draft={titleDraft}
|
||
theme={theme}
|
||
onDraftChange={setTitleDraft}
|
||
onEdit={() => setIsEditingTitle(true)}
|
||
onCommit={commitTitle}
|
||
onCancel={() => { setTitleDraft(data.title); setIsEditingTitle(false); }}
|
||
/>
|
||
<div
|
||
className="canvas-node-shell relative h-full w-full overflow-visible rounded-[var(--node-radius)]"
|
||
data-node-state={nodeState}
|
||
data-state={data.metadata?.status || (isActive ? "active" : isRelated ? "related" : "idle")}
|
||
style={{
|
||
background: hasImageContent || hasVideoContent ? "transparent" : "var(--card-surface)",
|
||
}}
|
||
onMouseDown={(event) => onMouseDown(event, data.id)}
|
||
onDoubleClick={(event) => {
|
||
if (isBatchRoot) {
|
||
event.stopPropagation();
|
||
onToggleBatch?.(data.id);
|
||
return;
|
||
}
|
||
if (data.type === CanvasNodeType.Image && hasImageContent) {
|
||
event.stopPropagation();
|
||
onViewImage?.(data);
|
||
return;
|
||
}
|
||
if (data.metadata?.directorSceneId) {
|
||
event.stopPropagation();
|
||
onOpenDirector?.(data);
|
||
return;
|
||
}
|
||
if (data.type === CanvasNodeType.Drawing) {
|
||
event.stopPropagation();
|
||
onOpenDrawing?.(data);
|
||
return;
|
||
}
|
||
if (!readOnly && data.type === CanvasNodeType.Text && data.metadata?.workflowKind === "character" && data.metadata.characterAssetId) {
|
||
event.stopPropagation();
|
||
onOpenTextEditor?.(data);
|
||
return;
|
||
}
|
||
if (readOnly || data.type !== CanvasNodeType.Text) return;
|
||
event.stopPropagation();
|
||
setIsEditingContent(true);
|
||
}}
|
||
>
|
||
<div
|
||
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot || data.type === CanvasNodeType.Script ? "overflow-visible" : "overflow-hidden"}`}
|
||
style={
|
||
{
|
||
background: hasImageContent || hasVideoContent ? "transparent" : "var(--card-surface)",
|
||
"--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 var(--motion-dur-base-calc) var(--motion-ease-in-out) both` : `canvas-batch-child-in var(--motion-dur-slow-calc) var(--motion-ease-out) both`) : undefined,
|
||
animationDelay: data.metadata?.batchRootId ? `${batchClosing ? 0 : 45 + (batchMotion?.index || 0) * 24}ms` : undefined,
|
||
} as React.CSSProperties
|
||
}
|
||
>
|
||
{/* 节点状态徽章(对应 #97 决策2:左上角 loading/success/error,近距离确认信号)*/}
|
||
{data.metadata?.status && data.metadata.status !== "idle" && data.type !== CanvasNodeType.Frame ? (
|
||
<NodeStatusBadge status={data.metadata.status} />
|
||
) : null}
|
||
<CanvasNodeContent
|
||
node={data}
|
||
theme={theme}
|
||
isEditingContent={isEditingContent}
|
||
textareaRef={textareaRef}
|
||
isBatchRoot={isBatchRoot}
|
||
batchCount={batchCount}
|
||
batchExpanded={batchExpanded}
|
||
batchOpening={batchOpening}
|
||
batchRecovering={batchRecovering}
|
||
renderNodeContent={renderNodeContent}
|
||
drawingProjectId={drawingProjectId}
|
||
mentionReferences={mentionReferences}
|
||
onContentChange={onContentChange}
|
||
onStopEditing={() => setIsEditingContent(false)}
|
||
onRetry={onRetry}
|
||
onCancelTask={onCancelTask}
|
||
onOpenTaskDetails={onOpenTaskDetails}
|
||
onToggleBatch={() => onToggleBatch?.(data.id)}
|
||
reduceMediaEffects={reduceMediaEffects}
|
||
/>
|
||
</div>
|
||
|
||
{hasReplaceAction ? (
|
||
<div
|
||
className={`absolute right-3 top-3 z-[var(--node-z-overlay)] motion-safe:transition motion-safe:duration-200 ${!hasMediaContent || isSelected || hovered ? "opacity-100" : "pointer-events-none opacity-0"}`}
|
||
onMouseDown={(event) => event.stopPropagation()}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="canvas-node-inline-action grid size-9 place-items-center p-0 backdrop-blur-xl transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
|
||
style={{ outlineColor: theme.accent.primary }}
|
||
onClick={(event) => { event.stopPropagation(); onReplaceMedia?.(data); }}
|
||
aria-label={hasMediaContent ? "替换媒体" : data.type === CanvasNodeType.Image ? "上传图片" : "上传视频"}
|
||
title={hasMediaContent ? "替换媒体" : data.type === CanvasNodeType.Image ? "上传图片" : "上传视频"}
|
||
>
|
||
{hasMediaContent ? <Replace className="size-3.5" /> : <Upload className="size-3.5" />}
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
|
||
{data.type === CanvasNodeType.Text && data.metadata?.workflowKind !== "character" && !readOnly ? (
|
||
<div
|
||
className={`absolute bottom-[10%] left-1/2 z-[var(--node-z-overlay)] -translate-x-1/2 motion-safe:transition motion-safe:duration-200 ${isSelected ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0"}`}
|
||
onMouseDown={(event) => event.stopPropagation()}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="canvas-node-inline-action inline-flex h-9 items-center gap-2 px-3 text-xs font-medium backdrop-blur-xl transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
|
||
style={{ outlineColor: theme.accent.primary }}
|
||
onClick={(event) => { event.stopPropagation(); onOpenTextEditor?.(data); }}
|
||
aria-label="放大编辑文本"
|
||
>
|
||
<Maximize2 className="size-3.5" />
|
||
放大编辑
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
|
||
{data.metadata?.versionLabel ? (
|
||
<button
|
||
type="button"
|
||
className="absolute left-3 top-3 z-[var(--node-z-overlay)] grid size-7 place-items-center rounded-[var(--r-full)] border p-0.5 text-[var(--node-badge-fs)] font-semibold leading-none backdrop-blur-md transition-[transform,background,border-color,box-shadow] hover:-translate-y-px focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 motion-reduce:hover:translate-y-0"
|
||
style={{
|
||
background: data.metadata.versionPrimary ? theme.accent.primarySoft : theme.toolbar.panel,
|
||
borderColor: data.metadata.versionPrimary ? theme.accent.primary : theme.toolbar.border,
|
||
color: data.metadata.versionPrimary ? theme.accent.primary : theme.node.text,
|
||
outlineColor: theme.accent.primary,
|
||
}}
|
||
title={data.metadata.versionLabel + (data.metadata.versionPrimary ? " · 主版本" : "") + ",点击查看版本对比"}
|
||
aria-label={data.metadata.versionLabel + (data.metadata.versionPrimary ? ",主版本" : "") + ",查看版本对比"}
|
||
onMouseDown={(event) => event.stopPropagation()}
|
||
onClick={(event) => { event.stopPropagation(); onOpenVersions?.(data); }}
|
||
>
|
||
{data.metadata.versionLabel}
|
||
</button>
|
||
) : null}
|
||
{showStatusTrack ? (
|
||
<div className={`absolute top-3 z-[var(--node-z-overlay)] flex min-w-0 items-center justify-end gap-1 ${hasReplaceAction ? "right-14" : "right-3"} ${data.metadata?.versionLabel ? "max-w-[calc(100%-104px)]" : "max-w-[calc(100%-24px)]"}`}>
|
||
{resourceLabel && data.type !== CanvasNodeType.Image ? <ResourceLabelBadge reference={resourceLabel} theme={theme} /> : null}
|
||
{hasMediaContent && !readOnly ? <ResourceStorageBadge storageKey={data.metadata?.storageKey} active={isActive} theme={theme} /> : null}
|
||
{isBatchRoot ? <BatchToggleBadge count={batchCount} expanded={batchExpanded} theme={theme} onToggle={() => onToggleBatch?.(data.id)} /> : null}
|
||
{isBatchChild && !readOnly ? <BatchPrimaryBadge visible={batchPrimary || hovered || isSelected} selected={batchPrimary} theme={theme} onSelect={() => onSetBatchPrimary?.(data)} /> : null}
|
||
{data.metadata?.locked ? <NodeLockBadge theme={theme} /> : null}
|
||
</div>
|
||
) : null}
|
||
{assetTags.length || (showImageInfo && hasImageContent) ? (
|
||
<div className="pointer-events-none absolute inset-x-3 bottom-3 z-[var(--node-z-overlay)] flex items-end justify-between gap-2">
|
||
{assetTags.length ? <AssetTagBadges tags={assetTags} theme={theme} /> : null}
|
||
{showImageInfo && hasImageContent ? <CanvasNodeImageInfo node={data} /> : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{!readOnly && !data.metadata?.locked ? <>
|
||
<ResizeHandle corner="top-left" onMouseDown={handleResizeMouseDown} />
|
||
<ResizeHandle corner="top-right" onMouseDown={handleResizeMouseDown} />
|
||
<ResizeHandle corner="bottom-left" onMouseDown={handleResizeMouseDown} />
|
||
<ResizeHandle corner="bottom-right" onMouseDown={handleResizeMouseDown} />
|
||
</> : null}
|
||
</div>
|
||
|
||
{!readOnly && data.type !== CanvasNodeType.Script ? <ConnectionSideRail side="left" scale={scale} visible={hovered || forceInputVisible} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null}
|
||
{!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config ? <ConnectionSideRail side="right" scale={scale} visible={hovered} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null}
|
||
|
||
</div>
|
||
);
|
||
});
|
||
|
||
function ResourceLabelBadge({ reference, theme }: { reference: CanvasResourceReference; theme: CanvasTheme }) {
|
||
return (
|
||
<span className="pointer-events-none min-w-0 max-w-28 truncate rounded-md px-1.5 py-1 text-[var(--fs-tiny)] font-medium leading-none" style={{ background: reference.active ? theme.accent.primary : "rgba(0,0,0,.35)", color: reference.active ? theme.accent.onPrimary : "#ffffff", opacity: reference.active ? 1 : 0.75 }} title={reference.title || reference.label}>
|
||
{reference.label}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function ResourceStorageBadge({ storageKey, active, theme }: { storageKey?: string; active: boolean; theme: CanvasTheme }) {
|
||
const location = resourceStorageLocation(storageKey);
|
||
const background = active ? (location === "local" ? "rgba(245,158,11,.9)" : theme.accent.primary) : "rgba(0,0,0,.35)";
|
||
return (
|
||
<span className="pointer-events-auto shrink-0 rounded-md px-1.5 py-1 text-[var(--fs-tiny)] font-medium leading-none" style={{ background, color: active && location !== "local" ? theme.accent.onPrimary : "#ffffff", opacity: active ? 1 : 0.75 }} title={resourceStorageTitle(storageKey)}>
|
||
{resourceStorageLabel(storageKey)}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function NodeLockBadge({ theme }: { theme: CanvasTheme }) {
|
||
return <span className="pointer-events-none grid size-7 shrink-0 place-items-center rounded-md border backdrop-blur" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.muted }} title="节点已锁定"><Lock className="size-3.5" /></span>;
|
||
}
|
||
|
||
function BatchToggleBadge({ count, expanded, theme, onToggle }: { count: number; expanded: boolean; theme: CanvasTheme; onToggle: () => void }) {
|
||
return (
|
||
<button type="button" className="canvas-node-tool-button inline-flex h-7 shrink-0 items-center gap-1 rounded-md border px-2 text-[var(--fs-tiny)] font-semibold backdrop-blur-md" style={{ background: `${theme.toolbar.panel}d9`, borderColor: `${theme.toolbar.border}cc`, color: theme.node.text }} aria-label={expanded ? "图片组已展开" : "图片组已收起"} onClick={(event) => { event.stopPropagation(); onToggle(); }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||
<span className="leading-none" style={{ color: theme.accent.primary }}>{count}</span>
|
||
<ChevronRight className={`size-3 opacity-55 transition-transform ${expanded ? "rotate-90" : ""}`} />
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function BatchPrimaryBadge({ visible, selected, theme, onSelect }: { visible: boolean; selected: boolean; theme: CanvasTheme; onSelect: () => void }) {
|
||
return (
|
||
<button type="button" className={`canvas-node-tool-button inline-flex h-7 shrink-0 items-center gap-1 rounded-md border px-2 text-[var(--fs-tiny)] font-medium backdrop-blur-md transition-opacity ${visible ? "opacity-100" : "pointer-events-none opacity-0"}`} style={{ background: theme.toolbar.panel, borderColor: selected ? theme.accent.primary : theme.toolbar.border, color: selected ? theme.accent.primary : theme.node.text }} aria-label={selected ? "当前主图" : "设置为主图"} aria-pressed={selected} onClick={(event) => { event.stopPropagation(); onSelect(); }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||
<Star className={`size-3 ${selected ? "fill-current" : ""}`} style={{ color: theme.accent.primary }} />
|
||
{selected ? "当前主图" : "主图"}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function AssetTagBadges({ tags, theme }: { tags: string[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||
return (
|
||
<div className="flex min-w-0 flex-1 flex-wrap items-end gap-1">
|
||
{tags.map((tag, index) => (
|
||
<span
|
||
key={`${tag}-${index}`}
|
||
className="max-w-full truncate rounded-md border px-1.5 py-1 text-[var(--fs-tiny)] font-medium leading-none backdrop-blur-sm"
|
||
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
|
||
>
|
||
{tag.trim()}
|
||
</span>
|
||
))}
|
||
</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-[var(--node-z-handle)] size-7 ${positionClass}`} onMouseDown={(event) => onMouseDown(event, corner)} />;
|
||
}
|
||
|
||
const NODE_EXTERNAL_HEADER_MIN_SCALE = 0.35;
|
||
|
||
function NodeExternalHeader({ node, scale, active, editable, editing, draft, theme, onDraftChange, onEdit, onCommit, onCancel }: {
|
||
node: CanvasNodeData;
|
||
scale: number;
|
||
active: boolean;
|
||
editable: boolean;
|
||
editing: boolean;
|
||
draft: string;
|
||
theme: CanvasTheme;
|
||
onDraftChange: (value: string) => void;
|
||
onEdit: () => void;
|
||
onCommit: () => void;
|
||
onCancel: () => void;
|
||
}) {
|
||
// 标题保持屏幕尺寸只适用于近景;远景继续反向缩放会遮住节点和连线。
|
||
if (scale < NODE_EXTERNAL_HEADER_MIN_SCALE && !editing) return null;
|
||
const inverseScale = 1 / Math.max(scale, 0.05);
|
||
const Icon = nodeTypeIcon(node.type);
|
||
const maxHeaderWidth = Math.min(240, node.width * scale);
|
||
|
||
return (
|
||
<div
|
||
className="canvas-node-external-header absolute bottom-full left-0 z-[var(--node-z-overlay)] flex h-6 items-center gap-1 overflow-hidden"
|
||
style={{ maxWidth: maxHeaderWidth, color: active ? theme.node.text : theme.node.label, transform: `scale(var(--canvas-live-inverse-scale, ${inverseScale}))`, transformOrigin: "left bottom" }}
|
||
onMouseDown={(event) => event.stopPropagation()}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
>
|
||
<Icon className="size-3 shrink-0" strokeWidth={1.8} />
|
||
{editing ? (
|
||
<input
|
||
autoFocus
|
||
value={draft}
|
||
className="h-6 min-w-20 max-w-[190px] flex-1 truncate rounded border bg-transparent px-1.5 text-xs font-medium outline-none"
|
||
style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: "var(--elevation-card)" }}
|
||
onChange={(event) => onDraftChange(event.target.value)}
|
||
onFocus={(event) => event.currentTarget.select()}
|
||
onBlur={onCommit}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter") event.currentTarget.blur();
|
||
if (event.key === "Escape") onCancel();
|
||
}}
|
||
aria-label="节点名称"
|
||
/>
|
||
) : editable ? (
|
||
<button type="button" className="group flex min-w-0 flex-1 items-center gap-1 rounded px-0.5 text-xs font-medium outline-none transition-opacity hover:opacity-100 focus-visible:ring-1" style={{ opacity: active ? 1 : 0.78, "--tw-ring-color": theme.node.activeStroke } as React.CSSProperties} onClick={onEdit} aria-label={`编辑节点名称:${node.title}`}>
|
||
<span className="min-w-0 flex-1 truncate" title={node.title}>{node.title}</span>
|
||
<Pencil className="size-2.5 shrink-0 opacity-55 transition-opacity group-hover:opacity-100" />
|
||
</button>
|
||
) : (
|
||
<span className="min-w-0 flex-1 truncate text-xs font-medium" title={node.title} style={{ opacity: active ? 1 : 0.78 }}>{node.title}</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function nodeTypeIcon(type: CanvasNodeType) {
|
||
if (type === CanvasNodeType.Image) return ImageIcon;
|
||
if (type === CanvasNodeType.Video) return Video;
|
||
if (type === CanvasNodeType.Audio) return Music2;
|
||
if (type === CanvasNodeType.Drawing) return Pencil;
|
||
if (type === CanvasNodeType.Script) return Clapperboard;
|
||
if (type === CanvasNodeType.Config) return Settings2;
|
||
if (type === CanvasNodeType.Skill) return BookOpenCheck;
|
||
return Type;
|
||
}
|
||
|
||
// 节点状态徽章(对应 #97 决策2:左上角状态指示,loading/success/error)
|
||
function NodeStatusBadge({ status }: { status: "loading" | "success" | "error" }) {
|
||
if (status === "loading") {
|
||
return (
|
||
<div
|
||
className="pointer-events-none absolute left-2 top-2 z-20 flex items-center gap-1 rounded-full px-2 py-0.5 backdrop-blur-sm"
|
||
style={{ background: "color-mix(in oklch, var(--status-loading) 20%, transparent)", color: "var(--status-loading)" }}
|
||
aria-label="生成中"
|
||
>
|
||
<span className="size-1.5 animate-pulse rounded-full" style={{ background: "var(--status-loading)" }} />
|
||
<span className="text-[var(--fs-micro)] font-medium leading-none">生成中</span>
|
||
</div>
|
||
);
|
||
}
|
||
if (status === "error") {
|
||
return (
|
||
<div
|
||
className="pointer-events-none absolute left-2 top-2 z-20 flex items-center gap-1 rounded-full px-2 py-0.5 backdrop-blur-sm"
|
||
style={{ background: "color-mix(in oklch, var(--status-error) 20%, transparent)", color: "var(--status-error)" }}
|
||
aria-label="生成失败"
|
||
>
|
||
<AlertCircle className="size-3" strokeWidth={2} />
|
||
<span className="text-[var(--fs-micro)] font-medium leading-none">失败</span>
|
||
</div>
|
||
);
|
||
}
|
||
// success:短暂闪现后淡出(由 CSS animation 控制,2s 后消失)
|
||
return (
|
||
<div
|
||
className="pointer-events-none absolute left-2 top-2 z-20 flex items-center gap-1 rounded-full px-2 py-0.5 backdrop-blur-sm"
|
||
style={{ background: "color-mix(in oklch, var(--status-success) 20%, transparent)", color: "var(--status-success)", animation: "canvas-status-success-fade 2s ease-out forwards" }}
|
||
aria-label="生成完成"
|
||
>
|
||
<CheckCircle2 className="size-3" strokeWidth={2} />
|
||
<span className="text-[var(--fs-micro)] font-medium leading-none">完成</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ConnectionSideRail({ side, scale, visible, theme, onPointerDown }: { side: "left" | "right"; scale: number; visible: boolean; theme: CanvasTheme; onPointerDown: (event: React.PointerEvent, anchorRatio: number) => void }) {
|
||
const handleRef = useRef<HTMLSpanElement>(null);
|
||
const anchorRatioRef = useRef(0.5);
|
||
const inverseScale = 1 / Math.max(scale, 0.05);
|
||
|
||
const resetAnchor = useCallback(() => {
|
||
anchorRatioRef.current = 0.5;
|
||
if (handleRef.current) handleRef.current.style.top = "50%";
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!visible) resetAnchor();
|
||
}, [resetAnchor, visible]);
|
||
|
||
const updateAnchor = (event: React.PointerEvent<HTMLButtonElement>) => {
|
||
const railBounds = event.currentTarget.getBoundingClientRect();
|
||
const nodeBounds = event.currentTarget.parentElement?.getBoundingClientRect() || railBounds;
|
||
const screenPadding = Math.min(railBounds.height * 0.35, 12);
|
||
const railRatio = Math.min(1 - screenPadding / Math.max(railBounds.height, 1), Math.max(screenPadding / Math.max(railBounds.height, 1), (event.clientY - railBounds.top) / Math.max(railBounds.height, 1)));
|
||
const anchorY = railBounds.top + railBounds.height * railRatio;
|
||
anchorRatioRef.current = Math.min(1, Math.max(0, (anchorY - nodeBounds.top) / Math.max(nodeBounds.height, 1)));
|
||
if (handleRef.current) handleRef.current.style.top = `${railRatio * 100}%`;
|
||
};
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={`group absolute top-1/2 z-[var(--node-z-overlay)] touch-none -translate-y-1/2 outline-none transition-opacity duration-150 ${visible ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`}
|
||
style={{ width: 56 * inverseScale, height: `min(100%, ${72 * inverseScale}px)`, ...(side === "left" ? { right: "100%" } : { left: "100%" }) }}
|
||
onPointerEnter={updateAnchor}
|
||
onPointerMove={updateAnchor}
|
||
onPointerLeave={resetAnchor}
|
||
onPointerDown={(event) => onPointerDown(event, anchorRatioRef.current)}
|
||
aria-label={`${side === "left" ? "输入" : "输出"}连接点,单击创建节点或拖动连线`}
|
||
>
|
||
<span
|
||
ref={handleRef}
|
||
className="absolute grid -translate-y-1/2 place-items-center rounded-full border transition-[background-color,box-shadow] duration-150 group-hover:brightness-125 group-focus-visible:brightness-125"
|
||
style={{
|
||
top: "50%",
|
||
width: 18 * inverseScale,
|
||
height: 18 * inverseScale,
|
||
...(side === "left" ? { right: 6 * inverseScale } : { left: 6 * inverseScale }),
|
||
borderWidth: inverseScale,
|
||
background: theme.spatial.elevated,
|
||
borderColor: theme.node.activeStroke,
|
||
color: theme.node.activeStroke,
|
||
boxShadow: "none",
|
||
}}
|
||
>
|
||
<Plus style={{ width: 10 * inverseScale, height: 10 * inverseScale }} strokeWidth={2} />
|
||
</span>
|
||
</button>
|
||
);
|
||
}
|