mirror of
https://github.com/ilyhalight/voice-over-translation.git
synced 2026-04-26 10:31:28 +00:00
refractor: optimize smartWrap & simplify UI/events
This commit is contained in:
parent
be0a7029dd
commit
2aa70d15b3
33 changed files with 727 additions and 701 deletions
Binary file not shown.
BIN
dist-ext/vot-extension-chrome-1.11.5.7.zip
Normal file
BIN
dist-ext/vot-extension-chrome-1.11.5.7.zip
Normal file
Binary file not shown.
Binary file not shown.
BIN
dist-ext/vot-extension-firefox-1.11.5.7.xpi
Normal file
BIN
dist-ext/vot-extension-firefox-1.11.5.7.xpi
Normal file
Binary file not shown.
|
|
@ -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
114
dist/vot-min.user.js
vendored
File diff suppressed because one or more lines are too long
479
dist/vot.user.js
vendored
479
dist/vot.user.js
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
15
src/utils/number.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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>>(
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
68
tests/smart-wrap.test.ts
Normal 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],
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue