feat(canvas): 沉浸专注与短剧流程 - 支持专注模式与流程条并入顶栏体系 (#157)
- 沉浸专注:进入后隐藏顶部栏、Dock、缩放控件、小地图与素材托盘,保留专注胶囊可唤出工具与智能体;小屏默认进入,⇧⌘F 切换,Esc 在无选中与浮层时退出 - 智能体面板改贴边停靠并按顶栏让位,作为沉浸专注的配套依赖 - 短剧流程条并入顶栏:折叠入口移入顶栏按钮组,展开条锚定顶栏下缘,与工作模式开关互斥 - 流程步骤改为里程碑节点与状态轨道,新增 --flow-step-* Design Token Co-authored-by: sikongyue <3155157671@qq.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
94
web/src/components/canvas/canvas-focus-mode-bar.tsx
Normal file
94
web/src/components/canvas/canvas-focus-mode-bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
const FOCUS_MODE_KEY = "canvas-focus-mode-v1";
|
||||
const FORCED_FOCUS_BREAKPOINT = 1024;
|
||||
const FOCUS_MODE_KEY = "canvas-focus-mode-v2";
|
||||
const SMALL_SCREEN_BREAKPOINT = 1024;
|
||||
|
||||
// 默认策略:小屏自动沉浸(不可在顶栏手动切换),宽屏由用户偏好决定。
|
||||
// 用户手动切换后以持久化偏好为准,窗口缩放不再覆盖用户选择。
|
||||
function readInitialPreference(): boolean {
|
||||
if (typeof window === "undefined") return true;
|
||||
const stored = window.localStorage.getItem(FOCUS_MODE_KEY);
|
||||
if (stored === "1") return true;
|
||||
if (stored === "0") return false;
|
||||
return true;
|
||||
if (stored !== null) return stored === "true";
|
||||
return window.innerWidth < SMALL_SCREEN_BREAKPOINT;
|
||||
}
|
||||
|
||||
export function useFocusMode() {
|
||||
const [userPreference, setUserPreference] = useState<boolean>(readInitialPreference);
|
||||
const [viewportWidth, setViewportWidth] = useState<number>(() => (typeof window !== "undefined" ? window.innerWidth : 1024));
|
||||
const [smallScreen, setSmallScreen] = useState<boolean>(() => window.innerWidth < SMALL_SCREEN_BREAKPOINT);
|
||||
|
||||
const focusMode = smallScreen || userPreference;
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const handleResize = () => setViewportWidth(window.innerWidth);
|
||||
handleResize();
|
||||
const handleResize = () => setSmallScreen(window.innerWidth < SMALL_SCREEN_BREAKPOINT);
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
const forcedOn = viewportWidth < FORCED_FOCUS_BREAKPOINT;
|
||||
const focusMode = forcedOn || userPreference;
|
||||
|
||||
const toggleFocusMode = useCallback(() => {
|
||||
setUserPreference((prev) => {
|
||||
const next = !prev;
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(FOCUS_MODE_KEY, next ? "1" : "0");
|
||||
}
|
||||
return next;
|
||||
});
|
||||
const persist = useCallback((next: boolean) => {
|
||||
setUserPreference(next);
|
||||
try {
|
||||
window.localStorage.setItem(FOCUS_MODE_KEY, String(next));
|
||||
} catch {
|
||||
// 忽略 localStorage 不可用场景,专注模式仍可在本次会话生效。
|
||||
}
|
||||
}, []);
|
||||
|
||||
const enterFocusMode = useCallback(() => persist(true), [persist]);
|
||||
const exitFocusMode = useCallback(() => persist(false), [persist]);
|
||||
const toggleFocusMode = useCallback(() => persist(!(smallScreen || userPreference)), [persist, smallScreen, userPreference]);
|
||||
|
||||
return {
|
||||
focusMode,
|
||||
userPreference,
|
||||
forcedOn,
|
||||
enterFocusMode,
|
||||
exitFocusMode,
|
||||
toggleFocusMode,
|
||||
};
|
||||
}
|
||||
|
||||
76
web/src/pages/canvas/canvas-assistant-panel-column.tsx
Normal file
76
web/src/pages/canvas/canvas-assistant-panel-column.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -585,6 +585,14 @@
|
||||
--zoom-control-min: 0.05;
|
||||
--zoom-control-max: 2;
|
||||
--zoom-control-step: 0.1;
|
||||
|
||||
/* 短剧流程引导步骤(里程碑节点 + 状态轨道)*/
|
||||
--flow-step-node: var(--space-5); /* 20px 完成/待办节点直径 */
|
||||
--flow-step-node-current: var(--space-6); /* 24px 当前节点直径 */
|
||||
--flow-step-gap: var(--space-1); /* 4px 节点与标签间距 */
|
||||
--flow-step-track-height: var(--stroke-2); /* 2px 状态轨道高 */
|
||||
--flow-step-track-width: var(--space-5); /* 20px 轨道段长 */
|
||||
--flow-step-current-glow: 0 0 0 4px var(--cn-active-stroke-soft); /* 当前节点光晕 */
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
Reference in New Issue
Block a user