Skip to content
181 changes: 162 additions & 19 deletions src/components/ai-edition/v4/FloatingInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
MAX_PLAYBACK_SPEED,
SPEED_OPTIONS,
ZOOM_DEPTH_SCALES,
type ZoomDepth,
} from "@/components/video-editor/types";
import { useScopedT } from "@/contexts/I18nContext";
import {
Expand Down Expand Up @@ -358,7 +359,142 @@ function convertAnnotationKind(
return { ...parked, type: next, content: restored };
}

const ZOOM_DEPTHS = [1, 2, 3, 4, 5, 6] as const;
const ZOOM_DEPTHS: readonly ZoomDepth[] = [1, 2, 3, 4, 5, 6];

/**
* The six zoom levels as one row of buttons, so a level is one click away instead of two
* (open the select, then pick). Six short labels fit the 300px pane on their own line, which
* is why this is a stacked label/row rather than a `paneRow`.
*
* `aria-pressed` buttons inside a labelled `role="group"` is `TranscriptLaneSwitch`'s pattern
* (the facet rail is the same buttons without the wrapper, since its own label carries), so
* every level stays in the Tab order and reads like its neighbours. Arrow keys step through
* the levels, which is what the `<select>` this replaces did once focused — and the one
* keyboard path that survives the editor shell's Tab binding (it cycles annotations whenever
* any exist, from any focused element).
*/
export function ZoomLevelControl({
region,
tl,
}: {
region: { id: string; depth: ZoomDepth };
tl: Pick<TimelineApi, "updateZoomDepth">;
}) {
const ts = useScopedT("settings");
const buttonsRef = useRef<Array<HTMLButtonElement | null>>([]);
// Last depth this instance asked for, and the generation of that request.
// Depth values repeat (only 1–6), so a Set of depths cannot tell "our older
// 4 landed" from "the latest request is 4" or from an undo that happens to
// land on 4. Each click/key gets a new gen. Every gen belonging to this
// region epoch is removed from `pending` when it settles — a superseded 4
// must still drain, or `pending` stays non-empty and undo/redo can never
// overwrite `requestedRef`. Only the latest gen may change `requestedRef`.
const requestedRef = useRef<ZoomDepth>(region.depth);
const genRef = useRef(0);
const pendingRef = useRef(new Set<number>());
const regionRef = useRef(region);
regionRef.current = region;

// biome-ignore lint/correctness/useExhaustiveDependencies: region.id is the trigger, not a read — the body resets request state; depth is taken from the render's ref so a same-depth other pill still clears the previous pill's pending gen.
useEffect(() => {
genRef.current += 1;
pendingRef.current.clear();
requestedRef.current = regionRef.current.depth;
}, [region.id]);

useEffect(() => {
if (pendingRef.current.size > 0) return;
requestedRef.current = region.depth;
}, [region.depth]);

const setDepth = (depth: ZoomDepth) => {
// Re-pressing the current level is not an edit: no save, no undo entry.
if (depth === requestedRef.current) return;
requestedRef.current = depth;
const gen = ++genRef.current;
pendingRef.current.add(gen);
const regionId = region.id;
void Promise.resolve(tl.updateZoomDepth(regionId, depth)).then(
(ok) => {
pendingRef.current.delete(gen);
if (gen !== genRef.current) return;
if (ok === false) requestedRef.current = regionRef.current.depth;
},
() => {
pendingRef.current.delete(gen);
if (gen !== genRef.current) return;
requestedRef.current = regionRef.current.depth;
},
);
};

return (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ fontSize: 12.5, color: "var(--fg-2)", fontWeight: 500 }}>
{ts("zoom.level")}
</span>
<div
role="group"
aria-label={ts("zoom.level")}
style={{ display: "flex", gap: 4 }}
onKeyDown={(e) => {
// A button activates on Enter and Space by itself — but the shell's play/pause
// shortcut is Space on WINDOW, and it `preventDefault()`s the keydown, which
// cancels that activation. Unstopped, Space on a level changed nothing and
// started playback instead. Stop the keystroke here so the button keeps its own
// key, and do NOT `preventDefault()` it, or the activation dies the same way.
if (e.key === "Enter" || e.key === " ") {
e.nativeEvent.stopPropagation();
return;
}
const step =
e.key === "ArrowRight" || e.key === "ArrowDown"
? 1
: e.key === "ArrowLeft" || e.key === "ArrowUp"
? -1
: 0;
if (step === 0) return;
// `nativeEvent.stopPropagation()`, not just the synthetic one: the editor shell
// listens on WINDOW, above React's root container, and ArrowLeft/ArrowRight seek
// the playhead there. Same reason as the pill's own keydown in V4Timeline.
e.preventDefault();
e.nativeEvent.stopPropagation();
// Step from the FOCUSED button, not from the selected level. Every level is a Tab
// stop, so the two can part company — and stepping from the selection then threw
// focus across the row (ArrowRight on the last button landed it in the middle).
// Focus moves one place and the level follows it; at either end neither moves.
const focused = buttonsRef.current.findIndex((b) => b === document.activeElement);
const from = focused >= 0 ? focused : ZOOM_DEPTHS.indexOf(requestedRef.current);
const next = ZOOM_DEPTHS[from + step];
if (next === undefined) return;
buttonsRef.current[next - 1]?.focus();
setDepth(next);
}}
>
{ZOOM_DEPTHS.map((d) => {
const pressed = d === region.depth;
return (
<button
key={d}
ref={(el) => {
buttonsRef.current[d - 1] = el;
}}
type="button"
aria-pressed={pressed}
onClick={() => setDepth(d)}
style={pressed ? zoomLevelPressedStyle : zoomLevelBtnStyle}
>
{/* La table, pas une formule : ce libellé annonçait « 2.0× » là où la pastille de la
timeline affiche « 1.80× » et où le rendu applique 1.8. */}
{ZOOM_DEPTH_SCALES[d]}×
</button>
);
})}
</div>
</div>
);
}

