refactor: rewrite subtitle widget with solid

This commit is contained in:
Toil 2026-08-31 21:41:57 +03:00
parent a0c6e80f90
commit 58b7ed8956
No known key found for this signature in database
GPG key ID: 57248E2819D32070
15 changed files with 721 additions and 1309 deletions

View file

@ -15,6 +15,7 @@
- Добавлены тултипы для кнопок "Картинка в картинке" и "Меню" в кнопке перевода
- Заменены иконки "Стандартных" и "Живых" голосов на более приятные
- Переработана иконка прогресса загрузки для аудио. Теперь, она стала более плавной, а также обзавелась визуальным выделением незаполненной части прогресса
- Улучшено визуальное выделение активного слова субтитров в некоторых случаях
- Весь UI расширения переведен на SolidJS
- Слайдер "Громкость при уменьшении" теперь отключается при включенном режиме "Адаптивная громкость"
- Оптимизирован предпросмотр позиции "Кнопки перевода". Теперь, он использует облегченную версию элемента, а не создает полный клон

View file

@ -0,0 +1,106 @@
import { createSignal, type JSX } from "solid-js";
import { localizationProvider } from "../../localization/localizationProvider";
import { render } from "../../ui/solid/renderer";
import { Tooltip, type TooltipControls } from "../Utils/Tooltip";
export type SubtitleTokenTooltipOptions = {
target: HTMLElement;
anchor: HTMLElement;
parentElement: HTMLElement | ShadowRoot;
maxWidth: number;
source: string;
context: string;
translationService: string;
};
export type SubtitleTokenTooltipHandle = {
show: () => void;
update: () => void;
isOpen: () => boolean;
setTranslation: (header: string, context: string) => void;
updateMount: (parentElement: HTMLElement | ShadowRoot) => void;
dispose: () => void;
};
function SubtitleTokenInfo(props: {
source: string;
context: () => string;
header: () => string;
translationService: string;
}): JSX.Element {
return (
<vot-block class="vot-subtitles-info" id="vot-subtitles-info">
<vot-block class="vot-subtitles-info-service" hidden>
{localizationProvider
.get("VOTTranslatedBy")
.replace("{0}", props.translationService)}
</vot-block>
<vot-block class="vot-subtitles-info-title">
<span class="vot-subtitles-info-source">{props.source}</span>
<span class="vot-subtitles-info-divider">—</span>
<span class="vot-subtitles-info-header">{props.header()}</span>
</vot-block>
<vot-block class="vot-subtitles-info-context">
{props.context()}
</vot-block>
</vot-block>
);
}
export function mountSubtitleTokenTooltip(
options: SubtitleTokenTooltipOptions,
): SubtitleTokenTooltipHandle {
const [header, setHeader] = createSignal(options.source);
const [context, setContext] = createSignal(options.context);
const [parentElement, setParentElement] = createSignal<
HTMLElement | ShadowRoot
>(options.parentElement);
let tooltipControls: TooltipControls | undefined;
const view = (): JSX.Element => (
<Tooltip
target={options.target}
anchor={options.anchor}
parentElement={parentElement()}
maxWidth={options.maxWidth}
offset={{ x: 4, y: 12 }}
mode="follow"
borderRadius={12}
bordered={false}
position="top"
trigger="click"
content={
<SubtitleTokenInfo
source={options.source}
context={context}
header={header}
translationService={options.translationService}
/>
}
controls={(c) => {
tooltipControls = c;
}}
/>
);
const dispose = render(() => view() as Node, parentElement());
return {
show: () => tooltipControls?.show(),
update: () => tooltipControls?.update(),
isOpen: () => tooltipControls?.isOpen() ?? false,
setTranslation: (nextHeader, nextContext) => {
setHeader(nextHeader);
setContext(nextContext);
tooltipControls?.update();
},
updateMount: (nextParentElement) => {
setParentElement(nextParentElement);
tooltipControls?.update();
},
dispose: () => {
dispose();
},
};
}

View file

@ -55,9 +55,9 @@
// Base size is CSS-driven with clamp; JS only overrides when manual size is enabled.
font-size: calc(
var(
--vot-subtitles-font-size,
clamp(18px, var(--vot-subtitles-smart-font-preferred, 2.2vw), 50px)
) *
--vot-subtitles-font-size,
clamp(18px, var(--vot-subtitles-smart-font-preferred, 2.2vw), 50px)
) *
var(--vot-subtitles-scale-compensation, 1)
);
// Increase specificity (class repeated) + !important to win against aggressive host CSS.
@ -182,7 +182,9 @@
background: rgba(31, 32, 35, 0.96);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 18px !important;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.34), 0 4px 16px rgba(0, 0, 0, 0.22);
box-shadow:
0 18px 48px rgba(0, 0, 0, 0.34),
0 4px 16px rgba(0, 0, 0, 0.22);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
text-align: start;
@ -239,7 +241,7 @@
& span[data-vot-highlight-index] {
&.passed {
color: var(--vot-subtitles-passed-color, rgb(33, 150, 243));
color: var(--vot-subtitles-passed-color, rgb(139, 180, 245));
}
}
@ -270,7 +272,15 @@
}
&.selected::before {
background: var(--vot-subtitles-passed-color, rgb(33, 150, 243));
background: var(--vot-subtitles-passed-color, rgb(139, 180, 245));
}
&.passed.selected::before {
background: rgba(var(--vot-primary-rgb, 139, 180, 245), 0.4);
}
&.passed:hover::before {
background: rgba(var(--vot-primary-rgb, 139, 180, 245), 0.3);
}
}
@ -375,11 +385,10 @@
:-webkit-full-screen .vot-subtitles {
font-size: calc(
var(
--vot-subtitles-font-size,
clamp(18px, var(--vot-subtitles-smart-font-preferred, 2vw), 50px)
) *
var(--vot-subtitles-fullscreen-scale, 1) *
0.95 *
--vot-subtitles-font-size,
clamp(18px, var(--vot-subtitles-smart-font-preferred, 2vw), 50px)
) *
var(--vot-subtitles-fullscreen-scale, 1) * 0.95 *
var(--vot-subtitles-scale-compensation, 1)
);
}

View file

@ -0,0 +1,163 @@
import { expect } from "storybook/test";
import type { Meta, StoryObj } from "storybook-solidjs-vite";
import { SubtitlesWidget as SubtitlesController } from "../../subtitles/widget";
import type { ProcessedSubtitles } from "../../types/subtitles";
import { createIntervalIdleChecker } from "../../utils/intervalIdleChecker";
import {
mountSolidSubtitlesWidget,
SolidSubtitlesWidget,
} from "./SubtitlesWidget";
const meta = {
component: SolidSubtitlesWidget,
} satisfies Meta<typeof SolidSubtitlesWidget>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Lifecycle: Story = {
args: {
parts: [],
lang: "",
onClick: () => {},
ref: () => {},
},
play: async ({ canvasElement }) => {
const container = document.createElement("vot-block");
canvasElement.append(container);
let lang = "en";
let clicks = 0;
let disposed = false;
const handle = mountSolidSubtitlesWidget(container, {
lang: () => lang,
onClick: () => {
clicks += 1;
},
});
try {
handle.setParts([
{ kind: "word", text: "one", highlightIndex: 0 },
{ kind: "word", text: "two", highlightIndex: 1 },
]);
const firstWord = handle.highlightEls()[0];
await expect(handle.block()).toHaveAttribute("lang", "en");
await expect(
handle.highlightEls().map((element) => element.textContent),
).toEqual(["one", "two"]);
lang = "";
handle.setParts([{ kind: "word", text: "three", highlightIndex: 0 }]);
await expect(handle.block().hasAttribute("lang")).toBe(false);
await expect(handle.highlightEls()[0]).toBe(firstWord);
await expect(handle.highlightEls()[0]).toHaveTextContent("three");
lang = "ar";
handle.setParts([{ kind: "word", text: "four", highlightIndex: 0 }]);
handle.block().click();
await expect(handle.block()).toHaveAttribute("lang", "ar");
await expect(clicks).toBe(1);
handle.dispose();
disposed = true;
await expect(container.childElementCount).toBe(0);
await expect(handle.highlightEls()).toEqual([]);
await expect(() => handle.block()).toThrow();
} finally {
if (!disposed) handle.dispose();
container.remove();
}
},
};
const subtitles = (color: string): ProcessedSubtitles => ({
format: "vtt",
subtitles: [
{
text: "same",
startMs: 0,
durationMs: 5_000,
speakerId: "0",
tokens: [
{
text: "same",
startMs: 0,
durationMs: 5_000,
isWordLike: true,
style: { color },
},
],
},
],
});
export const TrackReplacement: Story = {
args: {
parts: [],
lang: "",
onClick: () => {},
ref: () => {},
},
play: async ({ canvasElement }) => {
const container = document.createElement("vot-block");
const nextContainer = document.createElement("vot-block");
container.style.width = "640px";
container.style.height = "360px";
nextContainer.style.width = "800px";
nextContainer.style.height = "450px";
canvasElement.append(container, nextContainer);
const video = document.createElement("video");
const checker = createIntervalIdleChecker();
const NativeResizeObserver = globalThis.ResizeObserver;
const observed: Element[] = [];
const unobserved: Element[] = [];
class TrackingResizeObserver implements ResizeObserver {
disconnect(): void {}
observe(target: Element): void {
observed.push(target);
}
unobserve(target: Element): void {
unobserved.push(target);
}
}
globalThis.ResizeObserver = TrackingResizeObserver;
const widget = new SubtitlesController(video, container, checker);
try {
widget.setContent(subtitles("#ff0000"), "en");
const firstWord = container.querySelector<HTMLSpanElement>(
'span[data-vot-token="1"]',
);
await expect(firstWord).toBeInstanceOf(HTMLSpanElement);
await expect(firstWord).toHaveStyle(
"--vot-subtitles-inline-color: #ff0000",
);
widget.setContent(subtitles("#00ff00"), "fr");
const replacedWord = container.querySelector<HTMLSpanElement>(
'span[data-vot-token="1"]',
);
await expect(replacedWord).toBe(firstWord);
await expect(replacedWord).toHaveStyle(
"--vot-subtitles-inline-color: #00ff00",
);
await expect(replacedWord?.closest(".vot-subtitles")).toHaveAttribute(
"lang",
"fr",
);
widget.updateMount({ container: nextContainer });
await expect(unobserved).toContain(container);
await expect(observed).toContain(nextContainer);
await expect(nextContainer.contains(replacedWord)).toBe(true);
} finally {
widget.release();
checker.destroy();
globalThis.ResizeObserver = NativeResizeObserver;
container.remove();
nextContainer.remove();
}
},
};

