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

@@ -10,7 +10,7 @@ import type { GenerationTask } from "@/services/api/task-center";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
export function CanvasActiveTaskPanel({ tasks }: { tasks: GenerationTask[] }) {
export function CanvasActiveTaskPanel({ tasks, align = "right" }: { tasks: GenerationTask[]; align?: "left" | "right" }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
const reducedMotion = useReducedMotion();
@@ -41,7 +41,7 @@ export function CanvasActiveTaskPanel({ tasks }: { tasks: GenerationTask[] }) {
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.98 }}
transition={motionTransition}
className="pointer-events-none absolute right-3 top-[var(--canvas-topbar-offset)] z-[var(--z-panel-floating)] w-[var(--canvas-panel-width)]"
className={`pointer-events-none absolute top-3 z-[var(--z-panel-floating)] w-[var(--canvas-panel-width)] ${align === "left" ? "left-3" : "right-3"}`}
>
<LayoutGroup id="canvas-active-tasks">
<motion.section

View File

@@ -146,12 +146,10 @@ type CanvasAssistantPanelProps = {
onCollapse: () => void;
cinematicEntry?: boolean;
onCinematicEntryConsumed?: () => void;
width: number;
onWidthChange: (width: number) => void;
focusMode: boolean;
resizing?: boolean;
};
export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, projectId, sessions, activeSessionId, onSelectNodeIds, onSessionsChange, onApplyOps, canUndoOps, undoOpsCount, onUndoOps, onPasteImage, agentMode, onAgentModeChange, autoConnectLocal, closing, onCollapse, cinematicEntry = false, onCinematicEntryConsumed, width, onWidthChange, focusMode }: CanvasAssistantPanelProps) {
export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, projectId, sessions, activeSessionId, onSelectNodeIds, onSessionsChange, onApplyOps, canUndoOps, undoOpsCount, onUndoOps, onPasteImage, agentMode, onAgentModeChange, autoConnectLocal, closing, onCollapse, cinematicEntry = false, onCinematicEntryConsumed, resizing = false }: CanvasAssistantPanelProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const user = useUserStore((state) => state.user);
const effectiveConfig = useEffectiveConfig();
@@ -166,7 +164,6 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
const [isRunning, setIsRunning] = useState(false);
const [deleteChatIds, setDeleteChatIds] = useState<string[]>([]);
const [onlineLogs, setOnlineLogs] = useState<OnlineAgentLog[]>([]);
const [resizing, setResizing] = useState(false);
const [removedReferenceIds, setRemovedReferenceIds] = useState<Set<string>>(new Set());
const [localSessions, setLocalSessions] = useState<CanvasAssistantSession[]>(() => (sessions.length ? sessions : [createSession()]));
const [localActiveSessionId, setLocalActiveSessionId] = useState<string | null>(activeSessionId);
@@ -684,22 +681,6 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
if (file) onPasteImage(file);
};
const startResize = () => {
const move = (event: MouseEvent) => onWidthChange(Math.min(760, Math.max(320, window.innerWidth - event.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);
};
const collapse = () => {
onCollapse();
};
@@ -834,42 +815,20 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
</>
);
// 面板顶部偏移:顶部栏高度 + 间距,活动任务面板为 absolute 定位无需额外预留
const PANEL_SLOT_TOP_OFFSET = 80;
const panelOffsetRight = focusMode ? 0 : 12;
const panelOffsetBottom = focusMode ? 0 : 16;
return (
<motion.div
className="pointer-events-none absolute z-[var(--z-panel-floating)]"
initial={{ opacity: 0 }}
animate={{ opacity: closing ? 0 : 1 }}
<motion.aside
className="pointer-events-auto relative flex h-full w-full flex-col overflow-hidden rounded-[var(--panel-radius)] border"
initial={{ x: 48, opacity: 0 }}
animate={{ x: closing ? 28 : 0, opacity: closing ? 0 : 1 }}
exit={{ opacity: 0 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
style={{
top: PANEL_SLOT_TOP_OFFSET,
right: panelOffsetRight,
bottom: panelOffsetBottom,
width: closing ? 0 : width,
overflow: "clip",
pointerEvents: closing ? "none" : undefined,
borderColor: theme.toolbar.border,
background: theme.spatial.elevated,
color: theme.node.text,
boxShadow: `0 24px 72px ${theme.spatial.shadow}`,
}}
>
<motion.aside
className="pointer-events-auto relative flex shrink-0 flex-col overflow-hidden rounded-[var(--panel-radius)] border"
initial={{ x: 48, opacity: 0 }}
animate={{ x: closing ? 28 : 0, opacity: closing ? 0 : 1 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
style={{
width,
height: "100%",
borderColor: theme.toolbar.border,
background: theme.spatial.elevated,
color: theme.node.text,
boxShadow: focusMode ? "none" : `0 24px 72px ${theme.spatial.shadow}`,
}}
>
<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="调整右侧面板宽度" />
<AgentPanelChrome
theme={theme}
mode={agentMode}
@@ -896,8 +855,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
) : (
onlineContent
)}
</motion.aside>
</motion.div>
</motion.aside>
);
}

View File

@@ -140,6 +140,7 @@ export function CanvasNodeContextMenu({
<>
<SpotlightSurface
spotlightColor={theme.toolbar.itemHover}
data-canvas-context-menu
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.97, x: -3, y: -3 }}
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }}

