feat(studio): one timeline tool picker with select leftward and rightward (#4714)

* feat(studio): one timeline tool picker with select leftward and rightward

The Selection and Razor buttons become one button showing the active tool, with a menu:
Select (V), Split (B), Select leftward ([) and Select rightward (]). Leftward and rightward
select every clip, on every track, that starts before or at/after the playhead.

* test(studio): toolbar order and label follow the tool picker

* fix(studio): select leftward and rightward use the live playhead and the usual selection resets

* style(studio): keep playerStore's comment share at its base
This commit is contained in:
Miguel Ángel
2026-09-29 01:21:14 +00:00
committed by GitHub
parent 00a907d6b3
commit 6b59004de2
7 changed files with 190 additions and 31 deletions
@@ -96,14 +96,25 @@ it("leaves enabled buttons without the disabled attribute", () => {
expect(el.className).not.toContain("cursor-not-allowed");
});
it("orders the toolbar Undo, Redo, Select, Razor", () => {
it("orders the toolbar Undo, Redo, then the tool picker showing the active tool", () => {
const host = mount(<TimelineToolbar />);
const labels = Array.from(host.querySelectorAll("button"))
.map((b) => b.getAttribute("aria-label"))
.filter((l) => l !== null)
.slice(0, 4);
.slice(0, 3);
expect(labels).toEqual(["Undo", "Redo", "Selection tool", "Razor tool"]);
expect(labels).toEqual(["Undo", "Redo", "Timeline tool: Select"]);
});
it("shows the Split tool on the picker while the razor is active", async () => {
const { usePlayerStore } = await import("../player");
usePlayerStore.setState({ activeTool: "razor" });
try {
const host = mount(<TimelineToolbar />);
expect(host.querySelector('button[aria-label="Timeline tool: Split"]')).not.toBeNull();
} finally {
usePlayerStore.setState({ activeTool: "select" });
}
});
it("classifies Undo and Redo for the hotkey filters at their new location (KTD13)", () => {
@@ -0,0 +1,90 @@
import type { ReactNode } from "react";
import { ArrowLineLeft, ArrowLineRight, CaretDown } from "@phosphor-icons/react";
import { usePlayerStore } from "../player";
import { Scissors } from "../icons/SystemIcons";
import { Menu, MenuItem } from "./ui";
import { cn } from "./ui/cn";
import { flatIdle } from "./timelineToolbarStyles";
function SelectIcon() {
return (
<svg width="16" height="16" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
<path d="M2 0.5L10 6L6.5 6.5L8.5 11L6.5 11.5L4.5 7L2 9Z" />
</svg>
);
}
interface ToolRow {
label: string;
shortcut: string;
icon: ReactNode;
active: boolean;
run: () => void;
}
/** One button for the active timeline tool; its menu switches tools or selects clips around the playhead. */
export function TimelineToolPicker() {
const activeTool = usePlayerStore((s) => s.activeTool);
const store = () => usePlayerStore.getState();
const rows: ToolRow[] = [
{
label: "Select",
shortcut: "V",
icon: <SelectIcon />,
active: activeTool === "select",
run: () => store().setActiveTool("select"),
},
{
label: "Split",
shortcut: "B",
icon: <Scissors size={16} />,
active: activeTool === "razor",
run: () => store().setActiveTool("razor"),
},
{
label: "Select leftward",
shortcut: "[",
icon: <ArrowLineLeft size={16} aria-hidden="true" />,
active: false,
run: () => store().selectLeftward(),
},
{
label: "Select rightward",
shortcut: "]",
icon: <ArrowLineRight size={16} aria-hidden="true" />,
active: false,
run: () => store().selectRightward(),
},
];
const current = rows.find((row) => row.active) ?? rows[0];
return (
<Menu
aria-label="Timeline tools"
trigger={
<button
type="button"
aria-label={`Timeline tool: ${current.label}`}
className={cn(flatIdle, "gap-0.5")}
>
{current.icon}
<CaretDown size={10} weight="bold" aria-hidden="true" />
</button>
}
>
{rows.map((row) => (
<MenuItem
key={row.label}
shortcut={row.shortcut}
onClick={row.run}
data-active={row.active || undefined}
className={row.active ? "bg-neutral-800 text-white" : undefined}
>
<span className="flex items-center gap-2">
{row.icon}
{row.label}
</span>
</MenuItem>
))}
</Menu>
);
}
@@ -26,7 +26,7 @@ import { AudioMetersIcon } from "./icons/AudioMetersIcon";
import { RippleEditIcon } from "./icons/RippleEditIcon";
import { flatActive, flatBtn, flatDisabled, flatIdle } from "./timelineToolbarStyles";
import { TimelineHistoryButtons, type TimelineHistoryButtonsProps } from "./TimelineHistoryButtons";
import { Scissors } from "../icons/SystemIcons";
import { TimelineToolPicker } from "./TimelineToolPicker";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "./editor/domEditingTypes";
import { canSplitElement } from "../utils/timelineElementSplit";
@@ -144,8 +144,6 @@ export function TimelineToolbar({
showAddBeat = true,
showKeyframes = true,
}: TimelineToolbarProps) {
const activeTool = usePlayerStore((s) => s.activeTool);
const setActiveTool = usePlayerStore((s) => s.setActiveTool);
const timelineSnapEnabled = usePlayerStore((s) => s.timelineSnapEnabled);
const setTimelineSnapEnabled = usePlayerStore((s) => s.setTimelineSnapEnabled);
const rippleEditEnabled = usePlayerStore((s) => s.rippleEditEnabled);
@@ -216,30 +214,7 @@ export function TimelineToolbar({
<div className="flex items-center justify-between px-2 py-0.5">
<div className="flex items-center gap-0.5">
<TimelineHistoryButtons {...history} />
<Tooltip label="Selection tool (V)">
<button
type="button"
onClick={() => setActiveTool("select")}
aria-label="Selection tool"
aria-pressed={activeTool === "select"}
className={activeTool === "select" ? flatActive : flatIdle}
>
<svg width="16" height="16" viewBox="0 0 12 12" fill="currentColor">
<path d="M2 0.5L10 6L6.5 6.5L8.5 11L6.5 11.5L4.5 7L2 9Z" />
</svg>
</button>
</Tooltip>
<Tooltip label="Razor tool (B) — Shift+click splits all tracks">
<button
type="button"
onClick={() => setActiveTool("razor")}
aria-label="Razor tool"
aria-pressed={activeTool === "razor"}
className={activeTool === "razor" ? flatActive : flatIdle}
>
<Scissors size={16} />
</button>
</Tooltip>
<TimelineToolPicker />
{/* Divider: tool-mode | editing-actions */}
<div aria-hidden="true" className="mx-1 h-4 w-px bg-neutral-800" />
<Tooltip label={timelineSnapEnabled ? "Snapping on (N)" : "Snapping off (N)"}>
@@ -207,6 +207,14 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
return;
}
if ((key === "[" || key === "]") && !event.shiftKey && !event.altKey) {
event.preventDefault();
const store = usePlayerStore.getState();
if (key === "[") store.selectLeftward();
else store.selectRightward();
return;
}
// CapCut returns to the select tool with "A". Scoped to the razor being
// armed; usePlaybackKeyboard.ts's own "A" (seek to in-point) checks the
// same activeTool to stay out of the way.
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { dispatchModifierKey, dispatchPlainKey, type HotkeyCallbacks } from "./appHotkeysDispatch";
import { usePlayerStore } from "../player/store/playerStore";
import { liveTime, usePlayerStore } from "../player/store/playerStore";
import type { DomEditSelection } from "../components/editor/domEditing";
import { clearAutomationClipboard, copyRange } from "../player/components/automationClipboard";
import { VOLUME_RANGE } from "@hyperframes/core/audio-automation";
@@ -60,6 +60,63 @@ afterEach(() => {
});
});
describe("dispatchPlainKey — select leftward / rightward", () => {
const clips = [
{ ...bgmElement, id: "early", key: "early", start: 0, track: 0 },
{ ...bgmElement, id: "at", key: "at", start: 4, track: 1 },
{ ...bgmElement, id: "late", key: "late", start: 7, track: 2 },
];
beforeEach(() => usePlayerStore.setState({ elements: clips, currentTime: 4 }));
it("[ selects every clip starting before the playhead, on every track", () => {
const event = press("[");
dispatchPlainKey(event, "[", callbacks());
expect([...usePlayerStore.getState().selectedElementIds]).toEqual(["early"]);
expect(usePlayerStore.getState().selectedElementId).toBe("early");
expect(event.defaultPrevented).toBe(true);
});
it("uses the live playhead while playing, not the time stored at play start", () => {
usePlayerStore.setState({ isPlaying: true, currentTime: 0 });
liveTime.notify(8);
try {
dispatchPlainKey(press("["), "[", callbacks());
expect([...usePlayerStore.getState().selectedElementIds].sort()).toEqual([
"at",
"early",
"late",
]);
} finally {
usePlayerStore.setState({ isPlaying: false });
liveTime.notify(0);
}
});
it("clears a clicked keyframe like any other selection change", () => {
usePlayerStore.setState({ activeKeyframePct: 50 });
dispatchPlainKey(press("]"), "]", callbacks());
expect(usePlayerStore.getState().activeKeyframePct).toBeNull();
});
it("] selects every clip starting at or after the playhead, on every track", () => {
dispatchPlainKey(press("]"), "]", callbacks());
const { selectedElementIds, selectedElementId } = usePlayerStore.getState();
expect([...selectedElementIds].sort()).toEqual(["at", "late"]);
expect(selectedElementId).toBe("at");
});
it("selects nothing when no clip is on that side", () => {
usePlayerStore.setState({
currentTime: 0,
selectedElementId: "late",
selectedElementIds: new Set(["late"]),
});
dispatchPlainKey(press("["), "[", callbacks());
expect(usePlayerStore.getState().selectedElementIds.size).toBe(0);
expect(usePlayerStore.getState().selectedElementId).toBeNull();
});
});
describe("dispatchPlainKey — Delete arbitration", () => {
const selectBgm = () =>
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
@@ -52,6 +52,8 @@ export const DEFAULT_SHORTCUT_SECTIONS: readonly ShortcutSection[] = [
{ key: "⌘X", label: "Cut element" },
{ key: hintKey(STUDIO_PLAIN_KEYS.split), label: "Split clip at playhead" },
{ key: "⇧Click", label: "Razor tool: split all tracks" },
{ key: "[", label: "Select clips starting before the playhead" },
{ key: "]", label: "Select clips starting at or after the playhead" },
{ key: "⌘G", label: "Group elements" },
{ key: "⌘⇧G", label: "Ungroup" },
{ key: "Del", label: "Delete selected element (no keyframe selected)" },
@@ -23,6 +23,7 @@ import { createRangeSelectionSlice, type RangeSelectionSlice } from "./rangeSele
import { createTimelineResetState } from "./timelineResetState";
export type { KeyframeCacheEntry } from "./keyframeSlice";
export { liveTime } from "./liveTime";
import { liveTime } from "./liveTime";
export { createTimelineResetState };
import type {
@@ -96,6 +97,8 @@ interface PlayerState extends PlayerStoreSlices {
activeTool: TimelineTool;
setActiveTool: (tool: TimelineTool) => void;
selectLeftward: () => void;
selectRightward: () => void;
/** Tween-relative percentage of the last-clicked keyframe diamond. Operations
* (drag, resize, rotate) target this instead of recomputing from playhead. */
@@ -250,6 +253,17 @@ interface BeatHistoryEntry {
label: string;
}
/** Selects like the marquee: the primary first, so its resets run, then the whole set. */
function selectAroundPlayhead(
state: PlayerState,
keep: (start: number, playhead: number) => boolean,
): void {
const playhead = state.isPlaying ? liveTime.latest() : state.currentTime;
const ids = state.elements.filter((el) => keep(el.start, playhead)).map((el) => el.key ?? el.id);
state.setSelectedElementId(ids[0] ?? null);
state.setSelectedElementIds(new Set(ids));
}
export const usePlayerStore = create<PlayerState>((set, get) => ({
isPlaying: false,
currentTime: 0,
@@ -273,6 +287,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
activeTool: "select",
setActiveTool: (tool) => set({ activeTool: tool }),
selectLeftward: () => selectAroundPlayhead(get(), (start, playhead) => start < playhead),
selectRightward: () => selectAroundPlayhead(get(), (start, playhead) => start >= playhead),
...createKeyframeSlice(set, () => ({
timelineProjectId: get().timelineProjectId,