Skip to content
Open
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
54 changes: 54 additions & 0 deletions src/components/ai-edition/v4/V4Timeline.geometry.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,60 @@ describe("V4Timeline lane pills", () => {
});
});

describe("V4Timeline lane pill keyboard", () => {
// A pill carries `role="button"` and `tabIndex={0}`, so it is reachable by Tab and
// announced as activatable. Selection was pointer-only, which meant a keyboard user
// could focus a region and then reach nothing that acts on a selection — Delete,
// copy/paste and the inspector all key off `tl.selection`.
it("selects the focused pill on Enter", () => {
const { pill, tl } = renderTimeline();
fireEvent.keyDown(pill, { key: "Enter" });
expect(tl.selectRegion).toHaveBeenCalledWith("annotation", "ann1", { additive: false });
});

it("selects it on Space too, the other key a button answers to", () => {
const { pill, tl } = renderTimeline();
fireEvent.keyDown(pill, { key: " " });
expect(tl.selectRegion).toHaveBeenCalledWith("annotation", "ann1", { additive: false });
});

it("adds to the selection when Shift is held, matching shift-click", () => {
const { pill, tl } = renderTimeline();
fireEvent.keyDown(pill, { key: "Enter", shiftKey: true });
expect(tl.selectRegion).toHaveBeenCalledWith("annotation", "ann1", { additive: true });
});

it("leaves every other key to the shell's shortcut handler", () => {
// The editor binds single letters (Z adds a zoom, T a trim, D deletes). Swallowing
// them here would silently disable every shortcut while a pill has focus.
const { pill, tl } = renderTimeline();
for (const key of ["z", "t", "d", "Escape", "ArrowRight"]) {
fireEvent.keyDown(pill, { key });
}
expect(tl.selectRegion).not.toHaveBeenCalled();
});

it("stops Enter and Space reaching the window listener", () => {
// Space is bound to play/pause on WINDOW, above React's root container. Without
// stopping the NATIVE event the same keystroke would select the pill and toggle
// playback; the synthetic `stopPropagation` alone does not reach that far.
const onWindowKey = vi.fn();
window.addEventListener("keydown", onWindowKey);
try {
const { pill } = renderTimeline();
fireEvent.keyDown(pill, { key: " " });
fireEvent.keyDown(pill, { key: "Enter" });
expect(onWindowKey).not.toHaveBeenCalled();

// A key the pill ignores still gets there, or the shortcuts would be dead.
fireEvent.keyDown(pill, { key: "z" });
expect(onWindowKey).toHaveBeenCalledTimes(1);
} finally {
window.removeEventListener("keydown", onWindowKey);
}
});
});

describe("V4Timeline create-from-toolbar", () => {
// The button asks for a DURATION worth a fixed number of pixels at the current
// zoom, so the pill you get is always the same size on screen — which is what
Expand Down
29 changes: 27 additions & 2 deletions src/components/ai-edition/v4/V4Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -660,11 +660,18 @@ export function V4Timeline({
// Drag a lane pill to move it (mode "move", keeps duration) or resize one
// edge (mode "l"/"r"). Zoom/speed/annotation are timeline-ms; trims map
// back to source-seconds through their carrying clip.
const selectPill = useCallback(
(pill: LanePill, additive: boolean) => {
tl.selectRegion(pill.kind, pill.id, { additive });
},
[tl],
);

const startPillDrag = useCallback(
(e: ReactPointerEvent, pill: LanePill, dragMode: "move" | "l" | "r") => {
e.preventDefault();
e.stopPropagation();
tl.selectRegion(pill.kind, pill.id, { additive: e.shiftKey });
selectPill(pill, e.shiftKey);
// Scale drag deltas against the canvas (full zoomed timeline) width, so a
// drag tracks the cursor exactly regardless of padding, scrollbar or zoom.
const el = canvasRef.current;
Expand Down Expand Up @@ -776,7 +783,7 @@ export function V4Timeline({
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
},
[tl, total, clips, pxPerSec],
[tl, selectPill, total, clips, pxPerSec],
);

const startNavDrag = useCallback(
Expand Down Expand Up @@ -1158,6 +1165,24 @@ export function V4Timeline({
: {}),
}}
onPointerDown={seg.interactive ? (e) => startPillDrag(e, p, "move") : undefined}
// A pill is focusable and announced as a button, so Enter and Space have to
// activate it — without this a keyboard user could tab to a region and then
// reach nothing that acts on a selection: Delete, copy/paste, the inspector.
//
// `nativeEvent.stopPropagation()`, not just the synthetic one: the editor
// shell listens on WINDOW, above React's root container, and Space is bound
// to play/pause there. Stopping only the synthetic event would select the
// pill and toggle playback in the same keystroke.
onKeyDown={
seg.interactive
? (e) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
e.nativeEvent.stopPropagation();
selectPill(p, e.shiftKey);
}
: undefined
}
title={p.label}
>
{seg.interactive ? (
Expand Down
Loading