refactor(canvas): 浮层体系重构 — z-index 14档+专注模式+工具栏click触发 #126 #127 #128 (#129)

按视觉层重设计 z-index(12→14 档),新增节点层/激活节点层/节点工具栏层等
语义 token,并下沉节点内部 stacking context(--node-z-content/overlay/handle)
与 dock 子面板(--dock-z-popover),消除硬编码 z 值。

新增专注模式(避让分屏可切换):use-focus-mode 管理 localStorage 偏好与小屏
(<1024px)强制避让;助手面板从 flex 改为 absolute + 垂直分槽(顶部 288px
让位活动任务面板),画布区按面板宽度动态 padding;顶栏新增 PanelRight
切换按钮并带强制态反馈。

节点工具栏由 hover+select 触发放为 click-only(选中即显),组件由
canvas-node-hover-toolbar 重命名为 canvas-node-toolbar;移除 project 与
render-model 中冗余的 toolbarNodeId 状态。

活动任务面板 z-index 由 --z-modal-overlay(110) 降至 --z-panel-floating(80),
归位浮动面板层;AGENTS.md Elevation 同步至 14 档。

Co-authored-by: sikongyue <3155157671@qq.com>
This commit is contained in:
爱笑的毛毛虫
2026-08-04 20:46:25 +08:00
committed by GitHub
parent 850375158c
commit d6a42d53a1
17 changed files with 199 additions and 90 deletions

View File