View File

@@ -0,0 +1,94 @@
import { motion, useReducedMotion } from "motion/react";
import { Bot, PanelBottom, X, ZoomIn, ZoomOut } from "lucide-react";
import { Tooltip } from "antd";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
type CanvasFocusModeBarProps = {
dockRevealed: boolean;
agentOpen: boolean;
zoomPercent: number;
onToggleDock: () => void;
onToggleAgent: () => void;
onExit: () => void;
onZoomIn: () => void;
onZoomOut: () => void;
onFit: () => void;
};
export function CanvasFocusModeBar({ dockRevealed, agentOpen, zoomPercent, onToggleDock, onToggleAgent, onExit, onZoomIn, onZoomOut, onFit }: CanvasFocusModeBarProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const reducedMotion = useReducedMotion();
return (
<motion.div
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: -12 }}
animate={{ opacity: 1, y: 0 }}
transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.panel}
className="pointer-events-auto absolute left-1/2 top-2 z-[var(--z-toolbar)] -translate-x-1/2"
role="toolbar"
aria-label="专注模式工具栏"
>
<div className="flex items-center gap-0.5 rounded-full p-1 backdrop-blur-2xl" style={{ background: theme.spatial.elevated, color: theme.node.text, boxShadow: "var(--workspace-overlay-shadow)" }}>
<Tooltip title="退出专注模式(Esc)">
<button
type="button"
onClick={onExit}
className="grid size-8 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10"
style={{ color: theme.node.text }}
aria-label="退出专注模式"
>
<X className="size-4" />
</button>
</Tooltip>
<span className="mx-0.5 h-4 w-px" style={{ background: theme.toolbar.border }} />
<Tooltip title={dockRevealed ? "收起工具" : "工具"}>
<button
type="button"
onClick={onToggleDock}
className="grid size-8 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10"
style={{ color: theme.node.text, background: dockRevealed ? theme.toolbar.itemHover : undefined }}
aria-label="工具"
aria-pressed={dockRevealed}
>
<PanelBottom className="size-4" />
</button>
</Tooltip>
<Tooltip title={agentOpen ? "收起智能体" : "智能体"}>
<button
type="button"
onClick={onToggleAgent}
className="grid size-8 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10"
style={{ color: theme.node.text, background: agentOpen ? theme.toolbar.itemHover : undefined }}
aria-label="智能体"
aria-pressed={agentOpen}
>
<Bot className="size-4" />
</button>
</Tooltip>
<span className="mx-0.5 h-4 w-px" style={{ background: theme.toolbar.border }} />
<Tooltip title="缩小">
<button type="button" onClick={onZoomOut} className="grid size-8 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10" style={{ color: theme.node.text }} aria-label="缩小">
<ZoomOut className="size-4" />
</button>
</Tooltip>
<button
type="button"
onClick={onFit}
className="grid h-8 min-w-14 place-items-center rounded-full px-2 text-xs font-medium tabular-nums transition hover:bg-black/5 dark:hover:bg-white/10"
style={{ color: theme.node.text }}
title="适应画布"
>
{Math.round(zoomPercent * 100)}%
</button>
<Tooltip title="放大">
<button type="button" onClick={onZoomIn} className="grid size-8 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10" style={{ color: theme.node.text }} aria-label="放大">
<ZoomIn className="size-4" />
</button>
</Tooltip>
</div>
</motion.div>
);
}

View File

