e.stopPropagation()}
- className="absolute bottom-24 right-6 z-menu flex w-[22rem] max-w-[calc(100vw-3rem)] gap-3 rounded-2xl border border-white/15 bg-media-chrome/95 p-3.5 text-white shadow-2xl"
+ className="absolute bottom-24 right-6 z-menu flex w-[22rem] max-w-[calc(100vw-3rem)] gap-3 rounded-2xl border border-white/15 bg-media-chrome/95 media-focus p-3.5 text-white shadow-2xl"
>
{next && (
diff --git a/frontend/src/components/VideoEditor.partial.dom.test.tsx b/frontend/src/components/VideoEditor.partial.dom.test.tsx
index 96459b7a..5ea98ab4 100644
--- a/frontend/src/components/VideoEditor.partial.dom.test.tsx
+++ b/frontend/src/components/VideoEditor.partial.dom.test.tsx
@@ -137,6 +137,32 @@ describe("VideoEditor — a typed time and another segment", () => {
});
});
+// An unreadable time is said on a line under the segment's row: outside the field's label, so the
+// field's name stays "Out" and the line is read once, as its description.
+describe("VideoEditor — an unreadable time", () => {
+ it("is said under the row, outside the field's label", () => {
+ vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
+ render(
+
+ {}} />
+ ,
+ );
+ const video = document.querySelector("video")!;
+ Object.defineProperty(video, "currentTime", { value: 50, configurable: true, writable: true });
+ fireEvent.timeUpdate(video);
+ fireEvent.click(screen.getByText("editor.splitHere"));
+ fireEvent.pointerDown(screen.getAllByTitle("editor.keptSeg")[0]!);
+ const out = screen.getByRole("textbox", { name: "editor.out" });
+ fireEvent.change(out, { target: { value: "abc" } });
+ fireEvent.keyDown(out, { key: "Enter" });
+ const line = screen.getByRole("alert");
+ expect(line.textContent).toBe("common.timecodeInvalid");
+ expect(line.closest("label")).toBeNull();
+ expect(screen.getByRole("textbox", { name: "editor.out" })).toBe(out);
+ expect(out.getAttribute("aria-describedby")).toBe(line.id);
+ });
+});
+
// The cuts sit on the shared time track: each is a slider the keyboard steps, and a step stops a
// tenth short of the next cut, so no segment ever closes up.
describe("VideoEditor — a cut on the keyboard", () => {
@@ -235,11 +261,14 @@ describe("VideoEditor — a cut's remove button", () => {
expect(document.activeElement).toBe(splitButton);
});
- it("sits at the cut's foot, under the next segment's keep toggle at its head", () => {
+ it("sits under the track at its cut, off the cut's grab area; the keep toggle sits over it", () => {
split();
const remove = screen.getByRole("button", { name: "editor.removeCut" });
- expect(remove.className).toMatch(/\bbottom-0\b/);
const track = screen.getByRole("group", { name: "editor.timeline" });
+ // A press anywhere on the cut drags it; the X that deletes it (no undo) is below the track.
+ expect(track.contains(remove)).toBe(false);
+ const cut = screen.getByRole("slider", { name: "editor.cutN" });
+ expect(remove.closest("[style]")?.style.left).toBe(cut.style.left);
const toggles = within(track).getAllByRole("button", { pressed: true });
for (const toggle of toggles) expect(toggle.className).toMatch(/\bz-menu\b/);
});
diff --git a/frontend/src/components/VideoEditor.tsx b/frontend/src/components/VideoEditor.tsx
index 74648050..74167e6f 100644
--- a/frontend/src/components/VideoEditor.tsx
+++ b/frontend/src/components/VideoEditor.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -15,9 +15,11 @@ import { markReported } from "../lib/api/core";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
-import { TimecodeField } from "./ui/TimecodeField";
+import { TimecodeError, TimecodeField } from "./ui/TimecodeField";
+import { useTimecodeField } from "./ui/useTimecodeField";
import { SURFACE } from "./ui/surfaces";
import { RangeTrack } from "./ui/RangeTrack";
+import { trackLeft } from "./ui/trackPosition";
import { formatTimecode } from "../lib/timecode";
type Seg = { id: number; start: number; end: number; keep: boolean };
@@ -309,6 +311,18 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const sel = segments.find((s) => s.id === selId) ?? segments[0];
const selIdx = segments.findIndex((s) => s.id === sel?.id);
+ const hours = duration >= 3600;
+ const inField = useTimecodeField(
+ sel?.start ?? 0,
+ hours,
+ (v) => selIdx > 0 && moveBoundary(selIdx - 1, v),
+ );
+ const outField = useTimecodeField(
+ sel?.end ?? 0,
+ hours,
+ (v) => selIdx < segments.length - 1 && moveBoundary(selIdx, v),
+ );
+ const timeErrorId = useId();
return (
moveBoundary(bi, time),
- // At the cut's foot, clear of the next segment's eye at its head. A real button: a
- // click or Enter removes the cut; its press is not a seek of the track under it.
- adornment: (
+ }))}
+ />
+
+ {/* Each cut's remove button, under the track at the cut: a real button a click or Enter
+ works, off the cut's grab area, where a press drags it (a removal has no undo). */}
+ {boundaries.length > 0 && (
+
+ {boundaries.map((b, bi) => (
+
e.stopPropagation()}
onClick={() => {
// Refused while the clips are queued (the list holds still): then nothing is
// removed, and no focus is left waiting to be taken by a later change.
@@ -442,16 +465,16 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
deleteBoundary(bi);
}}
// 24px, the target floor; white on its black disc in either theme.
- className="pointer-events-auto absolute bottom-0 -right-0.5 p-1.5 rounded-full bg-black/70 text-white/80 hover:text-white focus-visible:-outline-offset-2"
+ className="flex p-1.5 rounded-full bg-black/70 text-white/80 hover:text-white"
aria-label={t("editor.removeCut", { n: bi + 1 })}
title={t("editor.removeCut", { n: bi + 1 })}
>
- ),
- }))}
- />
-
+
+ ))}
+