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
88 changes: 81 additions & 7 deletions src/components/ai-edition/CaptionsPane.placement.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// between what the slider offers and what the band can do, not any one number.

import "@testing-library/jest-dom";
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { I18nProvider } from "@/contexts/I18nContext";
import {
Expand Down Expand Up @@ -110,8 +110,8 @@ afterEach(() => {
describe("caption placement controls", () => {
it("offers both axes", () => {
show({});
expect(sliderFor("Vertical offset")).toBeInTheDocument();
expect(sliderFor("Horizontal offset")).toBeInTheDocument();
expect(sliderFor("Vertical position")).toBeInTheDocument();
expect(sliderFor("Horizontal position")).toBeInTheDocument();
});

it.each([
Expand All @@ -121,7 +121,7 @@ describe("caption placement controls", () => {
] as const)("bounds the %s anchor's slider by what the band can actually reach", (verticalPosition) => {
const settings = show({ verticalPosition });
const range = captionOffsetRange(settings);
const slider = sliderFor("Vertical offset");
const slider = sliderFor("Vertical position");
expect(Number(slider.min)).toBeCloseTo(range.y.min, 6);
expect(Number(slider.max)).toBeCloseTo(range.y.max, 6);
});
Expand All @@ -130,7 +130,7 @@ describe("caption placement controls", () => {
// A fixed step of 1 would leave `max` off-grid for these fractional bounds and
// the caption would stop just short of the frame edge — the #396 complaint.
const settings = show({ verticalPosition: "bottom" });
const slider = sliderFor("Vertical offset");
const slider = sliderFor("Vertical position");
const [min, max, step] = [slider.min, slider.max, slider.step].map(Number);
const steps = (max - min) / step;
expect(steps).toBeCloseTo(Math.round(steps), 6);
Expand All @@ -144,9 +144,83 @@ describe("caption placement controls", () => {

it("disables the horizontal slider only when the band fills the frame", () => {
show({ width: 100 });
expect(sliderFor("Horizontal offset")).toBeDisabled();
expect(sliderFor("Horizontal position")).toBeDisabled();
cleanup();
show({ width: DEFAULT_CAPTION_SETTINGS.width });
expect(sliderFor("Horizontal offset")).toBeEnabled();
expect(sliderFor("Horizontal position")).toBeEnabled();
});
});

describe("caption position presets", () => {
const preset = (label: string) => screen.getByRole("button", { name: label });

it("shows the default settings' presets pressed: Bottom and Position center", () => {
show({});
expect(preset("Bottom")).toHaveAttribute("aria-pressed", "true");
expect(preset("Top")).toHaveAttribute("aria-pressed", "false");
expect(preset("Position center")).toHaveAttribute("aria-pressed", "true");
expect(preset("Position left")).toHaveAttribute("aria-pressed", "false");
});

it("clicking a vertical preset resets the vertical slider and lights that preset up", () => {
show({ verticalPosition: "bottom", offsetY: -20 });
expect(preset("Bottom")).toHaveAttribute("aria-pressed", "false");

fireEvent.click(preset("Top"));

expect(sliderFor("Vertical position")).toHaveValue("0");
expect(preset("Top")).toHaveAttribute("aria-pressed", "true");
expect(preset("Bottom")).toHaveAttribute("aria-pressed", "false");
});

it("dragging the vertical slider clears every vertical preset's pressed state", () => {
show({});
expect(preset("Bottom")).toHaveAttribute("aria-pressed", "true");

fireEvent.change(sliderFor("Vertical position"), { target: { value: "-10" } });

expect(preset("Bottom")).toHaveAttribute("aria-pressed", "false");
expect(preset("Top")).toHaveAttribute("aria-pressed", "false");
expect(preset("Middle")).toHaveAttribute("aria-pressed", "false");
});

it("clicking Position left/right moves the horizontal slider to the true frame edge", () => {
const settings = show({});
const range = captionOffsetRange(settings);

fireEvent.click(preset("Position left"));
expect(Number(sliderFor("Horizontal position").value)).toBeCloseTo(range.x.min, 6);
expect(preset("Position left")).toHaveAttribute("aria-pressed", "true");

fireEvent.click(preset("Position right"));
expect(Number(sliderFor("Horizontal position").value)).toBeCloseTo(range.x.max, 6);
expect(preset("Position right")).toHaveAttribute("aria-pressed", "true");
expect(preset("Position left")).toHaveAttribute("aria-pressed", "false");
});

it("dragging the horizontal slider clears the horizontal preset row", () => {
show({});
fireEvent.change(sliderFor("Horizontal position"), { target: { value: "3" } });

expect(preset("Position center")).toHaveAttribute("aria-pressed", "false");
expect(preset("Position left")).toHaveAttribute("aria-pressed", "false");
expect(preset("Position right")).toHaveAttribute("aria-pressed", "false");
});

it("disables the horizontal preset row exactly when the horizontal slider is disabled", () => {
show({ width: 100 });
expect(preset("Position left")).toBeDisabled();
cleanup();
show({ width: DEFAULT_CAPTION_SETTINGS.width });
expect(preset("Position left")).toBeEnabled();
});

it("gives the text-align row its own section label, separate from Position", () => {
show({});
expect(screen.getByText("Text align")).toBeInTheDocument();
// The words "Left"/"Center"/"Right" belong to text-align; "Position left" etc.
// belong to the new row — both must resolve without ambiguity.
expect(preset("Left")).toBeInTheDocument();
expect(preset("Position left")).toBeInTheDocument();
});
});
117 changes: 91 additions & 26 deletions src/components/ai-edition/CaptionsPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,30 @@
// translation is stored beside the transcript, keyed by segment id, and picking
// "Original" goes straight back to the SSOT text.

import { Captions as CaptionsIcon, Languages, Loader2, Trash2 } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import {
AlignHorizontalJustifyCenter,
AlignHorizontalJustifyEnd,
AlignHorizontalJustifyStart,
Captions as CaptionsIcon,
Languages,
Loader2,
Trash2,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useScopedT } from "@/contexts/I18nContext";
import type { CaptionTextAlign, CaptionVerticalPosition } from "@/lib/ai-edition/captions";
import { captionOffsetRange, untranslatedUnits } from "@/lib/ai-edition/captions";
import type {
CaptionHorizontalPosition,
CaptionTextAlign,
CaptionVerticalPosition,
} from "@/lib/ai-edition/captions";
import {
activeHorizontalPositionPreset,
activeVerticalPositionPreset,
captionHorizontalPositionOffset,
captionOffsetRange,
untranslatedUnits,
} from "@/lib/ai-edition/captions";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import {
useTimelineTranscriptGate,
Expand Down Expand Up @@ -461,24 +480,43 @@ export function CaptionsPane() {
{/* ── Placement ──────────────────────────────────────────── */}
<div className={styles.sectionLabel}>{t("captions.position")}</div>
<Segmented<CaptionVerticalPosition>
value={settings.verticalPosition}
value={activeVerticalPositionPreset(settings)}
disabled={disabled}
options={[
{ value: "top", label: t("captions.positionTop") },
{ value: "middle", label: t("captions.positionMiddle") },
{ value: "bottom", label: t("captions.positionBottom") },
]}
onChange={(verticalPosition) => void set({ verticalPosition })}
// A preset button is a shortcut to a clean position, not a nudge on top
// of one — resetting the offset is what makes clicking it feel like
// "go here" instead of "go here, plus whatever was left over".
onChange={(verticalPosition) => void set({ verticalPosition, offsetY: 0 })}
/>
<Segmented<CaptionTextAlign>
value={settings.textAlign}
disabled={disabled}
<Segmented<CaptionHorizontalPosition>
value={activeHorizontalPositionPreset(settings)}
// Mirrors the horizontal slider's own disabled condition just below: a
// full-width band has nowhere left or right to go.
disabled={disabled || offsetRange.x.max <= offsetRange.x.min}
options={[
{ value: "left", label: t("captions.alignLeft") },
{ value: "center", label: t("captions.alignCenter") },
{ value: "right", label: t("captions.alignRight") },
{
value: "left",
label: t("captions.positionLeft"),
icon: AlignHorizontalJustifyStart,
},
{
value: "center",
label: t("captions.positionCenter"),
icon: AlignHorizontalJustifyCenter,
},
{
value: "right",
label: t("captions.positionRight"),
icon: AlignHorizontalJustifyEnd,
},
]}
onChange={(textAlign) => void set({ textAlign })}
onChange={(preset) =>
void set({ offsetX: captionHorizontalPositionOffset(settings, preset) })
}
/>
<div className={styles.sliderGrid}>
<SliderCell
Expand Down Expand Up @@ -520,6 +558,19 @@ export function CaptionsPane() {
/>
</div>

{/* ── Text align (inside the band — a different axis from Position) ── */}
<div className={styles.sectionLabel}>{t("captions.textAlign")}</div>
<Segmented<CaptionTextAlign>
value={settings.textAlign}
disabled={disabled}
options={[
{ value: "left", label: t("captions.alignLeft") },
{ value: "center", label: t("captions.alignCenter") },
{ value: "right", label: t("captions.alignRight") },
]}
onChange={(textAlign) => void set({ textAlign })}
/>

{/* ── Line length ────────────────────────────────────────── */}
<div className={styles.sectionLabel}>{t("captions.lineLength")}</div>
<div className={styles.paneRow}>
Expand Down Expand Up @@ -585,25 +636,39 @@ function Segmented<T extends string>({
disabled,
onChange,
}: {
value: T;
options: ReadonlyArray<{ value: T; label: string }>;
/** `null` means no option is currently active — e.g. a free-dragged slider
* has moved off every preset this row offers. */
value: T | null;
options: ReadonlyArray<{
value: T;
label: string;
/** Renders in place of the text label when given (with `label` still used
* as the accessible name and hover title) — for a row that would otherwise
* repeat another row's words for a different axis of meaning. */
icon?: LucideIcon;
}>;
disabled?: boolean;
onChange: (next: T) => void;
}) {
return (
<div className={styles.paneTabs}>
{options.map((option) => (
<button
type="button"
key={option.value}
className={value === option.value ? styles.isActive : ""}
aria-pressed={value === option.value}
disabled={disabled}
onClick={() => onChange(option.value)}
>
{option.label}
</button>
))}
{options.map((option) => {
const Icon = option.icon;
return (
<button
type="button"
key={option.value}
className={value === option.value ? styles.isActive : ""}
aria-pressed={value === option.value}
aria-label={Icon ? option.label : undefined}
title={Icon ? option.label : undefined}
disabled={disabled}
onClick={() => onChange(option.value)}
>
{Icon ? <Icon size={14} /> : option.label}
</button>
);
})}
</div>
);
}
8 changes: 6 additions & 2 deletions src/i18n/locales/ar/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -302,11 +302,15 @@
"positionTop": "أعلى",
"positionMiddle": "الوسط",
"positionBottom": "أسفل",
"positionLeft": "الموضع الأيسر",
"positionCenter": "الموضع الأوسط",
"positionRight": "الموضع الأيمن",
"textAlign": "محاذاة النص",
"alignLeft": "يسار",
"alignCenter": "توسيط",
"alignRight": "يمين",
"verticalOffset": "الإزاحة الرأسية",
"horizontalOffset": "الإزاحة الأفقية",
"verticalOffset": "الموضع الرأسي",
"horizontalOffset": "الموضع الأفقي",
"width": "العرض",
"lineLength": "طول السطر",
"minWords": "أقل عدد كلمات في السطر",
Expand Down
8 changes: 6 additions & 2 deletions src/i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -308,11 +308,15 @@
"positionTop": "Top",
"positionMiddle": "Middle",
"positionBottom": "Bottom",
"positionLeft": "Position left",
"positionCenter": "Position center",
"positionRight": "Position right",
"textAlign": "Text align",
"alignLeft": "Left",
"alignCenter": "Center",
"alignRight": "Right",
"verticalOffset": "Vertical offset",
"horizontalOffset": "Horizontal offset",
"verticalOffset": "Vertical position",
"horizontalOffset": "Horizontal position",
"width": "Width",
"lineLength": "Line length",
"minWords": "Min words per line",
Expand Down
8 changes: 6 additions & 2 deletions src/i18n/locales/es/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -302,11 +302,15 @@
"positionTop": "Arriba",
"positionMiddle": "Centro",
"positionBottom": "Abajo",
"positionLeft": "Posición izquierda",
"positionCenter": "Posición central",
"positionRight": "Posición derecha",
"textAlign": "Alineación del texto",
"alignLeft": "Izquierda",
"alignCenter": "Centro",
"alignRight": "Derecha",
"verticalOffset": "Desplazamiento vertical",
"horizontalOffset": "Desplazamiento horizontal",
"verticalOffset": "Posición vertical",
"horizontalOffset": "Posición horizontal",
"width": "Ancho",
"lineLength": "Longitud de línea",
"minWords": "Mín. palabras por línea",
Expand Down
8 changes: 6 additions & 2 deletions src/i18n/locales/fr/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -302,11 +302,15 @@
"positionTop": "Haut",
"positionMiddle": "Milieu",
"positionBottom": "Bas",
"positionLeft": "Position à gauche",
"positionCenter": "Position au centre",
"positionRight": "Position à droite",
"textAlign": "Alignement du texte",
"alignLeft": "Gauche",
"alignCenter": "Centre",
"alignRight": "Droite",
"verticalOffset": "Décalage vertical",
"horizontalOffset": "Décalage horizontal",
"verticalOffset": "Position verticale",
"horizontalOffset": "Position horizontale",
"width": "Largeur",
"lineLength": "Longueur des lignes",
"minWords": "Mots min. par ligne",
Expand Down
8 changes: 6 additions & 2 deletions src/i18n/locales/it/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -302,11 +302,15 @@
"positionTop": "Alto",
"positionMiddle": "Centro",
"positionBottom": "Basso",
"positionLeft": "Posizione a sinistra",
"positionCenter": "Posizione centrale",
"positionRight": "Posizione a destra",
"textAlign": "Allineamento testo",
"alignLeft": "Sinistra",
"alignCenter": "Centro",
"alignRight": "Destra",
"verticalOffset": "Scostamento verticale",
"horizontalOffset": "Scostamento orizzontale",
"verticalOffset": "Posizione verticale",
"horizontalOffset": "Posizione orizzontale",
"width": "Larghezza",
"lineLength": "Lunghezza riga",
"minWords": "Parole min. per riga",
Expand Down
8 changes: 6 additions & 2 deletions src/i18n/locales/ja-JP/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -302,11 +302,15 @@
"positionTop": "上",
"positionMiddle": "中央",
"positionBottom": "下",
"positionLeft": "左配置",
"positionCenter": "中央配置",
"positionRight": "右配置",
"textAlign": "文字揃え",
"alignLeft": "左",
"alignCenter": "中央",
"alignRight": "右",
"verticalOffset": "垂直オフセット",
"horizontalOffset": "水平オフセット",
"verticalOffset": "垂直位置",
"horizontalOffset": "水平位置",
"width": "幅",
"lineLength": "行の長さ",
"minWords": "1 行の最小単語数",
Expand Down
Loading
Loading