@@ -1,6 +1,6 @@
import type { CSSProperties, ReactNode } from "react";
import { Fragment, type CSSProperties, type ReactNode } from "react";
import { Dropdown } from "antd";
import { AlignLeft, ArrowRight, Bot, Check, ChevronDown, ChevronRight, ChevronUp, Clapperboard, FolderKanban, Images, MoreHorizontal, Palette, Pencil, Plus, Sparkles, Type, Upload, X } from "lucide-react";
import { AlignLeft, ArrowRight, Bot, Check, ChevronDown, ChevronUp, Clapperboard, FolderKanban, Images, MoreHorizontal, Palette, Pencil, Plus, Sparkles, Type, Upload, X } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import type { CanvasShortDramaProgress, CanvasShortDramaStepId } from "@/lib/canvas/canvas-short-drama";
@@ -127,37 +127,51 @@ export function CanvasShortDramaGuide({ progress, collapsed, onToggle, onSkip, o
onStepClick: (stepId: CanvasShortDramaStepId) => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
if (!progress.active) return null;
if (collapsed) {
return (
<button type="button" data-canvas-no-zoom className="absolute left-[calc(50%+64px)] top-2 z-[var(--z-toolbar)] inline-flex h-8 max-w-[calc(50vw-76px)] min-w-0 items-center gap-2 rounded-lg border px-3 text-xs font-medium shadow-sm backdrop-blur outline-none focus-visible:ring-2" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text, "--tw-ring-color": theme.accent.primary } as CSSProperties} onClick={onToggle}>
<Clapperboard className="size-3.5 shrink-0" /><span className="truncate">短剧流程 {progress.completedCount}/5</span><ChevronDown className="size-3 shrink-0" />
</button>
);
}
if (!progress.active || collapsed) return null;
return (
<div data-canvas-no-zoom className="absolute left-1/2 top-[68px] z-[var(--z-toolbar)] flex max-w-[calc(100%_-_24px)] -translate-x-1/2 items-center gap-1 rounded-lg border p-1 shadow-sm backdrop-blur" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}>
<div className="hide-scrollbar flex max-w-[min(760px,calc(100vw-150px))] items-center overflow-x-auto">
{progress.steps.map((step, index) => (
<span key={step.id} className="flex shrink-0 items-center">
{index ? <ChevronRight className="mx-0.5 size-3 opacity-25" /> : null}
<button
type="button"
aria-current={step.status === "current" ? "step" : undefined}
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-transparent px-2 text-xs font-medium outline-none transition hover:bg-black/5 focus-visible:ring-1 focus-visible:ring-inset dark:hover:bg-white/10"
style={{
background: step.status === "current" ? theme.accent.primarySoft : "transparent",
borderColor: step.status === "current" ? `${theme.accent.primary}40` : "transparent",
color: step.status === "current" ? theme.accent.primary : step.status === "completed" ? theme.node.text : theme.node.muted,
"--tw-ring-color": theme.accent.primary,
} as CSSProperties}
onClick={() => onStepClick(step.id)}
>
<span className="grid size-4 place-items-center rounded-full border text-[var(--fs-micro)]" style={{ borderColor: step.status === "current" ? theme.accent.primary : theme.node.stroke, background: step.status === "completed" ? theme.accent.primary : "transparent", color: step.status === "completed" ? "#fff" : "currentColor" }}>{step.status === "completed" ? <Check className="size-2.5" /> : index + 1}</span>
{step.label}
</button>
</span>
))}
<div data-canvas-no-zoom className="absolute left-1/2 top-[var(--canvas-topbar-offset)] z-[var(--z-toolbar)] flex max-w-[calc(100%_-_24px)] -translate-x-1/2 items-center gap-1 rounded-lg border p-1 shadow-sm backdrop-blur" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}>
<div className="hide-scrollbar flex min-w-0 flex-1 items-center overflow-x-auto">
<div className="flex shrink-0 items-center px-[var(--space-1)]">
{progress.steps.map((step, index) => (
<Fragment key={step.id}>
{index ? (
<span
aria-hidden
className="shrink-0 rounded-full transition-colors duration-150 motion-reduce:transition-none"
style={{ width: "var(--flow-step-track-width)", height: "var(--flow-step-track-height)", background: step.status === "pending" ? theme.node.stroke : theme.accent.primary }}
/>
) : null}
<button
type="button"
aria-current={step.status === "current" ? "step" : undefined}
className="flex h-8 shrink-0 items-center gap-[var(--flow-step-gap)] rounded-md px-[var(--space-1-half)] outline-none transition-colors motion-reduce:transition-none hover:bg-black/5 focus-visible:ring-1 focus-visible:ring-inset dark:hover:bg-white/10"
style={{ "--tw-ring-color": theme.accent.primary } as CSSProperties}
onClick={() => onStepClick(step.id)}
>
<span
className="grid shrink-0 place-items-center rounded-full font-semibold transition-all duration-150 motion-reduce:transition-none"
style={{
width: step.status === "current" ? "var(--flow-step-node-current)" : "var(--flow-step-node)",
height: step.status === "current" ? "var(--flow-step-node-current)" : "var(--flow-step-node)",
background: step.status === "pending" ? "transparent" : theme.accent.primary,
border: step.status === "pending" ? "var(--stroke-2) solid var(--cn-stroke)" : "none",
color: step.status === "pending" ? theme.node.muted : "#fff",
boxShadow: step.status === "current" ? "var(--flow-step-current-glow)" : undefined,
fontSize: step.status === "current" ? "var(--fs-body)" : "var(--fs-caption)",
}}
>
{step.status === "completed" ? <Check className="size-3.5" /> : index + 1}
</span>
<span
className="whitespace-nowrap text-[var(--fs-caption)] font-semibold transition-colors duration-150 motion-reduce:transition-none"
style={{ color: step.status === "current" ? theme.accent.primary : step.status === "completed" ? theme.node.text : theme.node.muted }}
>
{step.label}
</span>
</button>
</Fragment>
))}
</div>
</div>
<span className="mx-1 h-4 w-px shrink-0" style={{ background: theme.toolbar.border }} />
{!progress.completed ? <button type="button" className="inline-flex h-8 shrink-0 items-center gap-1 rounded-md px-2 text-[var(--fs-label)] outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" style={{ color: theme.node.muted, "--tw-ring-color": theme.accent.primary } as CSSProperties} onClick={onSkip}><X className="size-3" />跳过导引</button> : null}

