mirror of
https://github.com/ilyhalight/voice-over-translation.git
synced 2026-10-19 07:43:33 +00:00
refactor: rewrite subtitle widget with solid
This commit is contained in:
parent
a0c6e80f90
commit
58b7ed8956
15 changed files with 721 additions and 1309 deletions
|
|
@ -15,6 +15,7 @@
|
|||
- Добавлены тултипы для кнопок "Картинка в картинке" и "Меню" в кнопке перевода
|
||||
- Заменены иконки "Стандартных" и "Живых" голосов на более приятные
|
||||
- Переработана иконка прогресса загрузки для аудио. Теперь, она стала более плавной, а также обзавелась визуальным выделением незаполненной части прогресса
|
||||
- Улучшено визуальное выделение активного слова субтитров в некоторых случаях
|
||||
- Весь UI расширения переведен на SolidJS
|
||||
- Слайдер "Громкость при уменьшении" теперь отключается при включенном режиме "Адаптивная громкость"
|
||||
- Оптимизирован предпросмотр позиции "Кнопки перевода". Теперь, он использует облегченную версию элемента, а не создает полный клон
|
||||
|
|
|
|||
106
src/components/SubtitlesWidget/SubtitleTokenTooltip.tsx
Normal file
106
src/components/SubtitlesWidget/SubtitleTokenTooltip.tsx
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
163
src/components/SubtitlesWidget/SubtitlesWidget.stories.tsx
Normal file
163
src/components/SubtitlesWidget/SubtitlesWidget.stories.tsx
Normal 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();
|
||||
}
|
||||
},
|
||||
};
|
||||
181
src/components/SubtitlesWidget/SubtitlesWidget.tsx
Normal file
181
src/components/SubtitlesWidget/SubtitlesWidget.tsx
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = "";
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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
101
src/ui.ts
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue