fix: stabilize video canvas quick connect
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-11 23:14:05 +08:00
parent e054dcabcc
commit 3ad76b5c54
2 changed files with 250 additions and 179 deletions

View File

@ -120,9 +120,6 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
@ -274,6 +271,8 @@ interface VideoCanvasProjectExportPayload {
interface VideoCanvasInsertMenuState {
screenPosition: Position;
canvasPosition: Position;
sourceNodeId?: string | null;
openLeft?: boolean;
}
interface VideoCanvasInsertMenuChild {
@ -1430,6 +1429,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
});
const [videoCanvasInsertMenu, setVideoCanvasInsertMenu] =
useState<VideoCanvasInsertMenuState | null>(null);
const videoCanvasInsertMenuRef = useRef<HTMLDivElement | null>(null);
const [isVideoCanvasFileListOpen, setIsVideoCanvasFileListOpen] =
useState(false);
const [isVideoCanvasMarkerPanelOpen, setIsVideoCanvasMarkerPanelOpen] =
@ -2708,6 +2708,31 @@ export default function CanvasEditorPage({ params }: PageProps) {
return () => window.removeEventListener("resize", updateViewportWidth);
}, []);
useEffect(() => {
if (!videoCanvasInsertMenu) return;
const handlePointerDown = (event: PointerEvent) => {
const menu = videoCanvasInsertMenuRef.current;
if (menu && event.target instanceof Node && menu.contains(event.target)) {
return;
}
setVideoCanvasInsertMenu(null);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setVideoCanvasInsertMenu(null);
}
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [videoCanvasInsertMenu]);
useEffect(() => {
if (isAgentMode) {
setIsPanelOpen(true);
@ -5714,15 +5739,18 @@ export default function CanvasEditorPage({ params }: PageProps) {
nodeConfig: Parameters<typeof addNode>[0],
options?: {
connectFromSelection?: boolean;
connectFromNodeId?: string | null;
selectNewNode?: boolean;
}
) => {
const connectFromSelection = options?.connectFromSelection ?? true;
const selectNewNode = options?.selectNewNode ?? true;
const explicitSourceNodeId = options?.connectFromNodeId || null;
const nodeId = addNode(nodeConfig);
const sourceNodeId =
connectFromSelection && isVideoMode && selectedNodeIds.length === 1
? selectedNodeIds[0] || null
connectFromSelection && isVideoMode
? explicitSourceNodeId ||
(selectedNodeIds.length === 1 ? selectedNodeIds[0] || null : null)
: null;
if (sourceNodeId && sourceNodeId !== nodeId) {
addEdge({
@ -5745,6 +5773,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
options?: {
position?: Position;
connectFromSelection?: boolean;
connectFromNodeId?: string | null;
selectNewNode?: boolean;
}
) => {
@ -5754,6 +5783,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
options?.position || getVideoWorkflowPlacement(definition.size);
createCanvasNode(buildVideoWorkflowCanvasNode(templateType, placement), {
connectFromSelection: options?.connectFromSelection ?? true,
connectFromNodeId: options?.connectFromNodeId ?? null,
selectNewNode: options?.selectNewNode ?? true,
});
},
@ -5773,7 +5803,21 @@ export default function CanvasEditorPage({ params }: PageProps) {
if (!videoCanvasInsertMenu) return;
const definition = getVideoWorkflowNodeDefinition(templateType);
const anchoredPosition = definition
const sourceNode = videoCanvasInsertMenu.sourceNodeId
? nodes.find((node) => node.id === videoCanvasInsertMenu.sourceNodeId)
: null;
const anchoredPosition =
sourceNode && definition
? {
x: videoCanvasInsertMenu.openLeft
? sourceNode.position.x - definition.size.width - 180
: sourceNode.position.x + sourceNode.size.width + 180,
y:
sourceNode.position.y +
sourceNode.size.height / 2 -
definition.size.height / 2,
}
: definition
? {
x:
videoCanvasInsertMenu.canvasPosition.x -
@ -5786,11 +5830,12 @@ export default function CanvasEditorPage({ params }: PageProps) {
handleAddWorkflowNode(templateType, {
position: anchoredPosition,
connectFromSelection: false,
connectFromSelection: Boolean(sourceNode),
connectFromNodeId: sourceNode?.id || null,
});
setVideoCanvasInsertMenu(null);
},
[handleAddWorkflowNode, videoCanvasInsertMenu]
[handleAddWorkflowNode, nodes, videoCanvasInsertMenu]
);
const handleCreateSelectedAgentResultBranches = useCallback(async () => {
@ -8574,125 +8619,120 @@ export default function CanvasEditorPage({ params }: PageProps) {
/>
</div>
{isVideoMode && videoCanvasInsertMenu ? (
<DropdownMenu
modal={false}
onOpenChange={(open) => {
if (!open) {
setVideoCanvasInsertMenu(null);
}
}}
open={Boolean(videoCanvasInsertMenu)}
>
<DropdownMenuTrigger asChild>
<button
aria-hidden="true"
className="pointer-events-none fixed h-px w-px opacity-0"
style={{
left: `${videoCanvasInsertMenu.screenPosition.x}px`,
top: `${videoCanvasInsertMenu.screenPosition.y}px`,
}}
tabIndex={-1}
type="button"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-[312px] rounded-[28px] border border-slate-200 bg-white p-2.5 text-foreground shadow-[0_28px_80px_-32px_rgba(15,23,42,0.35)] dark:border-zinc-800 dark:bg-zinc-950"
onCloseAutoFocus={(event) => event.preventDefault()}
sideOffset={14}
>
<div className="px-4 py-3 font-semibold text-[14px] text-slate-500 dark:text-zinc-400">
@{siteSettingsData?.siteName || "视频画布"}
</div>
{VIDEO_CANVAS_INSERT_MENU_ITEMS.slice(0, -1).map((item) => {
{isVideoMode && videoCanvasInsertMenu
? (() => {
const menuWidth = 312;
const menuLeft =
viewportWidth > 0
? Math.max(
12,
Math.min(
videoCanvasInsertMenu.screenPosition.x,
Math.max(12, viewportWidth - menuWidth - 12)
)
)
: videoCanvasInsertMenu.screenPosition.x;
const menuTop = Math.max(
12,
videoCanvasInsertMenu.screenPosition.y + 14
);
const renderChildButton = (child: VideoCanvasInsertMenuChild) => (
<button
className="flex w-full items-center rounded-xl px-3 py-2 text-left font-medium text-[13px] text-slate-600 transition-colors hover:bg-slate-50 hover:text-slate-950 dark:text-slate-200 dark:hover:bg-zinc-900"
key={child.id}
onClick={() =>
handleInsertWorkflowNodeFromMenu(child.templateType)
}
type="button"
>
{child.label}
</button>
);
const renderInsertItem = (
item: VideoCanvasInsertMenuItem,
highlighted = false
) => {
const Icon = item.icon;
const textClassName = highlighted
? "text-orange-600 dark:text-orange-300"
: "text-slate-700 dark:text-slate-100";
const iconClassName = highlighted
? "text-orange-500"
: "text-slate-400 dark:text-zinc-500";
if (item.children?.length) {
return (
<DropdownMenuSub key={item.id}>
<DropdownMenuSubTrigger className="rounded-2xl px-4 py-3 font-medium text-[15px] text-slate-700 focus:bg-slate-50 data-[state=open]:bg-slate-50 dark:text-slate-100 dark:data-[state=open]:bg-zinc-900 dark:focus:bg-zinc-900">
<Icon className="h-5 w-5 text-slate-400 dark:text-zinc-500" />
<span>{item.label}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[220px] rounded-2xl border border-slate-200 bg-white p-1.5 shadow-[0_22px_60px_-28px_rgba(15,23,42,0.34)] dark:border-zinc-800 dark:bg-zinc-950">
{item.children.map((child) => (
<DropdownMenuItem
className="rounded-xl px-3 py-2.5 font-medium text-slate-700 text-sm focus:bg-slate-50 dark:text-slate-100 dark:focus:bg-zinc-900"
key={child.id}
onSelect={() =>
handleInsertWorkflowNodeFromMenu(
child.templateType
)
}
>
{child.label}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<div className="rounded-2xl" key={item.id}>
<div
className={cn(
"flex items-center gap-3 rounded-2xl px-4 py-3 font-semibold text-[15px]",
textClassName
)}
>
<Icon className={cn("h-5 w-5", iconClassName)} />
<span className="flex-1">{item.label}</span>
<ChevronRight className="h-4 w-4 opacity-60" />
</div>
<div className="ml-8 mr-2 mb-1 grid gap-0.5 border-slate-100 border-l pl-2 dark:border-zinc-800">
{item.children.map(renderChildButton)}
</div>
</div>
);
}
return (
<DropdownMenuItem
className="rounded-2xl px-4 py-3 font-medium text-[15px] text-slate-700 focus:bg-slate-50 dark:text-slate-100 dark:focus:bg-zinc-900"
<button
className={cn(
"flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-left font-medium text-[15px] transition-colors hover:bg-slate-50 dark:hover:bg-zinc-900",
textClassName
)}
key={item.id}
onSelect={() =>
onClick={() =>
item.templateType &&
handleInsertWorkflowNodeFromMenu(item.templateType)
}
type="button"
>
<Icon className="h-5 w-5 text-slate-400 dark:text-zinc-500" />
<Icon className={cn("h-5 w-5", iconClassName)} />
<span>{item.label}</span>
</DropdownMenuItem>
</button>
);
})}
};
const highlightedItem = VIDEO_CANVAS_INSERT_MENU_ITEMS.at(-1);
<DropdownMenuSeparator className="mx-3 my-2 bg-slate-200 dark:bg-zinc-800" />
return (
<div
className="fixed z-[70] max-h-[calc(100vh-24px)] overflow-y-auto rounded-[28px] border border-slate-200 bg-white p-2.5 text-foreground shadow-[0_28px_80px_-32px_rgba(15,23,42,0.35)] dark:border-zinc-800 dark:bg-zinc-950"
data-canvas-overlay="true"
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
ref={videoCanvasInsertMenuRef}
style={{
left: `${menuLeft}px`,
top: `${menuTop}px`,
width: `${menuWidth}px`,
}}
>
<div className="px-4 py-3 font-semibold text-[14px] text-slate-500 dark:text-zinc-400">
@{siteSettingsData?.siteName || "视频画布"}
</div>
{(() => {
const highlightedItem = VIDEO_CANVAS_INSERT_MENU_ITEMS.at(-1);
if (!highlightedItem) return null;
const HighlightIcon = highlightedItem.icon;
return highlightedItem.children?.length ? (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="rounded-2xl px-4 py-3 font-semibold text-[15px] text-orange-600 focus:bg-orange-50 data-[state=open]:bg-orange-50 dark:text-orange-300 dark:data-[state=open]:bg-orange-950/40 dark:focus:bg-orange-950/40">
<HighlightIcon className="h-5 w-5 text-orange-500" />
<span>{highlightedItem.label}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[220px] rounded-2xl border border-slate-200 bg-white p-1.5 shadow-[0_22px_60px_-28px_rgba(15,23,42,0.34)] dark:border-zinc-800 dark:bg-zinc-950">
{highlightedItem.children.map((child) => (
<DropdownMenuItem
className="rounded-xl px-3 py-2.5 font-medium text-slate-700 text-sm focus:bg-slate-50 dark:text-slate-100 dark:focus:bg-zinc-900"
key={child.id}
onSelect={() =>
handleInsertWorkflowNodeFromMenu(child.templateType)
}
>
{child.label}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
) : (
<DropdownMenuItem
className="rounded-2xl px-4 py-3 font-semibold text-[15px] text-orange-600 focus:bg-orange-50 dark:text-orange-300 dark:focus:bg-orange-950/40"
onSelect={() =>
highlightedItem.templateType &&
handleInsertWorkflowNodeFromMenu(
highlightedItem.templateType
)
}
>
<HighlightIcon className="h-5 w-5 text-orange-500" />
<span>{highlightedItem.label}</span>
</DropdownMenuItem>
);
})()}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<div className="grid gap-0.5">
{VIDEO_CANVAS_INSERT_MENU_ITEMS.slice(0, -1).map((item) =>
renderInsertItem(item)
)}
</div>
{highlightedItem ? (
<>
<div className="mx-3 my-2 h-px bg-slate-200 dark:bg-zinc-800" />
{renderInsertItem(highlightedItem, true)}
</>
) : null}
</div>
);
})()
: null}
{isVideoMode && isVideoModuleSidebarEnabled ? (
<div
@ -8930,7 +8970,9 @@ export default function CanvasEditorPage({ params }: PageProps) {
<button
aria-label="展开关联模块"
className="-translate-x-1/2 -translate-y-1/2 pointer-events-auto absolute top-0 left-0 flex h-14 w-14 items-center justify-center rounded-[20px] border border-[#d7dff1] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.34),0_18px_34px_rgba(22,24,29,0.12)] transition-colors hover:bg-[#f5f8ff] hover:text-[#3954db] dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-300 dark:hover:bg-zinc-900 dark:hover:text-zinc-100"
onClick={() =>
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setVideoCanvasInsertMenu({
screenPosition: {
x: selectedWorkflowQuickConnectAnchor.x,
@ -8944,8 +8986,10 @@ export default function CanvasEditorPage({ params }: PageProps) {
selectedWorkflowQuickConnectSourceNode.position.y +
selectedWorkflowQuickConnectSourceNode.size.height / 2,
},
})
}
sourceNodeId: selectedWorkflowQuickConnectSourceNode.id,
openLeft: selectedWorkflowQuickConnectAnchor.openLeft,
});
}}
type="button"
>
<Plus className="h-8 w-8" />
@ -8959,12 +9003,37 @@ export default function CanvasEditorPage({ params }: PageProps) {
? -1
: 1;
const Icon = getVideoWorkflowQuickConnectIcon(item.type);
const definition = getVideoWorkflowNodeDefinition(item.type);
return (
<button
aria-label={item.label}
className="-translate-x-1/2 -translate-y-1/2 pointer-events-auto absolute flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dbe2ee] bg-white text-[#566377] shadow-[0_0_0_1px_rgba(219,226,238,0.34),0_14px_26px_rgba(22,24,29,0.1)] transition-all hover:scale-[1.03] hover:border-[#cfdcff] hover:bg-[#f5f8ff] hover:text-[#3954db] dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-300 dark:hover:bg-zinc-900 dark:hover:text-zinc-100"
key={`quick-connect-node-${item.type}`}
onClick={() => handleAddWorkflowNode(item.type)}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
const position = definition
? {
x: selectedWorkflowQuickConnectAnchor.openLeft
? selectedWorkflowQuickConnectSourceNode.position.x -
definition.size.width -
180
: selectedWorkflowQuickConnectSourceNode.position.x +
selectedWorkflowQuickConnectSourceNode.size.width +
180,
y:
selectedWorkflowQuickConnectSourceNode.position.y +
selectedWorkflowQuickConnectSourceNode.size.height / 2 -
definition.size.height / 2 +
y / viewport.zoom,
}
: null;
handleAddWorkflowNode(item.type, {
connectFromSelection: true,
connectFromNodeId: selectedWorkflowQuickConnectSourceNode.id,
...(position ? { position } : {}),
});
}}
style={{
left: `${x * direction}px`,
top: `${y}px`,

View File

@ -1380,14 +1380,14 @@ export function DrawnixWorkspace({
src={displayLogo}
/>
) : (
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-[#111827] font-bold text-white text-xs shadow-sm">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-[#111827] font-bold text-white text-xs shadow-sm">
{siteName.slice(0, 1).toUpperCase()}
</div>
)}
<div className="flex items-center gap-2">
<input
aria-label="流程白板文件名"
className="h-7 max-w-[180px] rounded-lg border border-transparent bg-transparent px-2 font-bold text-[#111827] text-sm tracking-wide outline-none transition focus:border-black/10 focus:bg-[#f7f8fb]"
className="h-7 w-fit min-w-[120px] max-w-[200px] rounded-lg border border-transparent bg-transparent px-2 font-bold text-[#111827] text-sm tracking-wide outline-none transition focus:border-black/10 focus:bg-[#f7f8fb]"
onBlur={handleTitleCommit}
onChange={(event) => setCurrentTitle(event.target.value)}
onKeyDown={(event) => {
@ -1397,7 +1397,7 @@ export function DrawnixWorkspace({
}}
value={currentTitle}
/>
<span className="rounded-md bg-[#f7f8fb] px-1.5 py-0.5 font-medium text-[#7a8297] text-[11px]">
<span className="shrink-0 rounded-md bg-[#f7f8fb] px-1.5 py-0.5 font-medium text-[#7a8297] text-[11px]">
{siteName}
</span>
</div>
@ -1405,73 +1405,75 @@ export function DrawnixWorkspace({
<div className="h-5 w-px bg-black/10" />
<div className="flex items-center gap-1.5">
<button
className="flex h-8 items-center gap-1.5 rounded-xl px-2.5 font-medium text-[#4f566b] text-xs transition-colors hover:bg-black/5 hover:text-[#111827]"
disabled={isSavingNow}
onClick={() => flushPendingSave(true)}
type="button"
>
{isSavingNow ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
保存云端
</button>
<div className="flex flex-1 items-center justify-between gap-2">
<div className="flex items-center gap-1">
<button
className="flex h-8 items-center gap-1.5 rounded-xl px-2.5 font-medium text-[#4f566b] text-xs transition-colors hover:bg-black/5 hover:text-[#111827]"
disabled={isSavingNow}
onClick={() => flushPendingSave(true)}
type="button"
>
{isSavingNow ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
保存云端
</button>
<button
className="flex h-8 items-center gap-1.5 rounded-xl px-2.5 font-medium text-[#4f566b] text-xs transition-colors hover:bg-black/5 hover:text-[#111827]"
onClick={handleOpenFilePanel}
type="button"
>
<FolderOpen className="h-3.5 w-3.5" />
云文件
</button>
<button
className="flex h-8 items-center gap-1.5 rounded-xl px-2.5 font-medium text-[#4f566b] text-xs transition-colors hover:bg-black/5 hover:text-[#111827]"
onClick={handleOpenFilePanel}
type="button"
>
<FolderOpen className="h-3.5 w-3.5" />
云文件
</button>
<button
className="flex h-8 items-center gap-1.5 rounded-xl px-2.5 font-medium text-[#4f566b] text-xs transition-colors hover:bg-black/5 hover:text-[#111827] disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSavingNow || isImportingMindMap}
onClick={() => mindMapFileInputRef.current?.click()}
type="button"
>
{isImportingMindMap ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
导入
</button>
<button
className="flex h-8 items-center gap-1.5 rounded-xl px-2.5 font-medium text-[#4f566b] text-xs transition-colors hover:bg-black/5 hover:text-[#111827] disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSavingNow || isImportingMindMap}
onClick={() => mindMapFileInputRef.current?.click()}
type="button"
>
{isImportingMindMap ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
导入
</button>
<button
className={`flex h-8 items-center gap-1.5 rounded-xl px-2.5 font-medium text-xs transition-colors ${
isMarkerPanelOpen
? "bg-[#111827] text-white shadow-sm hover:bg-[#1f2937]"
: "text-[#4f566b] hover:bg-black/5 hover:text-[#111827]"
}`}
onClick={() => {
setIsFilePanelOpen(false);
setIsMarkerPanelOpen((open) => !open);
refreshMarkerSelectionState();
}}
type="button"
>
<Tag className="h-3.5 w-3.5" />
标记
</button>
<button
className={`flex h-8 items-center gap-1.5 rounded-xl px-2.5 font-medium text-xs transition-colors ${
isMarkerPanelOpen
? "bg-[#111827] text-white shadow-sm hover:bg-[#1f2937]"
: "text-[#4f566b] hover:bg-black/5 hover:text-[#111827]"
}`}
onClick={() => {
setIsFilePanelOpen(false);
setIsMarkerPanelOpen((open) => !open);
refreshMarkerSelectionState();
}}
type="button"
>
<Tag className="h-3.5 w-3.5" />
标记
</button>
<button
className="flex h-8 items-center gap-1.5 rounded-xl px-2.5 font-medium text-[#4f566b] text-xs transition-colors hover:bg-black/5 hover:text-[#111827]"
onClick={handleCreateCloudFile}
type="button"
>
<Plus className="h-3.5 w-3.5" />
新建
</button>
<button
className="flex h-8 items-center gap-1.5 rounded-xl px-2.5 font-medium text-[#4f566b] text-xs transition-colors hover:bg-black/5 hover:text-[#111827]"
onClick={handleCreateCloudFile}
type="button"
>
<Plus className="h-3.5 w-3.5" />
新建
</button>
</div>
<div className="ml-1 flex items-center gap-1.5 px-2 text-[#7a8297]">
<div className="flex shrink-0 items-center gap-1.5 pl-2 text-[#7a8297]">
<Save className="h-3.5 w-3.5" />
<span className="font-medium text-xs">
<span className="whitespace-nowrap font-medium text-xs">
{formatSavedAt(lastSavedAt)}
</span>
</div>