View file

@ -0,0 +1,181 @@
import {
batch,
createSignal,
Index,
type JSX,
Match,
onCleanup,
Switch,
} from "solid-js";
import "./SubtitlesWidget.scss";
import { buildSubtitleInlineStyleCssText } from "../../subtitles/inlineStyle";
import type {
SubtitleRenderPlanPart,
SubtitleRenderPlanSpanPart,
} from "../../subtitles/renderPlan";
import { effect, render } from "../../ui/solid/renderer";
export type SubtitlesWidgetProps = {
parts: SubtitleRenderPlanPart[];
lang: string;
onClick: (event: MouseEvent) => void;
onHighlightRef?: (index: number, element?: HTMLSpanElement) => void;
ref: (element: HTMLElement) => void;
};
export type SolidSubtitlesWidgetHandle = {
setParts: (parts: SubtitleRenderPlanPart[]) => void;
block: () => HTMLElement;
highlightEls: () => HTMLSpanElement[];
dispose: () => void;
};
function isSpanPart(
part: SubtitleRenderPlanPart,
): part is SubtitleRenderPlanSpanPart {
if (part.kind === "break") return false;
return (
part.kind === "word" ||
Boolean(part.style) ||
part.highlightIndex !== undefined
);
}
function SubtitlePart(props: {
index: number;
part: SubtitleRenderPlanPart;
onHighlightRef?: (index: number, element?: HTMLSpanElement) => void;
}): JSX.Element {
let span: HTMLSpanElement | undefined;
const spanPart = (): SubtitleRenderPlanSpanPart | undefined =>
isSpanPart(props.part) ? props.part : undefined;
const syncHighlightRef = () => {
const part = props.part;
props.onHighlightRef?.(
props.index,
span && isSpanPart(part) && part.highlightIndex !== undefined
? span
: undefined,
);
};
effect(syncHighlightRef);
onCleanup(() => props.onHighlightRef?.(props.index));
return (
<Switch fallback={props.part.kind === "text" ? props.part.text : undefined}>
<Match when={props.part.kind === "break"}>
<br class="vot-subtitles-br" />
</Match>
<Match when={spanPart()}>
{(part) => (
<span
ref={(element) => {
span = element;
syncHighlightRef();
}}
data-vot-token={part().kind === "word" ? "1" : undefined}
data-vot-highlight-index={part().highlightIndex}
data-vot-style-italic={part().style?.italic ? "1" : "0"}
data-vot-style-bold={part().style?.bold ? "1" : "0"}
data-vot-style-underline={part().style?.underline ? "1" : "0"}
data-vot-style-color={part().style?.color ? "1" : "0"}
style={buildSubtitleInlineStyleCssText(part().style)}
>
{part().text}
</span>
)}
</Match>
</Switch>
);
}
export function SolidSubtitlesWidget(props: SubtitlesWidgetProps): JSX.Element {
let block: HTMLElement | undefined;
const syncLang = () => {
const lang = props.lang;
if (!block) return;
if (lang) block.setAttribute("lang", lang);
else block.removeAttribute("lang");
};
effect(syncLang);
return (
<vot-block
ref={(element) => {
block = element;
props.ref(element);
syncLang();
}}
class="vot-subtitles"
dir="auto"
onClick={props.onClick}
>
<Index each={props.parts}>
{(part, index) => (
<SubtitlePart
index={index}
part={part()}
onHighlightRef={props.onHighlightRef}
/>
)}
</Index>
</vot-block>
);
}
export function mountSolidSubtitlesWidget(
container: HTMLElement,
options: {
lang: () => string;
onClick: (event: MouseEvent) => void;
},
): SolidSubtitlesWidgetHandle {
const [parts, setParts] = createSignal<SubtitleRenderPlanPart[]>([]);
const [lang, setLang] = createSignal(options.lang());
const highlightRefs = new Map<number, HTMLSpanElement>();
let block: HTMLElement | undefined;
container.replaceChildren();
const subtitlesView = (): JSX.Element => (
<SolidSubtitlesWidget
ref={(element) => {
block = element;
}}
parts={parts()}
lang={lang()}
onClick={options.onClick}
onHighlightRef={(index, element) => {
if (element) highlightRefs.set(index, element);
else highlightRefs.delete(index);
}}
/>
);
const disposeRoot = render(() => subtitlesView() as Node, container);
return {
setParts: (nextParts) => {
batch(() => {
setLang(options.lang());
setParts(nextParts);
});
},
block: () => {
if (!block) throw new Error("Subtitles widget failed to mount");
return block;
},
highlightEls: () =>
Array.from(highlightRefs.entries())
.sort(([left], [right]) => left - right)
.map(([, element]) => element),
dispose: () => {
disposeRoot();
highlightRefs.clear();
block = undefined;
container.replaceChildren();
},
};
}

View file

@ -46,9 +46,16 @@ type PositionBoundary = {
bottom: number;
};
export type TooltipControls = {
show: () => void;
update: () => void;
isOpen: () => boolean;
};
export type TooltipProps = Omit<TooltipOpts, "content"> & {
content?: JSX.Element;
ref?: (element: HTMLElement) => void;
controls?: (controls: TooltipControls) => void;
};
const DEFAULT_TOOLTIP_POS: Position = "top";
@ -610,6 +617,12 @@ export function Tooltip(props: TooltipProps): JSX.Element {
syncAriaDescribedBy(false);
});
finalProps.controls?.({
show,
update: floatingPosition.update,
isOpen: () => isMounted() && isVisible(),
});
const tooltipView = (): JSX.Element => (
<Show when={isMounted()}>
<vot-block

View file

@ -10,7 +10,7 @@
--vot-danger-rgb: 255, 99, 99;
--vot-subtitles-color: rgb(var(--vot-onsurface-rgb, 227, 227, 227));
--vot-subtitles-passed-color: rgb(var(--vot-primary-rgb, 33, 150, 243));
--vot-subtitles-passed-color: rgb(var(--vot-primary-rgb, 139, 180, 245));
/*
* UI design tokens (used by menus, dialogs and controls).

View file

@ -37,6 +37,8 @@
@use "SegmentedButton/SegmentedButtonMenu.scss";
@use "SegmentedButton/SegmentedButtonMenuOverlay.scss";
@use "SubtitlesWidget/SubtitlesWidget.scss";
@use "Utils/OrBlock.scss";
@use "Utils/Menu.scss";
@use "Utils/Tooltip.scss";

View file

@ -9,8 +9,6 @@
@use "components/tooltip.scss";
@use "components/voice-popover.scss";
@use "subtitles.scss";
:root {
--vot-font-family: "Roboto", "Segoe UI", system-ui, sans-serif;

View file

@ -26,16 +26,12 @@ export type SubtitleRenderPlanPart =
| SubtitleRenderPlanSpanPart
| SubtitleRenderPlanPartBreak;
const PUNCTUATION_OR_SYMBOL_RE = /^[\p{P}\p{S}]$/u;
export const LEADING_PUNCTUATION_RE = /^[\p{P}\p{S}]+/u;
export const TRAILING_PUNCTUATION_RE = /[\p{P}\p{S}]+$/u;
const TEXT_TOKEN_SLICE_RE = /\s+|[\p{P}\p{S}]+|[^\s\p{P}\p{S}]+/gu;
const LEADING_PUNCTUATION_RE = /^[\p{P}\p{S}]+/u;
const TRAILING_PUNCTUATION_RE = /[\p{P}\p{S}]+$/u;
const PUNCTUATION_ONLY_RE = /^[\p{P}\p{S}]+$/u;
const LEADING_WHITESPACE_RE = /^\s+/u;
const _isPunctuationOrSymbol = (char: string): boolean =>
PUNCTUATION_OR_SYMBOL_RE.test(char);
/**
* Consolidated from a per-character `for..of` scan. A single anchored regex is
* equivalent for the same input class (Unicode `\p{P}`/`\p{S}` runs) and avoids

View file

@ -15,8 +15,6 @@ export type SmartLayoutResult = {
maxWidthPx: number | null;
};
const roundToInt = (value: number): number => Math.round(value);
const resolveAspectBand = (
aspect: number,
): {
@ -126,6 +124,6 @@ export function computeSmartLayoutForBox(
return {
fontSizePx,
maxWidthPx: roundToInt(resolvedMaxWidthPx),
maxWidthPx: Math.round(resolvedMaxWidthPx),
};
}

View file

@ -1,194 +0,0 @@
import { createRenderEffect, createRoot, createSignal } from "solid-js";
import { buildSubtitleInlineStyleCssText } from "./inlineStyle";
import type {
SubtitleRenderPlanPart,
SubtitleRenderPlanPartBreak,
SubtitleRenderPlanPartText,
SubtitleRenderPlanSpanPart,
} from "./renderPlan";
export type SubtitleViewOptions = {
/** BCP-47 tag for the rendered block; empty string clears the attribute. */
lang: () => string;
onClick: (event: PointerEvent) => void;
};
export type SubtitleViewHandle = {
/** Replaces the rendered plan and applies it synchronously. */
setParts: (parts: SubtitleRenderPlanPart[]) => void;
/** The `.vot-subtitles` block element. */
block: () => HTMLElement;
/** Highlightable spans in plan order, collected while rendering. */
highlightEls: () => HTMLSpanElement[];
dispose: () => void;
};
type PartShape = "break" | "span" | "text";
type ClassifiedPart =
| { shape: "break"; part: SubtitleRenderPlanPartBreak }
| { shape: "span"; part: SubtitleRenderPlanSpanPart }
| { shape: "text"; part: SubtitleRenderPlanPartText };
function classifyPart(part: SubtitleRenderPlanPart): ClassifiedPart {
if (part.kind === "break") return { shape: "break", part };
if (part.kind === "word" || part.style || part.highlightIndex !== undefined) {
return { shape: "span", part };
}
return { shape: "text", part };
}
function shapeMatches(node: Node, shape: PartShape): boolean {
if (shape === "text") return node.nodeType === Node.TEXT_NODE;
if (shape === "break") return (node as Element).nodeName === "BR";
return (node as Element).nodeName === "SPAN";
}
/** Write-if-changed: an unchanged attribute write still costs a style recalc. */
function setAttr(el: Element, name: string, value: string | null): void {
if (value === null) {
if (el.hasAttribute(name)) el.removeAttribute(name);
return;
}
if (el.getAttribute(name) !== value) el.setAttribute(name, value);
}
function applySpan(
el: HTMLSpanElement,
part: SubtitleRenderPlanSpanPart,
): void {
if (el.textContent !== part.text) el.textContent = part.text;
setAttr(el, "data-vot-token", part.kind === "word" ? "1" : null);
setAttr(
el,
"data-vot-highlight-index",
part.highlightIndex === undefined ? null : String(part.highlightIndex),
);
setAttr(el, "data-vot-style-italic", part.style?.italic ? "1" : "0");
setAttr(el, "data-vot-style-bold", part.style?.bold ? "1" : "0");
setAttr(el, "data-vot-style-underline", part.style?.underline ? "1" : "0");
setAttr(el, "data-vot-style-color", part.style?.color ? "1" : "0");
const cssText = buildSubtitleInlineStyleCssText(part.style);
if (cssText) {
if (el.style.cssText !== cssText) el.style.cssText = cssText;
} else if (el.style.cssText) {
el.style.cssText = "";
}
}
function createNode(classified: ClassifiedPart): ChildNode {
switch (classified.shape) {
case "break": {
const br = document.createElement("br");
br.className = "vot-subtitles-br";
return br;
}
case "text":
return document.createTextNode(classified.part.text);
case "span": {
const span = document.createElement("span");
applySpan(span, classified.part);
return span;
}
}
}
/**
* Subtitle block rendered from a Solid reactive root.
*
* - One signal holds the whole plan; `equals: false` because the widget rebuilds
* the array on every tick and the identity check would be wasted work.
* - `createRenderEffect` (not `createEffect`) so DOM writes happen synchronously
* on `setParts`; the widget reads `block()` / `highlightEls()` immediately
* afterwards. Render effects are the same phase Solid uses for its own DOM
* bindings.
* - Rows are reconciled positionally and reused whenever the shape at an index
* is unchanged (measured in Chromium: 6.32 ms reuse vs 29.80 ms rebuild over
* 200 cue swaps). Plans are replaced wholesale and never reordered, so
* identity keying would only destroy reusable nodes.
* - No JSX: the build has no JSX transform, so the view drives the DOM with
* Solid's reactive primitives directly instead of compiled templates.
* - A native `click` listener is used because the container can live inside a
* shadow root, where Solid's document-level event delegation never sees the
* retargeted event.
*/
export function mountSubtitleView(
container: HTMLElement,
options: SubtitleViewOptions,
): SubtitleViewHandle {
const [parts, setParts] = createSignal<SubtitleRenderPlanPart[]>([], {
equals: false,
});
const blockEl = document.createElement("vot-block");
blockEl.className = "vot-subtitles";
blockEl.setAttribute("dir", "auto");
const onClick = options.onClick as EventListener;
blockEl.addEventListener("click", onClick);
const highlights: HTMLSpanElement[] = [];
const disposeRoot = createRoot((dispose) => {
createRenderEffect(() => {
setAttr(blockEl, "lang", options.lang() || null);
});
createRenderEffect(() => {
const plan = parts();
highlights.length = 0;
let node = blockEl.firstChild;
for (let i = 0; i < plan.length; i += 1) {
const classified = classifyPart(plan[i]);
const shape = classified.shape;
if (!node || !shapeMatches(node, shape)) {
const created = createNode(classified);
if (node) blockEl.replaceChild(created, node);
else blockEl.appendChild(created);
node = created;
} else if (classified.shape === "span") {
applySpan(node as HTMLSpanElement, classified.part);
} else if (
classified.shape === "text" &&
node.nodeValue !== classified.part.text
) {
node.nodeValue = classified.part.text;
}
if (
classified.shape === "span" &&
classified.part.highlightIndex !== undefined
) {
highlights.push(node as HTMLSpanElement);
}
node = node.nextSibling;
}
while (node) {
const next = node.nextSibling;
blockEl.removeChild(node);
node = next;
}
});
return dispose;
});
container.replaceChildren(blockEl);
return {
setParts: (next) => setParts(next),
block: () => blockEl,
highlightEls: () => highlights.slice(),
dispose: () => {
disposeRoot();
blockEl.removeEventListener("click", onClick);
highlights.length = 0;
container.textContent = "";
},
};
}

