Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-workspace-overlays.tsx
TongTongStudio c8cde54c41
Some checks failed
Publish container images / Quality checks (push) Has been cancelled
Publish container images / Resolve build version (push) Has been cancelled
Publish container images / Build open-ai-canvas-backend (amd64) (push) Has been cancelled
Publish container images / Build open-ai-canvas-backend (arm64) (push) Has been cancelled
Publish container images / Build open-ai-canvas-web (multi) (push) Has been cancelled
Publish container images / Publish open-ai-canvas-backend manifest (push) Has been cancelled
Publish container images / Publish open-ai-canvas-web manifest (push) Has been cancelled
Publish container images / Publish release assets (push) Has been cancelled
feat(canvas): 新增画布设置入口、区域选择滚轮平移与提交流程快捷键
2026-09-22 13:17:56 +08:00

467 lines
23 KiB
TypeScript

import { motion, useReducedMotion } from "motion/react";
import { useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, type RefObject } from "react";
import { Clapperboard, Image as ImageIcon, List, Music2, Pencil, Table2, Video, WandSparkles, Workflow as WorkflowIcon } from "lucide-react";
import { useCanvasOverlayLayer } from "@/components/canvas/canvas-overlay-layer";
import { canvasThemes } from "@/lib/canvas-theme";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { subscribeCanvasGraphicsViewportPreview, subscribeCanvasNodeDragPreview, subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import { CanvasNodeType, type CanvasNodeData, type ConnectionHandle, type Position, type ViewportTransform } from "@/types/canvas";
export type PendingConnectionCreate = {
connection: ConnectionHandle;
position: Position;
quick?: boolean;
batchSourceNodeIds?: string[];
};
export function CanvasSelectionToolbar({ anchorRef, containerRef, count, children }: { anchorRef: RefObject<HTMLDivElement | null>; containerRef: RefObject<HTMLDivElement | null>; count: number; children: ReactNode }) {
const theme = canvasThemes[useActiveTheme()];
const reducedMotion = useReducedMotion();
const toolbarRef = useRef<HTMLDivElement>(null);
const [anchor, setAnchor] = useState<{ left: number; top: number; placement: "above" | "below" } | null>(null);
useLayoutEffect(() => {
const element = anchorRef.current;
const container = containerRef.current;
if (!element || !container) {
setAnchor(null);
return;
}
const update = () => {
const bounds = element.getBoundingClientRect();
const containerBounds = container.getBoundingClientRect();
const toolbarWidth = toolbarRef.current?.offsetWidth || 320;
const toolbarHeight = toolbarRef.current?.offsetHeight || 38;
const halfWidth = Math.min(toolbarWidth / 2, Math.max(0, containerBounds.width / 2 - 12));
const center = bounds.left - containerBounds.left + bounds.width / 2;
const left = Math.min(Math.max(center, 12 + halfWidth), Math.max(12 + halfWidth, containerBounds.width - 12 - halfWidth));
const boundsTop = bounds.top - containerBounds.top;
const boundsBottom = bounds.bottom - containerBounds.top;
const placement = boundsTop - toolbarHeight - 8 >= 68 ? "above" : "below";
const top = placement === "above" ? boundsTop - 8 : Math.min(boundsBottom + 8, containerBounds.height - toolbarHeight - 12);
if (toolbarRef.current) {
toolbarRef.current.style.left = `${left}px`;
toolbarRef.current.style.top = `${top}px`;
toolbarRef.current.classList.toggle("-translate-y-full", placement === "above");
return;
}
setAnchor((current) => (current?.left === left && current.top === top && current.placement === placement ? current : { left, top, placement }));
};
update();
const resizeObserver = new ResizeObserver(update);
resizeObserver.observe(element);
resizeObserver.observe(container);
if (toolbarRef.current) resizeObserver.observe(toolbarRef.current);
const viewportLayer = element.parentElement;
const mutationObserver = new MutationObserver(update);
if (viewportLayer) mutationObserver.observe(viewportLayer, { attributes: true, attributeFilter: ["style"] });
const unsubscribeViewport = subscribeCanvasViewportPreview(container, update);
window.addEventListener("resize", update);
return () => {
resizeObserver.disconnect();
mutationObserver.disconnect();
unsubscribeViewport();
window.removeEventListener("resize", update);
};
}, [anchorRef, containerRef, count]);
if (!anchor) return null;
return (
<div
ref={toolbarRef}
data-canvas-no-zoom
className={`absolute z-[var(--z-panel-floating)] max-w-[calc(100%_-_24px)] -translate-x-1/2 ${anchor.placement === "above" ? "-translate-y-full" : ""}`}
style={{ left: anchor.left, top: anchor.top, color: theme.node.text, transformOrigin: anchor.placement === "above" ? "bottom center" : "top center" }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<motion.div
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.9, y: anchor.placement === "above" ? 8 : -8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={aceternityMotion.spring.panel}
className="flex items-center gap-2"
>
<span
className="aceternity-floating-panel shrink-0 rounded-full border px-2.5 py-1.5 text-[var(--fs-tiny)] font-semibold tabular-nums backdrop-blur-2xl"
style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.accent.primary }}
>
已选 {count}
</span>
<div className="max-w-[min(560px,calc(100vw-90px))]">{children}</div>
</motion.div>
</div>
);
}
export function CanvasNodePanelOverlay({
node,
viewport,
containerRef,
panelWidth,
panelHeight = 190,
dragOffset,
isDragging = false,
allowOverflow = false,
children,
}: {
node: CanvasNodeData;
viewport: ViewportTransform;
containerRef: RefObject<HTMLDivElement | null>;
panelWidth?: number;
panelHeight?: number;
dragOffset?: Position | null;
isDragging?: boolean;
allowOverflow?: boolean;
children: ReactNode;
}) {
const panelRef = useRef<HTMLDivElement>(null);
const { bringToFront, zIndex } = useCanvasOverlayLayer(`node-panel:${node.id}`, "var(--z-modal-overlay)");
const initialWidth = resolveNodePanelWidth(node, viewport, panelWidth);
const initialPosition = getNodePanelPosition(node, viewport, { width: containerRef.current?.clientWidth || 0, height: containerRef.current?.clientHeight || 0 }, initialWidth, panelHeight, dragOffset);
useLayoutEffect(() => {
bringToFront();
}, [bringToFront]);
useLayoutEffect(() => {
const container = containerRef.current;
const panel = panelRef.current;
if (!container || !panel) return;
let liveViewport = viewport;
let liveDragOffset = dragOffset;
let viewportSize = { width: container.clientWidth, height: container.clientHeight };
const update = (nextViewport: ViewportTransform) => {
liveViewport = nextViewport;
const nextWidth = resolveNodePanelWidth(node, nextViewport, panelWidth);
panel.style.width = `${nextWidth}px`;
const nodeElement = container.querySelector<HTMLElement>(`[data-node-id="${CSS.escape(node.id)}"]`);
const position = nodeElement ? getAttachedNodePanelPosition(nodeElement, container, nextWidth) : getNodePanelPosition(node, nextViewport, viewportSize, nextWidth, panelHeight, liveDragOffset);
panel.style.transform = `translate3d(${position.left}px, ${position.top}px, 0)`;
};
update(viewport);
const resizeObserver = new ResizeObserver(() => {
viewportSize = { width: container.clientWidth, height: container.clientHeight };
update(liveViewport);
});
resizeObserver.observe(container);
const unsubscribeViewport = subscribeCanvasGraphicsViewportPreview(container, update);
const unsubscribeDrag = subscribeCanvasNodeDragPreview(container, (preview) => {
liveDragOffset = preview?.nodeIds.has(node.id) ? { x: preview.x, y: preview.y } : null;
update(liveViewport);
});
return () => {
resizeObserver.disconnect();
unsubscribeViewport();
unsubscribeDrag();
};
}, [containerRef, dragOffset?.x, dragOffset?.y, isDragging, node.height, node.id, node.position.x, node.position.y, node.width, panelHeight, panelWidth, viewport]);
// 面板内任意位置按下 ⌘/Ctrl + Enter 时直接提交,避免先把手移回输入框或生成按钮。
// 只有面板自己声明了主按钮([data-canvas-panel-submit])才生效,且按钮禁用时不拦截按键。
const handleSubmitShortcut = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key !== "Enter" || event.shiftKey || event.altKey) return;
if (!(event.ctrlKey || event.metaKey)) return;
if (event.nativeEvent.isComposing) return;
const submit = panelRef.current?.querySelector<HTMLButtonElement>("[data-canvas-panel-submit]");
if (!submit || submit.disabled || submit.getAttribute("aria-disabled") === "true") return;
event.preventDefault();
event.stopPropagation();
submit.click();
};
return (
<div
ref={panelRef}
data-canvas-no-zoom
data-canvas-node-panel
className={`thin-scrollbar absolute max-w-[calc(100%_-_24px)] ${allowOverflow ? "overflow-visible" : "overflow-y-auto"}`}
style={{ left: 0, top: 0, transform: `translate3d(${initialPosition.left}px, ${initialPosition.top}px, 0)`, width: initialWidth, maxHeight: allowOverflow ? "none" : "calc(100% - 84px)", zIndex }}
onKeyDownCapture={handleSubmitShortcut}
onMouseDown={(event) => event.stopPropagation()}
onPointerDownCapture={bringToFront}
onFocusCapture={bringToFront}
onPointerDown={(event) => event.stopPropagation()}
>
{children}
</div>
);
}
function resolveNodePanelWidth(node: CanvasNodeData, viewport: ViewportTransform, requestedWidth?: number) {
if (requestedWidth) return requestedWidth;
return clamp(Math.round(node.width * viewport.k * 1.5), 680, 920);
}
export function CanvasConnectionCreateMenu({
pending,
viewport,
viewportSize,
containerRef,
canCreateDrawing,
getDisabledReason,
onCreate,
onClose,
}: {
pending: PendingConnectionCreate;
viewport: ViewportTransform;
viewportSize: { width: number; height: number };
containerRef: RefObject<HTMLDivElement | null>;
canCreateDrawing: boolean;
getDisabledReason: (
type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.BatchTable | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config | CanvasNodeType.MediaConversion,
provider?: "runninghub",
) => string;
onCreate: (
type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.BatchTable | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config | CanvasNodeType.MediaConversion,
provider?: "runninghub",
) => void;
onClose: () => void;
}) {
const theme = canvasThemes[useActiveTheme()];
const reducedMotion = useReducedMotion();
const menuRef = useRef<HTMLDivElement>(null);
const [activeOption, setActiveOption] = useState<string | null>(null);
const lastPointerRef = useRef<Position | null>(null);
const { bringToFront, zIndex } = useCanvasOverlayLayer("connection-create-menu", "var(--z-modal-overlay)");
const menuWidth = Math.min(288, viewportSize.width - 24);
const menuHeight = canCreateDrawing ? 448 : 404;
const gap = 12;
const initialPosition = getConnectionMenuPosition(pending.position, viewport, viewportSize, menuWidth, menuHeight, gap);
useLayoutEffect(() => {
bringToFront();
}, [bringToFront]);
useLayoutEffect(() => {
const container = containerRef.current;
const menu = menuRef.current;
if (!container || !menu) return;
const update = (nextViewport: ViewportTransform) => {
const containerBounds = container.getBoundingClientRect();
const position = getConnectionMenuPosition(pending.position, nextViewport, { width: containerBounds.width, height: containerBounds.height }, menu.offsetWidth || menuWidth, menu.offsetHeight || menuHeight, gap);
menu.style.left = `${position.left}px`;
menu.style.top = `${position.top}px`;
};
update(viewport);
return subscribeCanvasViewportPreview(container, update);
}, [containerRef, pending.position, viewport, viewportSize.height, viewportSize.width]);
return (
<motion.div
ref={menuRef}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }}
className="thin-scrollbar absolute origin-top-left overflow-x-hidden overflow-y-auto rounded-[var(--r-2xl)] border p-2"
data-canvas-no-zoom
data-connection-create-menu
aria-label="创建下一步"
onKeyDown={(event) => {
if (event.key === "Escape") {
event.stopPropagation();
onClose();
}
}}
style={{ width: menuWidth, maxHeight: Math.max(120, viewportSize.height - 84), left: initialPosition.left, top: initialPosition.top, zIndex, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDownCapture={bringToFront}
onFocusCapture={(event) => {
bringToFront();
if (event.target.matches(":focus-visible")) setActiveOption(event.target.closest<HTMLElement>("[data-create-option]")?.dataset.createOption || null);
}}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) setActiveOption(null);
}}
onPointerMove={(event) => {
if (event.pointerType === "touch") return;
const previous = lastPointerRef.current;
// Layout changes can retarget a stationary pointer; only real movement selects a new row.
if (previous?.x === event.clientX && previous.y === event.clientY) return;
lastPointerRef.current = { x: event.clientX, y: event.clientY };
const option = (event.target as Element).closest<HTMLElement>("[data-create-option]")?.dataset.createOption;
if (option) setActiveOption(option);
}}
onPointerLeave={() => {
lastPointerRef.current = null;
setActiveOption(null);
}}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="grid min-w-0 grid-cols-1 gap-1">
<ConnectionCreateOption
expanded={activeOption === "文本生成"}
motionEnabled={!reducedMotion}
icon={<List className="size-4" />}
title="文本生成"
description="引用当前内容,生成或改写文本"
disabledReason={getDisabledReason(CanvasNodeType.Text)}
onClick={() => onCreate(CanvasNodeType.Text)}
/>
<ConnectionCreateOption
expanded={activeOption === "分镜脚本"}
motionEnabled={!reducedMotion}
icon={<Clapperboard className="size-4" />}
title="分镜脚本"
description="根据剧情拆解镜头,编排分镜脚本"
disabledReason={getDisabledReason(CanvasNodeType.Script)}
onClick={() => onCreate(CanvasNodeType.Script)}
/>
<ConnectionCreateOption
expanded={activeOption === "批量创作表"}
motionEnabled={!reducedMotion}
icon={<Table2 className="size-4" />}
title="批量创作表"
description="汇总多张图片,批量执行换装或创意生图"
disabledReason={getDisabledReason(CanvasNodeType.BatchTable)}
onClick={() => onCreate(CanvasNodeType.BatchTable)}
/>
<ConnectionCreateOption
expanded={activeOption === "图片生成"}
motionEnabled={!reducedMotion}
icon={<ImageIcon className="size-4" />}
title="图片生成"
description="结合提示词和参考图,生成新的画面"
disabledReason={getDisabledReason(CanvasNodeType.Image)}
onClick={() => onCreate(CanvasNodeType.Image)}
/>
<ConnectionCreateOption
expanded={activeOption === "生成配置"}
motionEnabled={!reducedMotion}
icon={<WorkflowIcon className="size-4" />}
title="生成配置"
description="选择模型,或使用已启用的工作流插件"
disabledReason={getDisabledReason(CanvasNodeType.Config)}
onClick={() => onCreate(CanvasNodeType.Config)}
/>
{canCreateDrawing ? (
<ConnectionCreateOption
expanded={activeOption === "绘图"}
motionEnabled={!reducedMotion}
icon={<Pencil className="size-4" />}
title="绘图"
description="以参考图片为底图,自由绘制和标注"
disabledReason={getDisabledReason(CanvasNodeType.Drawing)}
onClick={() => onCreate(CanvasNodeType.Drawing)}
/>
) : null}
<ConnectionCreateOption
expanded={activeOption === "视频生成"}
motionEnabled={!reducedMotion}
icon={<Video className="size-4" />}
title="视频生成"
description="结合提示词与参考素材,生成动态视频"
disabledReason={getDisabledReason(CanvasNodeType.Video)}
onClick={() => onCreate(CanvasNodeType.Video)}
/>
<ConnectionCreateOption
expanded={activeOption === "音频参考"}
motionEnabled={!reducedMotion}
icon={<Music2 className="size-4" />}
title="音频参考"
description="连接文本或角色卡,创建音频生成节点"
disabledReason={getDisabledReason(CanvasNodeType.Audio)}
onClick={() => onCreate(CanvasNodeType.Audio)}
/>
<ConnectionCreateOption
expanded={activeOption === "转换"}
motionEnabled={!reducedMotion}
icon={<WandSparkles className="size-4" />}
title="转换"
description="本地处理图片或视频"
disabledReason={getDisabledReason(CanvasNodeType.MediaConversion)}
onClick={() => onCreate(CanvasNodeType.MediaConversion)}
/>
</div>
</motion.div>
);
}
function ConnectionCreateOption({
expanded,
motionEnabled,
icon,
title,
description,
disabledReason,
onClick,
}: {
expanded: boolean;
motionEnabled: boolean;
icon: ReactNode;
title: string;
description: string;
disabledReason?: string;
onClick: () => void;
}) {
const theme = canvasThemes[useActiveTheme()];
return (
<button
type="button"
aria-disabled={Boolean(disabledReason)}
aria-label={title}
aria-description={disabledReason || description}
data-create-option={title}
data-expanded={expanded}
data-motion={motionEnabled ? "enabled" : "reduced"}
className="canvas-connection-create-option group flex min-h-10 w-full cursor-pointer items-start gap-2 rounded-[var(--dock-item-radius)] px-2 py-1.5 text-left outline-none focus-visible:ring-2 aria-disabled:cursor-not-allowed aria-disabled:opacity-40"
style={{ color: theme.node.text, "--tw-ring-color": theme.node.muted, background: expanded ? theme.toolbar.itemHover : undefined } as CSSProperties}
onClick={() => {
if (!disabledReason) onClick();
}}
>
<span className="grid size-7 shrink-0 place-items-center rounded-[var(--r-md)] opacity-65 transition-opacity group-hover:opacity-100 [&_svg]:size-3.5" style={{ background: theme.toolbar.itemHover }}>
{icon}
</span>
<span className="min-w-0 flex-1 pt-1.5">
<span className="flex items-center gap-2 text-[var(--fs-tiny)] font-semibold leading-4">{title}</span>
<span aria-hidden="true" className="canvas-connection-create-description" style={{ color: theme.node.muted }}>
<span className="min-h-0 overflow-hidden">
<span className="block pt-1 whitespace-normal break-words text-[var(--fs-micro)] leading-relaxed">{disabledReason || description}</span>
</span>
</span>
</span>
</button>
);
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function getConnectionMenuPosition(position: Position, viewport: ViewportTransform, viewportSize: { width: number; height: number }, menuWidth: number, menuHeight: number, gap: number) {
const screenX = viewport.x + position.x * viewport.k;
const screenY = viewport.y + position.y * viewport.k;
return {
left: clamp(screenX, gap, Math.max(gap, viewportSize.width - menuWidth - gap)),
top: clamp(screenY, 72, Math.max(72, viewportSize.height - menuHeight - gap)),
};
}
function getAttachedNodePanelPosition(nodeElement: HTMLElement, container: HTMLElement, panelWidth: number) {
const gap = 10;
const nodeRect = nodeElement.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
return {
left: nodeRect.left - containerRect.left + nodeRect.width / 2 - panelWidth / 2,
top: nodeRect.bottom - containerRect.top + gap,
placement: "below" as const,
};
}
export function getNodePanelPosition(node: CanvasNodeData, viewport: ViewportTransform, _viewportSize: { width: number; height: number }, panelWidth: number, _panelHeight: number, dragOffset?: Position | null) {
const gap = 10;
const offsetX = dragOffset?.x || 0;
const offsetY = dragOffset?.y || 0;
const nodeCenterX = viewport.x + (node.position.x + offsetX + node.width / 2) * viewport.k;
const nodeBottom = viewport.y + (node.position.y + offsetY + node.height) * viewport.k;
return {
left: nodeCenterX - panelWidth / 2,
top: nodeBottom + gap,
placement: "below" as const,
};
}