fix(clip): typed times belong to their value, and every mark on the player chrome can be seen

TimecodeField drops a draft when the value moves under it (another segment selected, a nudge), so
leaving the field never commits it to another cut; it is invalid only once offered, with a line
under it saying how to type a time. On the always-dark chrome media-scope points every focus ring
at --media-mark and errors at the new --media-danger (a light theme's accent and danger were under
3:1 there). A track whose dragged handle unmounts ends the drag on the pointer's release instead of
locking up; a remove refused while clips are queued leaves no focus to take later; each remove-cut
button names its cut and is a 24px target. DESIGN.md records the media tokens; stale comments fixed.
This commit is contained in:
2026-10-10 20:28:52 +02:00
parent a867145bcb
commit c27cfdcb9c
18 changed files with 303 additions and 71 deletions
+53 -23
View File
@@ -1,12 +1,15 @@
import { useState, type ComponentProps } from "react";
import { useId, useState, type ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import { formatTimecode, parseTimecode } from "../../lib/timecode";
import { Input } from "./Input";
// A field a cut's time is typed into (lib/timecode): the editor's segment ends, the clip panel's In /
// Out. A typed draft is the field's until Enter or leaving the field takes it; then the field shows
// the value as it landed — the caller may clamp it (a time past the end) — and a time it cannot read
// stays, marked invalid, for fixing rather than vanishing or seeming taken. One element throughout,
// so Enter leaves the focus in the field (a field re-created per value dropped it on <body>).
// stays for fixing rather than vanishing or seeming taken: marked invalid, with a line under the
// field saying how to type one. Invalid is said of a time offered (Enter, leaving the field), never
// of one still being typed. One element throughout, so Enter leaves the focus in the field (a field
// re-created per value dropped it on <body>).
type Props = Omit<
ComponentProps<typeof Input>,
@@ -26,33 +29,60 @@ export function TimecodeField({
onCommit,
onBlur,
onKeyDown,
"aria-describedby": describedBy,
...rest
}: Props) {
const [draft, setDraft] = useState<string | null>(null);
const invalid = draft != null && parseTimecode(draft) == null;
const { t } = useTranslation();
const errorId = useId();
const [draft, setDraft] = useState<{ text: string; offered: boolean } | null>(null);
// A draft belongs to the value it was typed over. When the value moves without it (a nudge, "to
// the playhead", another segment selected under the same field), the draft is dropped: kept, it
// would show over the new value, and leaving the field would commit it to whatever the field
// edits now — another cut.
const [typedOver, setTypedOver] = useState(value);
if (typedOver !== value) {
setTypedOver(value);
setDraft(null);
}
const invalid = draft != null && draft.offered && parseTimecode(draft.text) == null;
const commit = () => {
if (draft == null) return;
const v = parseTimecode(draft);
if (v == null) return;
const v = parseTimecode(draft.text);
if (v == null) {
if (!draft.offered) setDraft({ text: draft.text, offered: true });
return;
}
setDraft(null);
if (Math.abs(v - value) > 1e-3) onCommit(v);
};
const described = [describedBy, invalid ? errorId : null].filter(Boolean).join(" ");
return (
<Input
{...rest}
value={draft ?? formatTimecode(value, hours)}
invalid={invalid}
onChange={(e) => setDraft(e.target.value)}
onBlur={(e) => {
commit();
onBlur?.(e);
}}
onKeyDown={(e) => {
onKeyDown?.(e);
if (e.key !== "Enter" || e.defaultPrevented) return;
e.preventDefault();
commit();
}}
/>
<span className="relative inline-flex">
<Input
{...rest}
value={draft?.text ?? formatTimecode(value, hours)}
invalid={invalid}
aria-describedby={described || undefined}
onChange={(e) => setDraft({ text: e.target.value, offered: draft?.offered ?? false })}
onBlur={(e) => {
commit();
onBlur?.(e);
}}
onKeyDown={(e) => {
onKeyDown?.(e);
if (e.key !== "Enter" || e.defaultPrevented) return;
e.preventDefault();
commit();
}}
/>
{invalid && (
<span
id={errorId}
className="absolute left-0 top-full z-menu mt-1 whitespace-nowrap rounded-md border border-danger bg-bg px-2 py-0.5 text-xs text-danger"
>
{t(hours ? "common.timecodeInvalidHours" : "common.timecodeInvalid")}
</span>
)}
</span>
);
}