From e74cf6d28be94e0c8f3239df45384af6a42e304b Mon Sep 17 00:00:00 2001 From: TongTongStudio Date: Thu, 17 Sep 2026 09:09:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E8=BD=A8=E8=BF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.test.tsx | 101 ++- src/App.tsx | 28 +- src/app/layout/DirectorDeskShell.tsx | 3 +- src/editor/canvas/DirectorCanvas.grid.test.ts | 24 +- src/editor/canvas/DirectorCanvas.test.tsx | 155 +++- src/editor/canvas/DirectorCanvas.tsx | 188 +++- src/editor/canvas/SceneRoot.test.tsx | 137 ++- src/editor/canvas/SceneRoot.tsx | 241 ++++- src/editor/canvas/ViewportToolbar.test.tsx | 79 ++ src/editor/canvas/ViewportToolbar.tsx | 104 ++- src/editor/canvas/sceneAxes.test.ts | 42 + src/editor/canvas/sceneAxes.ts | 49 + src/editor/canvas/sceneGround.test.ts | 21 + src/editor/canvas/sceneGround.ts | 25 + src/editor/canvas/sceneTransform.test.ts | 49 + src/editor/canvas/sceneTransform.ts | 23 + .../loaders/panoramaImport.storage.test.ts | 39 + src/editor/loaders/panoramaImport.ts | 38 +- src/editor/panels/CameraPanel.test.tsx | 69 +- src/editor/panels/CameraPanel.tsx | 46 +- src/editor/panels/CharacterPanel.test.tsx | 96 +- src/editor/panels/CharacterPanel.tsx | 20 +- src/editor/panels/InspectorControls.tsx | 32 +- src/editor/panels/ObjectTreePanel.test.tsx | 150 +++- src/editor/panels/ObjectTreePanel.tsx | 183 +++- src/editor/panels/PropPanel.test.tsx | 35 +- src/editor/panels/PropPanel.tsx | 4 + src/editor/panels/RightPanel.tsx | 2 + src/editor/panels/ScenePanel.test.tsx | 61 +- src/editor/panels/ScenePanel.tsx | 37 +- src/editor/panels/TimelinePanel.test.tsx | 142 ++- src/editor/panels/TimelinePanel.tsx | 678 +++++++++----- .../panels/TrajectoryPointPanel.test.tsx | 96 ++ src/editor/panels/TrajectoryPointPanel.tsx | 142 +++ src/editor/runtime/keyframeCommand.ts | 45 + src/editor/runtime/panoramaCommand.ts | 19 + src/editor/schema/directorProject.ts | 33 +- src/editor/store/directorSelectors.ts | 9 + src/editor/store/directorStore.test.ts | 110 +++ src/editor/store/directorStore.ts | 382 ++++++-- src/styles/index.css | 835 +++++++++++++++--- src/styles/index.css.test.ts | 8 +- 42 files changed, 4010 insertions(+), 570 deletions(-) create mode 100644 src/editor/canvas/sceneAxes.test.ts create mode 100644 src/editor/canvas/sceneAxes.ts create mode 100644 src/editor/canvas/sceneGround.test.ts create mode 100644 src/editor/canvas/sceneGround.ts create mode 100644 src/editor/canvas/sceneTransform.test.ts create mode 100644 src/editor/canvas/sceneTransform.ts create mode 100644 src/editor/loaders/panoramaImport.storage.test.ts create mode 100644 src/editor/panels/TrajectoryPointPanel.test.tsx create mode 100644 src/editor/panels/TrajectoryPointPanel.tsx create mode 100644 src/editor/runtime/keyframeCommand.ts create mode 100644 src/editor/runtime/panoramaCommand.ts diff --git a/src/App.test.tsx b/src/App.test.tsx index 2197792..42832d5 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -2,6 +2,7 @@ import { act, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { vi } from "vitest"; import { createInitialDirectorState, useDirectorStore } from "./editor/store/directorStore"; +import { usePlaybackStore } from "./editor/runtime/playbackStore"; vi.mock("./editor/canvas/DirectorCanvas", () => ({ DirectorCanvas: () =>
, @@ -24,7 +25,9 @@ it("renders the director desk header and view mode switch", () => { expect(screen.getByRole("button", { name: "机位视角" })).toBeInTheDocument(); expect(container.querySelector(".top-bar-center .mode-toggle")).toBeInTheDocument(); expect(screen.queryByLabelText("帮助")).not.toBeInTheDocument(); - expect(screen.getByLabelText("关闭")).toBeInTheDocument(); + // 右上角的「关闭」按钮已去掉 + expect(screen.queryByLabelText("关闭")).not.toBeInTheDocument(); + expect(container.querySelector(".top-bar-actions")).not.toBeInTheDocument(); }); it("notifies the host canvas when the director desk app is ready", () => { @@ -40,22 +43,6 @@ it("notifies the host canvas when the director desk app is ready", () => { postMessage.mockRestore(); }); -it("notifies the host canvas when the director desk close button is clicked", async () => { - const user = userEvent.setup(); - const postMessage = vi.spyOn(window.parent, "postMessage").mockImplementation(() => undefined); - - render(); - - await user.click(screen.getByRole("button", { name: "关闭" })); - - expect(postMessage).toHaveBeenCalledWith( - { type: "storyai:director-desk-close" }, - window.location.origin - ); - - postMessage.mockRestore(); -}); - it("uses a full-width director desk frame instead of floating card columns", () => { const { container } = render(); const shell = container.querySelector(".director-shell.director-shell-fullbleed"); @@ -85,6 +72,24 @@ it("collapses both side panels from the fullscreen toolbar action", async () => expect(screen.getByLabelText("属性")).toHaveAttribute("aria-hidden", "true"); }); +it("shows the floating timeline by default and hides it from the record action", () => { + const { container } = render(); + + expect(container.querySelector(".timeline-panel")).toBeInTheDocument(); + + act(() => { + useDirectorStore.getState().toggleTimelineVisible(); + }); + + expect(container.querySelector(".timeline-panel")).not.toBeInTheDocument(); + + act(() => { + useDirectorStore.getState().toggleTimelineVisible(); + }); + + expect(container.querySelector(".timeline-panel")).toBeInTheDocument(); +}); + it("switches from director mode to camera mode", async () => { const user = userEvent.setup(); render(); @@ -101,6 +106,68 @@ it("switches from director mode to camera mode", async () => { expect(cameraButton).toHaveAttribute("aria-pressed", "true"); }); +it("adds a keyframe at the playhead when K is pressed while the recording panel is open", async () => { + const user = userEvent.setup(); + render(); + + act(() => { + useDirectorStore.getState().selectObject("char_default_a"); + usePlaybackStore.getState().setPlayhead(1.5); + }); + + await user.keyboard("k"); + + const track = useDirectorStore + .getState() + .project.timeline.tracks.find((item) => item.objectId === "char_default_a"); + + expect(track?.keyframes).toHaveLength(1); + expect(track?.keyframes[0]?.time).toBe(1.5); +}); + +it("records a camera keyframe with K when the selected target is a camera object", async () => { + const user = userEvent.setup(); + render(); + + act(() => { + useDirectorStore.getState().selectObject("cam_object_1"); + usePlaybackStore.getState().setPlayhead(2); + }); + + await user.keyboard("k"); + + const track = useDirectorStore + .getState() + .project.timeline.cameraTracks.find((item) => item.cameraId === "cam_1"); + + expect(track?.keyframes).toHaveLength(1); + expect(track?.keyframes[0]?.time).toBe(2); +}); + +it("ignores K while the recording panel is hidden and while typing in a field", async () => { + const user = userEvent.setup(); + render(); + + act(() => { + useDirectorStore.getState().selectObject("char_default_a"); + // 录制面板隐藏 → K 不再是快捷键 + useDirectorStore.getState().toggleTimelineVisible(); + }); + + await user.keyboard("k"); + expect(useDirectorStore.getState().project.timeline.tracks).toHaveLength(0); + + act(() => { + useDirectorStore.getState().toggleTimelineVisible(); + }); + + // 在输入框里敲 k 只应该输入文字,不能打点 + await user.click(screen.getByLabelText("搜索场景内容")); + await user.keyboard("k"); + + expect(useDirectorStore.getState().project.timeline.tracks).toHaveLength(0); +}); + it("supports Cmd/Ctrl+C and Cmd/Ctrl+V to duplicate the selected object", async () => { const user = userEvent.setup(); render(); diff --git a/src/App.tsx b/src/App.tsx index fa4b915..a80b6a8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,9 @@ import "./styles/index.css"; import { useEffect } from "react"; -import { X } from "lucide-react"; import { DirectorDeskShell } from "./app/layout/DirectorDeskShell"; import { DirectorCanvas } from "./editor/canvas/DirectorCanvas"; import { initDirectorDeskHostBridge } from "./editor/io/hostBridge"; +import { addKeyframeAtPlayhead } from "./editor/runtime/keyframeCommand"; import { useDirectorStore } from "./editor/store/directorStore"; function isEditableShortcutTarget(target: EventTarget | null) { @@ -21,13 +21,20 @@ export default function App() { window.parent?.postMessage({ type: "storyai:director-desk-ready" }, window.location.origin); }, []); - function handleClose() { - window.parent?.postMessage({ type: "storyai:director-desk-close" }, window.location.origin); - } - useEffect(() => { function handleKeyDown(event: KeyboardEvent) { if (event.defaultPrevented || isEditableShortcutTarget(event.target)) return; + + // 录制时(悬浮时间轴 = 录制面板打开)按 K:在当前时间指针处打一个关键帧。 + // 只在录制面板打开时生效,避免平时打字/其它操作误触;长按不重复打点。 + if (event.key.toLowerCase() === "k" && !event.metaKey && !event.ctrlKey && !event.altKey) { + if (event.repeat || !useDirectorStore.getState().timelineVisible) return; + if (addKeyframeAtPlayhead()) { + event.preventDefault(); + } + return; + } + if (!event.metaKey && !event.ctrlKey) return; const key = event.key.toLowerCase(); @@ -82,17 +89,6 @@ export default function App() {
-
- -
diff --git a/src/app/layout/DirectorDeskShell.tsx b/src/app/layout/DirectorDeskShell.tsx index b8d0522..16bd8ba 100644 --- a/src/app/layout/DirectorDeskShell.tsx +++ b/src/app/layout/DirectorDeskShell.tsx @@ -6,6 +6,7 @@ import { useDirectorStore } from "../../editor/store/directorStore"; export function DirectorDeskShell({ children }: { children: ReactNode }) { const viewportPanelsCollapsed = useDirectorStore((state) => state.viewportPanelsCollapsed); + const timelineVisible = useDirectorStore((state) => state.timelineVisible); return (
{children} - + {timelineVisible ? : null}