diff --git a/src/components/ai-edition/CaptionsPane.placement.test.tsx b/src/components/ai-edition/CaptionsPane.placement.test.tsx
index 27d35a0d9..b7d41cec1 100644
--- a/src/components/ai-edition/CaptionsPane.placement.test.tsx
+++ b/src/components/ai-edition/CaptionsPane.placement.test.tsx
@@ -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 {
@@ -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([
@@ -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);
});
@@ -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);
@@ -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();
});
});
diff --git a/src/components/ai-edition/CaptionsPane.tsx b/src/components/ai-edition/CaptionsPane.tsx
index 983852f1d..6fc342a74 100644
--- a/src/components/ai-edition/CaptionsPane.tsx
+++ b/src/components/ai-edition/CaptionsPane.tsx
@@ -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,
@@ -461,24 +480,43 @@ export function CaptionsPane() {
{/* ── Placement ──────────────────────────────────────────── */}
{t("captions.position")}
- 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 })}
/>
-
- value={settings.textAlign}
- disabled={disabled}
+
+ 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) })
+ }
/>
+ {/* ── Text align (inside the band — a different axis from Position) ── */}
+ {t("captions.textAlign")}
+
+ 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 ────────────────────────────────────────── */}
{t("captions.lineLength")}
@@ -585,25 +636,39 @@ function Segmented
({
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 (
- {options.map((option) => (
- onChange(option.value)}
- >
- {option.label}
-
- ))}
+ {options.map((option) => {
+ const Icon = option.icon;
+ return (
+ onChange(option.value)}
+ >
+ {Icon ? : option.label}
+
+ );
+ })}
);
}
diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json
index b0beb268d..c19b80bce 100644
--- a/src/i18n/locales/ar/settings.json
+++ b/src/i18n/locales/ar/settings.json
@@ -302,11 +302,15 @@
"positionTop": "أعلى",
"positionMiddle": "الوسط",
"positionBottom": "أسفل",
+ "positionLeft": "الموضع الأيسر",
+ "positionCenter": "الموضع الأوسط",
+ "positionRight": "الموضع الأيمن",
+ "textAlign": "محاذاة النص",
"alignLeft": "يسار",
"alignCenter": "توسيط",
"alignRight": "يمين",
- "verticalOffset": "الإزاحة الرأسية",
- "horizontalOffset": "الإزاحة الأفقية",
+ "verticalOffset": "الموضع الرأسي",
+ "horizontalOffset": "الموضع الأفقي",
"width": "العرض",
"lineLength": "طول السطر",
"minWords": "أقل عدد كلمات في السطر",
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index 071fd4850..25430a23e 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -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",
diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json
index 099928b5d..93ac340d9 100644
--- a/src/i18n/locales/es/settings.json
+++ b/src/i18n/locales/es/settings.json
@@ -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",
diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json
index 1b7f5fa23..6a8ed73d3 100644
--- a/src/i18n/locales/fr/settings.json
+++ b/src/i18n/locales/fr/settings.json
@@ -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",
diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json
index 587aae961..37a585b2d 100644
--- a/src/i18n/locales/it/settings.json
+++ b/src/i18n/locales/it/settings.json
@@ -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",
diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json
index f856cde46..02f27575e 100644
--- a/src/i18n/locales/ja-JP/settings.json
+++ b/src/i18n/locales/ja-JP/settings.json
@@ -302,11 +302,15 @@
"positionTop": "上",
"positionMiddle": "中央",
"positionBottom": "下",
+ "positionLeft": "左配置",
+ "positionCenter": "中央配置",
+ "positionRight": "右配置",
+ "textAlign": "文字揃え",
"alignLeft": "左",
"alignCenter": "中央",
"alignRight": "右",
- "verticalOffset": "垂直オフセット",
- "horizontalOffset": "水平オフセット",
+ "verticalOffset": "垂直位置",
+ "horizontalOffset": "水平位置",
"width": "幅",
"lineLength": "行の長さ",
"minWords": "1 行の最小単語数",
diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json
index ee083c9b4..69e4b35e1 100644
--- a/src/i18n/locales/ko-KR/settings.json
+++ b/src/i18n/locales/ko-KR/settings.json
@@ -302,11 +302,15 @@
"positionTop": "위",
"positionMiddle": "가운데",
"positionBottom": "아래",
+ "positionLeft": "왼쪽 배치",
+ "positionCenter": "가운데 배치",
+ "positionRight": "오른쪽 배치",
+ "textAlign": "텍스트 정렬",
"alignLeft": "왼쪽",
"alignCenter": "가운데",
"alignRight": "오른쪽",
- "verticalOffset": "세로 오프셋",
- "horizontalOffset": "가로 오프셋",
+ "verticalOffset": "세로 위치",
+ "horizontalOffset": "가로 위치",
"width": "너비",
"lineLength": "줄 길이",
"minWords": "줄당 최소 단어 수",
diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json
index 6512686ba..acbd178b5 100644
--- a/src/i18n/locales/pt-BR/settings.json
+++ b/src/i18n/locales/pt-BR/settings.json
@@ -302,11 +302,15 @@
"positionTop": "Topo",
"positionMiddle": "Meio",
"positionBottom": "Base",
+ "positionLeft": "Posição à esquerda",
+ "positionCenter": "Posição central",
+ "positionRight": "Posição à direita",
+ "textAlign": "Alinhamento do texto",
"alignLeft": "Esquerda",
"alignCenter": "Centro",
"alignRight": "Direita",
- "verticalOffset": "Deslocamento vertical",
- "horizontalOffset": "Deslocamento horizontal",
+ "verticalOffset": "Posição vertical",
+ "horizontalOffset": "Posição horizontal",
"width": "Largura",
"lineLength": "Comprimento da linha",
"minWords": "Mín. de palavras por linha",
diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json
index dcd82de34..b070f0b95 100644
--- a/src/i18n/locales/ru/settings.json
+++ b/src/i18n/locales/ru/settings.json
@@ -302,11 +302,15 @@
"positionTop": "Сверху",
"positionMiddle": "По центру",
"positionBottom": "Снизу",
+ "positionLeft": "Положение слева",
+ "positionCenter": "Положение по центру",
+ "positionRight": "Положение справа",
+ "textAlign": "Выравнивание текста",
"alignLeft": "Слева",
"alignCenter": "По центру",
"alignRight": "Справа",
- "verticalOffset": "Смещение по вертикали",
- "horizontalOffset": "Смещение по горизонтали",
+ "verticalOffset": "Положение по вертикали",
+ "horizontalOffset": "Положение по горизонтали",
"width": "Ширина",
"lineLength": "Длина строки",
"minWords": "Мин. слов в строке",
diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json
index b3ccf10db..c5f1791e1 100644
--- a/src/i18n/locales/tr/settings.json
+++ b/src/i18n/locales/tr/settings.json
@@ -302,11 +302,15 @@
"positionTop": "Üst",
"positionMiddle": "Orta",
"positionBottom": "Alt",
+ "positionLeft": "Sol konum",
+ "positionCenter": "Orta konum",
+ "positionRight": "Sağ konum",
+ "textAlign": "Metin hizalama",
"alignLeft": "Sol",
"alignCenter": "Orta",
"alignRight": "Sağ",
- "verticalOffset": "Dikey ofset",
- "horizontalOffset": "Yatay ofset",
+ "verticalOffset": "Dikey konum",
+ "horizontalOffset": "Yatay konum",
"width": "Genişlik",
"lineLength": "Satır uzunluğu",
"minWords": "Satır başına en az kelime",
diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json
index 2274559b5..b87203235 100644
--- a/src/i18n/locales/vi/settings.json
+++ b/src/i18n/locales/vi/settings.json
@@ -302,11 +302,15 @@
"positionTop": "Trên",
"positionMiddle": "Giữa",
"positionBottom": "Dưới",
+ "positionLeft": "Vị trí trái",
+ "positionCenter": "Vị trí giữa",
+ "positionRight": "Vị trí phải",
+ "textAlign": "Căn chỉnh văn bản",
"alignLeft": "Trái",
"alignCenter": "Giữa",
"alignRight": "Phải",
- "verticalOffset": "Độ lệch dọc",
- "horizontalOffset": "Độ lệch ngang",
+ "verticalOffset": "Vị trí dọc",
+ "horizontalOffset": "Vị trí ngang",
"width": "Chiều rộng",
"lineLength": "Độ dài dòng",
"minWords": "Số từ tối thiểu mỗi dòng",
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index cd2856f78..7ab2ceef9 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -302,11 +302,15 @@
"positionTop": "顶部",
"positionMiddle": "中间",
"positionBottom": "底部",
+ "positionLeft": "左侧位置",
+ "positionCenter": "居中位置",
+ "positionRight": "右侧位置",
+ "textAlign": "文字对齐",
"alignLeft": "左对齐",
"alignCenter": "居中",
"alignRight": "右对齐",
- "verticalOffset": "垂直偏移",
- "horizontalOffset": "水平偏移",
+ "verticalOffset": "垂直位置",
+ "horizontalOffset": "水平位置",
"width": "宽度",
"lineLength": "行长",
"minWords": "每行最少词数",
diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json
index b8606a2e7..147d71233 100644
--- a/src/i18n/locales/zh-TW/settings.json
+++ b/src/i18n/locales/zh-TW/settings.json
@@ -303,11 +303,15 @@
"positionTop": "上",
"positionMiddle": "中",
"positionBottom": "下",
+ "positionLeft": "靠左位置",
+ "positionCenter": "置中位置",
+ "positionRight": "靠右位置",
+ "textAlign": "文字對齊",
"alignLeft": "靠左",
"alignCenter": "置中",
"alignRight": "靠右",
- "verticalOffset": "垂直位移",
- "horizontalOffset": "水平位移",
+ "verticalOffset": "垂直位置",
+ "horizontalOffset": "水平位置",
"width": "寬度",
"lineLength": "行長",
"minWords": "每行最少字數",
diff --git a/src/lib/ai-edition/captions/captions.test.ts b/src/lib/ai-edition/captions/captions.test.ts
index 73fe2a180..d6529489e 100644
--- a/src/lib/ai-edition/captions/captions.test.ts
+++ b/src/lib/ai-edition/captions/captions.test.ts
@@ -3,9 +3,13 @@ import type { AxcutDocument, AxcutTranscript } from "../schema";
import { captionCuesToTextRegions, deriveCaptionCues } from "./cues";
import type { CaptionSettings, CaptionSettingsPatch } from "./settings";
import {
+ activeHorizontalPositionPreset,
+ activeVerticalPositionPreset,
CAPTION_BAND_HEIGHT_PCT,
+ CAPTION_POSITION_PRESET_EPSILON,
captionBackgroundCss,
captionBandRect,
+ captionHorizontalPositionOffset,
captionInkHeightPct,
captionOffsetRange,
DEFAULT_CAPTION_SETTINGS,
@@ -251,6 +255,79 @@ describe("caption settings", () => {
});
});
+describe("caption position presets", () => {
+ it("reads a vertical preset as active only while there's no nudge off it", () => {
+ for (const verticalPosition of ["top", "middle", "bottom"] as const) {
+ const settings = { ...ON, verticalPosition, offsetY: 0 };
+ expect(activeVerticalPositionPreset(settings)).toBe(verticalPosition);
+ // Any nudge at all — even one too small to see — means the band is no
+ // longer exactly at the preset, so nothing should read as "active".
+ expect(activeVerticalPositionPreset({ ...settings, offsetY: 5 })).toBeNull();
+ }
+ });
+
+ it("reads left/center/right off offsetX, and null off the preset grid", () => {
+ expect(activeHorizontalPositionPreset(ON)).toBe("center");
+ const range = captionOffsetRange(ON);
+ expect(activeHorizontalPositionPreset({ ...ON, offsetX: range.x.min })).toBe("left");
+ expect(activeHorizontalPositionPreset({ ...ON, offsetX: range.x.max })).toBe("right");
+ expect(activeHorizontalPositionPreset({ ...ON, offsetX: range.x.min / 2 })).toBeNull();
+ });
+
+ it("collapses to center when the band is full-width, since left/right have nowhere to go", () => {
+ expect(activeHorizontalPositionPreset({ ...ON, width: 100, offsetX: 0 })).toBe("center");
+ });
+
+ it("still picks left/right over center when a near-full-width band squeezes them inside the epsilon", () => {
+ // At width this close to 100, range.x.min/max themselves fall inside
+ // CAPTION_POSITION_PRESET_EPSILON of 0 — checking "is this near center?"
+ // first would wrongly claim an offset that is exactly at the true edge.
+ const squeezed = { ...ON, width: 100 - 1e-7 };
+ const range = captionOffsetRange(squeezed);
+ expect(Math.abs(range.x.min)).toBeLessThan(CAPTION_POSITION_PRESET_EPSILON);
+ expect(activeHorizontalPositionPreset({ ...squeezed, offsetX: range.x.min })).toBe("left");
+ expect(activeHorizontalPositionPreset({ ...squeezed, offsetX: range.x.max })).toBe("right");
+ expect(activeHorizontalPositionPreset({ ...squeezed, offsetX: 0 })).toBe("center");
+ });
+
+ it("sets offsetX to the true frame edge for left/right, matching the reachable range", () => {
+ const range = captionOffsetRange(ON);
+ expect(captionHorizontalPositionOffset(ON, "left")).toBeCloseTo(range.x.min, 6);
+ expect(captionHorizontalPositionOffset(ON, "center")).toBe(0);
+ expect(captionHorizontalPositionOffset(ON, "right")).toBeCloseTo(range.x.max, 6);
+ });
+
+ it("reaches the true left and right frame edges through the left/right presets", () => {
+ const left = captionBandRect({ ...ON, offsetX: captionHorizontalPositionOffset(ON, "left") });
+ expect(left.x).toBeCloseTo(0, 6);
+ const right = captionBandRect({
+ ...ON,
+ offsetX: captionHorizontalPositionOffset(ON, "right"),
+ });
+ expect(right.x + right.width).toBeCloseTo(100, 6);
+ });
+
+ it("de-activates the horizontal preset when width moves the band, without touching offsetX", () => {
+ // The real asymmetry against the vertical axis: `range.x` moves with
+ // `width` (`captionAnchor.x` depends on it), so a preset that was flush
+ // can stop being flush purely because the band got narrower or wider.
+ // `offsetY === 0` has no such dependency, so a vertical preset never does
+ // this — it's intended, not a regression.
+ const atWidth80 = {
+ ...ON,
+ width: 80,
+ offsetX: captionOffsetRange({ ...ON, width: 80 }).x.min,
+ };
+ expect(activeHorizontalPositionPreset(atWidth80)).toBe("left");
+ expect(captionBandRect(atWidth80).x).toBeCloseTo(0, 6);
+
+ const narrowed = { ...atWidth80, width: 50 };
+ expect(activeHorizontalPositionPreset(narrowed)).toBeNull();
+ expect(narrowed.offsetX).toBe(atWidth80.offsetX);
+ expect(captionBandRect(narrowed).x).toBeCloseTo(15, 6);
+ });
+});
+
describe("deriveCaptionCues", () => {
it("returns nothing while the layer is hidden", () => {
expect(deriveCaptionCues(doc(), DEFAULT_CAPTION_SETTINGS, {})).toEqual([]);
diff --git a/src/lib/ai-edition/captions/index.ts b/src/lib/ai-edition/captions/index.ts
index 6ed325c71..c238654bc 100644
--- a/src/lib/ai-edition/captions/index.ts
+++ b/src/lib/ai-edition/captions/index.ts
@@ -9,6 +9,7 @@ export {
} from "./cues";
export type {
CaptionBandRect,
+ CaptionHorizontalPosition,
CaptionOffsetRange,
CaptionSettings,
CaptionSettingsPatch,
@@ -16,10 +17,14 @@ export type {
CaptionVerticalPosition,
} from "./settings";
export {
+ activeHorizontalPositionPreset,
+ activeVerticalPositionPreset,
CAPTION_BAND_HEIGHT_PCT,
CAPTION_EDGE_MARGIN_PCT,
+ CAPTION_POSITION_PRESET_EPSILON,
captionBackgroundCss,
captionBandRect,
+ captionHorizontalPositionOffset,
captionInkHeightPct,
captionOffsetRange,
DEFAULT_CAPTION_SETTINGS,
diff --git a/src/lib/ai-edition/captions/settings.ts b/src/lib/ai-edition/captions/settings.ts
index 13cacdc94..469c67aaf 100644
--- a/src/lib/ai-edition/captions/settings.ts
+++ b/src/lib/ai-edition/captions/settings.ts
@@ -18,6 +18,13 @@ export type CaptionVerticalPosition = "top" | "middle" | "bottom";
/** Horizontal alignment of the text inside the (always centred) caption band. */
export type CaptionTextAlign = "left" | "center" | "right";
+/** Horizontal position preset for the caption band itself — a different axis of
+ * meaning from `CaptionTextAlign`, which aligns the text *inside* the band.
+ * Not a stored field: it is derived from `offsetX` (see
+ * `activeHorizontalPositionPreset`) and set by writing `offsetX` directly (see
+ * `captionHorizontalPositionOffset`). */
+export type CaptionHorizontalPosition = "left" | "center" | "right";
+
export interface CaptionSettings {
/** Master show/hide for the whole caption layer (preview AND export). */
enabled: boolean;
@@ -85,6 +92,12 @@ export const CAPTION_BAND_HEIGHT_PCT = 22;
/** Margin between the band and the frame edge for the top/bottom anchors, in %. */
export const CAPTION_EDGE_MARGIN_PCT = 3;
+/** Tolerance for "is this offset at a preset's clean value", in % of frame.
+ * Deliberately not `Number.EPSILON` (already used below as `sliderStep`'s
+ * divide-by-zero floor, and far too small to absorb real float noise) —
+ * matches the `toBeCloseTo(x, 6)` tolerance this file's own tests use. */
+export const CAPTION_POSITION_PRESET_EPSILON = 1e-6;
+
/** Reference frame height the px-valued settings are authored against, matching
* `annotationScale.ts` — `fontSize` is "pixels at a 1080-high frame". */
const CAPTION_REFERENCE_FRAME_HEIGHT = 1080;
@@ -175,6 +188,70 @@ export function captionOffsetRange(settings: CaptionSettings): CaptionOffsetRang
};
}
+/**
+ * Which vertical preset, if any, the current settings match exactly.
+ *
+ * `verticalPosition` is always a real stored value, but a preset button should
+ * only read as "active" while the user hasn't nudged away from it — otherwise
+ * clicking a slider would leave a preset highlighted that no longer describes
+ * where the band actually is. `offsetY` is the nudge *from* the anchor, so
+ * "at the preset" is exactly "no nudge".
+ */
+export function activeVerticalPositionPreset(
+ settings: CaptionSettings,
+): CaptionVerticalPosition | null {
+ return Math.abs(settings.offsetY) < CAPTION_POSITION_PRESET_EPSILON
+ ? settings.verticalPosition
+ : null;
+}
+
+/**
+ * Which horizontal position preset, if any, the current settings match exactly.
+ *
+ * There is no stored `horizontalPosition` field — `offsetX` is already an
+ * absolute-feeling value centred on 0 with a range that reaches both frame
+ * edges (see `captionOffsetRange`), so "left"/"center"/"right" are just names
+ * for three points on that existing range. All three coincide at `offsetX===0`
+ * when the band is full-width (no travel) — and can also *nearly* coincide
+ * for a band merely close to full-width, where `range.x.min`/`max` shrink
+ * toward 0 as well. Picking the CLOSEST candidate (not the first one within
+ * epsilon) is what keeps that near-degenerate case from reporting "center"
+ * for an offset that is actually sitting exactly on `range.x.min`/`max`.
+ */
+export function activeHorizontalPositionPreset(
+ settings: CaptionSettings,
+): CaptionHorizontalPosition | null {
+ const range = captionOffsetRange(settings);
+ const { offsetX } = settings;
+ const candidates: ReadonlyArray<[CaptionHorizontalPosition, number]> = [
+ ["center", 0],
+ ["left", range.x.min],
+ ["right", range.x.max],
+ ];
+ let closest: CaptionHorizontalPosition | null = null;
+ let closestDistance = CAPTION_POSITION_PRESET_EPSILON;
+ for (const [preset, target] of candidates) {
+ const distance = Math.abs(offsetX - target);
+ if (distance < closestDistance) {
+ closest = preset;
+ closestDistance = distance;
+ }
+ }
+ return closest;
+}
+
+/** The `offsetX` that puts the band at a given horizontal preset, for a preset
+ * button's click handler to write. `left`/`right` reach the true frame edge —
+ * the same span `activeHorizontalPositionPreset` reads back against. */
+export function captionHorizontalPositionOffset(
+ settings: CaptionSettings,
+ preset: CaptionHorizontalPosition,
+): number {
+ if (preset === "center") return 0;
+ const range = captionOffsetRange(settings);
+ return preset === "left" ? range.x.min : range.x.max;
+}
+
const VERTICAL_POSITIONS: readonly CaptionVerticalPosition[] = ["top", "middle", "bottom"];
const TEXT_ALIGNS: readonly CaptionTextAlign[] = ["left", "center", "right"];
diff --git a/technical-documentation/architecture/transcription-and-captions.md b/technical-documentation/architecture/transcription-and-captions.md
index df899cda3..2d34225fe 100644
--- a/technical-documentation/architecture/transcription-and-captions.md
+++ b/technical-documentation/architecture/transcription-and-captions.md
@@ -519,7 +519,7 @@ unit falls back to the original words (`untranslatedUnits`,
Caption appearance lives in `document.legacyEditor.captions`, accessed
through `getCaptionSettings` / `patchCaptionSettings`
-([`src/lib/ai-edition/captions/settings.ts:217,262`](../../src/lib/ai-edition/captions/settings.ts:217)).
+([`src/lib/ai-edition/captions/settings.ts:279,324`](../../src/lib/ai-edition/captions/settings.ts:279)).
| Field | Default | Notes |
|---|---|---|
@@ -579,6 +579,25 @@ ends were hardcoded to ±45 while the result was clamped separately, which left
the bottom anchor honouring only −45…+3 — nearly half the slider moved the handle
and nothing else.
+#### Position presets
+
+`verticalPosition` and `offsetX`/`offsetY` are independent fields — a preset is
+not a separate mode the offsets are locked out of, it's just a point on the same
+range the slider already covers. `activeVerticalPositionPreset` /
+`activeHorizontalPositionPreset` (`settings.ts`, right after `captionOffsetRange`)
+read "is a preset active" back out of that: a preset counts as active only while
+its axis' offset is (within a small epsilon) exactly the value that preset would
+set, so dragging a slider away from a preset silently un-highlights it with no
+separate "active preset" field to keep in sync. Clicking a preset writes that
+clean value back (`offsetY: 0` for a vertical preset; `captionHorizontalPositionOffset`
+for a horizontal one) rather than leaving whatever nudge was already there, which
+is what makes the click read as "go here" instead of "go here, plus whatever was
+left over." `CaptionHorizontalPosition` (left/center/right) is a new axis of
+meaning distinct from `CaptionTextAlign` (same three words, but for aligning the
+text *inside* the band) — there is no stored `horizontalPosition` field; it is
+derived from `offsetX` exactly the way the vertical preset is derived from
+`offsetY`.
+
The Captions pane itself
([`src/components/ai-edition/CaptionsPane.tsx`](../../src/components/ai-edition/CaptionsPane.tsx))
is the only place that runs `transcribe` from the editor shell, and