View file

@ -1,3 +1,12 @@
import {
mountSolidSubtitlesWidget,
type SolidSubtitlesWidgetHandle,
} from "../components/SubtitlesWidget/SubtitlesWidget";
import {
mountSubtitleTokenTooltip,
type SubtitleTokenTooltipHandle,
} from "../components/SubtitlesWidget/SubtitleTokenTooltip";
import { Overlay } from "../components/Utils/Overlay";
import { DEFAULT_TRANSLATION_SERVICE } from "../config/config";
import { translate } from "../core/translateApis";
import { localizationProvider } from "../localization/localizationProvider";
@ -8,14 +17,13 @@ import type {
SubtitlePositionPreset,
SubtitleToken,
} from "../types/subtitles";
import UI from "../ui";
import Tooltip from "../ui/components/tooltip";
import {
createShadowMount,
destroyShadowMount,
reparentShadowMount,
type ShadowMount,
} from "../ui/shadowMount";
import { render } from "../ui/solid/renderer";
import type { IntervalIdleChecker } from "../utils/intervalIdleChecker";
import { votStorage } from "../utils/storage";
import { buildActiveSubtitleRenderLine } from "./activeCues";
@ -40,7 +48,11 @@ import {
resolveCustomVerticalAnchor,
snapValueToNearestCandidate,
} from "./positionController";
import { buildSubtitleRenderPlan } from "./renderPlan";
import {
buildSubtitleRenderPlan,
LEADING_PUNCTUATION_RE,
TRAILING_PUNCTUATION_RE,
} from "./renderPlan";
import {
computeSmartLayoutForBox as computeSmartLayoutForBoxUtil,
type SmartCssMetrics,
@ -56,36 +68,24 @@ import {
type TokenPrecomputeMemo,
type TokenProcessingMemo,
} from "./smartWrap";
import { mountSubtitleView, type SubtitleViewHandle } from "./subtitleView";
import { computeNextWakeMs } from "./wakeSchedule";
import "../shims/rvfc-polyfill";
const EDGE_PUNCTUATION_OR_SYMBOL_RE = /^[\p{P}\p{S}]$/u;
const trimEdgePunctuation = (value: string): string =>
value
.trim()
.replace(LEADING_PUNCTUATION_RE, "")
.replace(TRAILING_PUNCTUATION_RE, "");
const isEdgePunctuationOrSymbol = (char: string): boolean =>
EDGE_PUNCTUATION_OR_SYMBOL_RE.test(char);
const trimEdgePunctuation = (value: string): string => {
const chars = Array.from(value);
let startIndex = 0;
let endIndex = chars.length;
while (
startIndex < endIndex &&
isEdgePunctuationOrSymbol(chars[startIndex])
) {
startIndex += 1;
}
while (
endIndex > startIndex &&
isEdgePunctuationOrSymbol(chars[endIndex - 1])
) {
endIndex -= 1;
}
return chars.slice(startIndex, endIndex).join("");
type LayoutMetrics = {
w: number;
h: number;
rect: DOMRect;
/** visual px / layout px */
scaleX: number;
/** visual px / layout px */
scaleY: number;
};
type DraggingState = {
/** active pointer id while the pointer is down inside the subtitles */
pointerId: number | null;
@ -97,42 +97,20 @@ type DraggingState = {
moved: boolean;
startClientX: number;
startClientY: number;
offset: {
x: number;
y: number;
};
};
type ClearRenderedContentOptions = {
releaseTooltip?: boolean;
};
type AnchorBoxLayout = {
/** Left of the anchor box in *layout* pixels relative to the widget container */
left: number;
/** Top of the anchor box in *layout* pixels relative to the widget container */
top: number;
/** Width of the anchor box in *layout* pixels */
w: number;
/** Height of the anchor box in *layout* pixels */
h: number;
};
type LayoutMetrics = {
w: number;
h: number;
rect: DOMRect;
/** visual px / layout px */
scaleX: number;
/** visual px / layout px */
scaleY: number;
offset: { x: number; y: number };
};
const WRAP_WIDTH_GUARD_PX = 8;
const WRAP_WIDTH_GUARD_RATIO = 0.97;
const MIN_EFFECTIVE_WRAP_WIDTH_PX = 24;
function applyWrapWidthGuard(maxWidthPx: number): number {
if (!Number.isFinite(maxWidthPx) || maxWidthPx <= 0) return 0;
const byPixelGuard = maxWidthPx - WRAP_WIDTH_GUARD_PX;
const byRatioGuard = maxWidthPx * WRAP_WIDTH_GUARD_RATIO;
const guarded = Math.min(byPixelGuard, byRatioGuard);
return Math.max(MIN_EFFECTIVE_WRAP_WIDTH_PX, guarded);
return Math.max(
MIN_EFFECTIVE_WRAP_WIDTH_PX,
Math.min(
maxWidthPx - WRAP_WIDTH_GUARD_PX,
maxWidthPx * WRAP_WIDTH_GUARD_RATIO,
),
);
}
function isDocumentHidden(): boolean {
return typeof document !== "undefined" && document.hidden === true;
@ -144,13 +122,13 @@ export class SubtitlesWidget {
private readonly fullscreenLayerController: FullscreenLayerController;
private tooltipMount?: ShadowMount;
private subtitlesContainer: HTMLElement | null = null;
private subtitleView: SubtitleViewHandle | null = null;
private subtitleView: SolidSubtitlesWidgetHandle | null = null;
private subtitleOverlayHost: HTMLElement | null = null;
private subtitlesBlock: HTMLElement | null = null;
private renderedHighlightEls: HTMLSpanElement[] = [];
/** Parsed highlight indices + last applied class state (see `highlightState.ts`). */
private readonly highlightState: HighlightState = createHighlightState();
/**
*/
private sourceEpoch = 0;
private contentEpoch = 0;
private styleEpoch = 0;
/** Monotonic tick counter used to cache layout reads within a single tick. */
@ -232,15 +210,9 @@ export class SubtitlesWidget {
moved: false,
startClientX: 0,
startClientY: 0,
offset: {
x: 0,
y: 0,
},
offset: { x: 0, y: 0 },
};
private dragLayoutCache: {
layout: LayoutMetrics;
anchorBox: AnchorBoxLayout;
} | null = null;
private dragLayoutCache: LayoutMetrics | null = null;
/** Newest un-applied pointer sample; older samples in the same frame are dropped. */
private pendingDragPoint: { clientX: number; clientY: number } | null = null;
private dragFrameId: number | null = null;
@ -249,7 +221,10 @@ export class SubtitlesWidget {
private suppressTokenClicksUntil = 0;
private readonly abortController = new AbortController();
private resizeObserver?: ResizeObserver;
private tokenTooltip?: Tooltip;
private resizeTarget?: Element;
private tokenTooltip?: SubtitleTokenTooltipHandle;
private tokenTooltipTarget?: HTMLElement;
private subtitleOverlayDispose?: () => void;
private tooltipTranslationRequestId = 0;
private readonly intervalIdleChecker: IntervalIdleChecker;
private checkerUnsubscribe: (() => void) | null = null;
@ -257,9 +232,6 @@ export class SubtitlesWidget {
private strTranslatedTokens = "";
private passedStateKey: string | null = null;
private readonly passedThresholds: number[] = [];
private normalizeTokenTextForTranslation(raw: string): string {
return trimEdgePunctuation(raw.trim());
}
private bottomInsetCachedPx = 0; // layout px
private safeAreaBottomInsetCachedPx = 0;
private containerPaddingBottomCachedPx = 0;
@ -328,10 +300,13 @@ export class SubtitlesWidget {
this.syncWidgetMount();
if (containerChanged) {
const parentElement = this.getTokenTooltipParentElement();
this.tokenTooltip?.updateMount({
parentElement,
});
this.syncResizeTarget();
this.dragLayoutCache = null;
this.invalidateLayoutCache();
this.invalidateStyleCaches();
if (this.tokenTooltip) {
this.tokenTooltip.updateMount(this.getTokenTooltipParentElement());
}
}
if (this.subtitles) {
@ -362,14 +337,6 @@ export class SubtitlesWidget {
this.lastRenderKey = null;
this.invalidateWakeDeadline();
}
private computeAnchorBoxLayout(layout: LayoutMetrics): AnchorBoxLayout {
return {
left: 0,
top: 0,
w: layout.w,
h: layout.h,
};
}
private readSmartCssMetrics(): SmartCssMetrics | null {
const block = this.subtitlesBlock;
if (!block) return null;
@ -400,7 +367,7 @@ export class SubtitlesWidget {
if (maxWidthPx <= 0) return null;
return { fontSizePx, maxWidthPx };
}
private ensureSmartLayout(anchorBox: AnchorBoxLayout): {
private ensureSmartLayout(anchorBox: LayoutMetrics): {
maxWidthPx: number | null;
} | null {
if (!this.smartLayoutEnabled) {
@ -530,7 +497,7 @@ export class SubtitlesWidget {
this.horizontalGuide?.removeAttribute("data-visible");
}
private updateSnapGuides(
anchorBox: AnchorBoxLayout,
anchorBox: LayoutMetrics,
options: {
showVerticalCenter?: boolean;
showHorizontalCenter?: boolean;
@ -543,8 +510,8 @@ export class SubtitlesWidget {
this.syncGuideLayerMount();
}
if (this.verticalGuide) {
this.verticalGuide.style.left = `${anchorBox.left + anchorBox.w / 2}px`;
this.verticalGuide.style.top = `${anchorBox.top}px`;
this.verticalGuide.style.left = `${anchorBox.w / 2}px`;
this.verticalGuide.style.top = "0px";
this.verticalGuide.style.height = `${anchorBox.h}px`;
if (showVerticalCenter) {
this.verticalGuide.dataset.visible = "true";
@ -553,8 +520,8 @@ export class SubtitlesWidget {
}
}
if (this.horizontalGuide) {
this.horizontalGuide.style.left = `${anchorBox.left}px`;
this.horizontalGuide.style.top = `${anchorBox.top + anchorBox.h / 2}px`;
this.horizontalGuide.style.left = "0px";
this.horizontalGuide.style.top = `${anchorBox.h / 2}px`;
this.horizontalGuide.style.width = `${anchorBox.w}px`;
if (showHorizontalCenter) {
this.horizontalGuide.dataset.visible = "true";
@ -572,10 +539,10 @@ export class SubtitlesWidget {
private syncWidgetMount(): void {
this.fullscreenLayerController.syncWidgetContainer(null);
if (
this.subtitlesContainer &&
this.subtitlesContainer.parentElement !== this.container
this.subtitleOverlayHost &&
this.subtitleOverlayHost.parentNode !== this.container
) {
this.container.appendChild(this.subtitlesContainer);
this.container.appendChild(this.subtitleOverlayHost);
}
if (this.tooltipMount) {
reparentShadowMount(this.tooltipMount, this.container);
@ -614,8 +581,30 @@ export class SubtitlesWidget {
if (this.subtitlesContainer) {
return this.subtitlesContainer;
}
const container = document.createElement("vot-block");
container.classList.add("vot-subtitles-widget");
let overlay: HTMLElement | undefined;
const host = document.createElement("vot-block");
const view = () => (
<Overlay
ref={(element) => {
overlay = element;
}}
classList={{ "vot-subtitles-widget": true }}
blockProps={
{
"oncapture:pointerdown": this.onPointerDownBound,
} as never
}
>
{""}
</Overlay>
);
this.subtitleOverlayDispose = render(() => view() as Node, host);
if (!overlay) {
this.subtitleOverlayDispose();
throw new Error("[VOT] Subtitles overlay failed to mount");
}
this.subtitleOverlayHost = host;
const container = overlay;
this.subtitlesContainer = container;
// A new element carries none of the previously written custom properties,
// so the write-if-changed bookkeeping must start from scratch.
@ -626,23 +615,16 @@ export class SubtitlesWidget {
this.invalidateLayoutCache();
this.invalidateStyleCaches();
this.syncWidgetMount();
container.addEventListener("pointerdown", this.onPointerDownBound, {
signal: this.abortController.signal,
passive: false,
capture: true,
});
this.syncVisualStyleVars();
this.insetCacheReady = false;
this.updateContainerRect();
return container;
}
private readonly onGlobalPointerDown = (event: PointerEvent): void => {
const eventPath =
typeof event.composedPath === "function" ? event.composedPath() : [];
if (
this.tokenTooltip?.container &&
(this.tokenTooltip.container.contains(event.target as Node) ||
eventPath.includes(this.tokenTooltip.container))
this.tokenTooltip &&
this.tooltipMount &&
event.composedPath().includes(this.tooltipMount.host)
) {
return;
}
@ -672,11 +654,7 @@ export class SubtitlesWidget {
);
}
this.resizeObserver = new ResizeObserver(() => this.onResize());
const resizeTarget =
this.container instanceof ShadowRoot
? this.container.host
: this.container;
this.resizeObserver.observe(resizeTarget);
this.syncResizeTarget();
if (this.video) this.resizeObserver.observe(this.video);
globalThis.visualViewport?.addEventListener(
"resize",
@ -695,6 +673,16 @@ export class SubtitlesWidget {
opts,
);
}
private syncResizeTarget(): void {
const nextTarget =
this.container instanceof ShadowRoot
? this.container.host
: this.container;
if (nextTarget === this.resizeTarget) return;
if (this.resizeTarget) this.resizeObserver?.unobserve(this.resizeTarget);
this.resizeTarget = nextTarget;
this.resizeObserver?.observe(nextTarget);
}
private getUpdateMinIntervalMs(): number {
return this.highlightWords
? this.updateMinIntervalHighlightMs
@ -911,10 +899,8 @@ export class SubtitlesWidget {
private updateContainerRect(): void {
const layout = this.getLayoutSize();
if (!layout.w || !layout.h) return;
const anchorBox = this.computeAnchorBoxLayout(layout);
if (!anchorBox.w || !anchorBox.h) return;
this.refreshBottomInsetNow(layout, anchorBox);
this.applySubtitlePositionWithLayout(layout, anchorBox);
this.refreshBottomInsetNow(layout);
this.applySubtitlePositionWithLayout(layout);
}
private getLayoutSize(): LayoutMetrics {
const cached = this.layoutSizeCache;
@ -1005,28 +991,18 @@ export class SubtitlesWidget {
const raw = anchorBoxH * preset.ratio;
return clampToRange(raw, preset.minPx, preset.maxPx);
}
private refreshBottomInsetNow(
layout?: LayoutMetrics,
anchorBox?: AnchorBoxLayout,
): void {
private refreshBottomInsetNow(layout = this.getLayoutSize()): void {
this.refreshInsetCache();
const anchorH =
anchorBox?.h ??
this.computeAnchorBoxLayout(layout ?? this.getLayoutSize()).h;
if (!anchorH) {
if (!layout.h) {
this.bottomInsetCachedPx = 0;
return;
}
const preset = this.getBottomInsetPreset();
this.bottomInsetCachedPx = this.computeReservedBottomInsetPx(
anchorH,
preset,
layout.h,
this.getBottomInsetPreset(),
);
}
private getBottomInsetPx(
layout?: LayoutMetrics,
anchorBox?: AnchorBoxLayout,
): number {
private getBottomInsetPx(layout = this.getLayoutSize()): number {
if (!this.insetCacheReady) {
this.refreshInsetCache();
}
@ -1036,11 +1012,8 @@ export class SubtitlesWidget {
if (this.isMobileViewport()) {
return Math.max(paddingBottom, safeAreaBottom);
}
const anchorH =
anchorBox?.h ??
this.computeAnchorBoxLayout(layout ?? this.getLayoutSize()).h;
const reserved = anchorH
? this.computeReservedBottomInsetPx(anchorH, preset)
const reserved = layout.h
? this.computeReservedBottomInsetPx(layout.h, preset)
: preset.minPx;
const stableInset = Math.max(this.bottomInsetCachedPx, reserved);
return Math.max(paddingBottom, safeAreaBottom, stableInset) + preset.gapPx;
@ -1057,20 +1030,13 @@ export class SubtitlesWidget {
const layout = this.getLayoutSize();
const { rect: containerRect, w, h, scaleX, scaleY } = layout;
if (!w || !h) return;
const anchorBox = this.computeAnchorBoxLayout(layout);
if (!anchorBox.w || !anchorBox.h) return;
this.lastPositionRefreshTs = performance.now();
const subRect = subtitlesContainer.getBoundingClientRect();
const pointerX =
(event.clientX - containerRect.left) / scaleX - anchorBox.left;
const pointerY =
(event.clientY - containerRect.top) / scaleY - anchorBox.top;
const pointerX = (event.clientX - containerRect.left) / scaleX;
const pointerY = (event.clientY - containerRect.top) / scaleY;
const anchorX =
(subRect.left - containerRect.left + subRect.width / 2) / scaleX -
anchorBox.left;
const anchorY =
(subRect.top - containerRect.top + subRect.height) / scaleY -
anchorBox.top;
(subRect.left - containerRect.left + subRect.width / 2) / scaleX;
const anchorY = (subRect.top - containerRect.top + subRect.height) / scaleY;
this.dragging.pointerId = event.pointerId;
this.dragging.candidate = true;
this.dragging.active = false;
@ -1082,7 +1048,7 @@ export class SubtitlesWidget {
this.hideSnapGuides();
// Cache the gesture-invariant layout metrics; they only change on resize,
// fullscreen transitions or reposition, all of which invalidate the cache.
this.dragLayoutCache = { layout, anchorBox };
this.dragLayoutCache = layout;
this.attachDragDocumentListeners();
}
private scheduleDragFrame(): void {
@ -1160,20 +1126,13 @@ export class SubtitlesWidget {
/**
* Applies a pointer sample to the subtitle anchor position.
*
* Math is unchanged from the previous inline implementation; the only
* difference is that layout metrics come from the per-gesture cache when it
* is available, so a drag performs one layout read instead of one per event.
*/
private applyDragPosition(clientX: number, clientY: number): void {
const cached = this.dragLayoutCache;
const layout = cached?.layout ?? this.getLayoutSize();
const layout = this.dragLayoutCache ?? this.getLayoutSize();
const { rect: containerRect, w, h, scaleX, scaleY } = layout;
if (!w || !h) return;
const anchorBox = cached?.anchorBox ?? this.computeAnchorBoxLayout(layout);
if (!anchorBox.w || !anchorBox.h) return;
const pointerX = (clientX - containerRect.left) / scaleX - anchorBox.left;
const pointerY = (clientY - containerRect.top) / scaleY - anchorBox.top;
const pointerX = (clientX - containerRect.left) / scaleX;
const pointerY = (clientY - containerRect.top) / scaleY;
let anchorX = pointerX + this.dragging.offset.x;
let anchorY = pointerY + this.dragging.offset.y;
// Layout read: route through the epoch-keyed cache used by the static
@ -1190,13 +1149,13 @@ export class SubtitlesWidget {
const bottomInset = 0;
const snappedX = snapValueToNearestCandidate({
current: anchorX,
candidates: [anchorBox.w / 2],
candidates: [layout.w / 2],
thresholdPx: this.snapThresholdPx,
});
if (snappedX.snapped) {
anchorX = snappedX.value;
}
const verticalCenterAnchor = anchorBox.h / 2 + elH / 2;
const verticalCenterAnchor = layout.h / 2 + elH / 2;
const snappedY = snapValueToNearestCandidate({
current: anchorY,
candidates: [verticalCenterAnchor],
@ -1210,75 +1169,56 @@ export class SubtitlesWidget {
anchorY,
elementWidth: elW,
elementHeight: elH,
boxWidth: anchorBox.w,
boxHeight: anchorBox.h,
boxWidth: layout.w,
boxHeight: layout.h,
bottomInset,
}));
this.positionPreset = "custom";
this.customVerticalAnchorState = captureCustomVerticalAnchorState({
anchorY,
elementHeight: elH,
boxHeight: anchorBox.h,
boxHeight: layout.h,
bottomInset,
});
this.position.left = (anchorX / anchorBox.w) * 100;
this.position.top = (anchorY / anchorBox.h) * 100;
this.updateSnapGuides(anchorBox, {
this.position.left = (anchorX / layout.w) * 100;
this.position.top = (anchorY / layout.h) * 100;
this.updateSnapGuides(layout, {
showVerticalCenter: snappedX.snapped,
showHorizontalCenter: snappedY.snapped,
});
this.applySubtitlePositionWithLayout(layout, anchorBox);
this.applySubtitlePositionWithLayout(layout);
}
private applySubtitlePosition(): void {
const subtitlesContainer = this.subtitlesContainer;
if (!subtitlesContainer) return;
const layout = this.getLayoutSize();
if (!layout.w || !layout.h) return;
const anchorBox = this.computeAnchorBoxLayout(layout);
if (!anchorBox.w || !anchorBox.h) return;
this.applySubtitlePositionWithLayout(layout, anchorBox);
this.applySubtitlePositionWithLayout(layout);
}
/**
* Single owner of the position-apply memo key.
*
* Previously this template literal existed verbatim in two places inside
* `applySubtitlePositionWithLayout` (entry guard + exit recompute), so any
* change to the key had to be mirrored by hand. Behavior is unchanged: the
* key is still recomputed after the writes, because they may bump
* `styleEpoch`.
*/
private buildPositionApplyKey(
layout: LayoutMetrics,
anchorBox: AnchorBoxLayout,
): string {
return `${Math.round(layout.w)}x${Math.round(layout.h)}|${layout.scaleX.toFixed(3)}x${layout.scaleY.toFixed(3)}|${Math.round(anchorBox.left)},${Math.round(anchorBox.top)},${Math.round(anchorBox.w)},${Math.round(anchorBox.h)}|${this.positionPreset}|${this.position.left.toFixed(3)},${this.position.top.toFixed(3)}|${this.contentEpoch}|${this.styleEpoch}`;
private buildPositionApplyKey(layout: LayoutMetrics): string {
return `${Math.round(layout.w)}x${Math.round(layout.h)}|${layout.scaleX.toFixed(3)}x${layout.scaleY.toFixed(3)}|${this.positionPreset}|${this.position.left.toFixed(3)},${this.position.top.toFixed(3)}|${this.contentEpoch}|${this.styleEpoch}`;
}
private applySubtitlePositionWithLayout(
layout: LayoutMetrics,
anchorBox: AnchorBoxLayout,
): void {
private applySubtitlePositionWithLayout(layout: LayoutMetrics): void {
const subtitlesContainer = this.subtitlesContainer;
if (!subtitlesContainer) return;
const applyKey = this.buildPositionApplyKey(layout, anchorBox);
const applyKey = this.buildPositionApplyKey(layout);
if (applyKey === this.lastPositionApplyKey) return;
this.applyScaleCompensation(subtitlesContainer, layout);
this.syncAnchorDimensions(subtitlesContainer, anchorBox);
if (this.smartLayoutEnabled) this.ensureSmartLayout(anchorBox);
this.syncAnchorDimensions(subtitlesContainer, layout);
if (this.smartLayoutEnabled) this.ensureSmartLayout(layout);
const { w: elW, h: elH } = this.measureContainerBox(subtitlesContainer);
const bottomInset =
this.positionPreset === "custom"
? 0
: this.getBottomInsetPx(layout, anchorBox);
this.positionPreset === "custom" ? 0 : this.getBottomInsetPx(layout);
const anchorPosition = this.resolveCurrentAnchorPosition(
anchorBox,
layout,
elW,
elH,
bottomInset,
);
const containerPosition = this.clampContainerPosition(
anchorBox,
layout,
anchorPosition.anchorX,
anchorPosition.anchorY,
elW,
@ -1287,14 +1227,12 @@ export class SubtitlesWidget {
);
const anchorX = containerPosition.anchorX;
const anchorY = containerPosition.anchorY;
const containerAnchorX = anchorBox.left + anchorX;
const containerAnchorY = anchorBox.top + anchorY;
const leftPct = (containerAnchorX / layout.w) * 100;
const topPct = (containerAnchorY / layout.h) * 100;
const leftPct = (anchorX / layout.w) * 100;
const topPct = (anchorY / layout.h) * 100;
this.updateContainerPosition(subtitlesContainer, leftPct, topPct);
this.tokenTooltip?.updatePos();
this.tokenTooltip?.update();
// Recompute the key: the writes above may have bumped `styleEpoch`.
this.lastPositionApplyKey = this.buildPositionApplyKey(layout, anchorBox);
this.lastPositionApplyKey = this.buildPositionApplyKey(layout);
}
private measureContainerBox(subtitlesContainer: HTMLElement): {
@ -1336,7 +1274,7 @@ export class SubtitlesWidget {
private syncAnchorDimensions(
subtitlesContainer: HTMLElement,
anchorBox: AnchorBoxLayout,
anchorBox: LayoutMetrics,
): void {
const anchorWidthPx = Math.max(1, Math.round(anchorBox.w));
const anchorHeightPx = Math.max(1, Math.round(anchorBox.h));
@ -1365,18 +1303,18 @@ export class SubtitlesWidget {
}
private resolveCurrentAnchorPosition(
anchorBox: AnchorBoxLayout,
layout: LayoutMetrics,
elementWidth: number,
elementHeight: number,
bottomInset: number,
): { anchorX: number; anchorY: number } {
let anchorX = (this.position.left / 100) * anchorBox.w;
let anchorY = (this.position.top / 100) * anchorBox.h;
let anchorX = (this.position.left / 100) * layout.w;
let anchorY = (this.position.top / 100) * layout.h;
if (this.positionPreset === "custom") {
anchorY = resolveCustomVerticalAnchor({
state: this.customVerticalAnchorState,
elementHeight,
boxHeight: anchorBox.h,
boxHeight: layout.h,
bottomInset,
});
return { anchorX, anchorY };
@ -1384,24 +1322,24 @@ export class SubtitlesWidget {
const presetPosition = this.resolvePresetAnchorPosition({
preset: this.positionPreset,
anchorBox,
anchorBox: layout,
elementWidth,
elementHeight,
bottomInset,
});
anchorX = presetPosition.anchorX;
anchorY = presetPosition.anchorY;
if (anchorBox.w > 0) {
this.position.left = (anchorX / anchorBox.w) * 100;
if (layout.w > 0) {
this.position.left = (anchorX / layout.w) * 100;
}
if (anchorBox.h > 0) {
this.position.top = (anchorY / anchorBox.h) * 100;
if (layout.h > 0) {
this.position.top = (anchorY / layout.h) * 100;
}
return { anchorX, anchorY };
}
private clampContainerPosition(
anchorBox: AnchorBoxLayout,
layout: LayoutMetrics,
anchorX: number,
anchorY: number,
elementWidth: number,
@ -1410,8 +1348,8 @@ export class SubtitlesWidget {
): { anchorX: number; anchorY: number } {
let leftPx = anchorX - elementWidth / 2;
let topPx = anchorY - elementHeight;
const maxLeftPx = anchorBox.w - elementWidth;
const maxTopPx = anchorBox.h - bottomInset - elementHeight;
const maxLeftPx = layout.w - elementWidth;
const maxTopPx = layout.h - bottomInset - elementHeight;
leftPx =
maxLeftPx >= 0 ? clampToRange(leftPx, 0, maxLeftPx) : maxLeftPx / 2;
topPx = maxTopPx >= 0 ? clampToRange(topPx, 0, maxTopPx) : 0;
@ -1450,7 +1388,7 @@ export class SubtitlesWidget {
bottomInset,
}: {
preset: SubtitlePositionPreset;
anchorBox: AnchorBoxLayout;
anchorBox: LayoutMetrics;
elementWidth: number;
elementHeight: number;
bottomInset: number;
@ -1485,20 +1423,7 @@ export class SubtitlesWidget {
});
}
private applyPositionAfterContentRender(): void {
const layout = this.getLayoutSize();
if (layout.w && layout.h) {
const anchorBox = this.computeAnchorBoxLayout(layout);
if (anchorBox.w && anchorBox.h) {
this.refreshBottomInsetNow(layout, anchorBox);
this.applySubtitlePositionWithLayout(layout, anchorBox);
return;
}
this.refreshBottomInsetNow(layout);
this.applySubtitlePosition();
return;
}
this.refreshBottomInsetNow();
this.applySubtitlePosition();
this.updateContainerRect();
}
private trimEdgeWhitespaceTokens(tokens: SubtitleToken[]): SubtitleToken[] {
if (!tokens.length) return tokens;
@ -1701,27 +1626,29 @@ export class SubtitlesWidget {
): number {
if (!segmentRanges.length) return -1;
let idx = this.lastSegmentIndex;
if (idx >= segmentRanges.length) idx = 0;
while (idx < segmentRanges.length - 1 && time >= segmentRanges[idx].endMs) {
const length = segmentRanges.length;
if (idx >= length) idx = 0;
while (idx < length - 1 && time >= segmentRanges[idx].endMs) {
idx += 1;
}
while (idx > 0 && time < segmentRanges[idx].startMs) {
idx -= 1;
}
if (
!(time >= segmentRanges[idx].startMs && time < segmentRanges[idx].endMs)
idx >= 0 &&
time >= segmentRanges[idx].startMs &&
time < segmentRanges[idx].endMs
) {
const found = segmentRanges.findIndex(
(s) => time >= s.startMs && time < s.endMs,
);
if (found >= 0) {
idx = found;
} else {
idx = time < segmentRanges[0].startMs ? 0 : segmentRanges.length - 1;
}
this.lastSegmentIndex = idx;
return idx;
}
this.lastSegmentIndex = idx;
return idx;
const found = segmentRanges.findIndex(
(s) => time >= s.startMs && time < s.endMs,
);
const resolved =
found >= 0 ? found : time < segmentRanges[0].startMs ? 0 : length - 1;
this.lastSegmentIndex = resolved;
return resolved;
}
private processTokens(
tokens: SubtitleToken[],
@ -1766,7 +1693,6 @@ export class SubtitlesWidget {
: [translated, translated];
const context = typeof pair[0] === "string" ? pair[0] : "";
const current = typeof pair[1] === "string" ? pair[1] : "";
this.strTranslatedTokens = context;
return [context, current];
}
private findTokenSpan(
@ -1784,9 +1710,7 @@ export class SubtitlesWidget {
? token
: null;
}
private resolveTokenSpanFromClick(
event: PointerEvent,
): HTMLSpanElement | null {
private resolveTokenSpanFromClick(event: MouseEvent): HTMLSpanElement | null {
const root: HTMLElement | null =
this.subtitlesBlock ?? this.subtitlesContainer;
if (!root) return null;
@ -1810,10 +1734,9 @@ export class SubtitlesWidget {
}
releaseTooltip(): this {
this.tooltipTranslationRequestId += 1;
if (this.tokenTooltip?.target) {
this.tokenTooltip.target.classList.remove("selected");
}
this.tokenTooltip?.release();
this.tokenTooltipTarget?.classList.remove("selected");
this.tokenTooltipTarget = undefined;
this.tokenTooltip?.dispose();
this.tokenTooltip = undefined;
destroyShadowMount(this.tooltipMount);
this.tooltipMount = undefined;
@ -1825,9 +1748,7 @@ export class SubtitlesWidget {
this.wrapPending = false;
this.positionRefreshPending = false;
}
clearRenderedContent({
releaseTooltip = false,
}: ClearRenderedContentOptions = {}): void {
clearRenderedContent(releaseTooltip = false): void {
if (releaseTooltip) this.releaseTooltip();
this.resetRenderMemo();
this.lastActiveLineKey = null;
@ -1857,7 +1778,7 @@ export class SubtitlesWidget {
this.subtitlesContainer.textContent = "";
}
}
onClick = async (event: PointerEvent): Promise<void> => {
onClick = async (event: MouseEvent): Promise<void> => {
if (performance.now() < this.suppressTokenClicksUntil) {
event.preventDefault();
event.stopPropagation();
@ -1873,9 +1794,7 @@ export class SubtitlesWidget {
}
this.releaseTooltip();
const requestId = this.tooltipTranslationRequestId;
const text = this.normalizeTokenTextForTranslation(
target.textContent ?? "",
);
const text = trimEdgePunctuation(target.textContent ?? "");
if (!text) return;
const service = await votStorage.get(
"translationService",
@ -1883,40 +1802,38 @@ export class SubtitlesWidget {
);
if (requestId !== this.tooltipTranslationRequestId) return;
target.classList.add("selected");
const subtitlesInfo = UI.createSubtitleInfo(
text,
this.strTranslatedTokens || this.strTokens,
service,
);
const tooltip = this.createTokenTooltip(target, subtitlesInfo.container);
const tooltip = this.createTokenTooltip(target, {
source: text,
context: this.strTranslatedTokens || this.strTokens,
translationService: service,
});
this.tokenTooltip = tooltip;
tooltip.create();
this.tokenTooltipTarget = target;
tooltip.show();
const strTokens = this.strTokens;
const translated = await this.translateStrTokens(text);
if (requestId !== this.tooltipTranslationRequestId) return;
if (this.shouldSkipTooltipUpdate(requestId, tooltip, target, strTokens)) {
return;
}
subtitlesInfo.header.textContent = translated[1];
subtitlesInfo.context.textContent = translated[0];
tooltip.setContent(subtitlesInfo.container);
this.strTranslatedTokens = translated[0];
tooltip.setTranslation(translated[1], translated[0]);
};
private toggleCurrentTooltipTarget(target: HTMLElement): boolean {
if (this.tokenTooltip?.target !== target || !this.tokenTooltip?.container) {
if (this.tokenTooltipTarget !== target || !this.tokenTooltip) {
return false;
}
if (this.tokenTooltip.showed) {
target.classList.add("selected");
} else {
target.classList.remove("selected");
}
target.classList.toggle("selected", this.tokenTooltip.isOpen());
return true;
}
private createTokenTooltip(
target: HTMLElement,
content: HTMLElement,
): Tooltip {
content: {
source: string;
context: string;
translationService: string;
},
): SubtitleTokenTooltipHandle {
const viewportWidth = Math.max(320, globalThis.innerWidth || 0);
const preferredWidth = Math.max(
360,
@ -1925,25 +1842,20 @@ export class SubtitlesWidget {
Math.min(this.subtitleMaxWidthPx || 0, 720),
);
const tooltipMaxWidth = Math.min(viewportWidth - 24, preferredWidth, 720);
const tooltipMount = this.ensureTooltipMount();
return new Tooltip({
return mountSubtitleTokenTooltip({
target,
anchor: this.subtitlesBlock ?? target,
content,
parentElement: tooltipMount.root,
offset: { x: 4, y: 12 },
parentElement: this.ensureTooltipMount().root,
maxWidth: tooltipMaxWidth,
mode: "follow",
borderRadius: 12,
bordered: false,
position: "top",
trigger: "click",
source: content.source,
context: content.context,
translationService: content.translationService,
});
}
private shouldSkipTooltipUpdate(
requestId: number,
tooltip: Tooltip,
tooltip: SubtitleTokenTooltipHandle,
target: HTMLElement,
strTokens: string,
): boolean {
@ -1951,8 +1863,8 @@ export class SubtitlesWidget {
requestId !== this.tooltipTranslationRequestId ||
strTokens !== this.strTokens ||
this.tokenTooltip !== tooltip ||
tooltip.target !== target ||
!tooltip.showed
this.tokenTooltipTarget !== target ||
!tooltip.isOpen()
);
}
private buildPassedState(
@ -2083,6 +1995,15 @@ export class SubtitlesWidget {
this.detachDragDocumentListeners();
return;
}
this.sourceEpoch += 1;
this.strTokens = "";
this.resetTranslationContext();
this.resetRenderMemo();
this.resetWrapMemo();
this.resetSegmentationMemo();
this.lastWrapTokens = null;
this.passedStateKey = null;
this.passedThresholds.length = 0;
this.createSubtitlesContainer();
this.subtitles = subtitles;
this.maxActiveCueLookbackMs = subtitles.subtitles.reduce(
@ -2170,7 +2091,7 @@ export class SubtitlesWidget {
private clearInactiveLineState(): void {
this.lastActiveLineKey = null;
if (this.subtitlesBlock || this.lastRenderKey !== null || this.strTokens) {
this.clearRenderedContent({ releaseTooltip: true });
this.clearRenderedContent(true);
return;
}
@ -2195,10 +2116,7 @@ export class SubtitlesWidget {
return;
}
const anchorBox = this.computeAnchorBoxLayout(layout);
if (anchorBox.w && anchorBox.h) {
this.ensureSmartLayout(anchorBox);
}
this.ensureSmartLayout(layout);
}
private getRenderState(
line: SubtitleLine,
@ -2240,7 +2158,7 @@ export class SubtitlesWidget {
this.subtitlesContainer ?? this.createSubtitlesContainer();
// One persistent Solid root per container: created lazily, then fed a new
// render plan. Solid updates only the parts whose text/attributes changed.
this.subtitleView ??= mountSubtitleView(this.subtitlesContainer, {
this.subtitleView ??= mountSolidSubtitlesWidget(this.subtitlesContainer, {
lang: () => this.subtitleLang ?? "",
onClick: this.onClick,
});
@ -2280,11 +2198,12 @@ export class SubtitlesWidget {
return;
}
this.lastActiveLineKey = activeLine.lineKey;
const activeLineKey = `${this.sourceEpoch}:${activeLine.lineKey}`;
this.lastActiveLineKey = activeLineKey;
this.refreshSmartLayoutIfNeeded();
const renderState = this.getRenderState(
activeLine.line,
activeLine.lineKey,
activeLineKey,
time,
);
const { tokens, tokensChanged, passedFlags, renderKey } = renderState;
@ -2323,10 +2242,15 @@ export class SubtitlesWidget {
this.checkerUnsubscribe?.();
this.checkerUnsubscribe = null;
this.releaseTooltip();
if (this.subtitlesContainer) {
this.subtitlesContainer.remove();
this.subtitlesContainer = null;
}
this.subtitleView?.dispose();
this.subtitleView = null;
this.subtitlesBlock = null;
this.renderedHighlightEls = [];
this.subtitleOverlayDispose?.();
this.subtitleOverlayDispose = undefined;
this.subtitleOverlayHost?.remove();
this.subtitleOverlayHost = null;
this.subtitlesContainer = null;
destroyShadowMount(this.tooltipMount);
this.tooltipMount = undefined;
this.fullscreenLayerController.release();
@ -2342,6 +2266,7 @@ export class SubtitlesWidget {
}
this.measureCtx = null;
this.measureCanvas = null;
this.resizeTarget = undefined;
this.lastAppliedLeftPct = null;
this.lastAppliedTopPct = null;
this.passedStateKey = null;

101
src/ui.ts
View file

@ -1,101 +0,0 @@
import { localizationProvider } from "./localization/localizationProvider";
declare global {
interface Window {
__votKeyboardNavInitialized?: boolean;
}
}
function initKeyboardNavigationMode(): void {
if (globalThis.__votKeyboardNavInitialized) return;
globalThis.__votKeyboardNavInitialized = true;
const root = document.documentElement;
const CLASS = "vot-keyboard-nav";
const enable = () => root.classList.add(CLASS);
const disable = () => root.classList.remove(CLASS);
// Only Tab indicates keyboard *navigation* intent.
globalThis.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Tab") enable();
},
true,
);
// Any pointer interaction switches back to pointer mode.
for (const evt of ["pointerdown", "touchstart"] as const) {
globalThis.addEventListener(evt, disable, {
capture: true,
passive: true,
});
}
}
// Initialize once at module load.
initKeyboardNavigationMode();
type SubtitleInfoElements = {
container: HTMLElement;
translatedWith: HTMLElement;
header: HTMLElement;
context: HTMLElement;
};
const UI = {
/**
* Auxiliary method for creating HTML elements
*/
createEl(
tag: string,
classes: string[] = [],
content: Node | string | null = null,
): HTMLElement {
const el = document.createElement(tag);
if (classes.length) el.classList.add(...classes);
if (content !== null) el.append(content);
return el;
},
createSubtitleInfo(
word: string,
desc: string,
translationService: string,
): SubtitleInfoElements {
const container = UI.createEl("vot-block", ["vot-subtitles-info"]);
container.id = "vot-subtitles-info";
const translatedWith = UI.createEl(
"vot-block",
["vot-subtitles-info-service"],
localizationProvider
.get("VOTTranslatedBy")
.replace("{0}", translationService),
);
translatedWith.hidden = true;
const title = UI.createEl("vot-block", ["vot-subtitles-info-title"]);
const source = UI.createEl("span", ["vot-subtitles-info-source"], word);
const divider = UI.createEl("span", ["vot-subtitles-info-divider"], "—");
const header = UI.createEl("span", ["vot-subtitles-info-header"], word);
title.append(source, divider, header);
const context = UI.createEl(
"vot-block",
["vot-subtitles-info-context"],
desc,
);
container.append(title, context);
return {
container,
translatedWith,
header,
context,
};
},
} as const;
export default UI;

View file

@ -1,685 +0,0 @@
import {
type PagePosition,
type Position,
positions,
type TooltipMode,
type TooltipOpts,
type Trigger,
tooltipModes,
triggers,
} from "../../types/components/tooltip";
import UI from "../../ui";
import { clamp } from "../../utils/utils";
import { createDomId, isEventInside } from "./componentShared";
type AnchorBox = {
left: number;
right: number;
top: number;
bottom: number;
centerX: number;
centerY: number;
};
type TooltipSize = {
width: number;
height: number;
};
type PositionBoundary = {
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
};
const DEFAULT_TOOLTIP_POS: Position = "top";
const DEFAULT_TOOLTIP_TRIGGER: Trigger = "hover";
const DEFAULT_TOOLTIP_MODE: TooltipMode = "default";
export default class Tooltip {
showed = false;
target: HTMLElement;
anchor: HTMLElement;
edgeAnchor: HTMLElement;
content: string | HTMLElement;
position: Position;
preferredPosition: Position;
trigger: Trigger;
offsetX: number;
offsetY: number;
private _hidden: boolean;
autoLayout: boolean;
maxWidth?: number;
mode: TooltipMode;
backgroundColor?: string;
borderRadius?: number;
private _bordered: boolean;
portal: HTMLElement | ShadowRoot;
container?: HTMLElement;
private resizeObserver?: ResizeObserver;
private intersectionObserver?: IntersectionObserver;
private scrollListening = false;
private positionRafId: number | null = null;
private destroyFallbackTimerId: ReturnType<typeof setTimeout> | undefined;
private static readonly DESTROY_FALLBACK_MS = 700;
private readonly tooltipId = createDomId("vot-tooltip");
private prevAriaDescribedBy: string | null = null;
constructor(opts: TooltipOpts) {
const target = opts.target;
if (!(target instanceof HTMLElement)) {
throw new TypeError("target must be a valid HTMLElement");
}
this.target = target;
this.anchor = opts.anchor instanceof HTMLElement ? opts.anchor : target;
this.edgeAnchor =
opts.edgeAnchor instanceof HTMLElement ? opts.edgeAnchor : this.anchor;
this.content = opts.content ?? "";
const offset = opts.offset ?? 4;
if (typeof offset === "number") {
this.offsetY = this.offsetX = offset;
} else {
this.offsetX = offset.x;
this.offsetY = offset.y;
}
this._hidden = opts.hidden ?? false;
this.autoLayout = opts.autoLayout ?? true;
this.trigger = Tooltip.normalizeTrigger(opts.trigger);
this.position = Tooltip.normalizePos(opts.position);
this.preferredPosition = this.position;
this.portal = opts.parentElement ?? document.body;
this.borderRadius = opts.borderRadius;
this._bordered = opts.bordered ?? true;
this.maxWidth = opts.maxWidth;
this.mode = Tooltip.normalizeMode(opts.mode);
this.backgroundColor = opts.backgroundColor;
this.init();
}
static normalizePos(position: Position): Position {
return positions.includes(position) ? position : DEFAULT_TOOLTIP_POS;
}
static normalizeTrigger(trigger: Trigger): Trigger {
return triggers.includes(trigger) ? trigger : DEFAULT_TOOLTIP_TRIGGER;
}
static normalizeMode(mode: TooltipMode): TooltipMode {
return tooltipModes.includes(mode) ? mode : DEFAULT_TOOLTIP_MODE;
}
setPosition(position: Position): this {
this.position = this.preferredPosition = Tooltip.normalizePos(position);
this.schedulePositionUpdate();
return this;
}
private syncContentClass(): void {
if (!this.container) return;
const isSubtitlesInfo =
this.content instanceof HTMLElement &&
this.content.classList.contains("vot-subtitles-info");
this.container.classList.toggle(
"vot-tooltip--subtitles-info",
isSubtitlesInfo,
);
}
setContent(content: string | HTMLElement): this {
this.content = content;
if (!this.container) {
return this;
}
this.container.replaceChildren();
if (typeof content === "string") {
this.container.textContent = content;
} else {
this.container.append(content);
}
this.syncContentClass();
this.schedulePositionUpdate();
return this;
}
/** Remove tooltip DOM immediately (no fade). Use when switching UI modes. */
dismissImmediate(): this {
return this.destroy(true);
}
/**
* After enabling the tooltip while the pointer never left the target,
* `pointerenter` does not fire again — call this to show immediately.
*/
revealIfHovered(): this {
if (this._hidden || this.trigger !== "hover") return this;
try {
if (!this.target.matches(":hover")) return this;
} catch {
return this;
}
this.create();
return this;
}
/**
* Update tooltip mount dependencies.
* If the tooltip is currently rendered, it will be moved to the new parent.
*/
updateMount({
parentElement,
}: {
parentElement?: HTMLElement | ShadowRoot;
}): this {
if (!(parentElement && this.portal !== parentElement)) {
return this;
}
this.portal = parentElement;
if (this.container?.isConnected) {
parentElement.appendChild(this.container);
this.schedulePositionUpdate();
}
return this;
}
private readonly onResize = (): void => {
this.schedulePositionUpdate();
};
private readonly onScroll = (): void => {
this.schedulePositionUpdate();
};
private readonly onClick = (): void => {
this.showed ? this.destroy() : this.create();
};
private readonly onDocumentPointerDown = (event: PointerEvent): void => {
if (!this.showed) return;
if (
isEventInside(event, this.target) ||
(this.container && isEventInside(event, this.container)) ||
(this.mode === "follow" && isEventInside(event, this.anchor))
) {
return;
}
this.destroy();
};
private readonly onTargetKeyDown = (event: KeyboardEvent): void => {
if (event.key === "Escape" && this.showed) {
this.destroy();
}
};
private readonly onPointerEnter = (_e: PointerEvent): void => {
this.create();
};
private readonly onPointerLeave = (e: PointerEvent): void => {
if (!this.isInTooltipContext(e.relatedTarget)) {
this.destroy();
}
};
private readonly onTooltipPointerLeave = (e: PointerEvent): void => {
if (!this.isInTooltipContext(e.relatedTarget)) {
this.destroy();
}
};
private readonly onTouchPointerDown = (e: PointerEvent): void => {
if (e.pointerType === "touch") {
this.create();
}
};
private readonly onTouchPointerUp = (e: PointerEvent): void => {
if (e.pointerType === "touch") {
this.destroy();
}
};
private isInTooltipContext(nextTarget: EventTarget | null): boolean {
if (!(nextTarget instanceof Node)) return false;
return (
this.target.contains(nextTarget) || this.container?.contains(nextTarget)
);
}
private init(): void {
this.resizeObserver = new ResizeObserver(this.onResize);
this.intersectionObserver = new IntersectionObserver(
this.onIntersect.bind(this),
);
this.target.addEventListener("keydown", this.onTargetKeyDown);
if (this.trigger === "click") {
this.target.addEventListener("pointerdown", this.onClick);
return;
}
this.target.addEventListener("pointerenter", this.onPointerEnter);
this.target.addEventListener("pointerleave", this.onPointerLeave);
this.target.addEventListener("pointerdown", this.onTouchPointerDown);
this.target.addEventListener("pointerup", this.onTouchPointerUp);
}
private onIntersect(entries: IntersectionObserverEntry[]): void {
const entry = entries[0];
if (!entry?.isIntersecting) {
this.destroy(true);
}
}
release(): this {
this.destroy(true);
this.detachScrollListener();
this.target.removeEventListener("keydown", this.onTargetKeyDown);
if (this.trigger === "click") {
this.target.removeEventListener("pointerdown", this.onClick);
return this;
}
this.target.removeEventListener("pointerenter", this.onPointerEnter);
this.target.removeEventListener("pointerleave", this.onPointerLeave);
this.target.removeEventListener("pointerdown", this.onTouchPointerDown);
this.target.removeEventListener("pointerup", this.onTouchPointerUp);
return this;
}
private schedulePositionUpdate(): void {
if (!this.container || this.positionRafId !== null) return;
this.positionRafId = requestAnimationFrame(() => {
this.positionRafId = null;
this.updatePos();
});
}
private cancelPositionUpdate(): void {
if (this.positionRafId !== null) {
cancelAnimationFrame(this.positionRafId);
this.positionRafId = null;
}
}
private clearDestroyFallbackTimer(): void {
if (this.destroyFallbackTimerId !== undefined) {
globalThis.clearTimeout(this.destroyFallbackTimerId);
this.destroyFallbackTimerId = undefined;
}
}
create(): this {
this.destroy(true);
this.showed = true;
this.container = UI.createEl("vot-block", ["vot-tooltip"], this.content);
this.syncContentClass();
if (this._bordered) {
this.container.classList.add("vot-tooltip-bordered");
}
this.container.setAttribute("role", "tooltip");
this.container.id = this.tooltipId;
this.container.dataset.trigger = this.trigger;
this.container.dataset.mode = this.mode;
this.container.dataset.position = this.position;
this.container.style.position = this.usesPortalCoordinates()
? "absolute"
: "fixed";
this.container.style.top = "0";
this.container.style.left = "0";
this.container.style.margin = "0";
this.portal.appendChild(this.container);
if (this.backgroundColor) {
this.container.style.backgroundColor = this.backgroundColor;
}
if (this.borderRadius !== undefined) {
this.container.style.borderRadius = `${this.borderRadius}px`;
}
if (this._hidden) {
this.container.hidden = true;
} else {
this.syncAriaDescribedBy(true);
}
this.container.style.opacity = "1";
if (this.trigger === "hover") {
this.container.addEventListener(
"pointerleave",
this.onTooltipPointerLeave,
);
} else {
document.addEventListener("pointerdown", this.onDocumentPointerDown, {
capture: true,
passive: true,
});
}
this.attachScrollListener();
this.resizeObserver?.observe(this.anchor);
if (this.edgeAnchor !== this.anchor) {
this.resizeObserver?.observe(this.edgeAnchor);
}
this.intersectionObserver?.observe(this.target);
this.updatePos();
return this;
}
updatePos(): this {
if (!this.container) return this;
const viewportWidth = window.innerWidth;
const availableWidth = Math.max(0, viewportWidth - this.offsetX * 2);
const maxWidth = clamp(this.maxWidth ?? availableWidth, 0, availableWidth);
this.container.style.maxWidth = `${maxWidth}px`;
const { top, left } = this.computePosition(
this.autoLayout,
this.preferredPosition,
);
const offset = this.getPortalViewportOffset();
this.container.style.transform = `translate(${left - offset.left}px, ${
top - offset.top
}px)`;
this.container.dataset.position = this.position;
return this;
}
private usesPortalCoordinates(): boolean {
if (this.portal instanceof ShadowRoot) return true;
if (
this.portal === document.body ||
this.portal === document.documentElement
) {
return false;
}
if (this.portal.classList.contains("vot-portal")) return false;
return true;
}
private getPortalViewportOffset(): PagePosition {
if (!this.usesPortalCoordinates()) {
return { top: 0, left: 0 };
}
const element =
this.portal instanceof ShadowRoot ? this.portal.host : this.portal;
const rect = element.getBoundingClientRect();
return {
top: rect.top,
left: rect.left,
};
}
private computePosition(
autoLayout: boolean,
preferred: Position,
): PagePosition {
const anchor = this.getAnchorBox();
const tooltipRect = this.container?.getBoundingClientRect();
const tooltip = {
width: tooltipRect.width || 100,
height: tooltipRect.height || 40,
};
const viewport = this.getPositionBoundary();
const position = autoLayout
? this.resolvePosition(anchor, tooltip, viewport, preferred)
: preferred;
const coords = this.getCoordinates(anchor, tooltip, position);
this.position = position;
return {
top: clamp(coords.top, viewport.top, viewport.bottom - tooltip.height),
left: clamp(coords.left, viewport.left, viewport.right - tooltip.width),
};
}
private getAnchorBox(): AnchorBox {
const anchorRect = this.anchor.getBoundingClientRect();
const edgeRect = this.edgeAnchor.getBoundingClientRect();
return {
left: edgeRect.left,
right: edgeRect.right,
top: edgeRect.top,
bottom: edgeRect.bottom,
centerX: anchorRect.left + anchorRect.width / 2,
centerY: anchorRect.top + anchorRect.height / 2,
};
}
private getPositionBoundary(): PositionBoundary {
const fallback = {
left: this.offsetX,
right: window.innerWidth - this.offsetX,
top: this.offsetY,
bottom: window.innerHeight - this.offsetY,
width: window.innerWidth,
height: window.innerHeight,
};
if (this.mode !== "follow" || !this.usesPortalCoordinates()) {
return fallback;
}
const element =
this.portal instanceof ShadowRoot ? this.portal.host : this.portal;
const rect = element.getBoundingClientRect();
if (!rect.width || !rect.height) {
return fallback;
}
return {
left: rect.left,
right: rect.right,
top: rect.top,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
};
}
private resolvePosition(
anchor: AnchorBox,
tooltip: TooltipSize,
viewport: PositionBoundary,
preferred: Position,
): Position {
if (this.mode === "follow") {
return this.resolveFollowPosition(anchor, tooltip, viewport, preferred);
}
switch (preferred) {
case "top": {
const spaceAbove = anchor.top - viewport.top;
const fitsAbove = spaceAbove >= tooltip.height + this.offsetY;
return fitsAbove ? "top" : "bottom";
}
case "bottom": {
const spaceBelow = viewport.bottom - anchor.bottom;
const fitsBelow = spaceBelow >= tooltip.height + this.offsetY;
return fitsBelow ? "bottom" : "top";
}
case "left": {
const spaceLeft = anchor.left - viewport.left;
const fitsLeft = spaceLeft >= tooltip.width + this.offsetX;
return fitsLeft ? "left" : "right";
}
case "right": {
const spaceRight = viewport.right - anchor.right;
const fitsRight = spaceRight >= tooltip.width + this.offsetX;
return fitsRight ? "right" : "left";
}
}
}
private resolveFollowPosition(
anchor: AnchorBox,
tooltip: TooltipSize,
viewport: PositionBoundary,
preferred: Position,
): Position {
if (preferred === "top" || preferred === "bottom") {
const topWouldClamp =
anchor.top - tooltip.height - this.offsetY < viewport.top;
const bottomWouldClamp =
anchor.bottom + this.offsetY + tooltip.height > viewport.bottom;
if (preferred === "top") {
if (!topWouldClamp || bottomWouldClamp) return "top";
return "bottom";
}
if (!bottomWouldClamp || topWouldClamp) return "bottom";
return "top";
}
const leftWouldClamp =
anchor.left - tooltip.width - this.offsetX < viewport.left;
const rightWouldClamp =
anchor.right + this.offsetX + tooltip.width > viewport.right;
if (preferred === "left") {
if (!leftWouldClamp || rightWouldClamp) return "left";
return "right";
}
if (!rightWouldClamp || leftWouldClamp) return "right";
return "left";
}
private getCoordinates(
anchor: AnchorBox,
tooltip: TooltipSize,
position: Position,
): PagePosition {
switch (position) {
case "top":
return {
top: anchor.top - tooltip.height - this.offsetY,
left: anchor.centerX - tooltip.width / 2,
};
case "bottom":
return {
top: anchor.bottom + this.offsetY,
left: anchor.centerX - tooltip.width / 2,
};
case "left":
return {
top: anchor.centerY - tooltip.height / 2,
left: anchor.left - tooltip.width - this.offsetX,
};
case "right":
return {
top: anchor.centerY - tooltip.height / 2,
left: anchor.right + this.offsetX,
};
}
}
private destroy(instant = false): this {
if (!this.container) return this;
const container = this.container;
this.cancelPositionUpdate();
this.clearDestroyFallbackTimer();
this.showed = false;
this.syncAriaDescribedBy(false);
this.resizeObserver?.disconnect();
this.intersectionObserver?.disconnect();
this.detachScrollListener();
this.detachOutsidePointerListener();
if (instant) {
container.remove();
this.container = undefined;
return this;
}
container.removeEventListener("pointerleave", this.onTooltipPointerLeave);
container.style.pointerEvents = "none";
container.style.opacity = "0";
const handleTransitionDone = (): void => {
this.clearDestroyFallbackTimer();
container?.remove();
if (this.container === container) {
this.container = undefined;
}
};
container.addEventListener("transitionend", handleTransitionDone, {
once: true,
});
container.addEventListener("transitioncancel", handleTransitionDone, {
once: true,
});
this.destroyFallbackTimerId = globalThis.setTimeout(
handleTransitionDone,
Tooltip.DESTROY_FALLBACK_MS,
);
return this;
}
private detachOutsidePointerListener(): void {
document.removeEventListener("pointerdown", this.onDocumentPointerDown, {
capture: true,
});
}
private syncAriaDescribedBy(isShowing: boolean): void {
const existing = this.target.getAttribute("aria-describedby");
this.prevAriaDescribedBy ??= existing;
if (!isShowing) {
if (this.prevAriaDescribedBy === null) {
this.target.removeAttribute("aria-describedby");
} else {
this.target.setAttribute("aria-describedby", this.prevAriaDescribedBy);
}
this.prevAriaDescribedBy = null;
return;
}
const tokens = new Set((existing ?? "").split(/\s+/).filter(Boolean));
tokens.add(this.tooltipId);
this.target.setAttribute("aria-describedby", Array.from(tokens).join(" "));
}
set bordered(value: boolean) {
this._bordered = value;
this.container?.classList.toggle("vot-tooltip-bordered", value);
}
get bordered(): boolean {
return this._bordered;
}
set hidden(value: boolean) {
this._hidden = value;
if (this.container) {
this.container.hidden = value;
}
// Keep aria-describedby in sync: if tooltip is effectively disabled,
// do not leave a describedby reference hanging.
if (this.showed) {
this.syncAriaDescribedBy(!value);
}
}
get hidden(): boolean {
return this._hidden;
}
private attachScrollListener(): void {
if (this.scrollListening) return;
this.scrollListening = true;
document.addEventListener("scroll", this.onScroll, {
passive: true,
capture: true,
});
}
private detachScrollListener(): void {
if (!this.scrollListening) return;
this.scrollListening = false;
document.removeEventListener("scroll", this.onScroll, {
capture: true,
});
}
}