mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user