View File

@@ -54,6 +54,8 @@ export const FloatingDock = forwardRef<HTMLDivElement, FloatingDockProps>(functi
const mouseX = useMotionValue(Number.POSITIVE_INFINITY);
const reducedMotion = useReducedMotion();
const [coarsePointer, setCoarsePointer] = useState(() => typeof window !== "undefined" && window.matchMedia("(pointer: coarse)").matches);
// 窄屏下 dock 按钮总宽易超出可用宽度:此时允许横向滚动并禁用放大(放大依赖 overflow-visible,与滚动互斥)
const [narrow, setNarrow] = useState(() => (typeof window !== "undefined" ? window.innerWidth < 768 : false));
useEffect(() => {
const media = window.matchMedia("(pointer: coarse)");
@@ -63,7 +65,15 @@ export const FloatingDock = forwardRef<HTMLDivElement, FloatingDockProps>(functi
return () => media.removeEventListener("change", update);
}, []);
const motionEnabled = !reducedMotion && !coarsePointer;
useEffect(() => {
const update = () => setNarrow(window.innerWidth < 768);
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, []);
// scrollable 场景(触屏或窄屏)禁用放大并允许横向滚动,保证按钮始终可达
const scrollable = coarsePointer || narrow;
const motionEnabled = !reducedMotion && !scrollable;
const metrics = coarsePointer ? TOUCH_DOCK_METRICS[size] : DOCK_METRICS[size];
return (
@@ -72,7 +82,8 @@ export const FloatingDock = forwardRef<HTMLDivElement, FloatingDockProps>(functi
role="toolbar"
aria-label={ariaLabel}
className={cn(
"aceternity-floating-dock flex overflow-visible",
"aceternity-floating-dock flex",
scrollable ? "overflow-x-auto" : "overflow-visible",
showLabels ? "items-center" : "items-end",
embedded ? "shadow-none" : "border backdrop-blur-2xl",
showLabels
@@ -163,6 +174,8 @@ function DockCommandButton({ command, mouseX, metrics, motionEnabled, compact, s
const iconSize = useSpring(iconTarget, aceternityMotion.spring.dock);
// 鼠标点击产生的 focus 不能阻塞提示收起,只有键盘可见焦点才持续显示提示。
const showTooltip = !showLabel && (hovered || focused) && !command.disabled;
// scrollable 场景自定义 tooltip 会被 overflow 裁剪,用原生 title 兜底
const nativeTitle = !motionEnabled ? command.label : undefined;
if (showLabel) {
return (
@@ -195,6 +208,7 @@ function DockCommandButton({ command, mouseX, metrics, motionEnabled, compact, s
<motion.button
type="button"
aria-label={command.label}
title={nativeTitle}
aria-expanded={command.expands ? command.active || undefined : undefined}
aria-pressed={command.expands ? undefined : command.active || undefined}
disabled={command.disabled}