refractor: optimize smartWrap & simplify UI/events
Some checks are pending
Build / node (22.x) (push) Waiting to run
Build / extension (22.x) (push) Waiting to run
Build / bun (push) Waiting to run
Release / Build Release (push) Waiting to run
Release / create_release (push) Blocked by required conditions

This commit is contained in:
NullVerdict 2026-04-25 23:43:34 +04:00
parent be0a7029dd
commit 2aa70d15b3
33 changed files with 727 additions and 701 deletions

Binary file not shown.

Binary file not shown.

View file

@ -3,8 +3,8 @@
"vot-extension@firefox": {
"updates": [
{
"version": "1.11.5.6",
"update_link": "https://raw.githubusercontent.com/ilyhalight/voice-over-translation/master/dist-ext/vot-extension-firefox-1.11.5.6.xpi"
"version": "1.11.5.7",
"update_link": "https://raw.githubusercontent.com/ilyhalight/voice-over-translation/master/dist-ext/vot-extension-firefox-1.11.5.7.xpi"
}
]
}

114
dist/vot-min.user.js vendored

File diff suppressed because one or more lines are too long

479
dist/vot.user.js vendored

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{
"name": "[VOT] - Voice Over Translation",
"description": "A small extension that adds a Yandex Browser video translation to other browsers",
"version": "1.11.5.6",
"version": "1.11.5.7",
"author": "Toil, SashaXser, MrSoczekXD, mynovelhost, sodapng",
"namespace": "vot",
"icon": "https://translate.yandex.ru/icons/favicon.ico",

View file

@ -63,12 +63,57 @@ type TokenTextBuffer = {
const STRONG_BREAK_RE = /[.!?:;][)"'\]»]*\s*$/u;
const SOFT_BREAK_RE = /[,،][)"'\]»]*\s*$/u;
const DISCOURAGED_LINE_START_RE = /^\s*[\p{Pe}\p{Pf},.;:!?%]/u;
const DISCOURAGED_LINE_END_RE = /\s*[\p{Ps}\p{Pi}¿¡([{«"'`-]\s*$/u;
const WHITESPACE_CHAR_RE = /\s/u;
const DISCOURAGED_LINE_START_CHAR_RE = /^[\p{Pe}\p{Pf},.;:!?%\u2030\u2026]$/u;
const DISCOURAGED_LINE_END_CHAR_RE =
/^[\p{Ps}\p{Pi}\u00BF\u00A1([{\u00AB\u201C"'`-]$/u;
const normalizeTokenText = (text: string): string =>
text.replaceAll(/\s+/gu, " ").trim();
const getNextChar = (
text: string,
index: number,
): {
char: string;
nextIndex: number;
} | null => {
if (index >= text.length) return null;
const codePoint = text.codePointAt(index);
if (codePoint === undefined) return null;
const char = String.fromCodePoint(codePoint);
return {
char,
nextIndex: index + char.length,
};
};
const getPreviousChar = (
text: string,
index: number,
): {
char: string;
previousIndex: number;
} | null => {
if (index <= 0) return null;
let start = index - 1;
const lastCodeUnit = text.charCodeAt(start);
if (lastCodeUnit >= 0xdc00 && lastCodeUnit <= 0xdfff && start > 0) {
start -= 1;
}
return {
char: text.slice(start, index),
previousIndex: start,
};
};
const isWhitespaceChar = (char: string): boolean =>
WHITESPACE_CHAR_RE.test(char);
const buildTokenTextBuffer = (tokens: SubtitleToken[]): TokenTextBuffer => {
const offsets = new Array(tokens.length + 1);
offsets[0] = 0;
@ -100,11 +145,45 @@ const resolveBoundary = (text: string): BoundaryKind => {
return "neutral";
};
const startsWithDiscouragedLineStart = (text: string): boolean =>
DISCOURAGED_LINE_START_RE.test(text);
const rangeStartsWithDiscouragedLineStart = (
buffer: TokenTextBuffer,
startToken: number,
endToken: number,
): boolean => {
let index = buffer.offsets[startToken];
const end = buffer.offsets[endToken];
const endsWithDiscouragedLineEnd = (text: string): boolean =>
DISCOURAGED_LINE_END_RE.test(text);
while (index < end) {
const next = getNextChar(buffer.fullText, index);
if (!next) return false;
if (!isWhitespaceChar(next.char)) {
return DISCOURAGED_LINE_START_CHAR_RE.test(next.char);
}
index = next.nextIndex;
}
return false;
};
const rangeEndsWithDiscouragedLineEnd = (
buffer: TokenTextBuffer,
startToken: number,
endToken: number,
): boolean => {
const start = buffer.offsets[startToken];
let index = buffer.offsets[endToken];
while (index > start) {
const previous = getPreviousChar(buffer.fullText, index);
if (!previous) return false;
if (!isWhitespaceChar(previous.char)) {
return DISCOURAGED_LINE_END_CHAR_RE.test(previous.char);
}
index = previous.previousIndex;
}
return false;
};
const isWordToken = (token: SubtitleToken | undefined): boolean =>
Boolean(token?.isWordLike && token.text.trim());
@ -170,6 +249,7 @@ const buildSliceFromWord = (
tokens: SubtitleToken[],
wordTokenIndex: number,
textBuffer: TokenTextBuffer,
includeMetrics: boolean,
): WordSlice => {
let startToken = wordTokenIndex;
while (
@ -197,21 +277,24 @@ const buildSliceFromWord = (
breakAfterTokenIndex: endToken - 1,
startToken,
endToken,
charLength: normalizeTokenText(text).length,
startMs: getRangeStartMs(tokens, startToken, endToken),
endMs: getRangeEndMs(tokens, startToken, endToken),
charLength: includeMetrics ? normalizeTokenText(text).length : 0,
startMs: includeMetrics ? getRangeStartMs(tokens, startToken, endToken) : 0,
endMs: includeMetrics ? getRangeEndMs(tokens, startToken, endToken) : 0,
boundary: resolveBoundary(text),
forcesLineBreak: false,
};
};
export function buildWordSlices(tokens: SubtitleToken[]): {
function buildWordSlicesFromBuffer(
tokens: SubtitleToken[],
textBuffer: TokenTextBuffer,
collectKey: boolean,
): {
slices: WordSlice[];
key: string;
} {
const textBuffer = buildTokenTextBuffer(tokens);
const slices: WordSlice[] = [];
const keyParts: string[] = [];
const keyParts: string[] | null = collectKey ? [] : null;
let index = 0;
while (index < tokens.length) {
@ -224,7 +307,7 @@ export function buildWordSlices(tokens: SubtitleToken[]): {
if (token.text === "\n") {
const slice = createForcedBreakSlice(tokens, index);
slices.push(slice);
keyParts.push("\n");
keyParts?.push("\n");
index += 1;
continue;
}
@ -234,9 +317,9 @@ export function buildWordSlices(tokens: SubtitleToken[]): {
continue;
}
const slice = buildSliceFromWord(tokens, index, textBuffer);
const slice = buildSliceFromWord(tokens, index, textBuffer, collectKey);
slices.push(slice);
keyParts.push(normalizeTokenText(slice.text));
keyParts?.push(normalizeTokenText(slice.text));
index = slice.breakAfterTokenIndex + 1;
}
@ -248,21 +331,28 @@ export function buildWordSlices(tokens: SubtitleToken[]): {
breakAfterTokenIndex: tokens.length - 1,
startToken: 0,
endToken: tokens.length,
charLength: normalizeTokenText(text).length,
startMs: getRangeStartMs(tokens, 0, tokens.length),
endMs: getRangeEndMs(tokens, 0, tokens.length),
charLength: collectKey ? normalizeTokenText(text).length : 0,
startMs: collectKey ? getRangeStartMs(tokens, 0, tokens.length) : 0,
endMs: collectKey ? getRangeEndMs(tokens, 0, tokens.length) : 0,
boundary: resolveBoundary(text),
forcesLineBreak: false,
});
keyParts.push(normalizeTokenText(text));
keyParts?.push(normalizeTokenText(text));
}
return {
slices,
key: keyParts.join("|"),
key: keyParts?.join("|") ?? "",
};
}
export function buildWordSlices(tokens: SubtitleToken[]): {
slices: WordSlice[];
key: string;
} {
return buildWordSlicesFromBuffer(tokens, buildTokenTextBuffer(tokens), true);
}
export function measureWordSlices(
wordSlices: WordSlice[],
measureText: MeasureText,
@ -462,18 +552,18 @@ const findFallbackBreakAfterTokenIndex = (
tokens.length,
measureText,
);
const firstText = getBufferedTokenText(textBuffer, 0, firstEndToken);
const secondText = getBufferedTokenText(
textBuffer,
secondStartToken,
tokens.length,
);
const score =
Math.max(0, firstWidth - maxWidthPx) * 12 +
Math.max(0, secondWidth - maxWidthPx) * 12 +
Math.abs(secondWidth - firstWidth) * 0.4 +
(startsWithDiscouragedLineStart(secondText) ? 260 : 0) +
(endsWithDiscouragedLineEnd(firstText) ? 70 : 0);
(rangeStartsWithDiscouragedLineStart(
textBuffer,
secondStartToken,
tokens.length,
)
? 260
: 0) +
(rangeEndsWithDiscouragedLineEnd(textBuffer, 0, firstEndToken) ? 70 : 0);
if (score < bestScore) {
bestScore = score;
@ -487,8 +577,8 @@ const findFallbackBreakAfterTokenIndex = (
const scoreBreakCandidate = ({
firstWidth,
secondWidth,
firstText,
secondText,
lineStartPenalty,
lineEndPenalty,
firstWordCount,
secondWordCount,
maxWidthPx,
@ -496,8 +586,8 @@ const scoreBreakCandidate = ({
}: {
firstWidth: number;
secondWidth: number;
firstText: string;
secondText: string;
lineStartPenalty: number;
lineEndPenalty: number;
firstWordCount: number;
secondWordCount: number;
maxWidthPx: number;
@ -511,8 +601,6 @@ const scoreBreakCandidate = ({
Math.abs(secondWidth / Math.max(firstWidth, 1) - balanceTarget) * 120;
const shortTopPenalty = firstWordCount < 2 ? 80 : 0;
const orphanPenalty = secondWordCount < 2 ? 80 : 0;
const lineStartPenalty = startsWithDiscouragedLineStart(secondText) ? 260 : 0;
const lineEndPenalty = endsWithDiscouragedLineEnd(firstText) ? 70 : 0;
const boundaryBonus =
boundary === "strong" ? -28 : boundary === "soft" ? -14 : 0;
@ -554,7 +642,7 @@ export function computeTokenWrapPlan(
};
}
const { slices } = buildWordSlices(tokens);
const { slices } = buildWordSlicesFromBuffer(tokens, textBuffer, false);
const measurableSlices = slices.filter((slice) => !slice.forcesLineBreak);
if (!measurableSlices.length) {
return {
@ -598,17 +686,23 @@ export function computeTokenWrapPlan(
tokens.length,
measureText,
);
const firstText = getBufferedTokenText(textBuffer, 0, firstEndToken);
const secondText = getBufferedTokenText(
textBuffer,
secondStartToken,
tokens.length,
);
const score = scoreBreakCandidate({
firstWidth,
secondWidth,
firstText,
secondText,
lineStartPenalty: rangeStartsWithDiscouragedLineStart(
textBuffer,
secondStartToken,
tokens.length,
)
? 260
: 0,
lineEndPenalty: rangeEndsWithDiscouragedLineEnd(
textBuffer,
0,
firstEndToken,
)
? 70
: 0,
firstWordCount: index + 1,
secondWordCount: measurableSlices.length - (index + 1),
maxWidthPx: safeMaxWidthPx,

View file

@ -140,7 +140,7 @@ export class SubtitlesWidget {
private readonly useVideoFrameCallbacks: boolean;
private videoFrameRequestId: number | null = null;
private lastPlaybackTimeMs: number | null = null;
private dragDocListenersAttached = false;
private dragAbortController: AbortController | null = null;
private lastPositionRefreshTs = 0;
private readonly positionRefreshIntervalMs = 250;
private subtitleMaxWidthPx = 0;
@ -558,27 +558,19 @@ export class SubtitlesWidget {
private bindEvents(): void {
const { signal } = this.abortController;
const opts = { signal } as AddEventListenerOptions;
this.video?.addEventListener("play", this.onPlaybackStateChangeBound, opts);
this.video?.addEventListener(
for (const eventName of [
"play",
"pause",
this.onPlaybackStateChangeBound,
opts,
);
this.video?.addEventListener(
"seeking",
this.onPlaybackStateChangeBound,
opts,
);
this.video?.addEventListener(
"seeked",
this.onPlaybackStateChangeBound,
opts,
);
this.video?.addEventListener(
"ended",
this.onPlaybackStateChangeBound,
opts,
);
] as const) {
this.video?.addEventListener(
eventName,
this.onPlaybackStateChangeBound,
opts,
);
}
this.resizeObserver = new ResizeObserver(() => this.onResize());
const resizeTarget =
this.container instanceof ShadowRoot
@ -708,21 +700,27 @@ export class SubtitlesWidget {
}
}
private attachDragDocumentListeners(): void {
if (this.dragDocListenersAttached) return;
this.dragDocListenersAttached = true;
if (this.dragAbortController) return;
const dragAbortController = new AbortController();
const { signal } = dragAbortController;
document.addEventListener("pointermove", this.onPointerMoveBound, {
signal,
passive: false,
capture: true,
});
document.addEventListener("pointerup", this.onPointerUpBound, true);
document.addEventListener("pointercancel", this.onPointerUpBound, true);
document.addEventListener("pointerup", this.onPointerUpBound, {
signal,
capture: true,
});
document.addEventListener("pointercancel", this.onPointerUpBound, {
signal,
capture: true,
});
this.dragAbortController = dragAbortController;
}
private detachDragDocumentListeners(): void {
if (!this.dragDocListenersAttached) return;
this.dragDocListenersAttached = false;
document.removeEventListener("pointermove", this.onPointerMoveBound, true);
document.removeEventListener("pointerup", this.onPointerUpBound, true);
document.removeEventListener("pointercancel", this.onPointerUpBound, true);
this.dragAbortController?.abort();
this.dragAbortController = null;
}
private onResize(): void {
this.syncWidgetMount();
@ -773,9 +771,10 @@ export class SubtitlesWidget {
this.insetCacheReady = true;
}
private isMobileViewport(): boolean {
if (typeof globalThis.matchMedia !== "function") return false;
return globalThis.matchMedia("(max-width: 900px) and (pointer: coarse)")
.matches;
return (
globalThis.matchMedia?.("(max-width: 900px) and (pointer: coarse)")
?.matches ?? false
);
}
private getBottomInsetPreset() {
const doc = document as Document & {
@ -1466,55 +1465,42 @@ export class SubtitlesWidget {
this.strTranslatedTokens = context;
return [context, current];
}
private isTokenSpanElement(el: unknown): el is HTMLSpanElement {
return el instanceof HTMLSpanElement && el.dataset.votToken === "1";
}
private findTokenSpanInPath(
path: EventTarget[],
private findTokenSpan(
candidate: EventTarget | null,
root: HTMLElement,
): HTMLSpanElement | null {
for (const node of path) {
if (this.isTokenSpanElement(node) && root.contains(node)) {
return node;
}
}
return null;
}
private findTokenSpanByPoint(
x: number,
y: number,
root: HTMLElement,
): HTMLSpanElement | null {
const hit = document.elementFromPoint(x, y);
if (this.isTokenSpanElement(hit) && root.contains(hit)) {
return hit;
}
if (!(hit instanceof Element)) return null;
const closest = hit.closest('span[data-vot-token="1"]');
if (closest instanceof HTMLSpanElement && root.contains(closest)) {
return closest;
}
return null;
const element =
candidate instanceof Element
? candidate
: candidate instanceof Text
? candidate.parentElement
: null;
const token = element?.closest<HTMLSpanElement>('span[data-vot-token="1"]');
return token instanceof HTMLSpanElement && root.contains(token)
? token
: null;
}
private resolveTokenSpanFromClick(event: MouseEvent): HTMLSpanElement | null {
const root: HTMLElement | null =
this.subtitlesBlock ?? this.subtitlesContainer;
if (!root) return null;
if (this.isTokenSpanElement(event.target) && root.contains(event.target)) {
return event.target;
const fromTarget = this.findTokenSpan(event.target, root);
if (fromTarget) {
return fromTarget;
}
const path =
typeof event.composedPath === "function" ? event.composedPath() : [];
const fromPath = this.findTokenSpanInPath(path, root);
if (fromPath) {
return fromPath;
for (const node of path) {
const fromPath = this.findTokenSpan(node, root);
if (fromPath) {
return fromPath;
}
}
const x = event.clientX;
const y = event.clientY;
if (Number.isFinite(x) && Number.isFinite(y)) {
return this.findTokenSpanByPoint(x, y, root);
}
return null;
return Number.isFinite(x) && Number.isFinite(y)
? this.findTokenSpan(document.elementFromPoint(x, y), root)
: null;
}
releaseTooltip(): this {
this.tooltipTranslationRequestId += 1;
@ -1788,14 +1774,6 @@ export class SubtitlesWidget {
}
return this.measureCtx;
}
private arraysEqual(a: number[], b: number[]): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false;
}
return true;
}
private recomputeWrapNow(): void {
const tokens = this.lastWrapTokens;
const block = this.subtitlesBlock;
@ -1817,10 +1795,12 @@ export class SubtitlesWidget {
(text) => ctx.measureText(text).width,
safeMaxWidthPx,
);
const breaksChanged = !this.arraysEqual(
next.breakAfterTokenIndices,
this.breakAfterTokenIndices,
);
const breaksChanged =
next.breakAfterTokenIndices.length !==
this.breakAfterTokenIndices.length ||
next.breakAfterTokenIndices.some(
(value, index) => value !== this.breakAfterTokenIndices[index],
);
if (breaksChanged) {
this.setBreakAfterTokenIndices(next.breakAfterTokenIndices);
this.resetRenderMemo();
@ -1915,11 +1895,7 @@ export class SubtitlesWidget {
this.applyOpacityStyle();
}
private stringifyTokens(tokens: SubtitleToken[]): string {
let out = "";
for (const token of tokens) {
out += token.text;
}
return out;
return tokens.map((token) => token.text).join("");
}
private resolveActiveLine(
time: number,

View file

@ -4,12 +4,6 @@ import { localizationProvider } from "../../localization/localizationProvider";
import type { AccountButtonProps } from "../../types/components/accountButton";
import UI from "../../ui";
import { KEY_ICON, REFRESH_ICON } from "../icons";
import {
addComponentEventListener,
getHiddenState,
removeComponentEventListener,
setHiddenState,
} from "./componentShared";
export default class AccountButton {
container: HTMLElement;
@ -114,7 +108,7 @@ export default class AccountButton {
type: "click" | "click:secret" | "refresh",
listener: () => void,
): this {
addComponentEventListener(this.events, type, listener);
this.events[type].addListener(listener);
return this;
}
@ -123,7 +117,7 @@ export default class AccountButton {
type: "click" | "click:secret" | "refresh",
listener: () => void,
): this {
removeComponentEventListener(this.events, type, listener);
this.events[type].removeListener(listener);
return this;
}
@ -164,10 +158,10 @@ export default class AccountButton {
}
set hidden(isHidden: boolean) {
setHiddenState(this.container, isHidden);
this.container.hidden = isHidden;
}
get hidden() {
return getHiddenState(this.container);
return this.container.hidden;
}
}

View file

@ -4,12 +4,6 @@ import { EventImpl } from "../../core/eventImpl";
import type { CheckboxProps } from "../../types/components/checkbox";
import type { LitHtml } from "../../types/components/shared";
import UI from "../../ui";
import {
addComponentEventListener,
getHiddenState,
removeComponentEventListener,
setHiddenState,
} from "./componentShared";
export default class Checkbox {
container: HTMLElement;
@ -17,9 +11,6 @@ export default class Checkbox {
label: HTMLSpanElement;
private readonly onChange = new EventImpl<[boolean]>();
private readonly events = {
change: this.onChange,
};
private readonly _labelHtml: LitHtml;
private _checked: boolean;
@ -65,7 +56,7 @@ export default class Checkbox {
_type: "change",
listener: (checked: boolean) => void,
): this {
addComponentEventListener(this.events, "change", listener);
this.onChange.addListener(listener);
return this;
}
@ -74,17 +65,17 @@ export default class Checkbox {
_type: "change",
listener: (checked: boolean) => void,
): this {
removeComponentEventListener(this.events, "change", listener);
this.onChange.removeListener(listener);
return this;
}
set hidden(isHidden: boolean) {
setHiddenState(this.container, isHidden);
this.container.hidden = isHidden;
}
get hidden() {
return getHiddenState(this.container);
return this.container.hidden;
}
get disabled() {

View file

@ -1,60 +1,31 @@
import type { EventImpl } from "../../core/eventImpl";
type EventHandler<Args extends unknown[]> = (...args: Args) => void;
type HiddenElement = { hidden: boolean };
export function addComponentEventListener<
T extends string,
Args extends unknown[],
>(
events: Record<T, EventImpl<Args>>,
type: T,
listener: EventHandler<Args>,
): void {
events[type].addListener(listener);
}
export function removeComponentEventListener<
T extends string,
Args extends unknown[],
>(
events: Record<T, EventImpl<Args>>,
type: T,
listener: EventHandler<Args>,
): void {
events[type].removeListener(listener);
}
export function setHiddenState(
element: HiddenElement,
isHidden: boolean,
): void {
element.hidden = isHidden;
}
export function getHiddenState(element: HiddenElement): boolean {
return element.hidden;
}
export function setInteractiveHiddenState(
element: HTMLElement,
isHidden: boolean,
): void {
setHiddenState(element, isHidden);
element.hidden = isHidden;
element.setAttribute("aria-hidden", isHidden ? "true" : "false");
element.toggleAttribute("inert", isHidden);
}
export function setDisabledState(
element: HTMLElement,
isDisabled: boolean,
): void {
if (isDisabled) {
element.setAttribute("disabled", "true");
return;
export function createDomId(prefix: string): string {
const suffix =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: Math.random().toString(36).slice(2);
return `${prefix}-${suffix}`;
}
export function isEventInside(event: Event, element: HTMLElement): boolean {
const target = event.target;
if (target instanceof Node && element.contains(target)) {
return true;
}
element.removeAttribute("disabled");
return (
typeof event.composedPath === "function" &&
event.composedPath().includes(element)
);
}
export function isPrimaryPointerAction(event: PointerEvent): boolean {

View file

@ -4,12 +4,6 @@ import { EventImpl } from "../../core/eventImpl";
import type { DetailsProps } from "../../types/components/details";
import UI from "../../ui";
import { CHEVRON_ICON } from "../icons";
import {
addComponentEventListener,
getHiddenState,
removeComponentEventListener,
setHiddenState,
} from "./componentShared";
export default class Details {
container: HTMLElement;
@ -17,9 +11,6 @@ export default class Details {
arrowIcon: HTMLElement;
private readonly onClick = new EventImpl();
private readonly events = {
click: this.onClick,
};
private readonly _titleHtml: HTMLElement | string;
@ -56,22 +47,22 @@ export default class Details {
}
addEventListener(_type: "click", listener: () => void): this {
addComponentEventListener(this.events, "click", listener);
this.onClick.addListener(listener);
return this;
}
removeEventListener(_type: "click", listener: () => void): this {
removeComponentEventListener(this.events, "click", listener);
this.onClick.removeListener(listener);
return this;
}
set hidden(isHidden: boolean) {
setHiddenState(this.container, isHidden);
this.container.hidden = isHidden;
}
get hidden() {
return getHiddenState(this.container);
return this.container.hidden;
}
}

View file

@ -3,12 +3,7 @@ import type { DialogProps } from "../../types/components/dialog";
import UI from "../../ui";
import { getDeepActiveElement } from "../../utils/dom";
import { CLOSE_ICON } from "../icons";
import {
addComponentEventListener,
getHiddenState,
removeComponentEventListener,
setInteractiveHiddenState,
} from "./componentShared";
import { createDomId, setInteractiveHiddenState } from "./componentShared";
export default class Dialog {
container: HTMLElement;
@ -23,9 +18,6 @@ export default class Dialog {
footerContainer: HTMLElement;
private readonly onClose = new EventImpl();
private readonly events = {
close: this.onClose,
};
// Focus management for accessibility.
private previouslyFocused: Element | null = null;
@ -38,10 +30,7 @@ export default class Dialog {
this.scheduleAdaptiveVerticalAlign();
};
private readonly titleId =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `vot-dialog-title-${Math.random().toString(36).slice(2)}`;
private readonly titleId = createDomId("vot-dialog-title");
private readonly _titleHtml: HTMLElement | string;
private readonly _isTemp: boolean;
@ -143,13 +132,13 @@ export default class Dialog {
}
addEventListener(_type: "close", listener: () => void): this {
addComponentEventListener(this.events, "close", listener);
this.onClose.addListener(listener);
return this;
}
removeEventListener(_type: "close", listener: () => void): this {
removeComponentEventListener(this.events, "close", listener);
this.onClose.removeListener(listener);
return this;
}
@ -372,7 +361,7 @@ export default class Dialog {
}
get hidden() {
return getHiddenState(this.container);
return this.container.hidden;
}
get isDialogOpen() {

View file

@ -1,12 +1,7 @@
import { EventImpl } from "../../core/eventImpl";
import UI from "../../ui";
import { clampPercentInt } from "../../utils/volume";
import { DOWNLOAD_ICON } from "../icons";
import {
addComponentEventListener,
getHiddenState,
removeComponentEventListener,
setHiddenState,
} from "./componentShared";
export default class DownloadButton {
button: HTMLElement;
@ -14,9 +9,6 @@ export default class DownloadButton {
loaderCircle: SVGCircleElement;
private readonly onClick = new EventImpl();
private readonly events = {
click: this.onClick,
};
private _progress = 0;
constructor() {
@ -49,13 +41,13 @@ export default class DownloadButton {
}
addEventListener(_type: "click", listener: () => void): this {
addComponentEventListener(this.events, "click", listener);
this.onClick.addListener(listener);
return this;
}
removeEventListener(_type: "click", listener: () => void): this {
removeComponentEventListener(this.events, "click", listener);
this.onClick.removeListener(listener);
return this;
}
@ -83,11 +75,11 @@ export default class DownloadButton {
}
set hidden(isHidden: boolean) {
setHiddenState(this.button, isHidden);
this.button.hidden = isHidden;
}
get hidden() {
return getHiddenState(this.button);
return this.button.hidden;
}
}
@ -97,5 +89,5 @@ function clampProgress(value: number): number {
// `1` is ambiguous (could mean 1% or 100%). Our download code reports
// integer percentages, so `1` should be treated as 1%.
const asPercent = value < 1 ? value * 100 : value;
return Math.max(0, Math.min(100, Math.round(asPercent)));
return clampPercentInt(asPercent);
}

View file

@ -2,21 +2,12 @@ import { EventImpl } from "../../core/eventImpl";
import { localizationProvider } from "../../localization/localizationProvider";
import type { HotkeyButtonProps } from "../../types/components/hotkeyButton";
import UI from "../../ui";
import {
addComponentEventListener,
getHiddenState,
removeComponentEventListener,
setHiddenState,
} from "./componentShared";
export default class HotkeyButton {
container: HTMLElement;
button: HTMLElement;
private readonly onChange = new EventImpl<[string | null]>();
private readonly events = {
change: this.onChange,
};
private readonly _labelHtml: string;
private _key: string | null;
@ -130,7 +121,7 @@ export default class HotkeyButton {
_type: "change",
listener: (key: string | null) => void,
): this {
addComponentEventListener(this.events, "change", listener);
this.onChange.addListener(listener);
return this;
}
@ -139,17 +130,17 @@ export default class HotkeyButton {
_type: "change",
listener: (key: string | null) => void,
): this {
removeComponentEventListener(this.events, "change", listener);
this.onChange.removeListener(listener);
return this;
}
set hidden(isHidden: boolean) {
setHiddenState(this.container, isHidden);
this.container.hidden = isHidden;
}
get hidden() {
return getHiddenState(this.container);
return this.container.hidden;
}
get key() {

View file

@ -2,7 +2,6 @@ import { render } from "lit-html";
import type { LabelProps } from "../../types/components/label";
import type { LitHtml } from "../../types/components/shared";
import UI from "../../ui";
import { getHiddenState, setHiddenState } from "./componentShared";
export default class Label {
container: HTMLElement;
@ -51,10 +50,10 @@ export default class Label {
}
set hidden(isHidden: boolean) {
setHiddenState(this.container, isHidden);
this.container.hidden = isHidden;
}
get hidden() {
return getHiddenState(this.container);
return this.container.hidden;
}
}

View file

@ -11,13 +11,6 @@ import type {
import type { Phrase } from "../../types/localization";
import UI from "../../ui";
import { CHEVRON_ICON } from "../icons";
import {
addComponentEventListener,
getHiddenState,
removeComponentEventListener,
setDisabledState,
setHiddenState,
} from "./componentShared";
import Dialog from "./dialog";
import Textfield from "./textfield";
@ -314,7 +307,7 @@ export default class Select<
type: "beforeOpen" | "selectItem",
listener: (...data: any[]) => void,
): this {
addComponentEventListener(this.events, type, listener);
this.events[type].addListener(listener);
return this;
}
@ -331,7 +324,7 @@ export default class Select<
type: "selectItem" | "beforeOpen",
listener: (...data: any[]) => void,
): this {
removeComponentEventListener(this.events, type, listener);
this.events[type].removeListener(listener);
return this;
}
@ -421,11 +414,11 @@ export default class Select<
}
set hidden(isHidden: boolean) {
setHiddenState(this.container, isHidden);
this.container.hidden = isHidden;
}
get hidden() {
return getHiddenState(this.container);
return this.container.hidden;
}
get disabled() {
@ -436,6 +429,10 @@ export default class Select<
}
set disabled(isDisabled: boolean) {
setDisabledState(this.outer, isDisabled);
if (isDisabled) {
this.outer.setAttribute("disabled", "true");
} else {
this.outer.removeAttribute("disabled");
}
}
}

View file

@ -4,7 +4,7 @@ import { EventImpl } from "../../core/eventImpl";
import type { LitHtml } from "../../types/components/shared";
import type { SliderProps } from "../../types/components/slider";
import UI from "../../ui";
import { getHiddenState, setHiddenState } from "./componentShared";
import { clampNumber } from "../../utils/number";
export default class Slider {
container: HTMLElement;
@ -155,16 +155,10 @@ export default class Slider {
}
set hidden(isHidden: boolean) {
setHiddenState(this.container, isHidden);
this.container.hidden = isHidden;
}
get hidden() {
return getHiddenState(this.container);
return this.container.hidden;
}
}
function clampNumber(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
if (max < min) return min;
return Math.max(min, Math.min(max, value));
}

View file

@ -1,6 +1,5 @@
import type { SliderLabelProps } from "../../types/components/sliderLabel";
import UI from "../../ui";
import { getHiddenState, setHiddenState } from "./componentShared";
export default class SliderLabel {
container: HTMLSpanElement;
@ -70,10 +69,10 @@ export default class SliderLabel {
}
set hidden(isHidden: boolean) {
setHiddenState(this.container, isHidden);
this.container.hidden = isHidden;
}
get hidden() {
return getHiddenState(this.container);
return this.container.hidden;
}
}

View file

@ -1,12 +1,6 @@
import { EventImpl } from "../../core/eventImpl";
import type { TextfieldProps } from "../../types/components/textfield";
import UI from "../../ui";
import {
addComponentEventListener,
getHiddenState,
removeComponentEventListener,
setHiddenState,
} from "./componentShared";
export default class Textfield {
container: HTMLElement;
@ -77,7 +71,7 @@ export default class Textfield {
type: "input" | "change",
listener: (value: string) => void,
): this {
addComponentEventListener(this.events, type, listener);
this.events[type].addListener(listener);
return this;
}
@ -86,7 +80,7 @@ export default class Textfield {
type: "input" | "change",
listener: (value: string) => void,
): this {
removeComponentEventListener(this.events, type, listener);
this.events[type].removeListener(listener);
return this;
}
@ -124,10 +118,10 @@ export default class Textfield {
}
set hidden(isHidden: boolean) {
setHiddenState(this.container, isHidden);
this.container.hidden = isHidden;
}
get hidden() {
return getHiddenState(this.container);
return this.container.hidden;
}
}

View file

@ -8,6 +8,7 @@ import {
} from "../../types/components/tooltip";
import UI from "../../ui";
import { clamp } from "../../utils/utils";
import { createDomId, isEventInside } from "./componentShared";
export default class Tooltip {
/** Whether tooltip element is currently mounted. */
@ -47,10 +48,7 @@ export default class Tooltip {
private static readonly DESTROY_FALLBACK_MS = 700;
// Accessibility: link trigger -> tooltip via aria-describedby.
private readonly tooltipId =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `vot-tooltip-${Math.random().toString(36).slice(2)}`;
private readonly tooltipId = createDomId("vot-tooltip");
private prevAriaDescribedBy: string | null = null;
constructor({
@ -164,6 +162,21 @@ export default class Tooltip {
this.showed ? this.destroy() : this.create();
};
onDocumentPointerDown = (event: PointerEvent) => {
if (!this.showed) {
return;
}
if (
isEventInside(event, this.target) ||
(this.container && isEventInside(event, this.container))
) {
return;
}
this.destroy();
};
onTargetKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape" || !this.showed) {
return;
@ -366,6 +379,11 @@ export default class Tooltip {
this.container.style.opacity = "1";
if (this.trigger === "hover") {
this.container.addEventListener("mouseleave", this.onTooltipMouseLeave);
} else {
document.addEventListener("pointerdown", this.onDocumentPointerDown, {
capture: true,
passive: true,
});
}
this.attachScrollListener();
this.onResizeObserver?.observe(this.layoutRoot);
@ -577,6 +595,7 @@ export default class Tooltip {
this.onResizeObserver?.disconnect();
this.intersectionObserver?.disconnect();
this.detachScrollListener();
this.detachOutsidePointerListener();
if (instant) {
container.remove();
this.container = undefined;
@ -609,6 +628,12 @@ export default class Tooltip {
return this;
}
private detachOutsidePointerListener() {
document.removeEventListener("pointerdown", this.onDocumentPointerDown, {
capture: true,
});
}
private syncAriaDescribedBy(isShowing: boolean) {
// Follow ARIA tooltip pattern: trigger references tooltip via aria-describedby.
const existing = this.target.getAttribute("aria-describedby");

View file

@ -8,7 +8,6 @@ import type {
} from "../../types/components/votButton";
import UI from "../../ui";
import { MENU_ICON, PIP_ICON_SVG, TRANSLATE_ICON_SVG } from "../icons";
import { getHiddenState, setHiddenState } from "./componentShared";
export default class VOTButton {
container: HTMLElement;
@ -167,11 +166,11 @@ export default class VOTButton {
}
set hidden(isHidden: boolean) {
setHiddenState(this.container, isHidden);
this.container.hidden = isHidden;
}
get hidden() {
return getHiddenState(this.container);
return this.container.hidden;
}
get position() {

View file

@ -1,7 +1,7 @@
import type { Position } from "../../types/components/votButton";
import type { VOTMenuProps } from "../../types/components/votMenu";
import UI from "../../ui";
import { getHiddenState, setInteractiveHiddenState } from "./componentShared";
import { createDomId, setInteractiveHiddenState } from "./componentShared";
export default class VOTMenu {
container: HTMLElement;
@ -16,15 +16,9 @@ export default class VOTMenu {
private _titleHtml: string;
// A11y: stable ids for aria-controls / aria-labelledby.
private readonly menuId =
typeof crypto !== "undefined" && "randomUUID" in crypto
? `vot-menu-${crypto.randomUUID()}`
: `vot-menu-${Math.random().toString(36).slice(2)}`;
private readonly menuId = createDomId("vot-menu");
private readonly titleId =
typeof crypto !== "undefined" && "randomUUID" in crypto
? `vot-menu-title-${crypto.randomUUID()}`
: `vot-menu-title-${Math.random().toString(36).slice(2)}`;
private readonly titleId = createDomId("vot-menu-title");
constructor({ position = "default", titleHtml = "" }: VOTMenuProps) {
this._position = position;
@ -104,7 +98,7 @@ export default class VOTMenu {
}
get hidden() {
return getHiddenState(this.container);
return this.container.hidden;
}
get position() {

View file

@ -2,6 +2,7 @@ import type { VideoHandler } from "../index";
import { localizationProvider } from "../localization/localizationProvider";
import type { Status } from "../types/components/votButton";
import debug from "../utils/debug";
import { isAbortError } from "../utils/errors";
import VOTLocalizedError from "../utils/VOTLocalizedError";
type TranslationButtonCommandDeps = {
@ -11,10 +12,6 @@ type TranslationButtonCommandDeps = {
transformBtn(status: Status, text: string): void;
};
function isAbortError(error: unknown) {
return error instanceof Error && error.name === "AbortError";
}
async function getVideoDataForTranslation(videoHandler: VideoHandler) {
if (!videoHandler.videoData?.videoId) {
throw new VOTLocalizedError("VOTNoVideoIDFound");

View file

@ -18,6 +18,7 @@ import { votStorage } from "../../utils/storage";
import { isPiPAvailable } from "../../utils/utils";
import {
addKeyboardActivationListener,
isEventInside,
isPrimaryPointerAction,
} from "../components/componentShared";
import DownloadButton from "../components/downloadButton";
@ -262,18 +263,6 @@ export class OverlayView {
addKeyboardActivationListener(element, handler, { signal });
}
private isEventInside(event: Event, element: HTMLElement): boolean {
const target = event.target as Node | null;
if (target && element.contains(target)) {
return true;
}
return (
typeof event.composedPath === "function" &&
event.composedPath().includes(element)
);
}
private flushDefaultVolumePersist(): void {
if (this.defaultVolumePersistTimer !== undefined) {
globalThis.clearTimeout(this.defaultVolumePersistTimer);
@ -614,9 +603,9 @@ export class OverlayView {
);
if (
this.isEventInside(e, this.votMenu.container) ||
this.isEventInside(e, this.votButton.menuButton) ||
this.isEventInside(e, this.votButton.container) ||
isEventInside(e, this.votMenu.container) ||
isEventInside(e, this.votButton.menuButton) ||
isEventInside(e, this.votButton.container) ||
isInsideDialog
) {
return;

View file

@ -103,6 +103,7 @@ import { detectServices, translateServices } from "../../utils/translateApis";
import { isPiPAvailable } from "../../utils/utils";
import AccountButton from "../components/accountButton";
import Checkbox from "../components/checkbox";
import { createDomId } from "../components/componentShared";
import Details from "../components/details";
import Dialog from "../components/dialog";
import HotkeyButton from "../components/hotkeyButton";
@ -298,12 +299,9 @@ export class SettingsView {
const section = ui.createEl("vot-block", ["vot-settings-section"]);
const header = new Details({ titleHtml: title });
header.container.classList.add("vot-settings-section-header");
const sectionId =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const headerId = `vot-settings-section-header-${sectionId}`;
const contentId = `vot-settings-section-content-${sectionId}`;
const sectionId = createDomId("vot-settings-section");
const headerId = `${sectionId}-header`;
const contentId = `${sectionId}-content`;
header.container.id = headerId;
const content = ui.createEl("vot-block", ["vot-settings-section-content"]);
content.id = contentId;

15
src/utils/number.ts Normal file
View file

@ -0,0 +1,15 @@
export function clampNumber(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
if (max < min) return min;
return Math.max(min, Math.min(max, value));
}
export function clampNumberWithSortedBounds(
value: number,
min: number,
max: number,
): number {
const lower = Math.min(min, max);
const upper = Math.max(min, max);
return Math.min(Math.max(value, lower), upper);
}

View file

@ -1,3 +1,5 @@
import { clampNumberWithSortedBounds } from "./number";
export { calculatedResLang } from "./localization";
const DEFAULT_OBJECT_URL_REVOKE_DELAY_MS = 30_000;
@ -219,9 +221,7 @@ export const getHeaders = (headers?: HeadersInit): Record<string, string> =>
headers ? Object.fromEntries(new Headers(headers)) : {};
export function clamp(value: number, min = 0, max = 100): number {
const lower = Math.min(min, max);
const upper = Math.max(min, max);
return Math.min(Math.max(value, lower), upper);
return clampNumberWithSortedBounds(value, min, max);
}
export function toFlatObj<T extends Record<string, unknown>>(

View file

@ -1,3 +1,5 @@
import { clampNumber } from "./number";
/**
* Volume utilities.
*
@ -14,12 +16,6 @@ export const VIDEO_VOLUME_STEP_01 = 0.01;
const EPS = 1e-6;
function clampNumber(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
if (max < min) return min;
return Math.max(min, Math.min(max, value));
}
export function clampInt(value: number, min: number, max: number): number {
return Math.trunc(clampNumber(value, min, max));
}

View file

@ -42,42 +42,22 @@ function mergeListenerSignals(
primary: AbortSignal,
secondary?: AbortSignal,
): AbortSignal {
if (!secondary || secondary === primary) {
return primary;
}
if (primary.aborted) {
return primary;
}
if (secondary.aborted) {
return secondary;
}
const canCombine = typeof AbortSignal !== "undefined" && "any" in AbortSignal;
if (canCombine) {
return (AbortSignal as any).any([primary, secondary]) as AbortSignal;
}
if (!secondary || secondary === primary) return primary;
const signals = [primary, secondary];
if (typeof AbortSignal.any === "function") return AbortSignal.any(signals);
const controller = new AbortController();
const cleanup = () => {
primary.removeEventListener("abort", onPrimaryAbort);
secondary.removeEventListener("abort", onSecondaryAbort);
};
const onPrimaryAbort = () => {
cleanup();
controller.abort(primary.reason);
};
const onSecondaryAbort = () => {
cleanup();
controller.abort(secondary.reason);
};
primary.addEventListener("abort", onPrimaryAbort, { once: true });
secondary.addEventListener("abort", onSecondaryAbort, { once: true });
for (const signal of signals) {
const abort = () => controller.abort(signal.reason);
if (signal.aborted) {
abort();
break;
}
signal.addEventListener("abort", abort, {
once: true,
signal: controller.signal,
});
}
return controller.signal;
}
@ -86,16 +66,9 @@ function createScopedListeners(signal: AbortSignal): {
addMany: ScopedAddListeners;
} {
const add: ScopedAddListener = (element, event, handler, options) => {
const mergedSignal = mergeListenerSignals(signal, options?.signal);
if (!options) {
element.addEventListener(event, handler, { signal: mergedSignal });
return;
}
const { signal: _ignoredSignal, ...restOptions } = options;
element.addEventListener(event, handler, {
...restOptions,
signal: mergedSignal,
...options,
signal: mergeListenerSignals(signal, options?.signal),
});
};
const addMany: ScopedAddListeners = (element, events, handler, options) => {
@ -110,29 +83,19 @@ function bindOverlayHoverFocusEvents(
target: EventTarget,
overlayVisibility: NonNullable<VideoHandler["overlayVisibility"]>,
): void {
addMany(target, ["focusin"], (event) =>
overlayVisibility.handleOverlayInteraction(event),
);
addMany(target, ["focusout"], (event) =>
overlayVisibility.scheduleHide(event),
);
const handleInteraction = (event: Event) =>
overlayVisibility.handleOverlayInteraction(event);
const scheduleHide = (event: Event) => overlayVisibility.scheduleHide(event);
if (isIframe() && globalThis.window !== undefined) {
addMany(target, ["focusin"], handleInteraction);
addMany(target, ["focusout"], scheduleHide);
return;
}
addMany(target, ["pointerenter"], (event) =>
overlayVisibility.handleOverlayInteraction(event),
);
addMany(
target,
["pointermove"],
(event) => overlayVisibility.handleOverlayInteraction(event),
{ passive: true },
);
addMany(target, ["pointerleave"], (event) =>
overlayVisibility.scheduleHide(event),
);
addMany(target, ["focusin", "pointerenter"], handleInteraction);
addMany(target, ["pointermove"], handleInteraction, { passive: true });
addMany(target, ["focusout", "pointerleave"], scheduleHide);
}
function toPercentInt(value: unknown, fallback = 0): number {
@ -363,10 +326,13 @@ function bindGlobalDismissAndHotkeys(ctx: ExtraEventsContext): void {
const button = overlayView.votButton?.container;
const menu = overlayView.votMenu?.container;
const settings = self.uiManager.votSettingsView?.dialog?.container;
const isButton = target && button ? button.contains(target) : false;
const isMenu = target && menu ? menu.contains(target) : false;
const isVideo = target ? self.container.contains(target) : false;
const isSettings = target && settings ? settings.contains(target) : false;
const path = event.composedPath();
const isInPath = (element?: EventTarget | null) =>
Boolean(element && path.includes(element));
const isButton = isInPath(button);
const isMenu = isInPath(menu);
const isVideo = isInPath(self.container);
const isSettings = isInPath(settings);
const isTempDialog =
target instanceof Element &&
target.closest(".vot-dialog-temp") instanceof Element;

68
tests/smart-wrap.test.ts Normal file
View file

@ -0,0 +1,68 @@
import { describe, expect, test } from "bun:test";
import {
buildWordSlices,
computeTokenWrapPlan,
} from "../src/subtitles/smartWrap.ts";
import type { SubtitleToken } from "../src/types/subtitles.ts";
const token = (text: string, isWordLike: boolean): SubtitleToken => ({
text,
startMs: 0,
durationMs: 100,
isWordLike,
});
const measureText = (text: string): number => text.length * 10;
describe("subtitle smart wrap", () => {
test("returns no wrap for empty, forced-break, and invalid-width input", () => {
expect(computeTokenWrapPlan([], measureText, 100)).toEqual({
breakAfterTokenIndices: [],
});
expect(
computeTokenWrapPlan(
[token("hello", true), token("\n", false), token("world", true)],
measureText,
100,
),
).toEqual({
breakAfterTokenIndices: [],
});
expect(
computeTokenWrapPlan([token("hello", true)], measureText, Number.NaN),
).toEqual({
breakAfterTokenIndices: [],
});
});
test("keeps normalized keys and char lengths stable", () => {
const { key, slices } = buildWordSlices([
token(" hello", true),
token(" ", false),
token("world ", true),
]);
expect(key).toBe("hello|world");
expect(slices.map((slice) => slice.charLength)).toEqual([5, 5]);
});
test("avoids breaking after dangling opening punctuation", () => {
const tokens = [
token("Intro", true),
token(" ", false),
token('"', false),
token(" ", false),
token("quoted", true),
token(" ", false),
token("text", true),
token(" ", false),
token("continues.", true),
];
expect(computeTokenWrapPlan(tokens, measureText, 55)).toEqual({
breakAfterTokenIndices: [5],
});
});
});