// The ladder the shared editor already ships (`SPEED_OPTIONS`), plus 1× so the select can
// express "back to normal". It stops at 5×; the free field in `SpeedControl` is what reaches
// `MAX_PLAYBACK_SPEED`.
Expand Down Expand Up @@ -519,24 +655,7 @@ function SelectionPane({ tl, onClose }: { tl: TimelineApi; onClose: () => void }
<div style={{ display: "flex", flexDirection: "column", minHeight: 0 }}>
{paneHeader(<ZoomIn size={15} />, tt("labels.zoom"), onClose, tc("actions.close"))}
<div style={bodyStyle}>
{paneRow(
ts("zoom.level"),
<select
value={region.depth}
onChange={(e) =>
void tl.updateZoomDepth(region.id, Number(e.target.value) as 1 | 2 | 3 | 4 | 5 | 6)
}
style={selectStyle}
>
{ZOOM_DEPTHS.map((d) => (
<option key={d} value={d}>
{/* La table, pas une formule : ce libellé annonçait « 2.0× » là où la pastille de la
timeline affiche « 1.80× » et où le rendu applique 1.8. */}
{ZOOM_DEPTH_SCALES[d]}×
</option>
))}
</select>,
)}
<ZoomLevelControl key={region.id} region={region} tl={tl} />
{paneRow(
ts("zoom.threeD.title"),
<select
Expand Down Expand Up @@ -1031,6 +1150,30 @@ const selectStyle: React.CSSProperties = {
font: "500 12.5px var(--font-display)",
};

// Six of these share the pane's 266px of content width, so each gets ~41px: enough for
// "1.25×" at 12px with room either side, and no horizontal padding to lose.
const zoomLevelBtnStyle: React.CSSProperties = {
flex: "1 1 0",
minWidth: 0,
height: 28,
padding: 0,
borderRadius: 8,
border: "1px solid var(--border)",
background: "var(--surface)",
color: "var(--fg-2)",
font: "500 12px var(--font-display)",
cursor: "pointer",
};

// Same signal as the pressed facet-rail button: accent text on the soft accent fill.
const zoomLevelPressedStyle: React.CSSProperties = {
...zoomLevelBtnStyle,
border: "1px solid var(--accent)",
background: "var(--accent-soft)",
color: "var(--accent)",
fontWeight: 600,
};

const secondaryBtnStyle: React.CSSProperties = {
padding: "9px 14px",
borderRadius: 10,
Expand Down
Loading
Loading