@@ -1,7 +1,7 @@
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, Plus, Redo2, Search, Settings2, Share2, Sparkles, Trash2, Undo2, Upload } from "lucide-react";
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 { Button, Dropdown, Modal, Tooltip } from "antd";
import { useWalletBalance } from "@/hooks/use-wallet-balance";
@@ -38,6 +38,9 @@ type CanvasTopBarProps = {
onMediaPerformanceModeChange: (mode: CanvasMediaPerformanceMode) => void;
onOpenSearch: () => void;
projectContext?: CanvasContextSummary & { projectId: string; projectName: string };
focusMode: boolean;
focusModeForced: boolean;
onToggleFocusMode: () => void;
};
export function CanvasTopBar({
@@ -66,6 +69,9 @@ export function CanvasTopBar({
onMediaPerformanceModeChange,
onOpenSearch,
projectContext,
focusMode,
focusModeForced,
onToggleFocusMode,
}: CanvasTopBarProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const user = useUserStore((state) => state.user);
@@ -89,7 +95,7 @@ export function CanvasTopBar({
return (
<>
<div className="pointer-events-none absolute left-0 right-0 top-0 z-50 flex h-16 items-center justify-between px-4">
<div className="pointer-events-none absolute left-0 right-0 top-0 z-[var(--z-toolbar)] flex h-16 items-center justify-between px-4">
<div className="pointer-events-auto flex min-w-0 items-center gap-3">
<Dropdown
trigger={["click"]}
@@ -200,6 +206,22 @@ export function CanvasTopBar({
<span>{availableMicrocredits === null ? "--" : (availableMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 3 })}</span>
</Link>
) : null}
<Tooltip title={focusModeForced ? "小屏强制避让模式" : focusMode ? "专注模式:已避让(点击切换浮层)" : "专注模式:已浮层(点击切换避让)"}>
<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="专注模式:画布与侧面板避让/浮层切换"
/>
</Tooltip>
<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
@@ -269,7 +291,7 @@ function CanvasWorkspaceModeSwitch({ mode, onChange }: { mode: CanvasWorkspaceMo
};
return (
<div ref={rootRef} className="aceternity-mode-switch pointer-events-auto absolute left-1/2 top-2 z-30 -translate-x-1/2">
<div ref={rootRef} className="aceternity-mode-switch pointer-events-auto absolute left-1/2 top-2 z-[var(--dock-z-popover)] -translate-x-1/2">
<motion.button
type="button"
whileHover={reducedMotion ? undefined : { y: -1, scale: 1.015 }}

View File

@@ -161,7 +161,7 @@ export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
{props.selectedNodeBounds && !props.selectionBox && !props.isNodeDragging ? (
<div
ref={props.selectionBoundsElementRef}
className="pointer-events-none absolute z-[65] rounded-xl"
className="pointer-events-none absolute z-[var(--z-panel-floating)] rounded-xl"
style={{
left: props.selectedNodeBounds.left - 12 / viewportScale,
top: props.selectedNodeBounds.top - 12 / viewportScale,

View File

@@ -27,7 +27,7 @@ import { CanvasCharacterReferenceNodeContent } from "@/components/canvas/canvas-
import { CanvasCharacterReferenceModal } from "@/components/canvas/canvas-character-reference-modal";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { resolveCanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal";
import { CanvasNodeHoverToolbar, CanvasNodeInfoModal } from "@/components/canvas/canvas-node-hover-toolbar";
import { CanvasNodeToolbar, CanvasNodeInfoModal } from "@/components/canvas/canvas-node-toolbar";
import { CanvasNodeAnglePanel } from "@/components/canvas/canvas-node-angle-dialog";
import { CanvasTextEditorModal } from "@/components/canvas/canvas-text-editor-modal";
import { CanvasNodeSearchModal } from "@/components/canvas/canvas-node-search-modal";
@@ -45,6 +45,7 @@ import { CanvasScriptEditor, CanvasScriptNodeContent, STORYBOARD_HEADER_HEIGHT,
import { CanvasDirectorNodePanel } from "@/components/canvas/director/canvas-director-node-panel";
import { CanvasVersionCompareModal } from "@/components/canvas/canvas-version-compare-modal";
import { CanvasLocalAgentPanel } from "@/components/canvas/canvas-local-agent-panel";
import { useFocusMode } from "@/hooks/use-focus-mode";
import { useCanvasAgentStore } from "@/stores/canvas/use-canvas-agent-store";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay } from "@/components/canvas/canvas-workspace-overlays";
@@ -151,20 +152,20 @@ function CanvasRefreshShell() {
}}
/>
<div className="absolute bottom-5 left-1/2 z-50 flex h-14 -translate-x-1/2 items-center gap-1 rounded-xl border px-2 shadow-lg backdrop-blur" style={{ background: "var(--background)", borderColor: "var(--border)" }} aria-hidden="true">
<div className="absolute bottom-5 left-1/2 z-[var(--z-toolbar)] flex h-14 -translate-x-1/2 items-center gap-1 rounded-xl border px-2 shadow-lg backdrop-blur" style={{ background: "var(--background)", borderColor: "var(--border)" }} aria-hidden="true">
{Array.from({ length: 7 }).map((_, index) => (
<div key={index} className="size-8 rounded-md bg-current opacity-10" />
))}
</div>
<div className="absolute bottom-24 left-6 z-50 h-40 w-[240px] rounded-lg border shadow-2xl backdrop-blur-sm" style={{ background: "var(--background)", borderColor: "var(--border)" }} aria-hidden="true">
<div className="absolute bottom-24 left-6 z-[var(--z-toolbar)] h-40 w-[240px] rounded-lg border shadow-2xl backdrop-blur-sm" style={{ background: "var(--background)", borderColor: "var(--border)" }} aria-hidden="true">
<div className="absolute left-7 top-7 h-5 w-12 rounded-sm bg-current opacity-10" />
<div className="absolute left-28 top-16 h-6 w-16 rounded-sm bg-current opacity-10" />
<div className="absolute bottom-7 left-16 h-8 w-20 rounded-sm bg-current opacity-10" />
<div className="absolute inset-5 rounded border border-current opacity-15" />
</div>
<div className="absolute bottom-5 left-5 z-50 flex h-14 w-[260px] items-center gap-2 rounded-xl border px-2 shadow-lg backdrop-blur" style={{ background: "var(--background)", borderColor: "var(--border)" }} aria-hidden="true">
<div className="absolute bottom-5 left-5 z-[var(--z-toolbar)] flex h-14 w-[260px] items-center gap-2 rounded-xl border px-2 shadow-lg backdrop-blur" style={{ background: "var(--background)", borderColor: "var(--border)" }} aria-hidden="true">
<div className="size-8 rounded-md bg-current opacity-10" />
<div className="size-8 rounded-md bg-current opacity-10" />
<div className="h-1 flex-1 rounded-full bg-current opacity-10" />
@@ -238,8 +239,12 @@ function InfiniteCanvasPage() {
const [titleDraft, setTitleDraft] = useState("");
const [shortcutRequestNonce, setShortcutRequestNonce] = useState(0);
const [cinematicAgentEntry, setCinematicAgentEntry] = useState(false);
const [assistantWidth, setAssistantWidth] = useState(520);
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;
useEffect(() => {
persistCanvasWorkspaceMode(workspaceMode);
@@ -805,7 +810,6 @@ function InfiniteCanvasPage() {
collapsingBatchIds,
addedSkills,
directorScenes: currentProject?.directorScenes,
toolbarNodeId,
infoNodeId,
cropNodeId,
maskEditNodeId,
@@ -1378,7 +1382,12 @@ 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}
<section className="relative min-w-0 flex-1 overflow-hidden">
<section
className="relative min-w-0 flex-1 overflow-hidden transition-[padding]"
style={{
paddingRight: focusMode && anyPanelOpen ? panelColumnWidth : undefined,
}}
>
<CanvasTopBar
title={currentProject?.title || "未命名画布"}
workspaceMode={workspaceMode}
@@ -1409,6 +1418,9 @@ function InfiniteCanvasPage() {
projectId: currentProject.projectId,
projectName: linkedProjectQuery.data?.project.name || currentProject.title,
} : undefined}
focusMode={focusMode}
focusModeForced={forcedOn}
onToggleFocusMode={toggleFocusMode}
/>
<CanvasNodeSearchModal
@@ -1564,7 +1576,7 @@ function InfiniteCanvasPage() {
{mergeVideoProgress ? <CanvasMergeStatusToast progress={mergeVideoProgress} theme={theme} /> : null}
{lastAgentChange ? <CanvasAgentChangeToast change={lastAgentChange} theme={theme} onView={viewLastAgentChange} onUndo={() => { undoAgentOps(); }} onClose={dismissLastAgentChange} /> : null}
<CanvasNodeHoverToolbar
<CanvasNodeToolbar
node={isNodeDragging || nodeImageSettingsOpen || emotionNodeId ? null : toolbarNode}
workspaceMode={workspaceMode}
viewport={viewport}
@@ -1777,31 +1789,34 @@ 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>
{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)}
/>
) : null}
</main>
</>
);

View File

@@ -5,7 +5,7 @@ import { Link, useParams } from "react-router";
import { nanoid } from "nanoid";
import { ConnectionPath } from "@/components/canvas/canvas-connections";
import { CanvasNodeHoverToolbar, CanvasNodeInfoModal } from "@/components/canvas/canvas-node-hover-toolbar";
import { CanvasNodeToolbar, CanvasNodeInfoModal } from "@/components/canvas/canvas-node-toolbar";
import { CanvasFrameNode } from "@/components/canvas/canvas-frame-node";
import { CanvasNode } from "@/components/canvas/canvas-node";
import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
@@ -215,7 +215,7 @@ export default function SharedCanvasPage() {
};
}));
const renderSharedNode = useCallback((node: CanvasNodeData): ReactNode => node.type === CanvasNodeType.Script ? <SharedScriptNode node={node} onUnauthorized={unauthorized} /> : <SharedConfigNode node={node} onUnauthorized={unauthorized} />, [unauthorized]);
const toolbarNodeKey = toolbarNodeId || selectedNodeId;
const toolbarNodeKey = selectedNodeId;
const toolbarNode = toolbarNodeKey ? nodeById.get(toolbarNodeKey) || null : null;
if (loading) return <FullScreenLoader label="正在打开共享画布" detail="读取节点、连线和视图状态" />;
@@ -256,7 +256,7 @@ export default function SharedCanvasPage() {
}} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onConnectStart={unauthorized} onResize={() => undefined} onContentChange={unauthorized} onRetry={unauthorized} onCancelTask={unauthorized} onOpenTaskDetails={unauthorized} onViewImage={(target) => setInfoNodeId(target.id)} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} />)}
</InfiniteCanvas>
<CanvasNodeHoverToolbar node={dragRef.current ? null : toolbarNode} viewport={viewport} containerRef={containerRef} onKeep={keepToolbar} onLeave={hideToolbar} onInfo={(node) => setInfoNodeId(node.id)} onEditText={unauthorized} onDecreaseFont={unauthorized} onIncreaseFont={unauthorized} onToggleDialog={unauthorized} onAnnotate={unauthorized} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onEmotion={unauthorized} onPortraitTexture={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={unauthorized} onViewImage={unauthorized} onExtractVideoLastFrame={unauthorized} extractingVideoFrame={false} onReversePrompt={unauthorized} onRetry={unauthorized} onToggleFreeResize={unauthorized} onToggleLocked={unauthorized} onDelete={unauthorized} />
<CanvasNodeToolbar node={dragRef.current ? null : toolbarNode} viewport={viewport} containerRef={containerRef} onKeep={keepToolbar} onLeave={hideToolbar} onInfo={(node) => setInfoNodeId(node.id)} onEditText={unauthorized} onDecreaseFont={unauthorized} onIncreaseFont={unauthorized} onToggleDialog={unauthorized} onAnnotate={unauthorized} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onEmotion={unauthorized} onPortraitTexture={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={unauthorized} onViewImage={unauthorized} onExtractVideoLastFrame={unauthorized} extractingVideoFrame={false} onReversePrompt={unauthorized} onRetry={unauthorized} onToggleFreeResize={unauthorized} onToggleLocked={unauthorized} onDelete={unauthorized} />
<div className="absolute bottom-5 left-5 z-[var(--z-panel-floating)]"><CanvasZoomControls scale={viewport.k} containerRef={containerRef} onScaleChange={setZoom} onReset={resetViewport} isMiniMapOpen={false} onToggleMiniMap={unauthorized} onOpenShortcuts={unauthorized} /></div>
<div className="pointer-events-none absolute bottom-5 right-5 z-[var(--z-panel-floating)] max-w-[340px] text-right text-xs leading-5" style={{ color: theme.node.muted }}>访客操作仅在当前页面临时生效</div>

View File

@@ -26,7 +26,6 @@ type UseCanvasRenderModelOptions = {
collapsingBatchIds: Set<string>;
addedSkills: Skill[];
directorScenes?: DirectorScene[];
toolbarNodeId: string | null;
infoNodeId: string | null;
cropNodeId: string | null;
maskEditNodeId: string | null;
@@ -57,7 +56,6 @@ export function useCanvasRenderModel({
collapsingBatchIds,
addedSkills,
directorScenes,
toolbarNodeId,
infoNodeId,
cropNodeId,
maskEditNodeId,
@@ -140,7 +138,8 @@ export function useCanvasRenderModel({
return nodes.filter((node) => (node.metadata?.versionOfNodeId || node.id) === versionCompareRootId).sort((a, b) => (a.metadata?.versionLabel || "").localeCompare(b.metadata?.versionLabel || ""));
}, [nodes, versionCompareRootId]);
const toolbarCandidate = toolbarNodeId ? nodeById.get(toolbarNodeId) || null : null;
const selectedNodeIdForToolbar = selectedNodeIds.size === 1 ? [...selectedNodeIds][0] : null;
const toolbarCandidate = selectedNodeIdForToolbar ? nodeById.get(selectedNodeIdForToolbar) || null : null;
const toolbarNode = isFrameNode(toolbarCandidate) ? null : toolbarCandidate;
const infoNode = infoNodeId ? nodeById.get(infoNodeId) || null : null;
const cropNode = cropNodeId ? nodeById.get(cropNodeId) || null : null;