From 341ad949e128d4aed2c4ef75a357d6ea75d5c7ae Mon Sep 17 00:00:00 2001 From: TongTongStudio Date: Mon, 21 Sep 2026 00:57:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(viewport):=20=E6=94=AF=E6=8C=81=E5=B7=A6?= =?UTF-8?q?=E5=8F=B3=E4=BE=A7=E6=A0=8F=E7=8B=AC=E7=AB=8B=E6=8A=98=E5=8F=A0?= =?UTF-8?q?=E4=B8=8E=E4=B8=93=E6=B3=A8=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 leftPanelVisible / rightPanelVisible 独立状态,可与专注模式叠加 - 添加侧栏折叠手柄及 [ / ] / \ 快捷键 - 画布安全区与悬浮控件按单侧可见性动态贴合边缘 - 顶部工具条「全屏」按钮更名为「专注模式」并更新提示 - 调整悬浮卡片顶部留白,统一视口四周间距 --- src/App.test.tsx | 21 ++++++ src/App.tsx | 21 ++++++ src/app/layout/DirectorDeskShell.tsx | 49 +++++++++++-- src/editor/canvas/DirectorCanvas.test.tsx | 28 +++++++- src/editor/canvas/DirectorCanvas.tsx | 25 ++++--- src/editor/canvas/ViewportToolbar.test.tsx | 6 +- src/editor/canvas/ViewportToolbar.tsx | 16 ++++- src/editor/store/directorStore.test.ts | 24 +++++++ src/editor/store/directorStore.ts | 35 +++++++++ src/styles/index.css | 82 +++++++++++++++++++--- 10 files changed, 276 insertions(+), 31 deletions(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index 673de04..440fa94 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -72,6 +72,27 @@ it("collapses both side panels from the fullscreen toolbar action", async () => expect(screen.getByLabelText("属性")).toHaveAttribute("aria-hidden", "true"); }); +it("collapses only one side panel and leaves a handle to bring it back", async () => { + const user = userEvent.setup(); + const { container } = render(); + + expect(screen.getByRole("button", { name: "收起场景面板" })).toBeInTheDocument(); + + act(() => { + useDirectorStore.getState().toggleLeftPanelVisible(); + }); + + expect(container.querySelector(".director-shell-fullbleed.is-left-collapsed")).toBeInTheDocument(); + expect(screen.getByLabelText("场景")).toHaveAttribute("aria-hidden", "true"); + // 右侧不受影响 + expect(screen.getByLabelText("属性")).not.toHaveAttribute("aria-hidden"); + + await user.click(screen.getByRole("button", { name: "展开场景面板" })); + + expect(screen.getByLabelText("场景")).not.toHaveAttribute("aria-hidden"); + expect(container.querySelector(".director-shell-fullbleed.is-left-collapsed")).not.toBeInTheDocument(); +}); + it("shows the floating timeline by default and hides it from the record action", () => { const { container } = render(); diff --git a/src/App.tsx b/src/App.tsx index 182637f..5a1a471 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -41,6 +41,27 @@ export default function App() { return; } + // 面板显隐: [ 左侧场景栏、] 右侧属性栏、\ 底部时间轴。都不带修饰键,方便单手切换。 + if (!event.metaKey && !event.ctrlKey && !event.altKey) { + if (event.key === "[") { + event.preventDefault(); + useDirectorStore.getState().toggleLeftPanelVisible(); + return; + } + + if (event.key === "]") { + event.preventDefault(); + useDirectorStore.getState().toggleRightPanelVisible(); + return; + } + + if (event.key === "\\") { + event.preventDefault(); + useDirectorStore.getState().toggleTimelineVisible(); + return; + } + } + if (!event.metaKey && !event.ctrlKey) return; const key = event.key.toLowerCase(); diff --git a/src/app/layout/DirectorDeskShell.tsx b/src/app/layout/DirectorDeskShell.tsx index 16bd8ba..424a332 100644 --- a/src/app/layout/DirectorDeskShell.tsx +++ b/src/app/layout/DirectorDeskShell.tsx @@ -1,17 +1,36 @@ import type { ReactNode } from "react"; +import { PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from "lucide-react"; import { ObjectTreePanel } from "../../editor/panels/ObjectTreePanel"; import { RightPanel } from "../../editor/panels/RightPanel"; import { TimelinePanel } from "../../editor/panels/TimelinePanel"; import { useDirectorStore } from "../../editor/store/directorStore"; +const HANDLE_ICON_PROPS = { "aria-hidden": true, size: 15, strokeWidth: 2 } as const; + export function DirectorDeskShell({ children }: { children: ReactNode }) { const viewportPanelsCollapsed = useDirectorStore((state) => state.viewportPanelsCollapsed); + const leftPanelVisible = useDirectorStore((state) => state.leftPanelVisible); + const rightPanelVisible = useDirectorStore((state) => state.rightPanelVisible); const timelineVisible = useDirectorStore((state) => state.timelineVisible); + const toggleLeftPanelVisible = useDirectorStore((state) => state.toggleLeftPanelVisible); + const toggleRightPanelVisible = useDirectorStore((state) => state.toggleRightPanelVisible); + + // 「专注模式」是临时一次收起两侧,最终可见性由两侧各自的开关与之相与决定 + const leftVisible = !viewportPanelsCollapsed && leftPanelVisible; + const rightVisible = !viewportPanelsCollapsed && rightPanelVisible; + + const shellClassName = [ + "director-shell", + "director-shell-fullbleed", + leftVisible ? "" : "is-left-collapsed", + rightVisible ? "" : "is-right-collapsed", + !leftVisible && !rightVisible ? "is-sidebars-collapsed" : "", + ] + .filter(Boolean) + .join(" "); return ( -
+
{children} @@ -20,18 +39,38 @@ export function DirectorDeskShell({ children }: { children: ReactNode }) {
+ +
); } diff --git a/src/editor/canvas/DirectorCanvas.test.tsx b/src/editor/canvas/DirectorCanvas.test.tsx index 504edde..698a928 100644 --- a/src/editor/canvas/DirectorCanvas.test.tsx +++ b/src/editor/canvas/DirectorCanvas.test.tsx @@ -1,5 +1,5 @@ import { forwardRef } from "react"; -import { fireEvent, render, screen, within } from "@testing-library/react"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; import { afterEach, beforeEach, vi } from "vitest"; import { DoubleSide, PerspectiveCamera, Vector3 } from "three"; import { clearViewportCaptureHandler, requestViewportCapture } from "../io/captureBridge"; @@ -484,6 +484,32 @@ it("offsets the native viewport gizmo inward when overlay side panels are open", }); }); +it("pulls the viewport overlays to the edge when only the right panel is collapsed", () => { + act(() => { + useDirectorStore.setState({ + ...useDirectorStore.getState(), + viewMode: "director", + viewportPanelsCollapsed: false, + leftPanelVisible: true, + rightPanelVisible: true, + }); + }); + + render(); + + expect(screen.getByLabelText("3D视口原生坐标控件")).toHaveStyle({ right: "332px" }); + + act(() => { + useDirectorStore.getState().toggleRightPanelVisible(); + }); + + // 右侧面板收起后这一侧整块让给画布:坐标控件 / 重置视角 / 镜头监视器都要贴到边缘, + // 不能只看「专注模式」总开关 + expect(screen.getByLabelText("3D视口原生坐标控件")).toHaveStyle({ right: "20px" }); + expect(document.querySelector(".viewport-view-reset")).toHaveStyle({ right: "20px" }); + expect(screen.getByLabelText("镜头监视器")).toHaveStyle({ right: "20px" }); +}); + it("resets the director view from the button below the native gizmo", () => { useDirectorStore.getState().setViewMode("camera"); render(); diff --git a/src/editor/canvas/DirectorCanvas.tsx b/src/editor/canvas/DirectorCanvas.tsx index a9a013d..8646d9e 100644 --- a/src/editor/canvas/DirectorCanvas.tsx +++ b/src/editor/canvas/DirectorCanvas.tsx @@ -935,6 +935,8 @@ export function DirectorCanvas() { const viewportAspectRatio = useDirectorStore((state) => state.viewportAspectRatio); const viewportRuleOfThirdsEnabled = useDirectorStore((state) => state.viewportRuleOfThirdsEnabled); const viewportPanelsCollapsed = useDirectorStore((state) => state.viewportPanelsCollapsed); + const leftPanelVisible = useDirectorStore((state) => state.leftPanelVisible); + const rightPanelVisible = useDirectorStore((state) => state.rightPanelVisible); const trajectoryPlacementObjectId = useDirectorStore((state) => state.trajectoryPlacementObjectId); const setViewMode = useDirectorStore((state) => state.setViewMode); const setViewportRuleOfThirdsEnabled = useDirectorStore((state) => state.setViewportRuleOfThirdsEnabled); @@ -947,17 +949,18 @@ export function DirectorCanvas() { target: getCameraLookPoint(activeCameraWorldView.position, activeCameraWorldView.lookRotation), } : directorViewSnapshot; - const viewportSafeAreaInsets: ViewportSafeAreaInsets = viewportPanelsCollapsed - ? { left: 0, right: 0, top: 0, bottom: 0 } - : { - left: LEFT_PANEL_WIDTH + FLOATING_PANEL_GAP, - right: RIGHT_PANEL_WIDTH + FLOATING_PANEL_GAP, - top: 0, - bottom: 0, - }; - const gizmoRightOffset = viewportPanelsCollapsed - ? GIZMO_EDGE_PADDING - : RIGHT_PANEL_WIDTH + FLOATING_PANEL_GAP; + // 某一侧面板收起后,画布这一侧就整块让出来了:坐标控件 / 重置视角 / 镜头监视器、 + // 以及画幅安全区都得跟着贴到新的画布边缘。判据是「这一侧是否真的显示」, + // 不能只看「专注模式」总开关 —— 单独按 [ / ] 收起一侧时总开关并没有变。 + const leftPanelShown = !viewportPanelsCollapsed && leftPanelVisible; + const rightPanelShown = !viewportPanelsCollapsed && rightPanelVisible; + const viewportSafeAreaInsets: ViewportSafeAreaInsets = { + left: leftPanelShown ? LEFT_PANEL_WIDTH + FLOATING_PANEL_GAP : 0, + right: rightPanelShown ? RIGHT_PANEL_WIDTH + FLOATING_PANEL_GAP : 0, + top: 0, + bottom: 0, + }; + const gizmoRightOffset = rightPanelShown ? RIGHT_PANEL_WIDTH + FLOATING_PANEL_GAP : GIZMO_EDGE_PADDING; useLayoutEffect(() => { const element = toolbarRef.current; diff --git a/src/editor/canvas/ViewportToolbar.test.tsx b/src/editor/canvas/ViewportToolbar.test.tsx index 2a42446..d786cef 100644 --- a/src/editor/canvas/ViewportToolbar.test.tsx +++ b/src/editor/canvas/ViewportToolbar.test.tsx @@ -71,7 +71,7 @@ it("renders the viewport capsule as project icon-system buttons", () => { "当前视角截图", "四方位截图", "十二方位截图", - "全屏", + "专注模式", "添加轨迹", "录制", ]; @@ -113,7 +113,7 @@ it("uses the requested viewport toolbar SVG icons for camera and capture actions expect(screen.getByRole("button", { name: "十二方位截图" }).querySelector("svg")).toHaveClass("lucide-grid3x3"); }); -it("uses the fullscreen button to collapse the side panels instead of entering browser fullscreen", async () => { +it("uses the focus-mode button to collapse the side panels instead of entering browser fullscreen", async () => { const user = userEvent.setup(); render(); @@ -121,7 +121,7 @@ it("uses the fullscreen button to collapse the side panels instead of entering b false ); - await user.click(screen.getByRole("button", { name: "全屏" })); + await user.click(screen.getByRole("button", { name: "专注模式" })); expect((useDirectorStore.getState() as { viewportPanelsCollapsed?: boolean }).viewportPanelsCollapsed ?? false).toBe( true diff --git a/src/editor/canvas/ViewportToolbar.tsx b/src/editor/canvas/ViewportToolbar.tsx index 3e354f4..97d8d8a 100644 --- a/src/editor/canvas/ViewportToolbar.tsx +++ b/src/editor/canvas/ViewportToolbar.tsx @@ -143,7 +143,11 @@ export function ViewportToolbar({ const setViewMode = useDirectorStore((state) => state.setViewMode); const setTransformMode = useDirectorStore((state) => state.setTransformMode); const setViewportAspectRatio = useDirectorStore((state) => state.setViewportAspectRatio); - const toggleViewportPanelsCollapsed = useDirectorStore((state) => state.toggleViewportPanelsCollapsed); + const setViewportPanelsCollapsed = useDirectorStore((state) => state.setViewportPanelsCollapsed); + // 左右侧栏是否都不见了:既可能是「专注模式」,也可能是被 [ / ] 各自收起 + const sidePanelsHidden = useDirectorStore( + (state) => state.viewportPanelsCollapsed || (!state.leftPanelVisible && !state.rightPanelVisible) + ); const project = useDirectorStore((state) => state.project); const selectedObjectId = useDirectorStore((state) => state.selectedObjectId); const timelineVisible = useDirectorStore((state) => state.timelineVisible); @@ -511,7 +515,15 @@ export function ViewportToolbar({ { label: "当前视角截图", icon: ScanLine, onClick: () => void handleCapture("current") }, { label: "四方位截图", icon: Grid2X2, onClick: () => void handleCapture("four") }, { label: "十二方位截图", icon: Grid3X3, onClick: () => void handleCapture("twelve") }, - { label: "全屏", icon: Expand, onClick: toggleViewportPanelsCollapsed }, + { + label: "专注模式", + icon: Expand, + pressed: sidePanelsHidden, + title: sidePanelsHidden + ? "恢复左右面板(可用 [ 左侧 / ] 右侧 单独切换一侧)" + : "专注模式:一次收起左右面板,只看画布(可用 [ 左侧 / ] 右侧 单独切换一侧)", + onClick: () => setViewportPanelsCollapsed(!sidePanelsHidden), + }, // 打点 / 录制放在最右侧 { label: "添加轨迹", diff --git a/src/editor/store/directorStore.test.ts b/src/editor/store/directorStore.test.ts index 0e3d52a..b342307 100644 --- a/src/editor/store/directorStore.test.ts +++ b/src/editor/store/directorStore.test.ts @@ -91,6 +91,30 @@ it("toggles the viewport side panel collapse flag in ui state", () => { expect((useDirectorStore.getState() as CollapseUiState).viewportPanelsCollapsed ?? false).toBe(true); }); +it("toggles the left and right side panels independently", () => { + useDirectorStore.setState(createInitialDirectorState()); + + useDirectorStore.getState().toggleLeftPanelVisible(); + + const state = useDirectorStore.getState(); + expect(state.leftPanelVisible).toBe(false); + // 只收起左侧,右侧不受影响 + expect(state.rightPanelVisible).toBe(true); + // 单侧收起不改变「专注模式」标记 + expect(state.viewportPanelsCollapsed).toBe(false); +}); + +it("leaves focus mode and keeps only the toggled side when a panel switch is used", () => { + useDirectorStore.setState({ ...createInitialDirectorState(), viewportPanelsCollapsed: true }); + + useDirectorStore.getState().toggleRightPanelVisible(); + + const state = useDirectorStore.getState(); + expect(state.viewportPanelsCollapsed).toBe(false); + expect(state.leftPanelVisible).toBe(false); + expect(state.rightPanelVisible).toBe(true); +}); + it("routes the right panel by object type and view mode", () => { const state = createInitialDirectorState(); const characterId = state.project.objects.find((item) => item.kind === "character")!.id; diff --git a/src/editor/store/directorStore.ts b/src/editor/store/directorStore.ts index 698aa64..bbe25b4 100644 --- a/src/editor/store/directorStore.ts +++ b/src/editor/store/directorStore.ts @@ -140,7 +140,12 @@ export interface DirectorUiState { transformMode: TransformMode; viewportAspectRatio: ViewportAspectRatio; viewportRuleOfThirdsEnabled: boolean; + /** 「专注模式」:一次性收起左右两侧栏。与左右各自的显隐开关相与后决定是否渲染。 */ viewportPanelsCollapsed: boolean; + /** 左侧「场景」面板是否显示(专注模式下会被一并收起) */ + leftPanelVisible: boolean; + /** 右侧「属性」面板是否显示(专注模式下会被一并收起) */ + rightPanelVisible: boolean; /** 悬浮时间轴(录制面板)是否显示,由视口快捷工具条的「录制」按钮切换 */ timelineVisible: boolean; /** 用户自定义的时间轴轨道顺序(轨道 key 列表)。 @@ -183,6 +188,10 @@ export interface DirectorActions { setViewportRuleOfThirdsEnabled: (enabled: boolean) => void; toggleViewportPanelsCollapsed: () => void; setViewportPanelsCollapsed: (collapsed: boolean) => void; + /** 单独收起 / 展开左侧「场景」面板 */ + toggleLeftPanelVisible: () => void; + /** 单独收起 / 展开右侧「属性」面板 */ + toggleRightPanelVisible: () => void; toggleTimelineVisible: () => void; setTimelineVisible: (visible: boolean) => void; /** 覆盖时间轴轨道顺序(传入解析后的完整顺序) */ @@ -435,6 +444,8 @@ const DEFAULT_UI_STATE: DirectorUiState = { viewportAspectRatio: "auto", viewportRuleOfThirdsEnabled: false, viewportPanelsCollapsed: false, + leftPanelVisible: true, + rightPanelVisible: true, timelineVisible: true, timelineTrackOrder: [], timelineCollapsedTracks: [], @@ -917,6 +928,8 @@ function extractPersistedDirectorState(state: DirectorRuntimeState): DirectorSta viewportAspectRatio: state.viewportAspectRatio, viewportRuleOfThirdsEnabled: state.viewportRuleOfThirdsEnabled, viewportPanelsCollapsed: state.viewportPanelsCollapsed, + leftPanelVisible: state.leftPanelVisible, + rightPanelVisible: state.rightPanelVisible, timelineVisible: state.timelineVisible, timelineTrackOrder: state.timelineTrackOrder, timelineCollapsedTracks: state.timelineCollapsedTracks, @@ -981,6 +994,8 @@ function readPersistedDirectorState(options: DirectorStateOptions = {}): Directo viewportAspectRatio: state.viewportAspectRatio ?? "auto", viewportRuleOfThirdsEnabled: Boolean(state.viewportRuleOfThirdsEnabled), viewportPanelsCollapsed: Boolean(state.viewportPanelsCollapsed), + leftPanelVisible: state.leftPanelVisible === false ? false : true, + rightPanelVisible: state.rightPanelVisible === false ? false : true, timelineVisible: state.timelineVisible === false ? false : true, timelineTrackOrder: readPersistedStringArray(state.timelineTrackOrder), timelineCollapsedTracks: readPersistedStringArray(state.timelineCollapsedTracks), @@ -2160,7 +2175,27 @@ export const useDirectorStore = create((set, get) => { commitUiMutation((state) => ({ ...state, viewportPanelsCollapsed: collapsed, + // 退出专注模式时把两侧还原成可见,避免「点了展开却没反应」 + leftPanelVisible: collapsed ? state.leftPanelVisible : true, + rightPanelVisible: collapsed ? state.rightPanelVisible : true, })), + toggleLeftPanelVisible: () => + commitUiMutation((state) => { + // 专注模式下切换某一侧:退出专注模式,只保留这一侧,避免「按了没反应」 + if (state.viewportPanelsCollapsed) { + return { ...state, viewportPanelsCollapsed: false, leftPanelVisible: true, rightPanelVisible: false }; + } + + return { ...state, leftPanelVisible: !state.leftPanelVisible }; + }), + toggleRightPanelVisible: () => + commitUiMutation((state) => { + if (state.viewportPanelsCollapsed) { + return { ...state, viewportPanelsCollapsed: false, leftPanelVisible: false, rightPanelVisible: true }; + } + + return { ...state, rightPanelVisible: !state.rightPanelVisible }; + }), toggleTimelineVisible: () => commitUiMutation((state) => ({ ...state, diff --git a/src/styles/index.css b/src/styles/index.css index bd781fd..09c7e3e 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -18,10 +18,11 @@ --right-sidebar-width: 300px; --left-sidebar-content-width: 260px; --right-sidebar-content-width: 260px; - /* 悬浮卡片:与视口边缘的间距、以及让开顶部工具条的起始高度 */ + /* 悬浮卡片与视口边缘的间距(左右、以及相对时间轴的上下留白都用它), + 卡片顶部同样取这个值,四周留白保持一致 */ --floating-inset: 16px; - --floating-top: 76px; - /* 悬浮卡片可用高度上限:扣掉顶部工具条、上下内缩、以及悬浮时间轴占用的高度 */ + --floating-top: var(--floating-inset); + /* 悬浮卡片可用高度上限:扣掉卡片起始高度、上下内缩、以及悬浮时间轴占用的高度 */ --floating-card-max-height: calc(100vh - var(--floating-top) - var(--floating-inset) * 2 - var(--timeline-panel-height, 0px)); /* 视口快捷工具条高度(ViewportToolbar 会按实测值覆盖),供悬浮时间轴让位使用 */ --viewport-toolbar-height: 46px; @@ -379,7 +380,9 @@ textarea:focus-visible { } .director-shell-fullbleed.is-sidebars-collapsed > .left-sidebar, -.director-shell-fullbleed.is-sidebars-collapsed > .right-sidebar { +.director-shell-fullbleed.is-sidebars-collapsed > .right-sidebar, +.director-shell-fullbleed.is-left-collapsed > .left-sidebar, +.director-shell-fullbleed.is-right-collapsed > .right-sidebar { display: none; } @@ -3576,6 +3579,15 @@ button.inspector-dropdown-option.is-selected { display: none; } + /* 窄屏下右侧栏本就强制隐藏,连它的手柄一起去掉,免得出现「点了没反应」的控件 */ + .panel-edge-handle-right { + display: none; + } + + .panel-edge-handle-left { + left: calc(var(--floating-inset) + min(220px, 28vw) - 11px); + } + .viewport-toolbar { max-width: calc(100% - 32px); } @@ -5035,9 +5047,9 @@ body.is-timeline-dragging * { } /* ===== 全屏画布 + 悬浮卡片 ===== - 画布铺满整屏;顶部工具条、左右侧栏、底部时间轴一律脱离文档流悬浮在画布之上。 - 1) 顶部工具条改为绝对定位的透明浮层,只有标题/视角切换/关闭按钮可点击,其余区域让给画布。 - 2) 右侧栏脱离第 1 行,直接落在 1fr 行上,从而占满整屏高度。 */ + 画布铺满整屏;顶部工具条、左右侧栏、底部时间轴一律脱离文档流悬浮在画布之上。 + 1) 顶部工具条改为绝对定位的透明浮层,只有视角切换按钮可点击,其余区域让给画布。 + 2) 右侧栏脱离第 1 行,直接落在 1fr 行上,从而占满整屏高度。 */ .app-shell > .director-shell { grid-row: 2; } @@ -5053,8 +5065,13 @@ body.is-timeline-dragging * { pointer-events: none; } -.top-bar-left, +/* 只有视角切换按钮需要接管点击:整格可点会连带吃掉画布顶部中段、 + 以及延伸到顶部的侧栏边缘,所以这里收到按钮本身 */ .top-bar-center { + pointer-events: none; +} + +.top-bar-center .mode-toggle { pointer-events: auto; } @@ -5066,7 +5083,7 @@ body.is-timeline-dragging * { -webkit-backdrop-filter: blur(32px); } -/* 左右侧栏:从贴边全高面板改成内缩、按内容包裹的圆角悬浮卡片 +/* 左右侧栏:从贴边全高面板改成内缩、按内容包裹的悬浮卡片,顶部延伸到视口顶端 bottom 置为 auto + max-height 封顶 → 内容少时卡片只有内容那么高,内容多时卡片内部滚动 */ .director-shell-fullbleed .left-sidebar, .director-shell-fullbleed .right-sidebar { @@ -5096,6 +5113,53 @@ body.is-timeline-dragging * { right: var(--floating-inset); } +/* 侧栏折叠手柄:展开时半嵌在卡片外缘(落在卡片 20px 内边距里,不压内容), + 收起后缩到视口边上,只占一条 22px 的窄边,把画布整块让出来。 + z-index 高于侧栏(25) / 时间轴(30),低于顶部条(45)。 */ +.panel-edge-handle { + position: absolute; + top: calc(var(--floating-top) + 18px); + z-index: 40; + display: grid; + place-items: center; + width: 22px; + height: 48px; + padding: 0; + border: 1px solid rgb(var(--border-rgb) / 0.42); + border-radius: 999px; + color: rgb(var(--text-muted-rgb)); + background: rgb(var(--panel-rgb) / 0.9); + box-shadow: var(--ui-shadow-panel); + backdrop-filter: blur(32px); + -webkit-backdrop-filter: blur(32px); + cursor: pointer; + transition: + color 0.15s ease, + background 0.15s ease; +} + +.panel-edge-handle:hover, +.panel-edge-handle:focus-visible { + color: rgb(var(--text-rgb)); + background: rgb(var(--panel-rgb) / 0.98); +} + +.panel-edge-handle-left { + left: calc(var(--floating-inset) + var(--left-sidebar-width) - 11px); +} + +.panel-edge-handle-right { + right: calc(var(--floating-inset) + var(--right-sidebar-width) - 11px); +} + +.director-shell-fullbleed.is-left-collapsed > .panel-edge-handle-left { + left: 8px; +} + +.director-shell-fullbleed.is-right-collapsed > .panel-edge-handle-right { + right: 8px; +} + /* 卡片自身半透明,内部面板背景需让位,避免把悬浮磨砂底压成不透明; 同时把内层依赖父级高度的 100% 显式改成跟随内容,配合卡片「按内容包裹」 */ .director-shell-fullbleed .left-sidebar .object-tree-panel {