Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 19 additions & 15 deletions packages/studio/src/components/TimelineToolPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ interface ToolRow {
}

/** One button for the active timeline tool; its menu switches tools or selects clips around the playhead. */
export function TimelineToolPicker() {
export function TimelineToolPicker({ showSelectAroundPlayhead = true }) {
const activeTool = usePlayerStore((s) => s.activeTool);
const store = () => usePlayerStore.getState();
const rows: ToolRow[] = [
Expand All @@ -41,21 +41,25 @@ export function TimelineToolPicker() {
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(),
},
];
if (showSelectAroundPlayhead) {
rows.push(
{
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
Expand Down
62 changes: 62 additions & 0 deletions packages/studio/src/components/TimelineToolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { usePlayerStore } from "../player/store/playerStore";
import { makeSelection } from "../hooks/domSelectionTestHarness";
import { useAudioMetersVisible } from "../utils/audioMeterVisibility";
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
import { dispatchPlainKey, type HotkeyCallbacks } from "../hooks/appHotkeysDispatch";
import { AudioMeterStrip } from "./nle/AudioMeterStrip";
import { TimelineToolbar } from "./TimelineToolbar";

Expand Down Expand Up @@ -295,6 +296,67 @@ describe("TimelineToolbar history", () => {
});
});

describe("TimelineToolbar tool menu", () => {
async function openMenuRows(props: Partial<React.ComponentProps<typeof TimelineToolbar>>) {
const { host, root } = renderToolbar(undefined, props);
const trigger = host.querySelector<HTMLButtonElement>('button[aria-label^="Timeline tool:"]')!;
await act(async () => {
trigger.click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
const rows = [...document.querySelectorAll('[role="menuitem"] > span:first-child')].map(
(label) => label.textContent,
);
act(() => root.unmount());
return rows;
}

it("lists Select, Split, Select leftward and Select rightward by default", async () => {
expect(await openMenuRows({})).toEqual([
"Select",
"Split",
"Select leftward",
"Select rightward",
]);
});

it("lists only Select and Split when the host hides select around the playhead", async () => {
expect(await openMenuRows({ showSelectAroundPlayhead: false })).toEqual(["Select", "Split"]);
});

it.each([true, false])("[ and ] select around the playhead with the rows shown: %s", (shown) => {
usePlayerStore.setState({
currentTime: 4,
elements: [0, 4, 7].map((start, track) => ({
id: `c${start}`,
key: `c${start}`,
tag: "div",
start,
duration: 2,
track,
})),
});
const { root } = renderToolbar(undefined, { showSelectAroundPlayhead: shown });
const press = (key: string) =>
act(() =>
dispatchPlainKey(new KeyboardEvent("keydown", { key }), key, {} as HotkeyCallbacks),
);
try {
press("[");
expect([...usePlayerStore.getState().selectedElementIds]).toEqual(["c0"]);
press("]");
expect([...usePlayerStore.getState().selectedElementIds].sort()).toEqual(["c4", "c7"]);
} finally {
act(() => root.unmount());
usePlayerStore.setState({
elements: [],
selectedElementId: null,
selectedElementIds: new Set(),
});
}
});
});

describe("TimelineToolbar add beat", () => {
it("shows Add beat by default, as Studio does", () => {
const { host, root } = renderToolbar();
Expand Down
4 changes: 3 additions & 1 deletion packages/studio/src/components/TimelineToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface TimelineToolbarProps {
onSplitElement?: (element: TimelineElement, splitTime: number) => void;
history?: TimelineHistoryButtonsProps;
showHistory?: boolean;
showSelectAroundPlayhead?: boolean;
showAddBeat?: boolean;
/** Hides Add keyframe and auto-record, and turns off auto-record and the K shortcut with them. */
showKeyframes?: boolean;
Expand Down Expand Up @@ -143,6 +144,7 @@ export function TimelineToolbar({
onSplitElement,
history,
showHistory = true,
showSelectAroundPlayhead = true,
showAddBeat = true,
showKeyframes = true,
}: TimelineToolbarProps) {
Expand Down Expand Up @@ -216,7 +218,7 @@ export function TimelineToolbar({
<div className="flex items-center justify-between px-2 py-0.5">
<div className="flex items-center gap-0.5">
{showHistory && <TimelineHistoryButtons {...history} />}
<TimelineToolPicker />
<TimelineToolPicker showSelectAroundPlayhead={showSelectAroundPlayhead} />
{/* 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)"}>
Expand Down
12 changes: 7 additions & 5 deletions packages/studio/src/hooks/useAppHotkeys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,14 @@ describe("dispatchPlainKey — select leftward / rightward", () => {
{ ...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 },
{ ...bgmElement, id: "gone", key: "gone", start: 0, duration: 2, track: 3 },
];
beforeEach(() => usePlayerStore.setState({ elements: clips, currentTime: 4 }));

it("[ selects every clip starting before the playhead, on every track", () => {
it("[ selects every clip that started before the playhead, crossing clips included", () => {
const event = press("[");
dispatchPlainKey(event, "[", callbacks());
expect([...usePlayerStore.getState().selectedElementIds]).toEqual(["early"]);
expect([...usePlayerStore.getState().selectedElementIds].sort()).toEqual(["early", "gone"]);
expect(usePlayerStore.getState().selectedElementId).toBe("early");
expect(event.defaultPrevented).toBe(true);
});
Expand All @@ -84,6 +85,7 @@ describe("dispatchPlainKey — select leftward / rightward", () => {
expect([...usePlayerStore.getState().selectedElementIds].sort()).toEqual([
"at",
"early",
"gone",
"late",
]);
} finally {
Expand All @@ -98,11 +100,11 @@ describe("dispatchPlainKey — select leftward / rightward", () => {
expect(usePlayerStore.getState().activeKeyframePct).toBeNull();
});

it("] selects every clip starting at or after the playhead, on every track", () => {
it("] selects every clip still running at or after the playhead, crossing clips included", () => {
dispatchPlainKey(press("]"), "]", callbacks());
const { selectedElementIds, selectedElementId } = usePlayerStore.getState();
expect([...selectedElementIds].sort()).toEqual(["at", "late"]);
expect(selectedElementId).toBe("at");
expect([...selectedElementIds].sort()).toEqual(["at", "early", "late"]);
expect(selectedElementId).toBe("early");
});

it("selects nothing when no clip is on that side", () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/player/components/studioShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const DEFAULT_SHORTCUT_SECTIONS: readonly ShortcutSection[] = [
{ 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: "]", label: "Select clips running at or after the playhead" },
{ key: "⌘G", label: "Group elements" },
{ key: "⌘⇧G", label: "Ungroup" },
{ key: "Del", label: "Delete selected element (no keyframe selected)" },
Expand Down
8 changes: 4 additions & 4 deletions packages/studio/src/player/store/playerStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,10 +257,10 @@ interface BeatHistoryEntry {
/** 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,
keep: (el: TimelineElement, 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);
const ids = state.elements.filter((el) => keep(el, playhead)).map((el) => el.key ?? el.id);
state.setSelectedElementId(ids[0] ?? null);
state.setSelectedElementIds(new Set(ids));
}
Expand Down Expand Up @@ -289,8 +289,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),
selectLeftward: () => selectAroundPlayhead(get(), (el, t) => el.start < t),
selectRightward: () => selectAroundPlayhead(get(), (el, t) => el.start + el.duration > t),

...createKeyframeSlice(set, () => ({
timelineProjectId: get().timelineProjectId,
Expand Down
Loading