feat(viewport): 支持左右侧栏独立折叠与专注模式

- 新增 leftPanelVisible / rightPanelVisible 独立状态,可与专注模式叠加
- 添加侧栏折叠手柄及 [ / ] / \ 快捷键
- 画布安全区与悬浮控件按单侧可见性动态贴合边缘
- 顶部工具条「全屏」按钮更名为「专注模式」并更新提示
- 调整悬浮卡片顶部留白,统一视口四周间距
This commit is contained in:
2026-09-21 00:57:17 +08:00
parent 07d9e5e798
commit 341ad949e1
10 changed files with 276 additions and 31 deletions

View File

@@ -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(<App />);
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(<App />);

View File

@@ -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();

View File

@@ -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 (
<div
className={`director-shell director-shell-fullbleed${viewportPanelsCollapsed ? " is-sidebars-collapsed" : ""}`}
>
<div className={shellClassName}>
<section className="viewport-column" aria-label="3D视口">
<div className="viewport-stack">
{children}
@@ -20,18 +39,38 @@ export function DirectorDeskShell({ children }: { children: ReactNode }) {
</section>
<aside
className="left-sidebar director-sidebar"
aria-hidden={viewportPanelsCollapsed ? "true" : undefined}
aria-hidden={leftVisible ? undefined : "true"}
aria-label="场景"
>
<ObjectTreePanel />
</aside>
<aside
className="right-sidebar director-sidebar"
aria-hidden={viewportPanelsCollapsed ? "true" : undefined}
aria-hidden={rightVisible ? undefined : "true"}
aria-label="属性"
>
<RightPanel />
</aside>
<button
aria-expanded={leftVisible}
aria-label={leftVisible ? "收起场景面板" : "展开场景面板"}
className="panel-edge-handle panel-edge-handle-left"
title={leftVisible ? "收起场景面板([)" : "展开场景面板([)"}
type="button"
onClick={toggleLeftPanelVisible}
>
{leftVisible ? <PanelLeftClose {...HANDLE_ICON_PROPS} /> : <PanelLeftOpen {...HANDLE_ICON_PROPS} />}
</button>
<button
aria-expanded={rightVisible}
aria-label={rightVisible ? "收起属性面板" : "展开属性面板"}
className="panel-edge-handle panel-edge-handle-right"
title={rightVisible ? "收起属性面板(])" : "展开属性面板(])"}
type="button"
onClick={toggleRightPanelVisible}
>
{rightVisible ? <PanelRightClose {...HANDLE_ICON_PROPS} /> : <PanelRightOpen {...HANDLE_ICON_PROPS} />}
</button>
</div>
);
}

View File

@@ -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(<App />);
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(<App />);

View File

@@ -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;

View File

@@ -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(<ViewportToolbar />);
@@ -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

View File

@@ -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: "添加轨迹",

View File

@@ -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;

View File

@@ -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<DirectorStore>((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,

View File

@@ -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 {