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..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