feat(canvas): 沉浸专注与短剧流程 - 支持专注模式与流程条并入顶栏体系 (#157)

- 沉浸专注:进入后隐藏顶部栏、Dock、缩放控件、小地图与素材托盘,保留专注胶囊可唤出工具与智能体;小屏默认进入,⇧⌘F 切换,Esc 在无选中与浮层时退出
- 智能体面板改贴边停靠并按顶栏让位,作为沉浸专注的配套依赖
- 短剧流程条并入顶栏:折叠入口移入顶栏按钮组,展开条锚定顶栏下缘,与工作模式开关互斥
- 流程步骤改为里程碑节点与状态轨道,新增 --flow-step-* Design Token

Co-authored-by: sikongyue <3155157671@qq.com>
This commit is contained in:
爱笑的毛毛虫
2026-08-06 08:32:38 +08:00
committed by GitHub
parent 6671c331e3
commit 971489a336
12 changed files with 519 additions and 255 deletions

View File

@@ -0,0 +1,76 @@
import { useCallback, useRef, useState, type ReactNode } from "react";
// 根据视口宽度动态计算面板宽度约束,避免小屏幕上面板挤压画布
export function getPanelWidthBounds(): { min: number; max: number } {
const vw = typeof window !== "undefined" ? window.innerWidth : 1280;
if (vw < 768) return { min: 260, max: 360 };
if (vw < 1024) return { min: 280, max: 440 };
if (vw < 1440) return { min: 320, max: 560 };
return { min: 360, max: 760 };
}
// 智能体面板列包裹器:承载面板宽度管理、resize 拖拽和顶部停靠偏移。
// 面板始终作为 flex 子元素避让画布;topInset 用于避让绝对定位的顶部栏(沉浸专注时顶部栏隐藏,传 0)。
// closing 期间保持宽度和渲染,让面板播放滑出动画;动画结束后由父组件卸载列。
export function AssistantPanelColumn({
width,
closing,
topInset,
onWidthChange,
children,
}: {
width: number;
closing: boolean;
topInset: string;
onWidthChange: (width: number) => void;
children: (resizing: boolean) => ReactNode;
}) {
const columnRef = useRef<HTMLDivElement>(null);
const [resizing, setResizing] = useState(false);
// 拖拽时列右边缘固定(flex 末位),左边缘随鼠标移动。
const startResize = useCallback((event: React.MouseEvent) => {
event.preventDefault();
const rightEdge = columnRef.current?.getBoundingClientRect().right ?? 0;
const { min, max } = getPanelWidthBounds();
const move = (e: MouseEvent) => {
onWidthChange(Math.min(max, Math.max(min, rightEdge - e.clientX)));
};
const stop = () => {
setResizing(false);
document.body.style.cursor = "";
document.body.style.userSelect = "";
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", stop);
};
setResizing(true);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
document.addEventListener("mousemove", move);
document.addEventListener("mouseup", stop);
}, [onWidthChange]);
return (
<div
ref={columnRef}
className="relative flex shrink-0 overflow-hidden"
style={{
width,
paddingTop: topInset,
transition: resizing ? "none" : "width var(--motion-dur-base-calc) var(--motion-ease-out), padding-top var(--motion-dur-base-calc) var(--motion-ease-out)",
}}
>
<div className="h-full w-full">
{!closing ? (
<button
type="button"
className="absolute inset-y-0 left-0 z-[var(--node-z-overlay)] w-4 -translate-x-1/2 cursor-col-resize"
onMouseDown={startResize}
aria-label="调整右侧面板宽度"
/>
) : null}
{children(resizing)}
</div>
</div>
);
}

View File

@@ -1,12 +1,13 @@
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Link } from "react-router";
import { Bot, Check, ChevronDown, Coins, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, PanelRightClose, PanelRightOpen, Plus, Redo2, Search, Settings2, Share2, Sparkles, Trash2, Undo2, Upload } from "lucide-react";
import { Bot, Check, ChevronDown, Clapperboard, Coins, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Settings2, Share2, Sparkles, Trash2, Undo2, Upload } from "lucide-react";
import { Button, Dropdown, Modal, Tooltip } from "antd";
import { useWalletBalance } from "@/hooks/use-wallet-balance";
import { aceternityMotion } from "@/lib/aceternity-motion";
import type { CanvasContextSummary } from "@/lib/canvas/canvas-context-summary";
import type { CanvasShortDramaProgress } from "@/lib/canvas/canvas-short-drama";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
@@ -38,9 +39,8 @@ type CanvasTopBarProps = {
onMediaPerformanceModeChange: (mode: CanvasMediaPerformanceMode) => void;
onOpenSearch: () => void;
projectContext?: CanvasContextSummary & { projectId: string; projectName: string };
focusMode: boolean;
focusModeForced: boolean;
onToggleFocusMode: () => void;
onEnterFocusMode: () => void;
shortDramaGuide?: { progress: CanvasShortDramaProgress; collapsed: boolean; onToggle: () => void };
};
export function CanvasTopBar({
@@ -69,9 +69,8 @@ export function CanvasTopBar({
onMediaPerformanceModeChange,
onOpenSearch,
projectContext,
focusMode,
focusModeForced,
onToggleFocusMode,
onEnterFocusMode,
shortDramaGuide,
}: CanvasTopBarProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const user = useUserStore((state) => state.user);
@@ -79,6 +78,19 @@ export function CanvasTopBar({
const { availableMicrocredits, refreshing } = useWalletBalance(user?.id, creditsEnabled);
const titleRef = useRef<HTMLDivElement>(null);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const [workspaceModeOpen, setWorkspaceModeOpen] = useState(false);
const handleWorkspaceModeOpenChange = (next: boolean) => {
// 模式开关下拉向下展开,会覆盖展开中的短剧流程条,打开时先收起流程条避免重叠。
if (next && shortDramaGuide && !shortDramaGuide.collapsed) shortDramaGuide.onToggle();
setWorkspaceModeOpen(next);
};
const handleShortDramaGuideToggle = () => {
// 展开流程条会与模式开关下拉在顶部中心重叠,展开前先关闭模式开关。
if (shortDramaGuide?.collapsed) setWorkspaceModeOpen(false);
shortDramaGuide?.onToggle();
};
useEffect(() => {
if (shortcutRequestNonce > 0) setShortcutsOpen(true);
@@ -175,7 +187,7 @@ export function CanvasTopBar({
</div>
</div>
<CanvasWorkspaceModeSwitch mode={workspaceMode} onChange={onWorkspaceModeChange} />
<CanvasWorkspaceModeSwitch mode={workspaceMode} onChange={onWorkspaceModeChange} open={workspaceModeOpen} onOpenChange={handleWorkspaceModeOpenChange} />
<div className="pointer-events-auto flex items-center gap-1.5">
<Button type="text" className="!hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Search className="size-4" />} onClick={onOpenSearch} aria-label="搜索画布节点" title="搜索画布节点" />
@@ -206,22 +218,30 @@ export function CanvasTopBar({
<span>{availableMicrocredits === null ? "--" : (availableMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 3 })}</span>
</Link>
) : null}
<Tooltip title={focusModeForced ? "小屏强制避让模式" : focusMode ? "专注模式:已避让(点击切换浮层)" : "专注模式:已浮层(点击切换避让)"}>
<Tooltip title="进入专注模式(⇧⌘F)">
<Button
type="text"
className="!h-10 !w-10 !min-w-10 !rounded-xl !p-0"
style={{
color: theme.node.text,
opacity: focusModeForced ? 0.45 : 1,
background: focusMode ? theme.toolbar.panel : undefined,
}}
icon={focusMode ? <PanelRightClose className="size-4" /> : <PanelRightOpen className="size-4" />}
onClick={onToggleFocusMode}
disabled={focusModeForced}
aria-pressed={focusMode}
aria-label="专注模式:画布与侧面板避让/浮层切换"
style={{ color: theme.node.text }}
icon={<Focus className="size-4" />}
onClick={onEnterFocusMode}
aria-label="进入专注模式"
/>
</Tooltip>
{shortDramaGuide ? (
<Tooltip title={shortDramaGuide.collapsed ? "展开短剧流程" : "收起短剧流程"}>
<Button
type="text"
className="!h-10 !rounded-xl !px-2.5 !font-medium"
style={{ color: theme.node.text, background: shortDramaGuide.collapsed ? undefined : theme.toolbar.activeBg }}
icon={<Clapperboard className="size-4" />}
onClick={handleShortDramaGuideToggle}
aria-label="短剧流程"
>
<span className="tabular-nums">{shortDramaGuide.progress.completedCount}/5</span>
</Button>
</Tooltip>
) : null}
<Button type="text" className="!h-10 !w-10 !min-w-10 !rounded-xl !p-0" style={{ color: theme.node.text }} icon={<Share2 className="size-4" />} onClick={onShare} aria-label="分享画布" title="分享画布" />
<span className="h-6 w-px" style={{ background: theme.toolbar.border }} />
<Button
@@ -246,6 +266,7 @@ export function CanvasTopBar({
<Shortcut keys={["Alt", "点击 / 框选"]} value="移除选择节点" />
<Shortcut keys={["Ctrl / Cmd", "1 / 2 / 3"]} value="100% / 适应全部 / 适应选择" />
<Shortcut keys={["?"]} value="打开快捷键" />
<Shortcut keys={["Shift / Ctrl / Cmd", "F"]} value="进入 / 退出专注模式" />
<Shortcut keys={["Ctrl / Cmd", "A"]} value="全选节点" />
<Shortcut keys={["Ctrl / Cmd", "K"]} value="搜索并定位节点" />
<Shortcut keys={["Ctrl / Cmd", "C / V"]} value="复制 / 粘贴节点,或粘贴剪切板文本/图片" />
@@ -262,20 +283,19 @@ export function CanvasTopBar({
);
}
function CanvasWorkspaceModeSwitch({ mode, onChange }: { mode: CanvasWorkspaceMode; onChange: (mode: CanvasWorkspaceMode) => void }) {
function CanvasWorkspaceModeSwitch({ mode, onChange, open, onOpenChange }: { mode: CanvasWorkspaceMode; onChange: (mode: CanvasWorkspaceMode) => void; open: boolean; onOpenChange: (open: boolean) => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const reducedMotion = useReducedMotion();
const simple = mode === "simple";
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const closeOnOutsidePress = (event: PointerEvent) => {
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) setOpen(false);
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) onOpenChange(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
if (event.key === "Escape") onOpenChange(false);
};
document.addEventListener("pointerdown", closeOnOutsidePress);
document.addEventListener("keydown", closeOnEscape);
@@ -283,11 +303,11 @@ function CanvasWorkspaceModeSwitch({ mode, onChange }: { mode: CanvasWorkspaceMo
document.removeEventListener("pointerdown", closeOnOutsidePress);
document.removeEventListener("keydown", closeOnEscape);
};
}, [open]);
}, [open, onOpenChange]);
const selectMode = (nextMode: CanvasWorkspaceMode) => {
if (nextMode !== mode) onChange(nextMode);
setOpen(false);
onOpenChange(false);
};
return (
@@ -302,7 +322,7 @@ function CanvasWorkspaceModeSwitch({ mode, onChange }: { mode: CanvasWorkspaceMo
aria-haspopup="listbox"
aria-expanded={open}
aria-label={`当前为${simple ? "简洁" : "专业"}模式,点击切换`}
onClick={() => setOpen((value) => !value)}
onClick={() => onOpenChange(!open)}
>
<span className="grid size-6 shrink-0 place-items-center rounded-full" style={{ background: theme.toolbar.itemHover, color: theme.accent.primary }}>
{simple ? <Sparkles className="size-3" /> : <Settings2 className="size-3" />}

View File

@@ -19,6 +19,7 @@ import { getNodeSpec } from "@/constant/canvas";
import { CanvasConfigComposer } from "@/components/canvas/canvas-config-composer";
import { CanvasConfigNodePanel } from "@/components/canvas/canvas-config-node-panel";
import { CanvasAssistantPanel } from "@/components/canvas/canvas-assistant-panel";
import { AssistantPanelColumn, getPanelWidthBounds } from "./canvas-assistant-panel-column";
import { CanvasActiveTaskPanel } from "@/components/canvas/canvas-active-task-panel";
import { CanvasAssetTray } from "@/components/canvas/canvas-asset-tray";
import { CanvasProjectSidebar } from "@/components/canvas/canvas-project-sidebar";
@@ -67,6 +68,7 @@ import {
getGenerationCount,
} from "@/lib/canvas/canvas-project-generation";
import { CanvasTopBar } from "./canvas-project-top-bar";
import { CanvasFocusModeBar } from "@/components/canvas/canvas-focus-mode-bar";
import { CanvasProjectContextMenu } from "./canvas-project-context-menu";
import { CanvasProjectMediaDialogs } from "./canvas-project-media-dialogs";
import { CanvasProjectSelectionToolbar } from "./canvas-project-selection-toolbar";
@@ -239,12 +241,27 @@ function InfiniteCanvasPage() {
const [titleDraft, setTitleDraft] = useState("");
const [shortcutRequestNonce, setShortcutRequestNonce] = useState(0);
const [cinematicAgentEntry, setCinematicAgentEntry] = useState(false);
const [assistantWidth, setAssistantWidth] = useState(520);
// 面板初始宽度根据视口宽度动态选择,避免小屏幕上初始就过宽
const [assistantWidth, setAssistantWidth] = useState(() => {
const vw = typeof window !== "undefined" ? window.innerWidth : 1280;
if (vw < 768) return 300;
if (vw < 1024) return 360;
if (vw < 1440) return 440;
return 520;
});
// 窗口跨越断点时把面板宽度 clamp 到当前断点的合理区间,避免宽屏值在窄屏挤压画布
useEffect(() => {
const clamp = () => {
const { min, max } = getPanelWidthBounds();
setAssistantWidth((prev) => (prev < min ? min : prev > max ? max : prev));
};
window.addEventListener("resize", clamp);
return () => window.removeEventListener("resize", clamp);
}, []);
const { agentMode, assistantClosing, assistantMounted, assistantOpen, closeAgent, openAgent, setAgentMode } = useCanvasAssistantVisibility();
const { tasks: activeTasks } = useCanvasActiveTasks(projectId, projectLoaded);
const { focusMode, forcedOn, toggleFocusMode } = useFocusMode();
const anyPanelOpen = assistantMounted && !assistantClosing;
const panelColumnWidth = anyPanelOpen ? Math.max(assistantWidth, 332) : 0;
const { focusMode, enterFocusMode, exitFocusMode, toggleFocusMode } = useFocusMode();
const [focusDockRevealed, setFocusDockRevealed] = useState(false);
useEffect(() => {
persistCanvasWorkspaceMode(workspaceMode);
@@ -372,6 +389,17 @@ function InfiniteCanvasPage() {
openAgent("local");
}, [openAgent, projectLoaded, searchParams, setAgentMode]);
// 沉浸专注进入时收起智能体与小地图、重置 Dock 唤出态;仅响应「进入」瞬间,避免关闭专注内主动唤出的面板。
const prevFocusModeRef = useRef(focusMode);
useEffect(() => {
const enteredFocus = focusMode && !prevFocusModeRef.current;
prevFocusModeRef.current = focusMode;
if (!enteredFocus) return;
closeAgent();
setIsMiniMapOpen(false);
setFocusDockRevealed(false);
}, [closeAgent, focusMode]);
useEffect(() => {
if (!dialogNodeId) setNodeImageSettingsOpen(false);
@@ -929,6 +957,10 @@ function InfiniteCanvasPage() {
openTextEditor: openTextNodeEditor,
});
const shortDramaGuide = shortDramaEnabled && !currentProject?.projectId && shortDramaProgress.active
? { progress: shortDramaProgress, collapsed: shortDramaGuideCollapsed, onToggle: () => setShortDramaGuideCollapsed((value) => !value) }
: undefined;
const clearCanvas = useCallback(() => {
const drawingIds = nodesRef.current.flatMap((node) => node.type === CanvasNodeType.Drawing && node.metadata?.drawingId ? [node.metadata.drawingId] : []);
if (drawingIds.length) {
@@ -981,6 +1013,9 @@ function InfiniteCanvasPage() {
deselectCanvas,
zoomCanvasIn,
zoomCanvasOut,
focusMode,
exitFocusMode,
toggleFocusMode,
});
const handleAssistantSessionsChange = useCallback((sessions: CanvasAssistantSession[], activeId: string | null) => {
@@ -1381,47 +1416,45 @@ function InfiniteCanvasPage() {
<>
<a href="#canvas-main" className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[var(--z-toast)] focus:rounded-md focus:border focus:bg-background focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg">跳转到画布主内容</a>
<main id="canvas-main" tabIndex={-1} className="flex h-full min-h-0 overflow-hidden outline-none" style={{ background: theme.canvas.background, color: theme.node.text }}>
{shortDramaEnabled && currentProject?.projectId ? <CanvasProjectSidebar projectId={currentProject.projectId} detail={linkedProjectQuery.data} onAddChapter={handleProjectChapterInsert} onLocateStyle={locateProjectStyleNode} onOpenAssets={() => openProjectAssets()} /> : null}
{!focusMode && shortDramaEnabled && currentProject?.projectId ? <CanvasProjectSidebar projectId={currentProject.projectId} detail={linkedProjectQuery.data} onAddChapter={handleProjectChapterInsert} onLocateStyle={locateProjectStyleNode} onOpenAssets={() => openProjectAssets()} /> : null}
<section
className="relative min-w-0 flex-1 flex flex-col min-h-0 overflow-hidden transition-[padding]"
style={{
paddingRight: anyPanelOpen ? panelColumnWidth : undefined,
}}
className="relative min-w-0 flex-1 flex flex-col min-h-0 overflow-hidden"
>
<CanvasTopBar
title={currentProject?.title || "未命名画布"}
workspaceMode={workspaceMode}
onWorkspaceModeChange={setWorkspaceMode}
titleDraft={titleDraft}
isTitleEditing={titleEditing}
onTitleDraftChange={setTitleDraft}
onStartTitleEditing={startTitleEditing}
onFinishTitleEditing={finishTitleEditing}
onCancelTitleEditing={() => setTitleEditing(false)}
canUndo={historyState.canUndo}
canRedo={historyState.canRedo}
onCreateProject={createAndOpenProject}
onDeleteProject={deleteCurrentProject}
onImportImage={() => handleUploadRequest()}
onUndo={undoCanvas}
onRedo={redoCanvas}
onShare={() => setShareModalOpen(true)}
agentOpen={assistantOpen}
compactAgentStatus={codexCompactAgent ? { connected: localAgentConnected, enabled: localAgentEnabled, activity: localAgentActivity } : undefined}
onToggleAgent={() => (assistantOpen ? closeAgent() : openAgent())}
shortcutRequestNonce={shortcutRequestNonce}
mediaPerformanceMode={mediaPerformanceMode}
onMediaPerformanceModeChange={setMediaPerformanceMode}
onOpenSearch={() => setNodeSearchOpen(true)}
projectContext={shortDramaEnabled && currentProject?.projectId ? {
...canvasContext,
projectId: currentProject.projectId,
projectName: linkedProjectQuery.data?.project.name || currentProject.title,
} : undefined}
focusMode={focusMode}
focusModeForced={forcedOn}
onToggleFocusMode={toggleFocusMode}
/>
{!focusMode ? (
<CanvasTopBar
title={currentProject?.title || "未命名画布"}
workspaceMode={workspaceMode}
onWorkspaceModeChange={setWorkspaceMode}
titleDraft={titleDraft}
isTitleEditing={titleEditing}
onTitleDraftChange={setTitleDraft}
onStartTitleEditing={startTitleEditing}
onFinishTitleEditing={finishTitleEditing}
onCancelTitleEditing={() => setTitleEditing(false)}
canUndo={historyState.canUndo}
canRedo={historyState.canRedo}
onCreateProject={createAndOpenProject}
onDeleteProject={deleteCurrentProject}
onImportImage={() => handleUploadRequest()}
onUndo={undoCanvas}
onRedo={redoCanvas}
onShare={() => setShareModalOpen(true)}
agentOpen={assistantOpen}
compactAgentStatus={codexCompactAgent ? { connected: localAgentConnected, enabled: localAgentEnabled, activity: localAgentActivity } : undefined}
onToggleAgent={() => (assistantOpen ? closeAgent() : openAgent())}
shortcutRequestNonce={shortcutRequestNonce}
mediaPerformanceMode={mediaPerformanceMode}
onMediaPerformanceModeChange={setMediaPerformanceMode}
onOpenSearch={() => setNodeSearchOpen(true)}
projectContext={shortDramaEnabled && currentProject?.projectId ? {
...canvasContext,
projectId: currentProject.projectId,
projectName: linkedProjectQuery.data?.project.name || currentProject.title,
} : undefined}
onEnterFocusMode={enterFocusMode}
shortDramaGuide={shortDramaGuide}
/>
) : null}
<CanvasNodeSearchModal
open={nodeSearchOpen}
@@ -1441,15 +1474,14 @@ function InfiniteCanvasPage() {
}}
/>
<CanvasActiveTaskPanel tasks={activeTasks} />
{shortDramaEnabled && !currentProject?.projectId ? <CanvasShortDramaGuide progress={shortDramaProgress} collapsed={shortDramaGuideCollapsed} onToggle={() => setShortDramaGuideCollapsed((value) => !value)} onSkip={skipShortDramaGuide} onStepClick={activateShortDramaStep} /> : null}
{!focusMode && shortDramaGuide ? <CanvasShortDramaGuide progress={shortDramaGuide.progress} collapsed={shortDramaGuide.collapsed} onToggle={shortDramaGuide.onToggle} onSkip={skipShortDramaGuide} onStepClick={activateShortDramaStep} /> : null}
<CanvasShareModal projectId={projectId} open={shareModalOpen} onClose={() => setShareModalOpen(false)} beforeCreate={saveCanvasProject} />
<CanvasStylePickerModal open={stylePickerOpen} value={activeStylePresetId} onClose={() => setStylePickerOpen(false)} onSelect={selectCanvasStyle} />
<div className="relative flex-1 min-h-0">
<div className="relative flex min-h-0 min-w-0 flex-1">
<div className="relative min-w-0 flex-1 overflow-hidden">
<InfiniteCanvas
containerRef={containerRef}
viewport={viewport}
@@ -1542,6 +1574,97 @@ function InfiniteCanvasPage() {
onOpenDrawing={openDrawingNode}
/>
</InfiniteCanvas>
<CanvasActiveTaskPanel tasks={activeTasks} />
{focusMode ? (
<CanvasFocusModeBar
dockRevealed={focusDockRevealed}
agentOpen={assistantOpen}
zoomPercent={viewport.k}
onToggleDock={() => setFocusDockRevealed((value) => !value)}
onToggleAgent={() => (assistantOpen ? closeAgent() : openAgent())}
onExit={exitFocusMode}
onZoomIn={zoomCanvasIn}
onZoomOut={zoomCanvasOut}
onFit={resetViewport}
/>
) : null}
<CanvasFileDropOverlay active={fileDropActive} theme={theme} />
{emptyCanvasState}
{!focusMode || focusDockRevealed ? (
<CanvasToolbar
selectedCount={selectedNodeIds.size}
workspaceMode={workspaceMode}
canvasTool={canvasTool}
onToolChange={setCanvasTool}
isProjectLinked={Boolean(shortDramaEnabled && currentProject?.projectId)}
canUndo={historyState.canUndo}
canRedo={historyState.canRedo}
backgroundMode={backgroundMode}
showImageInfo={showImageInfo}
onAddImage={() => createNode(CanvasNodeType.Image)}
onAddVideo={() => createNode(CanvasNodeType.Video)}
onAddAudio={() => createNode(CanvasNodeType.Audio)}
onAddText={() => createNode(CanvasNodeType.Text)}
onChooseStyle={() => setStylePickerOpen(true)}
onAddScript={() => createNode(CanvasNodeType.Script)}
onAddFrame={() => createNode(CanvasNodeType.Frame)}
onAddDrawing={() => createNode(CanvasNodeType.Drawing)}
onOpenDirector={() => createDirectorShot()}
onUndo={undoCanvas}
onRedo={redoCanvas}
onUpload={() => handleUploadRequest()}
onDelete={() => deleteNodes(new Set(selectedNodeIds))}
onClear={() => setClearConfirmOpen(true)}
onDeselect={deselectCanvas}
onBackgroundModeChange={setBackgroundMode}
onShowImageInfoChange={setShowImageInfo}
onOpenMyAssets={() => {
openAssetsAtPosition();
}}
onOpenProjectCharacters={() => openProjectAssets("character")}
/>
) : null}
</div>
{assistantMounted ? (
<AssistantPanelColumn
width={assistantWidth}
closing={assistantClosing}
topInset={focusMode ? "0px" : "var(--canvas-topbar-offset)"}
onWidthChange={setAssistantWidth}
>
{(resizing) => (
<CanvasAssistantPanel
nodes={nodes}
selectedNodeIds={selectedNodeIds}
snapshot={agentSnapshot}
projectId={projectId}
sessions={chatSessions}
activeSessionId={activeChatId}
onSelectNodeIds={setSelectedNodeIds}
onSessionsChange={handleAssistantSessionsChange}
onApplyOps={applyAgentOps}
canUndoOps={canUndoAgentOps}
undoOpsCount={agentUndoCount}
onUndoOps={undoAgentOps}
onPasteImage={pasteAssistantImage}
agentMode={agentMode}
onAgentModeChange={setAgentMode}
autoConnectLocal={codexAutoConnect}
closing={assistantClosing}
onCollapse={closeAgent}
cinematicEntry={cinematicAgentEntry}
onCinematicEntryConsumed={() => setCinematicAgentEntry(false)}
resizing={resizing}
/>
)}
</AssistantPanelColumn>
) : null}
</div>
{angleNode?.metadata?.content ? (
@@ -1566,10 +1689,6 @@ function InfiniteCanvasPage() {
</CanvasNodePanelOverlay>
) : null}
<CanvasFileDropOverlay active={fileDropActive} theme={theme} />
{emptyCanvasState}
{pendingConnectionCreate ? <CanvasConnectionCreateMenu pending={pendingConnectionCreate} viewport={viewport} viewportSize={size} containerRef={containerRef} canCreateDrawing={canCreateDrawingFromConnection} onCreate={(type) => void createConnectedNode(type, pendingConnectionCreate)} onClose={cancelPendingConnectionCreate} /> : null}
{selectedNodeBounds && !selectionBox && !isNodeDragging ? <CanvasProjectSelectionToolbar anchorRef={selectionBoundsElementRef} containerRef={containerRef} count={selectedNodeBounds.count} selectedVideoCount={selectedVideoNodes.length} mergingVideos={Boolean(mergeVideoProgress)} onAlign={alignSelectedNodes} onArrange={arrangeSelectedNodes} onCreateStoryboard={createStoryboardGroup} onCreateReferenceGroup={createReferenceGroup} onMergeVideos={() => void mergeSelectedVideos()} /> : null}
@@ -1613,45 +1732,14 @@ function InfiniteCanvasPage() {
onDelete={(node) => deleteNodes(new Set([node.id]))}
/>
<CanvasToolbar
selectedCount={selectedNodeIds.size}
workspaceMode={workspaceMode}
canvasTool={canvasTool}
onToolChange={setCanvasTool}
isProjectLinked={Boolean(shortDramaEnabled && currentProject?.projectId)}
canUndo={historyState.canUndo}
canRedo={historyState.canRedo}
backgroundMode={backgroundMode}
showImageInfo={showImageInfo}
onAddImage={() => createNode(CanvasNodeType.Image)}
onAddVideo={() => createNode(CanvasNodeType.Video)}
onAddAudio={() => createNode(CanvasNodeType.Audio)}
onAddText={() => createNode(CanvasNodeType.Text)}
onChooseStyle={() => setStylePickerOpen(true)}
onAddScript={() => createNode(CanvasNodeType.Script)}
onAddFrame={() => createNode(CanvasNodeType.Frame)}
onAddDrawing={() => createNode(CanvasNodeType.Drawing)}
onOpenDirector={() => createDirectorShot()}
onUndo={undoCanvas}
onRedo={redoCanvas}
onUpload={() => handleUploadRequest()}
onDelete={() => deleteNodes(new Set(selectedNodeIds))}
onClear={() => setClearConfirmOpen(true)}
onDeselect={deselectCanvas}
onBackgroundModeChange={setBackgroundMode}
onShowImageInfoChange={setShowImageInfo}
onOpenMyAssets={() => {
openAssetsAtPosition();
}}
onOpenProjectCharacters={() => openProjectAssets("character")}
/>
{isMiniMapOpen && !focusMode ? <Minimap nodes={nodes} viewport={viewport} viewportSize={size} canvasContainerRef={containerRef} onViewportPreviewChange={previewViewport} onViewportChange={handleViewportChange} /> : null}
{isMiniMapOpen ? <Minimap nodes={nodes} viewport={viewport} viewportSize={size} canvasContainerRef={containerRef} onViewportPreviewChange={previewViewport} onViewportChange={handleViewportChange} /> : null}
<div data-canvas-no-zoom className="absolute bottom-4 left-4 z-[var(--z-panel)] flex items-end gap-2" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()}>
<CanvasZoomControls scale={viewport.k} containerRef={containerRef} onScaleChange={setZoomScale} onReset={resetViewport} isMiniMapOpen={isMiniMapOpen} onToggleMiniMap={() => setIsMiniMapOpen((value) => !value)} onOpenShortcuts={() => setShortcutRequestNonce((value) => value + 1)} />
<CanvasAssetTray assetImages={imageAssets} canvasImages={canvasImageNodes} showLibrary={!currentProject?.projectId} activeNodeId={selectedNodeIds.size === 1 ? Array.from(selectedNodeIds)[0] : null} onInsertAssetImage={(asset) => void createImageAssetNode(asset)} onFocusCanvasImage={focusCanvasImageNode} />
</div>
{!focusMode ? (
<div data-canvas-no-zoom className="absolute bottom-[calc(var(--canvas-inset-y)+var(--space-16))] left-4 z-[var(--z-panel)] flex items-end gap-2 lg:bottom-[var(--canvas-inset-y)]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()}>
<CanvasZoomControls scale={viewport.k} containerRef={containerRef} onScaleChange={setZoomScale} onReset={resetViewport} isMiniMapOpen={isMiniMapOpen} onToggleMiniMap={() => setIsMiniMapOpen((value) => !value)} onOpenShortcuts={() => setShortcutRequestNonce((value) => value + 1)} />
<CanvasAssetTray assetImages={imageAssets} canvasImages={canvasImageNodes} showLibrary={!currentProject?.projectId} activeNodeId={selectedNodeIds.size === 1 ? Array.from(selectedNodeIds)[0] : null} onInsertAssetImage={(asset) => void createImageAssetNode(asset)} onFocusCanvasImage={focusCanvasImageNode} />
</div>
) : null}
<CanvasProjectContextMenu
menu={contextMenu}
@@ -1792,33 +1880,6 @@ function InfiniteCanvasPage() {
/>
<CanvasProjectAssetModal open={projectAssetOpen} detail={linkedProjectQuery.data} initialCategory={projectAssetInitialCategory} onClose={closeProjectAssets} onInsert={(payloads) => handleProjectAssetsInsert(payloads, projectAssetInsertPosition)} />
{codexCompactAgent && !assistantMounted ? <CanvasLocalAgentPanel headless snapshot={agentSnapshot} canUndoOps={canUndoAgentOps} undoOpsCount={agentUndoCount} onApplyOps={applyAgentOps} onUndoOps={undoAgentOps} autoConnect={codexAutoConnect} /> : null}
{assistantMounted ? (
<CanvasAssistantPanel
nodes={nodes}
selectedNodeIds={selectedNodeIds}
snapshot={agentSnapshot}
projectId={projectId}
sessions={chatSessions}
activeSessionId={activeChatId}
onSelectNodeIds={setSelectedNodeIds}
onSessionsChange={handleAssistantSessionsChange}
onApplyOps={applyAgentOps}
canUndoOps={canUndoAgentOps}
undoOpsCount={agentUndoCount}
onUndoOps={undoAgentOps}
onPasteImage={pasteAssistantImage}
agentMode={agentMode}
onAgentModeChange={setAgentMode}
autoConnectLocal={codexAutoConnect}
closing={assistantClosing}
onCollapse={closeAgent}
cinematicEntry={cinematicAgentEntry}
onCinematicEntryConsumed={() => setCinematicAgentEntry(false)}
width={assistantWidth}
onWidthChange={setAssistantWidth}
focusMode={focusMode}
/>
) : null}
</section>
</main>
</>

View File

@@ -31,6 +31,9 @@ type UseCanvasKeyboardOptions = {
deselectCanvas: () => void;
zoomCanvasIn: () => void;
zoomCanvasOut: () => void;
focusMode: boolean;
exitFocusMode: () => void;
toggleFocusMode: () => void;
};
export function useCanvasKeyboard({
@@ -62,6 +65,9 @@ export function useCanvasKeyboard({
deselectCanvas,
zoomCanvasIn,
zoomCanvasOut,
focusMode,
exitFocusMode,
toggleFocusMode,
}: UseCanvasKeyboardOptions) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -92,6 +98,11 @@ export function useCanvasKeyboard({
if (!event.repeat) void saveCanvasProject();
return;
}
if (isModifierShortcut && !event.altKey && key === "f") {
event.preventDefault();
if (!event.repeat) toggleFocusMode();
return;
}
if (isTextEditingTarget) return;
const isCanvasControlTarget = Boolean(target?.closest("[data-canvas-no-zoom]"));
if (isCanvasControlTarget && !(isModifierShortcut && !event.altKey && (key === "c" || key === "v"))) return;
@@ -147,6 +158,13 @@ export function useCanvasKeyboard({
else if (selectedConnectionId) deleteConnection(selectedConnectionId);
}
if (event.key === "Escape") {
// 沉浸专注:无选中且无弹窗/下拉/右键菜单时,Esc 退出专注;否则保留原有取消选择行为。
const hasFocusOverlay = Boolean(document.querySelector(".ant-modal-wrap, .ant-dropdown, .ant-select-dropdown, .ant-popover, [data-canvas-context-menu]"));
if (focusMode && !selectedNodeIdsRef.current.size && !hasFocusOverlay) {
event.stopPropagation();
exitFocusMode();
return;
}
deselectCanvas();
setInfoNodeId(null);
setCropNodeId(null);
@@ -175,5 +193,5 @@ export function useCanvasKeyboard({
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("paste", handlePaste, true);
};
}, [cancelSelectionBox, copySelectedNodes, deleteConnection, deleteNodes, deselectCanvas, fitCanvasContent, fitCanvasSelection, nodesRef, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, restoreCopiedNodesFromText, saveCanvasProject, selectedConnectionId, selectedNodeIdsRef, setAnnotationNodeId, setContextMenu, setCropNodeId, setInfoNodeId, setMaskEditNodeId, setSelectedConnectionId, setSelectedNodeIds, setShortcutRequestNonce, shouldPreferCopiedNodes, undoCanvas, zoomCanvasIn, zoomCanvasOut, zoomToActualSize]);
}, [cancelSelectionBox, copySelectedNodes, deleteConnection, deleteNodes, deselectCanvas, exitFocusMode, fitCanvasContent, fitCanvasSelection, focusMode, nodesRef, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, restoreCopiedNodesFromText, saveCanvasProject, selectedConnectionId, selectedNodeIdsRef, setAnnotationNodeId, setContextMenu, setCropNodeId, setInfoNodeId, setMaskEditNodeId, setSelectedConnectionId, setSelectedNodeIds, setShortcutRequestNonce, shouldPreferCopiedNodes, toggleFocusMode, undoCanvas, zoomCanvasIn, zoomCanvasOut, zoomToActualSize]);
}