From 3a1600fddab594b5fcec50b5f2bf3e798a1799f8 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 22 Aug 2026 11:58:01 +0200 Subject: [PATCH 1/3] fix(captions): position presets you can click, sliders that reach the true edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The offset sliders shipped in #396 let a caption band reach every position but gave users no legible way to say "put it at the top" short of finding the right number. Presets (top/middle/bottom, and a new left/center/right band position) are now the everyday path; a preset button is highlighted only while its axis' offset is exactly the value that preset would set — so dragging a slider away silently clears the highlight, and clicking a preset snaps the slider back to a clean value, with no separate "active preset" state to keep in sync. The new left/center/right row is band position, not text alignment — kept visually distinct from the existing (unchanged) text-align row with its own section label and icon buttons, since the two would otherwise read as the same control. offsetX/offsetY already reached the true frame edge (that was #396); this only changes how presets and sliders talk to each other. --- .../CaptionsPane.placement.test.tsx | 88 +++++++++++-- src/components/ai-edition/CaptionsPane.tsx | 117 ++++++++++++++---- src/i18n/locales/ar/settings.json | 8 +- src/i18n/locales/en/settings.json | 8 +- src/i18n/locales/es/settings.json | 8 +- src/i18n/locales/fr/settings.json | 8 +- src/i18n/locales/it/settings.json | 8 +- src/i18n/locales/ja-JP/settings.json | 8 +- src/i18n/locales/ko-KR/settings.json | 8 +- src/i18n/locales/pt-BR/settings.json | 8 +- src/i18n/locales/ru/settings.json | 8 +- src/i18n/locales/tr/settings.json | 8 +- src/i18n/locales/vi/settings.json | 8 +- src/i18n/locales/zh-CN/settings.json | 8 +- src/i18n/locales/zh-TW/settings.json | 8 +- src/lib/ai-edition/captions/captions.test.ts | 64 ++++++++++ src/lib/ai-edition/captions/index.ts | 5 + src/lib/ai-edition/captions/settings.ts | 62 ++++++++++ 18 files changed, 381 insertions(+), 59 deletions(-) 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) => ( - - ))} + {options.map((option) => { + const Icon = option.icon; + return ( + + ); + })}
); } 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..a4d12f257 100644 --- a/src/lib/ai-edition/captions/captions.test.ts +++ b/src/lib/ai-edition/captions/captions.test.ts @@ -3,9 +3,12 @@ import type { AxcutDocument, AxcutTranscript } from "../schema"; import { captionCuesToTextRegions, deriveCaptionCues } from "./cues"; import type { CaptionSettings, CaptionSettingsPatch } from "./settings"; import { + activeHorizontalPositionPreset, + activeVerticalPositionPreset, CAPTION_BAND_HEIGHT_PCT, captionBackgroundCss, captionBandRect, + captionHorizontalPositionOffset, captionInkHeightPct, captionOffsetRange, DEFAULT_CAPTION_SETTINGS, @@ -251,6 +254,67 @@ 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("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..3f80bd05b 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,55 @@ 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, checked in this order because all + * three coincide at `offsetX === 0` when the band is full-width (no travel). + */ +export function activeHorizontalPositionPreset( + settings: CaptionSettings, +): CaptionHorizontalPosition | null { + const range = captionOffsetRange(settings); + const { offsetX } = settings; + if (Math.abs(offsetX) < CAPTION_POSITION_PRESET_EPSILON) return "center"; + if (Math.abs(offsetX - range.x.min) < CAPTION_POSITION_PRESET_EPSILON) return "left"; + if (Math.abs(offsetX - range.x.max) < CAPTION_POSITION_PRESET_EPSILON) return "right"; + return null; +} + +/** 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"]; From 3eee701ecc729c62fb318584d47ff903d57b0d48 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 22 Aug 2026 11:58:12 +0200 Subject: [PATCH 2/3] docs(captions): describe the position-preset derivation, fix a drifted anchor settings.ts:217,262 was already stale before this change; the new preset helpers land right above getCaptionSettings/patchCaptionSettings and shift it further, to :279,324. Kept separate from the fix commit: docs-only changes aren't part of what the release branch's cherry-pick lane accepts. --- .../transcription-and-captions.md | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/technical-documentation/architecture/transcription-and-captions.md b/technical-documentation/architecture/transcription-and-captions.md index 02bf9ae8b..bb990f8c1 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 From 7bffee12c6128b0e46a41a9b42207d9baf555338 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 22 Aug 2026 12:14:55 +0200 Subject: [PATCH 3/3] fix(captions): pick the closest position preset, not the first one checked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit activeHorizontalPositionPreset checked "is this centered?" before "is this flush left/right?" — fine normally, but as width approaches 100 the whole reachable range shrinks toward 0 right along with it, so a band sitting exactly at the true left/right edge could fall inside the center check's epsilon too and get reported as centered. Comparing all three candidates and keeping the nearest one is correct regardless of how narrow the range gets. Found via code review (CodeRabbit hit its OSS rate limit on the PR, so this ran as a subagent review instead). Reachable today only through a hand-edited or externally-generated project file — legacyEditor.captions has no schema validation — not through the shipped integer-stepped width slider. --- src/lib/ai-edition/captions/captions.test.ts | 13 ++++++++++ src/lib/ai-edition/captions/settings.ts | 27 +++++++++++++++----- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/lib/ai-edition/captions/captions.test.ts b/src/lib/ai-edition/captions/captions.test.ts index a4d12f257..d6529489e 100644 --- a/src/lib/ai-edition/captions/captions.test.ts +++ b/src/lib/ai-edition/captions/captions.test.ts @@ -6,6 +6,7 @@ import { activeHorizontalPositionPreset, activeVerticalPositionPreset, CAPTION_BAND_HEIGHT_PCT, + CAPTION_POSITION_PRESET_EPSILON, captionBackgroundCss, captionBandRect, captionHorizontalPositionOffset, @@ -277,6 +278,18 @@ describe("caption position presets", () => { 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); diff --git a/src/lib/ai-edition/captions/settings.ts b/src/lib/ai-edition/captions/settings.ts index 3f80bd05b..469c67aaf 100644 --- a/src/lib/ai-edition/captions/settings.ts +++ b/src/lib/ai-edition/captions/settings.ts @@ -211,18 +211,33 @@ export function activeVerticalPositionPreset( * 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, checked in this order because all - * three coincide at `offsetX === 0` when the band is full-width (no travel). + * 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; - if (Math.abs(offsetX) < CAPTION_POSITION_PRESET_EPSILON) return "center"; - if (Math.abs(offsetX - range.x.min) < CAPTION_POSITION_PRESET_EPSILON) return "left"; - if (Math.abs(offsetX - range.x.max) < CAPTION_POSITION_PRESET_EPSILON) return "right"; - return null; + 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