diff --git a/web/src/components/canvas/canvas-active-task-panel.tsx b/web/src/components/canvas/canvas-active-task-panel.tsx index eaa988d4a..a7e30489b 100644 --- a/web/src/components/canvas/canvas-active-task-panel.tsx +++ b/web/src/components/canvas/canvas-active-task-panel.tsx @@ -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"}`} > 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([]); const [onlineLogs, setOnlineLogs] = useState([]); - const [resizing, setResizing] = useState(false); const [removedReferenceIds, setRemovedReferenceIds] = useState>(new Set()); const [localSessions, setLocalSessions] = useState(() => (sessions.length ? sessions : [createSession()])); const [localActiveSessionId, setLocalActiveSessionId] = useState(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 ( - - - + + + + + + + + + + + + + + + + + + + ); +} diff --git a/web/src/components/canvas/canvas-short-drama-entry.tsx b/web/src/components/canvas/canvas-short-drama-entry.tsx index 4f385b88c..1ad806b4b 100644 --- a/web/src/components/canvas/canvas-short-drama-entry.tsx +++ b/web/src/components/canvas/canvas-short-drama-entry.tsx @@ -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 ( - - ); - } + if (!progress.active || collapsed) return null; return ( -
-
- {progress.steps.map((step, index) => ( - - {index ? : null} - - - ))} +
+
+
+ {progress.steps.map((step, index) => ( + + {index ? ( + + ) : null} + + + ))} +
{!progress.completed ? : null} diff --git a/web/src/components/ui/aceternity/floating-dock.tsx b/web/src/components/ui/aceternity/floating-dock.tsx index f16df275a..92892a6c7 100644 --- a/web/src/components/ui/aceternity/floating-dock.tsx +++ b/web/src/components/ui/aceternity/floating-dock.tsx @@ -54,6 +54,8 @@ export const FloatingDock = forwardRef(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(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(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 (readInitialPreference); - const [viewportWidth, setViewportWidth] = useState(() => (typeof window !== "undefined" ? window.innerWidth : 1024)); + const [smallScreen, setSmallScreen] = useState(() => 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, }; } diff --git a/web/src/pages/canvas/canvas-assistant-panel-column.tsx b/web/src/pages/canvas/canvas-assistant-panel-column.tsx new file mode 100644 index 000000000..bf5e82930 --- /dev/null +++ b/web/src/pages/canvas/canvas-assistant-panel-column.tsx @@ -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(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 ( +
+
+ {!closing ? ( +
+
+ ); +} diff --git a/web/src/pages/canvas/canvas-project-top-bar.tsx b/web/src/pages/canvas/canvas-project-top-bar.tsx index f5d028eec..f8c629b4f 100644 --- a/web/src/pages/canvas/canvas-project-top-bar.tsx +++ b/web/src/pages/canvas/canvas-project-top-bar.tsx @@ -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(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({
- +
+ + ) : null}