// ==UserScript== // @name Bilibili 视频时间轴 // @description 根据视频字幕, 生成视频时间轴. // @version 2.0.0 // @author Yiero // @match https://www.bilibili.com/video/* // @run-at document-body // @tag bilibili // @tag video // @tag timeline // @license GPL-3 // @namespace https://github.com/AliubYiero/Yiero_WebScripts // @noframes // @icon https://www.bilibili.com/favicon.ico // @grant GM_download // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_addValueChangeListener // @grant GM_removeValueChangeListener // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @grant GM_setClipboard // @grant GM_addStyle // ==/UserScript== /* ==UserConfig== 时间轴配置: alwaysLoad: title: 自动加载时间轴 description: '页面载入时, 自动加载时间轴到页面中' type: checkbox default: true jumpTimeMode: title: 点击时间轴跳转视频的模式 description: '点击某一行字幕的位置, 会将视频跳转到对应的开始时间' type: mult-select values: - 时间跳转 - 文本跳转 default: - 时间跳转 lockHighlightCol: title: '高亮时间轴锁定位置 (行) ' description: 高亮时间轴锁定位置 type: number default: 2 min: 0 showInWebScreen: title: 网页全屏显示时间轴 description: 网页全屏显示将时间轴 type: checkbox default: false isCopyTime: title: 自动复制时间 description: '点击时间的时候, 自动复制时间到粘贴板' type: checkbox default: false isCopyContent: title: 自动复制文本 description: '点击文本的时候, 自动复制文本到粘贴板' type: checkbox default: false 时间轴样式: showEndTime: title: 显示时间轴结束时间 description: 显示时间轴结束时间 type: checkbox default: false disableSelectTime: title: 禁止选中时间文本 description: 字幕的时间将无法选中和复制 type: checkbox default: true disableSelectContent: title: 禁止选中字幕文本 description: 字幕的内容将无法选中和复制 type: checkbox default: false showTitle: title: 显示字幕标题 description: 显示字幕标题 type: checkbox default: true showSubtitleId: title: 显示子标题 description: '视频的 av 号和 bv 号' type: checkbox default: true showSubtitleButton: title: 显示容器按钮 description: '"时间轴锁定" 和 "跳过空白"' type: checkbox default: true timeFontSize: title: '时间字体大小 (px)' description: "" type: number default: 12 min: 0 showTimeIcon: title: 在时间前面显示图标 description: '在时间前面显示图标, 便于辨认时间是开始时间还是结束时间' type: checkbox default: true contentFontSize: title: '文本内容字体大小 (px)' description: "" type: number default: 14 min: 0 normalContainerWidth: title: '常规模式下的时间轴容器宽度 (px)' description: "" type: number default: 411 min: 0 normalContainerHeightPercent: title: '常规模式下的时间轴容器高度 (页面高度的百分比)' description: "" type: number default: 70 min: 0 max: 100 webScreenContainerWidth: title: '网页全屏模式下的时间轴容器宽度 (px)' description: "" type: number default: 411 min: 0 ==/UserConfig== */ (function() { "use strict"; const gmDownload = (url, filename, details = {}) => new Promise((resolve, reject) => { const abortHandle = GM_download({ url, name: filename, ...details, onload(event) { details.onload?.(event); resolve(true); }, onerror(err) { details.onerror?.(err); reject(err.error); }, ontimeout() { details.ontimeout?.(); reject("time_out"); }, onprogress(response) { details.onprogress?.(response, abortHandle); } }); }); gmDownload.blob = async (blob, filename, details = {}) => { const url = URL.createObjectURL(blob); return gmDownload(url, filename, details).then((res) => { URL.revokeObjectURL(url); return res; }); }; gmDownload.text = (content, filename, mimeType = "text/plain", details = {}) => { const blob = new Blob([ content ], { type: mimeType }); return gmDownload.blob(blob, filename, details); }; const returnElement = (selector, options, resolve, reject) => { setTimeout(() => { const element = options.parent.querySelector(selector); if (!element) return void reject(new Error(`Element "${selector}" not found`)); resolve(element); }, 1e3 * options.delayPerSecond); }; const getElementByTimer = (selector, options, resolve, reject) => { const intervalDelay = 100; let intervalCounter = 0; const maxIntervalCounter = Math.ceil(1e3 * options.timeoutPerSecond / intervalDelay); const timer = window.setInterval(() => { if (++intervalCounter > maxIntervalCounter) { clearInterval(timer); returnElement(selector, options, resolve, reject); return; } const element = options.parent.querySelector(selector); if (element) { clearInterval(timer); returnElement(selector, options, resolve, reject); } }, intervalDelay); }; const getElementByMutationObserver = (selector, options, resolve, reject) => { const timer = options.timeoutPerSecond && window.setTimeout(() => { observer.disconnect(); reject(new Error(`Element "${selector}" not found within ${options.timeoutPerSecond} seconds`)); }, 1e3 * options.timeoutPerSecond); const observeElementCallback = (mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((addNode) => { if (addNode.nodeType !== Node.ELEMENT_NODE) return; const addedElement = addNode; const element = addedElement.matches(selector) ? addedElement : addedElement.querySelector(selector); if (element) { timer && clearTimeout(timer); returnElement(selector, options, resolve, reject); } }); }); }; const observer = new MutationObserver(observeElementCallback); observer.observe(options.parent, { subtree: true, childList: true }); return true; }; function elementWaiter(selector, options) { const elementWaiterOptions = { parent: document, timeoutPerSecond: 20, delayPerSecond: 0.5, ...options }; return new Promise((resolve, reject) => { const targetElement = elementWaiterOptions.parent.querySelector(selector); if (targetElement) return void returnElement(selector, elementWaiterOptions, resolve, reject); if (MutationObserver) return void getElementByMutationObserver(selector, elementWaiterOptions, resolve, reject); getElementByTimer(selector, elementWaiterOptions, resolve, reject); }); } function elementGetter(selector, options) { const elementGetterOptions = { parent: document, timeoutPerSecond: 20, delayPerSecond: 0.5, ...options }; return new Promise((resolve, reject) => { const targetElement = elementGetterOptions.parent.querySelector(selector); if (targetElement) return void returnElement(selector, elementGetterOptions, resolve, reject); getElementByTimer(selector, elementGetterOptions, resolve, reject); }); } class GmStorage { key; defaultValue; listenerId = null; constructor(key, defaultValue) { this.key = key; this.defaultValue = defaultValue; } get value() { return this.get(); } get() { return GM_getValue(this.key, this.defaultValue); } set(value) { GM_setValue(this.key, value); } remove() { GM_deleteValue(this.key); } updateListener(callback) { this.removeListener(); this.listenerId = GM_addValueChangeListener(this.key, (key, oldValue, newValue, remote) => { callback({ key, oldValue, newValue, remote }); }); } removeListener() { if (null !== this.listenerId) { GM_removeValueChangeListener(this.listenerId); this.listenerId = null; } } } function inferDefaultValue(item) { if (void 0 !== item.default) return item.default; switch (item.type) { case "number": return 0; case "checkbox": return false; case "text": case "textarea": return ""; case "mult-select": return []; case "select": throw new Error(`\u914D\u7F6E\u9879 "${item.title}" \u7C7B\u578B\u4E3A select\uFF0C\u5FC5\u987B\u63D0\u4F9B\u9ED8\u8BA4\u503C`); default: throw new Error(`\u914D\u7F6E\u9879 "${item.title}" \u7C7B\u578B\u672A\u77E5: ${item.type}`); } } function createUserConfigStorage(userConfig) { const result = {}; for (const [groupName, group] of Object.entries(userConfig)) for (const [configKey, item] of Object.entries(group)) { const storageKey = `${groupName}.${configKey}`; const storageName = `${configKey}Store`; const defaultValue = inferDefaultValue(item); result[storageName] = new GmStorage(storageKey, defaultValue); } return result; } class gmMenuCommand { static list = []; static _renderSuspended = false; constructor() { } static get(title) { const commandButton = gmMenuCommand.list.find((commandButton2) => commandButton2.title === title); if (!commandButton) throw new Error("\u83DC\u5355\u6309\u94AE\u4E0D\u5B58\u5728"); return commandButton; } static createToggle(details, defaultState = "active") { const isActiveInitially = "active" === defaultState; gmMenuCommand.list.push({ title: details.active.title, onClick: () => { gmMenuCommand.toggleActive(details.active.title); gmMenuCommand.toggleActive(details.inactive.title); details.active.onClick(); }, isActive: isActiveInitially, id: 0 }); gmMenuCommand.list.push({ title: details.inactive.title, onClick: () => { gmMenuCommand.toggleActive(details.active.title); gmMenuCommand.toggleActive(details.inactive.title); details.inactive.onClick(); }, isActive: !isActiveInitially, id: 0 }); return gmMenuCommand.render(); } static click(title) { const commandButton = gmMenuCommand.get(title); commandButton.onClick(); return gmMenuCommand; } static create(title, onClick, isActive = true) { if (gmMenuCommand.list.some((commandButton) => commandButton.title === title)) throw new Error("\u83DC\u5355\u6309\u94AE\u5DF2\u5B58\u5728"); gmMenuCommand.list.push({ title, onClick, isActive, id: 0 }); return gmMenuCommand.render(); } static remove(title) { gmMenuCommand.list = gmMenuCommand.list.filter((commandButton) => { const isRemove = commandButton.title !== title; if (isRemove) gmMenuCommand.unregisterMenuCommand(commandButton.id); return isRemove; }); return gmMenuCommand.render(); } static reset() { gmMenuCommand.list.forEach(({ id }) => { gmMenuCommand.unregisterMenuCommand(id); }); gmMenuCommand.list = []; return gmMenuCommand.render(); } static batch(callback) { gmMenuCommand._renderSuspended = true; callback(); gmMenuCommand._renderSuspended = false; return gmMenuCommand.render(); } static swap(title1, title2) { const index1 = gmMenuCommand.list.findIndex((commandButton) => commandButton.title === title1); const index2 = gmMenuCommand.list.findIndex((commandButton) => commandButton.title === title2); if (-1 === index1 || -1 === index2) throw new Error("\u83DC\u5355\u6309\u94AE\u4E0D\u5B58\u5728"); [gmMenuCommand.list[index1], gmMenuCommand.list[index2]] = [ gmMenuCommand.list[index2], gmMenuCommand.list[index1] ]; return gmMenuCommand.render(); } static modify(title, details) { const commandButton = gmMenuCommand.get(title); if (details.onClick) commandButton.onClick = details.onClick; if (details.isActive) commandButton.isActive = details.isActive; return gmMenuCommand.render(); } static toggleActive(title) { const commandButton = gmMenuCommand.get(title); commandButton.isActive = !commandButton.isActive; return gmMenuCommand.render(); } static render() { if (gmMenuCommand._renderSuspended) return gmMenuCommand; gmMenuCommand.list.forEach((commandButton) => { gmMenuCommand.unregisterMenuCommand(commandButton.id); if (commandButton.isActive) commandButton.id = GM_registerMenuCommand(commandButton.title, commandButton.onClick); }); return gmMenuCommand; } static unregisterMenuCommand(id) { GM_unregisterMenuCommand(id); } } let currentCallback = null; let originalPushState = null; let originalReplaceState = null; let isFallbackInitialized = false; let popstateHandler = null; let hashchangeHandler = null; function isNavigationSupported() { return "navigation" in window && window.navigation instanceof window.Navigation; } function triggerCallback(to, type, info, intercept, from) { if (!currentCallback) return; const event = { to, from: from ?? window.location.href, type, info, intercept }; currentCallback(event); } function setupNavigationApi(callback) { currentCallback = callback; const handleNavigate = (event) => { triggerCallback(event.destination.url, event.navigationType, event.info, event.canIntercept ? (handler) => { event.intercept({ handler }); } : void 0); }; window.navigation.addEventListener("navigate", handleNavigate); return () => { window.navigation.removeEventListener("navigate", handleNavigate); currentCallback = null; }; } function initFallback() { originalPushState = history.pushState; originalReplaceState = history.replaceState; history.pushState = function(data, unused, url) { const fromUrl = window.location.href; originalPushState?.call(this, data, unused, url); const fullUrl = url ? new URL(url, fromUrl).href : window.location.href; triggerCallback(fullUrl, "push", void 0, void 0, fromUrl); }; history.replaceState = function(data, unused, url) { const fromUrl = window.location.href; originalReplaceState?.call(this, data, unused, url); const fullUrl = url ? new URL(url, fromUrl).href : window.location.href; triggerCallback(fullUrl, "replace", void 0, void 0, fromUrl); }; popstateHandler = () => { triggerCallback(window.location.href, "traverse"); }; window.addEventListener("popstate", popstateHandler); hashchangeHandler = () => { triggerCallback(window.location.href, "hash"); }; window.addEventListener("hashchange", hashchangeHandler); isFallbackInitialized = true; } function cleanupFallback() { if (originalPushState) { history.pushState = originalPushState; originalPushState = null; } if (originalReplaceState) { history.replaceState = originalReplaceState; originalReplaceState = null; } if (popstateHandler) { window.removeEventListener("popstate", popstateHandler); popstateHandler = null; } if (hashchangeHandler) { window.removeEventListener("hashchange", hashchangeHandler); hashchangeHandler = null; } isFallbackInitialized = false; } function setupFallback(callback) { currentCallback = callback; if (!isFallbackInitialized) initFallback(); return () => { currentCallback = null; cleanupFallback(); }; } function onRouteChange(callback) { if (isNavigationSupported()) return setupNavigationApi(callback); return setupFallback(callback); } const normalizeHeaders = (headers) => { const normalized = {}; for (const key in headers) normalized[key.toLowerCase()] = headers[key]; return normalized; }; const processBody = (body, headers) => { if (null == body) return null; if (body instanceof FormData || body instanceof URLSearchParams || body instanceof Blob || body instanceof ArrayBuffer || body instanceof ReadableStream || "string" == typeof body) return body; if ("object" == typeof body) { if (!headers["content-type"]) headers["content-type"] = "application/json;charset=UTF-8"; return JSON.stringify(body); } return String(body); }; async function xhrRequest(url, options = {}) { const { method = "GET", withCredentials = false, timeout = 2e4, onProgress } = options; const headers = normalizeHeaders(options.headers || {}); const requestBody = processBody(options.body, headers); if (options.params) { const searchParams = new URLSearchParams(options.params); url += `?${searchParams.toString()}`; } let responseType = options.responseType; if (!responseType) { const accept = headers.accept; responseType = accept?.includes("text/html") ? "document" : accept?.includes("text/") ? "text" : "json"; } return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open(method.toUpperCase(), url, true); xhr.timeout = timeout; xhr.withCredentials = withCredentials; xhr.responseType = responseType; Object.entries(headers).forEach(([key, value]) => { xhr.setRequestHeader(key, value); }); if (onProgress) xhr.addEventListener("progress", onProgress); xhr.addEventListener("load", () => { if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.response); else reject(new Error(`HTTP Error ${xhr.status}: ${xhr.statusText} @ ${url}`)); }); xhr.addEventListener("error", () => { reject(new Error(`Network Error: Failed to connect to ${url}`)); }); xhr.addEventListener("timeout", () => { xhr.abort(); reject(new Error(`Request Timeout: Exceeded ${timeout}ms`)); }); xhr.send(requestBody); }); } xhrRequest.get = (url, options) => xhrRequest(url, { ...options, method: "GET" }); xhrRequest.getWithCredentials = (url, options) => xhrRequest(url, { ...options, method: "GET", withCredentials: true }); xhrRequest.post = (url, options) => xhrRequest(url, { ...options, method: "POST" }); xhrRequest.postWithCredentials = (url, options) => xhrRequest(url, { ...options, method: "POST", withCredentials: true }); function api_getPlayerInfo(id, cid, login) { const idParam = "number" == typeof id ? { aid: String(id) } : { bvid: String(id) }; const request = login ? xhrRequest.getWithCredentials : xhrRequest.get; return request("https://api.bilibili.com/x/player/wbi/v2", { params: { cid: String(cid), ...idParam } }); } async function api_getSubtitleContent(url) { const response = await fetch(url).then((r) => r.json()); return response; } function api_getVideoInfo(id, login = false) { if (null == id) throw new TypeError("api_getVideoInfo: id \u53C2\u6570\u4E0D\u80FD\u4E3A\u7A7A\uFF0C\u8BF7\u63D0\u4F9B\u6709\u6548\u7684 BV \u53F7\u6216 AV \u53F7"); const params = {}; if ("string" == typeof id && id.startsWith("BV")) params.bvid = id; else params.aid = id.toString(); const url = "https://api.bilibili.com/x/web-interface/view"; if (login) return xhrRequest.getWithCredentials(url, { params }); return xhrRequest.get(url, { params }); } const XOR_CODE = 23442827791579n; const MASK_CODE = 2251799813685247n; const MAX_AID = 1n << 51n; const BASE = 58n; const DATA = "FcwAPNKTMug3GV5Lj7EJnHpWsx4tb8haYeviqBz6rkCy12mUSDQX9RdoZf"; function av2bv(aid) { const bytes = [ "B", "V", "1", "0", "0", "0", "0", "0", "0", "0", "0", "0" ]; let bvIndex = bytes.length - 1; let tmp = (MAX_AID | BigInt(aid)) ^ XOR_CODE; while (tmp > 0) { bytes[bvIndex] = DATA[Number(tmp % BigInt(BASE))]; tmp /= BASE; bvIndex -= 1; } [bytes[3], bytes[9]] = [ bytes[9], bytes[3] ]; [bytes[4], bytes[7]] = [ bytes[7], bytes[4] ]; return bytes.join(""); } function bv2av(bvid) { const bvidArr = Array.from(bvid); [bvidArr[3], bvidArr[9]] = [ bvidArr[9], bvidArr[3] ]; [bvidArr[4], bvidArr[7]] = [ bvidArr[7], bvidArr[4] ]; bvidArr.splice(0, 3); const tmp = bvidArr.reduce((pre, bvidChar) => pre * BASE + BigInt(DATA.indexOf(bvidChar)), 0n); return Number(tmp & MASK_CODE ^ XOR_CODE); } const getVideoId = (url) => { const pathname = url ? new URL(url).pathname : location.pathname; const videoId = pathname.split("/").find((id) => /^(BV1|av)/.test(id)); if (!videoId) return; const videoPart = Number(new URLSearchParams(url ? new URL(url).search : location.search).get("p") || "1"); if (videoId.startsWith("BV1")) return { bvId: videoId, avId: bv2av(videoId), part: videoPart }; if (videoId.startsWith("av")) { const avId = Number(videoId.slice(2)); return { avId, bvId: av2bv(avId), part: videoPart }; } }; function lanDocOrder(lan_doc) { if (/中文|简体|繁体|zh[-_]?/i.test(lan_doc)) return 0; if (/英语|英文|en[-_]?/i.test(lan_doc)) return 1; return 2; } function compareSubtitleItems(a, b) { const orderA = lanDocOrder(a.lan_doc); const orderB = lanDocOrder(b.lan_doc); if (orderA !== orderB) return orderA - orderB; const aIsAi = /ai/i.test(a.lan); const bIsAi = /ai/i.test(b.lan); if (aIsAi !== bIsAi) return aIsAi ? 1 : -1; return 0; } async function getVideoSubtitlesList(id, part = 1, login = true) { if (!id) { const videoId = getVideoId(); if (!videoId) throw new TypeError("getVideoSubtitlesList: id \u53C2\u6570\u4E0D\u80FD\u4E3A\u7A7A\uFF0C\u8BF7\u63D0\u4F9B\u6709\u6548\u7684 BV \u53F7\u6216 AV \u53F7"); id = videoId.avId; part = videoId.part; } const videoResponse = await api_getVideoInfo(id, login); const videoInfo = videoResponse.data; const { title, desc, pages, bvid, aid, owner } = videoInfo; const { mid: uid, face: upFace, name: upName } = owner; if (!pages || 0 === pages.length) throw new Error(`\u89C6\u9891 ${id} \u6CA1\u6709\u5206P\u4FE1\u606F`); const pageItem = pages.find((p) => p.page === part); if (!pageItem) throw new Error(`\u5206P ${part} \u4E0D\u5B58\u5728\uFF0C\u89C6\u9891\u5171 ${pages.length}P`); const { cid, part: partTitle } = pageItem; const playerResponse = await api_getPlayerInfo(id, cid, login); const playerInfo = playerResponse.data; const subtitles = (playerInfo.subtitle?.subtitles ?? []).map((sub) => { const subtitleUrl = sub.subtitle_url.startsWith("https") ? sub.subtitle_url : `https:${sub.subtitle_url}`; return { id: sub.id, lan: sub.lan, lan_doc: sub.lan_doc, is_lock: sub.is_lock, subtitle_url: sub.subtitle_url, subtitle_url_v2: sub.subtitle_url_v2, type: sub.type, id_str: sub.id_str, ai_type: sub.ai_type, ai_status: sub.ai_status, getContent: () => api_getSubtitleContent(subtitleUrl) }; }); subtitles.sort(compareSubtitleItems); return { title, desc, partTitle, bvid, avid: aid, cid, part, uid, upFace, upName, subtitles }; } const formatTime = (second) => { if (!Number.isFinite(second) || second < 0) { return "00:00:00.00"; } const hours = Math.floor(second / 3600); const minutes = Math.floor(second % 3600 / 60); const secs = Math.floor(second % 60); const milliseconds = Math.floor(second % 1 * 100); const pad2 = (num, size = 2) => num.toString().padStart(size, "0"); return `${pad2(hours)}:${pad2(minutes)}:${pad2(secs)}.${pad2(milliseconds)}`; }; const parseSubtitleResponse = (subtitle) => { return subtitle.body.map((subtitleLine, index) => ({ ...subtitleLine, sid: subtitleLine.sid || index + 1, startTime: formatTime(subtitleLine.from), endTime: formatTime(subtitleLine.to) })); }; class SubtitleIndex { constructor(data) { this.lastIndex = 0; this.lastTime = 0; this.sortedData = data.slice().sort((a, b) => a.from - b.from); } getSubtitleAt(time) { const { sortedData, lastIndex, lastTime } = this; const len = sortedData.length; if (len === 0) return null; if (time >= lastTime && lastIndex < len - 1) { let idx = lastIndex; while (idx < len - 1 && sortedData[idx + 1].from <= time) { idx++; } if (sortedData[idx].from <= time && sortedData[idx].to >= time) { this.lastIndex = idx; this.lastTime = time; return sortedData[idx]; } } let low = 0; let high = len - 1; while (low <= high) { const mid = low + high >>> 1; const item = sortedData[mid]; if (time >= item.from && time <= item.to) { this.lastIndex = mid; this.lastTime = time; return item; } if (time < item.from) { high = mid - 1; } else { low = mid + 1; } } return null; } } class MusicFilterManager { constructor(allData, initialEnabled) { this.normalHeightCache = []; this.filteredHeightCache = []; this.normalCumulatedHeights = []; this.filteredCumulatedHeights = []; this.normalTotalHeight = 0; this.filteredTotalHeight = 0; this.sidToFilteredIndex = []; this.allData = allData; this.filteredData = allData.filter( (item) => (item.music ?? 0) < 0.5 ); this.hasDifference = this.filteredData.length !== allData.length; this.enabled = initialEnabled && this.hasDifference; this.emptyTimeAll = MusicFilterManager.calculateEmptyTime(allData); this.emptyTimeFiltered = MusicFilterManager.calculateEmptyTime(this.filteredData); } /** 计算字幕数据中的空白时间总和(相邻项之间的时间差) */ static calculateEmptyTime(data) { if (data.length < 2) return 0; const sorted = [...data].sort((a, b) => a.from - b.from); let total = 0; for (let i = 0; i < sorted.length - 1; i++) { const gap = sorted[i + 1].from - sorted[i].to; if (gap > 0) total += gap; } return total; } // ---- 计算属性:调用方无需检查 enabled ---- get currentData() { return this.enabled ? this.filteredData : this.allData; } get currentHeightCache() { return this.enabled ? this.filteredHeightCache : this.normalHeightCache; } get currentCumulatedHeights() { return this.enabled ? this.filteredCumulatedHeights : this.normalCumulatedHeights; } get currentTotalHeight() { return this.enabled ? this.filteredTotalHeight : this.normalTotalHeight; } get currentEmptyTime() { return this.enabled ? this.emptyTimeFiltered : this.emptyTimeAll; } // ---- 缓存注入 ---- setNormalCache(cache, cumulated, total) { this.normalHeightCache = cache; this.normalCumulatedHeights = cumulated; this.normalTotalHeight = total; } setFilteredCache(cache, cumulated, total) { this.filteredHeightCache = cache; this.filteredCumulatedHeights = cumulated; this.filteredTotalHeight = total; } // ---- sid 映射 (O(n) 构建) ---- buildSidMap() { const maxSid = this.allData.length; const indexBySid = /* @__PURE__ */ new Map(); for (let i = 0; i < this.filteredData.length; i++) { indexBySid.set(this.filteredData[i].sid, i); } this.sidToFilteredIndex = new Array(maxSid + 1).fill(-1); let lastValid = -1; for (let sid = 1; sid <= maxSid; sid++) { const idx = indexBySid.get(sid); if (idx !== void 0) lastValid = idx; this.sidToFilteredIndex[sid] = lastValid; } } /** * 将 sid 映射到当前活跃数据中的索引 * 在过滤模式下:通过 sidToFilteredIndex 映射 * 在正常模式下:sid - 1 直接对应 */ mapSidToCurrentIndex(sid) { if (this.enabled) { return this.sidToFilteredIndex[sid] ?? -1; } return sid - 1; } /** * 切换过滤模式后,将旧数据中的索引映射到新数据中的索引 */ mapIndexAfterToggle(oldIndex, prevEnabled) { if (oldIndex === -1) return -1; const prevData = prevEnabled ? this.filteredData : this.allData; const oldSid = prevData[oldIndex]?.sid; if (!oldSid) return -1; return this.mapSidToCurrentIndex(oldSid); } } const latin1BidiTypes = [ "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "S", "B", "S", "WS", "B", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "B", "B", "B", "S", "WS", "ON", "ON", "ET", "ET", "ET", "ON", "ON", "ON", "ON", "ON", "ES", "CS", "ES", "CS", "CS", "EN", "EN", "EN", "EN", "EN", "EN", "EN", "EN", "EN", "EN", "CS", "ON", "ON", "ON", "ON", "ON", "ON", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "ON", "ON", "ON", "ON", "ON", "ON", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "ON", "ON", "ON", "ON", "BN", "BN", "BN", "BN", "BN", "BN", "B", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "BN", "CS", "ON", "ET", "ET", "ET", "ET", "ON", "ON", "ON", "ON", "L", "ON", "ON", "BN", "ON", "ON", "ET", "ET", "EN", "EN", "ON", "L", "ON", "ON", "ON", "EN", "L", "ON", "ON", "ON", "ON", "ON", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "ON", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "L", "ON", "L", "L", "L", "L", "L", "L", "L", "L" ]; const nonLatin1BidiRanges = [ [697, 698, "ON"], [706, 719, "ON"], [722, 735, "ON"], [741, 749, "ON"], [751, 767, "ON"], [768, 879, "NSM"], [884, 885, "ON"], [894, 894, "ON"], [900, 901, "ON"], [903, 903, "ON"], [1014, 1014, "ON"], [1155, 1161, "NSM"], [1418, 1418, "ON"], [1421, 1422, "ON"], [1423, 1423, "ET"], [1424, 1424, "R"], [1425, 1469, "NSM"], [1470, 1470, "R"], [1471, 1471, "NSM"], [1472, 1472, "R"], [1473, 1474, "NSM"], [1475, 1475, "R"], [1476, 1477, "NSM"], [1478, 1478, "R"], [1479, 1479, "NSM"], [1480, 1535, "R"], [1536, 1541, "AN"], [1542, 1543, "ON"], [1544, 1544, "AL"], [1545, 1546, "ET"], [1547, 1547, "AL"], [1548, 1548, "CS"], [1549, 1549, "AL"], [1550, 1551, "ON"], [1552, 1562, "NSM"], [1563, 1610, "AL"], [1611, 1631, "NSM"], [1632, 1641, "AN"], [1642, 1642, "ET"], [1643, 1644, "AN"], [1645, 1647, "AL"], [1648, 1648, "NSM"], [1649, 1749, "AL"], [1750, 1756, "NSM"], [1757, 1757, "AN"], [1758, 1758, "ON"], [1759, 1764, "NSM"], [1765, 1766, "AL"], [1767, 1768, "NSM"], [1769, 1769, "ON"], [1770, 1773, "NSM"], [1774, 1775, "AL"], [1776, 1785, "EN"], [1786, 1808, "AL"], [1809, 1809, "NSM"], [1810, 1839, "AL"], [1840, 1866, "NSM"], [1867, 1957, "AL"], [1958, 1968, "NSM"], [1969, 1983, "AL"], [1984, 2026, "R"], [2027, 2035, "NSM"], [2036, 2037, "R"], [2038, 2041, "ON"], [2042, 2044, "R"], [2045, 2045, "NSM"], [2046, 2069, "R"], [2070, 2073, "NSM"], [2074, 2074, "R"], [2075, 2083, "NSM"], [2084, 2084, "R"], [2085, 2087, "NSM"], [2088, 2088, "R"], [2089, 2093, "NSM"], [2094, 2136, "R"], [2137, 2139, "NSM"], [2140, 2143, "R"], [2144, 2191, "AL"], [2192, 2193, "AN"], [2194, 2198, "AL"], [2199, 2207, "NSM"], [2208, 2249, "AL"], [2250, 2273, "NSM"], [2274, 2274, "AN"], [2275, 2306, "NSM"], [2362, 2362, "NSM"], [2364, 2364, "NSM"], [2369, 2376, "NSM"], [2381, 2381, "NSM"], [2385, 2391, "NSM"], [2402, 2403, "NSM"], [2433, 2433, "NSM"], [2492, 2492, "NSM"], [2497, 2500, "NSM"], [2509, 2509, "NSM"], [2530, 2531, "NSM"], [2546, 2547, "ET"], [2555, 2555, "ET"], [2558, 2558, "NSM"], [2561, 2562, "NSM"], [2620, 2620, "NSM"], [2625, 2626, "NSM"], [2631, 2632, "NSM"], [2635, 2637, "NSM"], [2641, 2641, "NSM"], [2672, 2673, "NSM"], [2677, 2677, "NSM"], [2689, 2690, "NSM"], [2748, 2748, "NSM"], [2753, 2757, "NSM"], [2759, 2760, "NSM"], [2765, 2765, "NSM"], [2786, 2787, "NSM"], [2801, 2801, "ET"], [2810, 2815, "NSM"], [2817, 2817, "NSM"], [2876, 2876, "NSM"], [2879, 2879, "NSM"], [2881, 2884, "NSM"], [2893, 2893, "NSM"], [2901, 2902, "NSM"], [2914, 2915, "NSM"], [2946, 2946, "NSM"], [3008, 3008, "NSM"], [3021, 3021, "NSM"], [3059, 3064, "ON"], [3065, 3065, "ET"], [3066, 3066, "ON"], [3072, 3072, "NSM"], [3076, 3076, "NSM"], [3132, 3132, "NSM"], [3134, 3136, "NSM"], [3142, 3144, "NSM"], [3146, 3149, "NSM"], [3157, 3158, "NSM"], [3170, 3171, "NSM"], [3192, 3198, "ON"], [3201, 3201, "NSM"], [3260, 3260, "NSM"], [3276, 3277, "NSM"], [3298, 3299, "NSM"], [3328, 3329, "NSM"], [3387, 3388, "NSM"], [3393, 3396, "NSM"], [3405, 3405, "NSM"], [3426, 3427, "NSM"], [3457, 3457, "NSM"], [3530, 3530, "NSM"], [3538, 3540, "NSM"], [3542, 3542, "NSM"], [3633, 3633, "NSM"], [3636, 3642, "NSM"], [3647, 3647, "ET"], [3655, 3662, "NSM"], [3761, 3761, "NSM"], [3764, 3772, "NSM"], [3784, 3790, "NSM"], [3864, 3865, "NSM"], [3893, 3893, "NSM"], [3895, 3895, "NSM"], [3897, 3897, "NSM"], [3898, 3901, "ON"], [3953, 3966, "NSM"], [3968, 3972, "NSM"], [3974, 3975, "NSM"], [3981, 3991, "NSM"], [3993, 4028, "NSM"], [4038, 4038, "NSM"], [4141, 4144, "NSM"], [4146, 4151, "NSM"], [4153, 4154, "NSM"], [4157, 4158, "NSM"], [4184, 4185, "NSM"], [4190, 4192, "NSM"], [4209, 4212, "NSM"], [4226, 4226, "NSM"], [4229, 4230, "NSM"], [4237, 4237, "NSM"], [4253, 4253, "NSM"], [4957, 4959, "NSM"], [5008, 5017, "ON"], [5120, 5120, "ON"], [5760, 5760, "WS"], [5787, 5788, "ON"], [5906, 5908, "NSM"], [5938, 5939, "NSM"], [5970, 5971, "NSM"], [6002, 6003, "NSM"], [6068, 6069, "NSM"], [6071, 6077, "NSM"], [6086, 6086, "NSM"], [6089, 6099, "NSM"], [6107, 6107, "ET"], [6109, 6109, "NSM"], [6128, 6137, "ON"], [6144, 6154, "ON"], [6155, 6157, "NSM"], [6158, 6158, "BN"], [6159, 6159, "NSM"], [6277, 6278, "NSM"], [6313, 6313, "NSM"], [6432, 6434, "NSM"], [6439, 6440, "NSM"], [6450, 6450, "NSM"], [6457, 6459, "NSM"], [6464, 6464, "ON"], [6468, 6469, "ON"], [6622, 6655, "ON"], [6679, 6680, "NSM"], [6683, 6683, "NSM"], [6742, 6742, "NSM"], [6744, 6750, "NSM"], [6752, 6752, "NSM"], [6754, 6754, "NSM"], [6757, 6764, "NSM"], [6771, 6780, "NSM"], [6783, 6783, "NSM"], [6832, 6877, "NSM"], [6880, 6891, "NSM"], [6912, 6915, "NSM"], [6964, 6964, "NSM"], [6966, 6970, "NSM"], [6972, 6972, "NSM"], [6978, 6978, "NSM"], [7019, 7027, "NSM"], [7040, 7041, "NSM"], [7074, 7077, "NSM"], [7080, 7081, "NSM"], [7083, 7085, "NSM"], [7142, 7142, "NSM"], [7144, 7145, "NSM"], [7149, 7149, "NSM"], [7151, 7153, "NSM"], [7212, 7219, "NSM"], [7222, 7223, "NSM"], [7376, 7378, "NSM"], [7380, 7392, "NSM"], [7394, 7400, "NSM"], [7405, 7405, "NSM"], [7412, 7412, "NSM"], [7416, 7417, "NSM"], [7616, 7679, "NSM"], [8125, 8125, "ON"], [8127, 8129, "ON"], [8141, 8143, "ON"], [8157, 8159, "ON"], [8173, 8175, "ON"], [8189, 8190, "ON"], [8192, 8202, "WS"], [8203, 8205, "BN"], [8207, 8207, "R"], [8208, 8231, "ON"], [8232, 8232, "WS"], [8233, 8233, "B"], [8234, 8238, "BN"], [8239, 8239, "CS"], [8240, 8244, "ET"], [8245, 8259, "ON"], [8260, 8260, "CS"], [8261, 8286, "ON"], [8287, 8287, "WS"], [8288, 8303, "BN"], [8304, 8304, "EN"], [8308, 8313, "EN"], [8314, 8315, "ES"], [8316, 8318, "ON"], [8320, 8329, "EN"], [8330, 8331, "ES"], [8332, 8334, "ON"], [8352, 8399, "ET"], [8400, 8432, "NSM"], [8448, 8449, "ON"], [8451, 8454, "ON"], [8456, 8457, "ON"], [8468, 8468, "ON"], [8470, 8472, "ON"], [8478, 8483, "ON"], [8485, 8485, "ON"], [8487, 8487, "ON"], [8489, 8489, "ON"], [8494, 8494, "ET"], [8506, 8507, "ON"], [8512, 8516, "ON"], [8522, 8525, "ON"], [8528, 8543, "ON"], [8585, 8587, "ON"], [8592, 8721, "ON"], [8722, 8722, "ES"], [8723, 8723, "ET"], [8724, 9013, "ON"], [9083, 9108, "ON"], [9110, 9257, "ON"], [9280, 9290, "ON"], [9312, 9351, "ON"], [9352, 9371, "EN"], [9450, 9899, "ON"], [9901, 10239, "ON"], [10496, 11123, "ON"], [11126, 11263, "ON"], [11493, 11498, "ON"], [11503, 11505, "NSM"], [11513, 11519, "ON"], [11647, 11647, "NSM"], [11744, 11775, "NSM"], [11776, 11869, "ON"], [11904, 11929, "ON"], [11931, 12019, "ON"], [12032, 12245, "ON"], [12272, 12287, "ON"], [12288, 12288, "WS"], [12289, 12292, "ON"], [12296, 12320, "ON"], [12330, 12333, "NSM"], [12336, 12336, "ON"], [12342, 12343, "ON"], [12349, 12351, "ON"], [12441, 12442, "NSM"], [12443, 12444, "ON"], [12448, 12448, "ON"], [12539, 12539, "ON"], [12736, 12773, "ON"], [12783, 12783, "ON"], [12829, 12830, "ON"], [12880, 12895, "ON"], [12924, 12926, "ON"], [12977, 12991, "ON"], [13004, 13007, "ON"], [13175, 13178, "ON"], [13278, 13279, "ON"], [13311, 13311, "ON"], [19904, 19967, "ON"], [42128, 42182, "ON"], [42509, 42511, "ON"], [42607, 42610, "NSM"], [42611, 42611, "ON"], [42612, 42621, "NSM"], [42622, 42623, "ON"], [42654, 42655, "NSM"], [42736, 42737, "NSM"], [42752, 42785, "ON"], [42888, 42888, "ON"], [43010, 43010, "NSM"], [43014, 43014, "NSM"], [43019, 43019, "NSM"], [43045, 43046, "NSM"], [43048, 43051, "ON"], [43052, 43052, "NSM"], [43064, 43065, "ET"], [43124, 43127, "ON"], [43204, 43205, "NSM"], [43232, 43249, "NSM"], [43263, 43263, "NSM"], [43302, 43309, "NSM"], [43335, 43345, "NSM"], [43392, 43394, "NSM"], [43443, 43443, "NSM"], [43446, 43449, "NSM"], [43452, 43453, "NSM"], [43493, 43493, "NSM"], [43561, 43566, "NSM"], [43569, 43570, "NSM"], [43573, 43574, "NSM"], [43587, 43587, "NSM"], [43596, 43596, "NSM"], [43644, 43644, "NSM"], [43696, 43696, "NSM"], [43698, 43700, "NSM"], [43703, 43704, "NSM"], [43710, 43711, "NSM"], [43713, 43713, "NSM"], [43756, 43757, "NSM"], [43766, 43766, "NSM"], [43882, 43883, "ON"], [44005, 44005, "NSM"], [44008, 44008, "NSM"], [44013, 44013, "NSM"], [64285, 64285, "R"], [64286, 64286, "NSM"], [64287, 64296, "R"], [64297, 64297, "ES"], [64298, 64335, "R"], [64336, 64450, "AL"], [64451, 64466, "ON"], [64467, 64829, "AL"], [64830, 64847, "ON"], [64848, 64911, "AL"], [64912, 64913, "ON"], [64914, 64967, "AL"], [64968, 64975, "ON"], [64976, 65007, "BN"], [65008, 65020, "AL"], [65021, 65023, "ON"], [65024, 65039, "NSM"], [65040, 65049, "ON"], [65056, 65071, "NSM"], [65072, 65103, "ON"], [65104, 65104, "CS"], [65105, 65105, "ON"], [65106, 65106, "CS"], [65108, 65108, "ON"], [65109, 65109, "CS"], [65110, 65118, "ON"], [65119, 65119, "ET"], [65120, 65121, "ON"], [65122, 65123, "ES"], [65124, 65126, "ON"], [65128, 65128, "ON"], [65129, 65130, "ET"], [65131, 65131, "ON"], [65136, 65278, "AL"], [65279, 65279, "BN"], [65281, 65282, "ON"], [65283, 65285, "ET"], [65286, 65290, "ON"], [65291, 65291, "ES"], [65292, 65292, "CS"], [65293, 65293, "ES"], [65294, 65295, "CS"], [65296, 65305, "EN"], [65306, 65306, "CS"], [65307, 65312, "ON"], [65339, 65344, "ON"], [65371, 65381, "ON"], [65504, 65505, "ET"], [65506, 65508, "ON"], [65509, 65510, "ET"], [65512, 65518, "ON"], [65520, 65528, "BN"], [65529, 65533, "ON"], [65534, 65535, "BN"], [65793, 65793, "ON"], [65856, 65932, "ON"], [65936, 65948, "ON"], [65952, 65952, "ON"], [66045, 66045, "NSM"], [66272, 66272, "NSM"], [66273, 66299, "EN"], [66422, 66426, "NSM"], [67584, 67870, "R"], [67871, 67871, "ON"], [67872, 68096, "R"], [68097, 68099, "NSM"], [68100, 68100, "R"], [68101, 68102, "NSM"], [68103, 68107, "R"], [68108, 68111, "NSM"], [68112, 68151, "R"], [68152, 68154, "NSM"], [68155, 68158, "R"], [68159, 68159, "NSM"], [68160, 68324, "R"], [68325, 68326, "NSM"], [68327, 68408, "R"], [68409, 68415, "ON"], [68416, 68863, "R"], [68864, 68899, "AL"], [68900, 68903, "NSM"], [68904, 68911, "AL"], [68912, 68921, "AN"], [68922, 68927, "AL"], [68928, 68937, "AN"], [68938, 68968, "R"], [68969, 68973, "NSM"], [68974, 68974, "ON"], [68975, 69215, "R"], [69216, 69246, "AN"], [69247, 69290, "R"], [69291, 69292, "NSM"], [69293, 69311, "R"], [69312, 69327, "AL"], [69328, 69336, "ON"], [69337, 69369, "AL"], [69370, 69375, "NSM"], [69376, 69423, "R"], [69424, 69445, "AL"], [69446, 69456, "NSM"], [69457, 69487, "AL"], [69488, 69505, "R"], [69506, 69509, "NSM"], [69510, 69631, "R"], [69633, 69633, "NSM"], [69688, 69702, "NSM"], [69714, 69733, "ON"], [69744, 69744, "NSM"], [69747, 69748, "NSM"], [69759, 69761, "NSM"], [69811, 69814, "NSM"], [69817, 69818, "NSM"], [69826, 69826, "NSM"], [69888, 69890, "NSM"], [69927, 69931, "NSM"], [69933, 69940, "NSM"], [70003, 70003, "NSM"], [70016, 70017, "NSM"], [70070, 70078, "NSM"], [70089, 70092, "NSM"], [70095, 70095, "NSM"], [70191, 70193, "NSM"], [70196, 70196, "NSM"], [70198, 70199, "NSM"], [70206, 70206, "NSM"], [70209, 70209, "NSM"], [70367, 70367, "NSM"], [70371, 70378, "NSM"], [70400, 70401, "NSM"], [70459, 70460, "NSM"], [70464, 70464, "NSM"], [70502, 70508, "NSM"], [70512, 70516, "NSM"], [70587, 70592, "NSM"], [70606, 70606, "NSM"], [70608, 70608, "NSM"], [70610, 70610, "NSM"], [70625, 70626, "NSM"], [70712, 70719, "NSM"], [70722, 70724, "NSM"], [70726, 70726, "NSM"], [70750, 70750, "NSM"], [70835, 70840, "NSM"], [70842, 70842, "NSM"], [70847, 70848, "NSM"], [70850, 70851, "NSM"], [71090, 71093, "NSM"], [71100, 71101, "NSM"], [71103, 71104, "NSM"], [71132, 71133, "NSM"], [71219, 71226, "NSM"], [71229, 71229, "NSM"], [71231, 71232, "NSM"], [71264, 71276, "ON"], [71339, 71339, "NSM"], [71341, 71341, "NSM"], [71344, 71349, "NSM"], [71351, 71351, "NSM"], [71453, 71453, "NSM"], [71455, 71455, "NSM"], [71458, 71461, "NSM"], [71463, 71467, "NSM"], [71727, 71735, "NSM"], [71737, 71738, "NSM"], [71995, 71996, "NSM"], [71998, 71998, "NSM"], [72003, 72003, "NSM"], [72148, 72151, "NSM"], [72154, 72155, "NSM"], [72160, 72160, "NSM"], [72193, 72198, "NSM"], [72201, 72202, "NSM"], [72243, 72248, "NSM"], [72251, 72254, "NSM"], [72263, 72263, "NSM"], [72273, 72278, "NSM"], [72281, 72283, "NSM"], [72330, 72342, "NSM"], [72344, 72345, "NSM"], [72544, 72544, "NSM"], [72546, 72548, "NSM"], [72550, 72550, "NSM"], [72752, 72758, "NSM"], [72760, 72765, "NSM"], [72850, 72871, "NSM"], [72874, 72880, "NSM"], [72882, 72883, "NSM"], [72885, 72886, "NSM"], [73009, 73014, "NSM"], [73018, 73018, "NSM"], [73020, 73021, "NSM"], [73023, 73029, "NSM"], [73031, 73031, "NSM"], [73104, 73105, "NSM"], [73109, 73109, "NSM"], [73111, 73111, "NSM"], [73459, 73460, "NSM"], [73472, 73473, "NSM"], [73526, 73530, "NSM"], [73536, 73536, "NSM"], [73538, 73538, "NSM"], [73562, 73562, "NSM"], [73685, 73692, "ON"], [73693, 73696, "ET"], [73697, 73713, "ON"], [78912, 78912, "NSM"], [78919, 78933, "NSM"], [90398, 90409, "NSM"], [90413, 90415, "NSM"], [92912, 92916, "NSM"], [92976, 92982, "NSM"], [94031, 94031, "NSM"], [94095, 94098, "NSM"], [94178, 94178, "ON"], [94180, 94180, "NSM"], [113821, 113822, "NSM"], [113824, 113827, "BN"], [117760, 117973, "ON"], [118e3, 118009, "EN"], [118010, 118012, "ON"], [118016, 118451, "ON"], [118458, 118480, "ON"], [118496, 118512, "ON"], [118528, 118573, "NSM"], [118576, 118598, "NSM"], [119143, 119145, "NSM"], [119155, 119162, "BN"], [119163, 119170, "NSM"], [119173, 119179, "NSM"], [119210, 119213, "NSM"], [119273, 119274, "ON"], [119296, 119361, "ON"], [119362, 119364, "NSM"], [119365, 119365, "ON"], [119552, 119638, "ON"], [120513, 120513, "ON"], [120539, 120539, "ON"], [120571, 120571, "ON"], [120597, 120597, "ON"], [120629, 120629, "ON"], [120655, 120655, "ON"], [120687, 120687, "ON"], [120713, 120713, "ON"], [120745, 120745, "ON"], [120771, 120771, "ON"], [120782, 120831, "EN"], [121344, 121398, "NSM"], [121403, 121452, "NSM"], [121461, 121461, "NSM"], [121476, 121476, "NSM"], [121499, 121503, "NSM"], [121505, 121519, "NSM"], [122880, 122886, "NSM"], [122888, 122904, "NSM"], [122907, 122913, "NSM"], [122915, 122916, "NSM"], [122918, 122922, "NSM"], [123023, 123023, "NSM"], [123184, 123190, "NSM"], [123566, 123566, "NSM"], [123628, 123631, "NSM"], [123647, 123647, "ET"], [124140, 124143, "NSM"], [124398, 124399, "NSM"], [124643, 124643, "NSM"], [124646, 124646, "NSM"], [124654, 124655, "NSM"], [124661, 124661, "NSM"], [124928, 125135, "R"], [125136, 125142, "NSM"], [125143, 125251, "R"], [125252, 125258, "NSM"], [125259, 126063, "R"], [126064, 126143, "AL"], [126144, 126207, "R"], [126208, 126287, "AL"], [126288, 126463, "R"], [126464, 126703, "AL"], [126704, 126705, "ON"], [126706, 126719, "AL"], [126720, 126975, "R"], [126976, 127019, "ON"], [127024, 127123, "ON"], [127136, 127150, "ON"], [127153, 127167, "ON"], [127169, 127183, "ON"], [127185, 127221, "ON"], [127232, 127242, "EN"], [127243, 127247, "ON"], [127279, 127279, "ON"], [127338, 127343, "ON"], [127405, 127405, "ON"], [127584, 127589, "ON"], [127744, 128728, "ON"], [128732, 128748, "ON"], [128752, 128764, "ON"], [128768, 128985, "ON"], [128992, 129003, "ON"], [129008, 129008, "ON"], [129024, 129035, "ON"], [129040, 129095, "ON"], [129104, 129113, "ON"], [129120, 129159, "ON"], [129168, 129197, "ON"], [129200, 129211, "ON"], [129216, 129217, "ON"], [129232, 129240, "ON"], [129280, 129623, "ON"], [129632, 129645, "ON"], [129648, 129660, "ON"], [129664, 129674, "ON"], [129678, 129734, "ON"], [129736, 129736, "ON"], [129741, 129756, "ON"], [129759, 129770, "ON"], [129775, 129784, "ON"], [129792, 129938, "ON"], [129940, 130031, "ON"], [130032, 130041, "EN"], [130042, 130042, "ON"], [131070, 131071, "BN"], [196606, 196607, "BN"], [262142, 262143, "BN"], [327678, 327679, "BN"], [393214, 393215, "BN"], [458750, 458751, "BN"], [524286, 524287, "BN"], [589822, 589823, "BN"], [655358, 655359, "BN"], [720894, 720895, "BN"], [786430, 786431, "BN"], [851966, 851967, "BN"], [917502, 917759, "BN"], [917760, 917999, "NSM"], [918e3, 921599, "BN"], [983038, 983039, "BN"], [1048574, 1048575, "BN"], [1114110, 1114111, "BN"] ]; function classifyCodePoint(codePoint) { if (codePoint <= 255) return latin1BidiTypes[codePoint]; let lo = 0; let hi = nonLatin1BidiRanges.length - 1; while (lo <= hi) { const mid = lo + hi >> 1; const range = nonLatin1BidiRanges[mid]; if (codePoint < range[0]) { hi = mid - 1; continue; } if (codePoint > range[1]) { lo = mid + 1; continue; } return range[2]; } return "L"; } function computeBidiLevels(str) { const len = str.length; if (len === 0) return null; const types = new Array(len); let sawBidi = false; for (let i = 0; i < len; ) { const first = str.charCodeAt(i); let codePoint = first; let codeUnitLength = 1; if (first >= 55296 && first <= 56319 && i + 1 < len) { const second = str.charCodeAt(i + 1); if (second >= 56320 && second <= 57343) { codePoint = (first - 55296 << 10) + (second - 56320) + 65536; codeUnitLength = 2; } } const t = classifyCodePoint(codePoint); if (t === "R" || t === "AL" || t === "AN") sawBidi = true; for (let j = 0; j < codeUnitLength; j++) { types[i + j] = t; } i += codeUnitLength; } if (!sawBidi) return null; let startLevel = 0; for (let i = 0; i < len; i++) { const t = types[i]; if (t === "L") { startLevel = 0; break; } if (t === "R" || t === "AL") { startLevel = 1; break; } } const levels = new Int8Array(len); for (let i = 0; i < len; i++) levels[i] = startLevel; const e = startLevel & 1 ? "R" : "L"; const sor = e; let lastType = sor; for (let i = 0; i < len; i++) { if (types[i] === "NSM") types[i] = lastType; else lastType = types[i]; } lastType = sor; for (let i = 0; i < len; i++) { const t = types[i]; if (t === "EN") types[i] = lastType === "AL" ? "AN" : "EN"; else if (t === "R" || t === "L" || t === "AL") lastType = t; } for (let i = 0; i < len; i++) { if (types[i] === "AL") types[i] = "R"; } for (let i = 1; i < len - 1; i++) { if (types[i] === "ES" && types[i - 1] === "EN" && types[i + 1] === "EN") { types[i] = "EN"; } if (types[i] === "CS" && (types[i - 1] === "EN" || types[i - 1] === "AN") && types[i + 1] === types[i - 1]) { types[i] = types[i - 1]; } } for (let i = 0; i < len; i++) { if (types[i] !== "EN") continue; let j; for (j = i - 1; j >= 0 && types[j] === "ET"; j--) types[j] = "EN"; for (j = i + 1; j < len && types[j] === "ET"; j++) types[j] = "EN"; } for (let i = 0; i < len; i++) { const t = types[i]; if (t === "WS" || t === "ES" || t === "ET" || t === "CS") types[i] = "ON"; } lastType = sor; for (let i = 0; i < len; i++) { const t = types[i]; if (t === "EN") types[i] = lastType === "L" ? "L" : "EN"; else if (t === "R" || t === "L") lastType = t; } for (let i = 0; i < len; i++) { if (types[i] !== "ON") continue; let end = i + 1; while (end < len && types[end] === "ON") end++; const before = i > 0 ? types[i - 1] : sor; const after = end < len ? types[end] : sor; const bDir = before !== "L" ? "R" : "L"; const aDir = after !== "L" ? "R" : "L"; if (bDir === aDir) { for (let j = i; j < end; j++) types[j] = bDir; } i = end - 1; } for (let i = 0; i < len; i++) { if (types[i] === "ON") types[i] = e; } for (let i = 0; i < len; i++) { const t = types[i]; if ((levels[i] & 1) === 0) { if (t === "R") levels[i]++; else if (t === "AN" || t === "EN") levels[i] += 2; } else if (t === "L" || t === "AN" || t === "EN") { levels[i]++; } } return levels; } function computeSegmentLevels(normalized, segStarts) { const bidiLevels = computeBidiLevels(normalized); if (bidiLevels === null) return null; const segLevels = new Int8Array(segStarts.length); for (let i = 0; i < segStarts.length; i++) { segLevels[i] = bidiLevels[segStarts[i]]; } return segLevels; } const collapsibleWhitespaceRunRe = /[ \t\n\r\f]+/g; const needsWhitespaceNormalizationRe = /[\t\n\r\f]| {2,}|^ | $/; function getWhiteSpaceProfile(whiteSpace) { const mode = whiteSpace ?? "normal"; return mode === "pre-wrap" ? { mode, preserveOrdinarySpaces: true, preserveHardBreaks: true } : { mode, preserveOrdinarySpaces: false, preserveHardBreaks: false }; } function normalizeWhitespaceNormal(text) { if (!needsWhitespaceNormalizationRe.test(text)) return text; let normalized = text.replace(collapsibleWhitespaceRunRe, " "); if (normalized.charCodeAt(0) === 32) { normalized = normalized.slice(1); } if (normalized.length > 0 && normalized.charCodeAt(normalized.length - 1) === 32) { normalized = normalized.slice(0, -1); } return normalized; } function normalizeWhitespacePreWrap(text) { if (!/[\r\f]/.test(text)) return text; return text.replace(/\r\n/g, "\n").replace(/[\r\f]/g, "\n"); } let sharedWordSegmenter = null; let segmenterLocale; function getSharedWordSegmenter() { if (sharedWordSegmenter === null) { sharedWordSegmenter = new Intl.Segmenter(segmenterLocale, { granularity: "word" }); } return sharedWordSegmenter; } const arabicScriptRe = /\p{Script=Arabic}/u; const combiningMarkRe = /\p{M}/u; const decimalDigitRe = /\p{Nd}/u; function containsArabicScript(text) { return arabicScriptRe.test(text); } function isCJKCodePoint(codePoint) { return codePoint >= 19968 && codePoint <= 40959 || codePoint >= 13312 && codePoint <= 19903 || codePoint >= 131072 && codePoint <= 173791 || codePoint >= 173824 && codePoint <= 177983 || codePoint >= 177984 && codePoint <= 178207 || codePoint >= 178208 && codePoint <= 183983 || codePoint >= 183984 && codePoint <= 191471 || codePoint >= 191472 && codePoint <= 192093 || codePoint >= 194560 && codePoint <= 195103 || codePoint >= 196608 && codePoint <= 201551 || codePoint >= 201552 && codePoint <= 205743 || codePoint >= 205744 && codePoint <= 210041 || codePoint >= 63744 && codePoint <= 64255 || codePoint >= 12288 && codePoint <= 12351 || codePoint >= 12352 && codePoint <= 12447 || codePoint >= 12448 && codePoint <= 12543 || codePoint >= 12592 && codePoint <= 12687 || codePoint >= 44032 && codePoint <= 55215 || codePoint >= 65280 && codePoint <= 65519; } function isCJK(s) { for (let i = 0; i < s.length; i++) { const first = s.charCodeAt(i); if (first < 12288) continue; if (first >= 55296 && first <= 56319 && i + 1 < s.length) { const second = s.charCodeAt(i + 1); if (second >= 56320 && second <= 57343) { const codePoint = (first - 55296 << 10) + (second - 56320) + 65536; if (isCJKCodePoint(codePoint)) return true; i++; continue; } } if (isCJKCodePoint(first)) return true; } return false; } function endsWithLineStartProhibitedText(text) { const last = getLastCodePoint(text); return last !== null && (kinsokuStart.has(last) || leftStickyPunctuation.has(last)); } const keepAllGlueChars = /* @__PURE__ */ new Set([ "\xA0", "\u202F", "\u2060", "\uFEFF" ]); function containsCJKText(text) { return isCJK(text); } function endsWithKeepAllGlueText(text) { const last = getLastCodePoint(text); return last !== null && keepAllGlueChars.has(last); } function canContinueKeepAllTextRun(previousText) { return !endsWithLineStartProhibitedText(previousText) && !endsWithKeepAllGlueText(previousText); } const kinsokuStart = /* @__PURE__ */ new Set([ "\uFF0C", "\uFF0E", "\uFF01", "\uFF1A", "\uFF1B", "\uFF1F", "\u3001", "\u3002", "\u30FB", "\uFF09", "\u3015", "\u3009", "\u300B", "\u300D", "\u300F", "\u3011", "\u3017", "\u3019", "\u301B", "\u30FC", "\u3005", "\u303B", "\u309D", "\u309E", "\u30FD", "\u30FE" ]); const kinsokuEnd = /* @__PURE__ */ new Set([ '"', "(", "[", "{", "\u201C", "\u2018", "\xAB", "\u2039", "\uFF08", "\u3014", "\u3008", "\u300A", "\u300C", "\u300E", "\u3010", "\u3016", "\u3018", "\u301A" ]); const forwardStickyGlue = /* @__PURE__ */ new Set([ "'", "\u2019" ]); const leftStickyPunctuation = /* @__PURE__ */ new Set([ ".", ",", "!", "?", ":", ";", "\u060C", "\u061B", "\u061F", "\u0964", "\u0965", "\u104A", "\u104B", "\u104C", "\u104D", "\u104F", ")", "]", "}", "%", '"', "\u201D", "\u2019", "\xBB", "\u203A", "\u2026" ]); const arabicNoSpaceTrailingPunctuation = /* @__PURE__ */ new Set([ ":", ".", "\u060C", "\u061B" ]); const myanmarMedialGlue = /* @__PURE__ */ new Set([ "\u104F" ]); const closingQuoteChars = /* @__PURE__ */ new Set([ "\u201D", "\u2019", "\xBB", "\u203A", "\u300D", "\u300F", "\u3011", "\u300B", "\u3009", "\u3015", "\uFF09" ]); function isLeftStickyPunctuationSegment(segment) { if (isEscapedQuoteClusterSegment(segment)) return true; let sawPunctuation = false; for (const ch of segment) { if (leftStickyPunctuation.has(ch)) { sawPunctuation = true; continue; } if (sawPunctuation && combiningMarkRe.test(ch)) continue; return false; } return sawPunctuation; } function isCJKLineStartProhibitedSegment(segment) { for (const ch of segment) { if (!kinsokuStart.has(ch) && !leftStickyPunctuation.has(ch)) return false; } return segment.length > 0; } function isForwardStickyClusterSegment(segment) { if (isEscapedQuoteClusterSegment(segment)) return true; for (const ch of segment) { if (!kinsokuEnd.has(ch) && !forwardStickyGlue.has(ch) && !combiningMarkRe.test(ch)) return false; } return segment.length > 0; } function isEscapedQuoteClusterSegment(segment) { let sawQuote = false; for (const ch of segment) { if (ch === "\\" || combiningMarkRe.test(ch)) continue; if (kinsokuEnd.has(ch) || leftStickyPunctuation.has(ch) || forwardStickyGlue.has(ch)) { sawQuote = true; continue; } return false; } return sawQuote; } function previousCodePointStart(text, end) { const last = end - 1; if (last <= 0) return Math.max(last, 0); const lastCodeUnit = text.charCodeAt(last); if (lastCodeUnit < 56320 || lastCodeUnit > 57343) return last; const maybeHigh = last - 1; if (maybeHigh < 0) return last; const highCodeUnit = text.charCodeAt(maybeHigh); return highCodeUnit >= 55296 && highCodeUnit <= 56319 ? maybeHigh : last; } function getLastCodePoint(text) { if (text.length === 0) return null; const start = previousCodePointStart(text, text.length); return text.slice(start); } function splitTrailingForwardStickyCluster(text) { const chars = Array.from(text); let splitIndex = chars.length; while (splitIndex > 0) { const ch = chars[splitIndex - 1]; if (combiningMarkRe.test(ch)) { splitIndex--; continue; } if (kinsokuEnd.has(ch) || forwardStickyGlue.has(ch)) { splitIndex--; continue; } break; } if (splitIndex <= 0 || splitIndex === chars.length) return null; return { head: chars.slice(0, splitIndex).join(""), tail: chars.slice(splitIndex).join("") }; } function getRepeatableSingleCharRunChar(text, isWordLike, kind) { return kind === "text" && !isWordLike && text.length === 1 && text !== "-" && text !== "\u2014" ? text : null; } function materializeDeferredSingleCharRun(texts, chars, lengths, index) { const ch = chars[index]; const text = texts[index]; if (ch == null) return text; const length = lengths[index]; if (text.length === length) return text; const materialized = ch.repeat(length); texts[index] = materialized; return materialized; } function hasArabicNoSpacePunctuation(containsArabic, lastCodePoint) { return containsArabic && lastCodePoint !== null && arabicNoSpaceTrailingPunctuation.has(lastCodePoint); } function endsWithMyanmarMedialGlue(segment) { const lastCodePoint = getLastCodePoint(segment); return lastCodePoint !== null && myanmarMedialGlue.has(lastCodePoint); } function splitLeadingSpaceAndMarks(segment) { if (segment.length < 2 || segment[0] !== " ") return null; const marks = segment.slice(1); if (/^\p{M}+$/u.test(marks)) { return { space: " ", marks }; } return null; } function endsWithClosingQuote(text) { let end = text.length; while (end > 0) { const start = previousCodePointStart(text, end); const ch = text.slice(start, end); if (closingQuoteChars.has(ch)) return true; if (!leftStickyPunctuation.has(ch)) return false; end = start; } return false; } function classifySegmentBreakChar(ch, whiteSpaceProfile) { if (whiteSpaceProfile.preserveOrdinarySpaces || whiteSpaceProfile.preserveHardBreaks) { if (ch === " ") return "preserved-space"; if (ch === " ") return "tab"; if (whiteSpaceProfile.preserveHardBreaks && ch === "\n") return "hard-break"; } if (ch === " ") return "space"; if (ch === "\xA0" || ch === "\u202F" || ch === "\u2060" || ch === "\uFEFF") { return "glue"; } if (ch === "\u200B") return "zero-width-break"; if (ch === "\xAD") return "soft-hyphen"; return "text"; } const breakCharRe = /[\x20\t\n\xA0\xAD\u200B\u202F\u2060\uFEFF]/; function joinTextParts(parts) { return parts.length === 1 ? parts[0] : parts.join(""); } function joinReversedPrefixParts(prefixParts, tail) { const parts = []; for (let i = prefixParts.length - 1; i >= 0; i--) { parts.push(prefixParts[i]); } parts.push(tail); return joinTextParts(parts); } function splitSegmentByBreakKind(segment, isWordLike, start, whiteSpaceProfile) { if (!breakCharRe.test(segment)) { return [{ text: segment, isWordLike, kind: "text", start }]; } const pieces = []; let currentKind = null; let currentTextParts = []; let currentStart = start; let currentWordLike = false; let offset = 0; for (const ch of segment) { const kind = classifySegmentBreakChar(ch, whiteSpaceProfile); const wordLike = kind === "text" && isWordLike; if (currentKind !== null && kind === currentKind && wordLike === currentWordLike) { currentTextParts.push(ch); offset += ch.length; continue; } if (currentKind !== null) { pieces.push({ text: joinTextParts(currentTextParts), isWordLike: currentWordLike, kind: currentKind, start: currentStart }); } currentKind = kind; currentTextParts = [ch]; currentStart = start + offset; currentWordLike = wordLike; offset += ch.length; } if (currentKind !== null) { pieces.push({ text: joinTextParts(currentTextParts), isWordLike: currentWordLike, kind: currentKind, start: currentStart }); } return pieces; } function isTextRunBoundary(kind) { return kind === "space" || kind === "preserved-space" || kind === "zero-width-break" || kind === "hard-break"; } const urlSchemeSegmentRe = /^[A-Za-z][A-Za-z0-9+.-]*:$/; function isUrlLikeRunStart(segmentation, index) { const text = segmentation.texts[index]; if (text.startsWith("www.")) return true; return urlSchemeSegmentRe.test(text) && index + 1 < segmentation.len && segmentation.kinds[index + 1] === "text" && segmentation.texts[index + 1] === "//"; } function isUrlQueryBoundarySegment(text) { return text.includes("?") && (text.includes("://") || text.startsWith("www.")); } function mergeUrlLikeRuns(segmentation) { const texts = segmentation.texts.slice(); const isWordLike = segmentation.isWordLike.slice(); const kinds = segmentation.kinds.slice(); const starts = segmentation.starts.slice(); for (let i = 0; i < segmentation.len; i++) { if (kinds[i] !== "text" || !isUrlLikeRunStart(segmentation, i)) continue; const mergedParts = [texts[i]]; let j = i + 1; while (j < segmentation.len && !isTextRunBoundary(kinds[j])) { mergedParts.push(texts[j]); isWordLike[i] = true; const endsQueryPrefix = texts[j].includes("?"); kinds[j] = "text"; texts[j] = ""; j++; if (endsQueryPrefix) break; } texts[i] = joinTextParts(mergedParts); } let compactLen = 0; for (let read = 0; read < texts.length; read++) { const text = texts[read]; if (text.length === 0) continue; if (compactLen !== read) { texts[compactLen] = text; isWordLike[compactLen] = isWordLike[read]; kinds[compactLen] = kinds[read]; starts[compactLen] = starts[read]; } compactLen++; } texts.length = compactLen; isWordLike.length = compactLen; kinds.length = compactLen; starts.length = compactLen; return { len: compactLen, texts, isWordLike, kinds, starts }; } function mergeUrlQueryRuns(segmentation) { const texts = []; const isWordLike = []; const kinds = []; const starts = []; for (let i = 0; i < segmentation.len; i++) { const text = segmentation.texts[i]; texts.push(text); isWordLike.push(segmentation.isWordLike[i]); kinds.push(segmentation.kinds[i]); starts.push(segmentation.starts[i]); if (!isUrlQueryBoundarySegment(text)) continue; const nextIndex = i + 1; if (nextIndex >= segmentation.len || isTextRunBoundary(segmentation.kinds[nextIndex])) { continue; } const queryParts = []; const queryStart = segmentation.starts[nextIndex]; let j = nextIndex; while (j < segmentation.len && !isTextRunBoundary(segmentation.kinds[j])) { queryParts.push(segmentation.texts[j]); j++; } if (queryParts.length > 0) { texts.push(joinTextParts(queryParts)); isWordLike.push(true); kinds.push("text"); starts.push(queryStart); i = j - 1; } } return { len: texts.length, texts, isWordLike, kinds, starts }; } const numericJoinerChars = /* @__PURE__ */ new Set([ ":", "-", "/", "\xD7", ",", ".", "+", "\u2013", "\u2014" ]); const asciiPunctuationChainSegmentRe = /^[A-Za-z0-9_]+[,:;]*$/; const asciiPunctuationChainTrailingJoinersRe = /[,:;]+$/; function segmentContainsDecimalDigit(text) { for (const ch of text) { if (decimalDigitRe.test(ch)) return true; } return false; } function isNumericRunSegment(text) { if (text.length === 0) return false; for (const ch of text) { if (decimalDigitRe.test(ch) || numericJoinerChars.has(ch)) continue; return false; } return true; } function mergeNumericRuns(segmentation) { const texts = []; const isWordLike = []; const kinds = []; const starts = []; for (let i = 0; i < segmentation.len; i++) { const text = segmentation.texts[i]; const kind = segmentation.kinds[i]; if (kind === "text" && isNumericRunSegment(text) && segmentContainsDecimalDigit(text)) { const mergedParts = [text]; let j = i + 1; while (j < segmentation.len && segmentation.kinds[j] === "text" && isNumericRunSegment(segmentation.texts[j])) { mergedParts.push(segmentation.texts[j]); j++; } texts.push(joinTextParts(mergedParts)); isWordLike.push(true); kinds.push("text"); starts.push(segmentation.starts[i]); i = j - 1; continue; } texts.push(text); isWordLike.push(segmentation.isWordLike[i]); kinds.push(kind); starts.push(segmentation.starts[i]); } return { len: texts.length, texts, isWordLike, kinds, starts }; } function mergeAsciiPunctuationChains(segmentation) { const texts = []; const isWordLike = []; const kinds = []; const starts = []; for (let i = 0; i < segmentation.len; i++) { const text = segmentation.texts[i]; const kind = segmentation.kinds[i]; const wordLike = segmentation.isWordLike[i]; if (kind === "text" && wordLike && asciiPunctuationChainSegmentRe.test(text)) { const mergedParts = [text]; let endsWithJoiners = asciiPunctuationChainTrailingJoinersRe.test(text); let j = i + 1; while (endsWithJoiners && j < segmentation.len && segmentation.kinds[j] === "text" && segmentation.isWordLike[j] && asciiPunctuationChainSegmentRe.test(segmentation.texts[j])) { const nextText = segmentation.texts[j]; mergedParts.push(nextText); endsWithJoiners = asciiPunctuationChainTrailingJoinersRe.test(nextText); j++; } texts.push(joinTextParts(mergedParts)); isWordLike.push(true); kinds.push("text"); starts.push(segmentation.starts[i]); i = j - 1; continue; } texts.push(text); isWordLike.push(wordLike); kinds.push(kind); starts.push(segmentation.starts[i]); } return { len: texts.length, texts, isWordLike, kinds, starts }; } function splitHyphenatedNumericRuns(segmentation) { const texts = []; const isWordLike = []; const kinds = []; const starts = []; for (let i = 0; i < segmentation.len; i++) { const text = segmentation.texts[i]; if (segmentation.kinds[i] === "text" && text.includes("-")) { const parts = text.split("-"); let shouldSplit = parts.length > 1; for (let j = 0; j < parts.length; j++) { const part = parts[j]; if (!shouldSplit) break; if (part.length === 0 || !segmentContainsDecimalDigit(part) || !isNumericRunSegment(part)) { shouldSplit = false; } } if (shouldSplit) { let offset = 0; for (let j = 0; j < parts.length; j++) { const part = parts[j]; const splitText = j < parts.length - 1 ? `${part}-` : part; texts.push(splitText); isWordLike.push(true); kinds.push("text"); starts.push(segmentation.starts[i] + offset); offset += splitText.length; } continue; } } texts.push(text); isWordLike.push(segmentation.isWordLike[i]); kinds.push(segmentation.kinds[i]); starts.push(segmentation.starts[i]); } return { len: texts.length, texts, isWordLike, kinds, starts }; } function mergeGlueConnectedTextRuns(segmentation) { const texts = []; const isWordLike = []; const kinds = []; const starts = []; let read = 0; while (read < segmentation.len) { const textParts = [segmentation.texts[read]]; let wordLike = segmentation.isWordLike[read]; let kind = segmentation.kinds[read]; let start = segmentation.starts[read]; if (kind === "glue") { const glueParts = [textParts[0]]; const glueStart = start; read++; while (read < segmentation.len && segmentation.kinds[read] === "glue") { glueParts.push(segmentation.texts[read]); read++; } const glueText = joinTextParts(glueParts); if (read < segmentation.len && segmentation.kinds[read] === "text") { textParts[0] = glueText; textParts.push(segmentation.texts[read]); wordLike = segmentation.isWordLike[read]; kind = "text"; start = glueStart; read++; } else { texts.push(glueText); isWordLike.push(false); kinds.push("glue"); starts.push(glueStart); continue; } } else { read++; } if (kind === "text") { while (read < segmentation.len && segmentation.kinds[read] === "glue") { const glueParts = []; while (read < segmentation.len && segmentation.kinds[read] === "glue") { glueParts.push(segmentation.texts[read]); read++; } const glueText = joinTextParts(glueParts); if (read < segmentation.len && segmentation.kinds[read] === "text") { textParts.push(glueText, segmentation.texts[read]); wordLike = wordLike || segmentation.isWordLike[read]; read++; continue; } textParts.push(glueText); } } texts.push(joinTextParts(textParts)); isWordLike.push(wordLike); kinds.push(kind); starts.push(start); } return { len: texts.length, texts, isWordLike, kinds, starts }; } function carryTrailingForwardStickyAcrossCJKBoundary(segmentation) { const texts = segmentation.texts.slice(); const isWordLike = segmentation.isWordLike.slice(); const kinds = segmentation.kinds.slice(); const starts = segmentation.starts.slice(); for (let i = 0; i < texts.length - 1; i++) { if (kinds[i] !== "text" || kinds[i + 1] !== "text") continue; if (!isCJK(texts[i]) || !isCJK(texts[i + 1])) continue; const split = splitTrailingForwardStickyCluster(texts[i]); if (split === null) continue; texts[i] = split.head; texts[i + 1] = split.tail + texts[i + 1]; starts[i + 1] = starts[i] + split.head.length; } return { len: texts.length, texts, isWordLike, kinds, starts }; } function buildMergedSegmentation(normalized, profile, whiteSpaceProfile) { const wordSegmenter = getSharedWordSegmenter(); let mergedLen = 0; const mergedTexts = []; const mergedTextParts = []; const mergedWordLike = []; const mergedKinds = []; const mergedStarts = []; const mergedSingleCharRunChars = []; const mergedSingleCharRunLengths = []; const mergedContainsCJK = []; const mergedContainsArabicScript = []; const mergedEndsWithClosingQuote = []; const mergedEndsWithMyanmarMedialGlue = []; const mergedHasArabicNoSpacePunctuation = []; for (const s of wordSegmenter.segment(normalized)) { for (const piece of splitSegmentByBreakKind(s.segment, s.isWordLike ?? false, s.index, whiteSpaceProfile)) { let appendPieceToPrevious = function() { if (mergedSingleCharRunChars[prevIndex] !== null) { mergedTextParts[prevIndex] = [ materializeDeferredSingleCharRun(mergedTexts, mergedSingleCharRunChars, mergedSingleCharRunLengths, prevIndex) ]; mergedSingleCharRunChars[prevIndex] = null; } mergedTextParts[prevIndex].push(piece.text); mergedWordLike[prevIndex] = mergedWordLike[prevIndex] || piece.isWordLike; mergedContainsCJK[prevIndex] = mergedContainsCJK[prevIndex] || pieceContainsCJK; mergedContainsArabicScript[prevIndex] = mergedContainsArabicScript[prevIndex] || pieceContainsArabicScript; mergedEndsWithClosingQuote[prevIndex] = pieceEndsWithClosingQuote; mergedEndsWithMyanmarMedialGlue[prevIndex] = pieceEndsWithMyanmarMedialGlue; mergedHasArabicNoSpacePunctuation[prevIndex] = hasArabicNoSpacePunctuation(mergedContainsArabicScript[prevIndex], pieceLastCodePoint); }; const isText = piece.kind === "text"; const repeatableSingleCharRunChar = getRepeatableSingleCharRunChar(piece.text, piece.isWordLike, piece.kind); const pieceContainsCJK = isCJK(piece.text); const pieceContainsArabicScript = containsArabicScript(piece.text); const pieceLastCodePoint = getLastCodePoint(piece.text); const pieceEndsWithClosingQuote = endsWithClosingQuote(piece.text); const pieceEndsWithMyanmarMedialGlue = endsWithMyanmarMedialGlue(piece.text); const prevIndex = mergedLen - 1; if (profile.carryCJKAfterClosingQuote && isText && mergedLen > 0 && mergedKinds[prevIndex] === "text" && pieceContainsCJK && mergedContainsCJK[prevIndex] && mergedEndsWithClosingQuote[prevIndex]) { appendPieceToPrevious(); } else if (isText && mergedLen > 0 && mergedKinds[prevIndex] === "text" && isCJKLineStartProhibitedSegment(piece.text) && mergedContainsCJK[prevIndex]) { appendPieceToPrevious(); } else if (isText && mergedLen > 0 && mergedKinds[prevIndex] === "text" && mergedEndsWithMyanmarMedialGlue[prevIndex]) { appendPieceToPrevious(); } else if (isText && mergedLen > 0 && mergedKinds[prevIndex] === "text" && piece.isWordLike && pieceContainsArabicScript && mergedHasArabicNoSpacePunctuation[prevIndex]) { appendPieceToPrevious(); mergedWordLike[prevIndex] = true; } else if (repeatableSingleCharRunChar !== null && mergedLen > 0 && mergedKinds[prevIndex] === "text" && mergedSingleCharRunChars[prevIndex] === repeatableSingleCharRunChar) { mergedSingleCharRunLengths[prevIndex] = (mergedSingleCharRunLengths[prevIndex] ?? 1) + 1; } else if (isText && !piece.isWordLike && mergedLen > 0 && mergedKinds[prevIndex] === "text" && !mergedContainsCJK[prevIndex] && (isLeftStickyPunctuationSegment(piece.text) || piece.text === "-" && mergedWordLike[prevIndex])) { appendPieceToPrevious(); } else { mergedTexts[mergedLen] = piece.text; mergedTextParts[mergedLen] = [piece.text]; mergedWordLike[mergedLen] = piece.isWordLike; mergedKinds[mergedLen] = piece.kind; mergedStarts[mergedLen] = piece.start; mergedSingleCharRunChars[mergedLen] = repeatableSingleCharRunChar; mergedSingleCharRunLengths[mergedLen] = repeatableSingleCharRunChar === null ? 0 : 1; mergedContainsCJK[mergedLen] = pieceContainsCJK; mergedContainsArabicScript[mergedLen] = pieceContainsArabicScript; mergedEndsWithClosingQuote[mergedLen] = pieceEndsWithClosingQuote; mergedEndsWithMyanmarMedialGlue[mergedLen] = pieceEndsWithMyanmarMedialGlue; mergedHasArabicNoSpacePunctuation[mergedLen] = hasArabicNoSpacePunctuation(pieceContainsArabicScript, pieceLastCodePoint); mergedLen++; } } } for (let i = 0; i < mergedLen; i++) { if (mergedSingleCharRunChars[i] !== null) { mergedTexts[i] = materializeDeferredSingleCharRun(mergedTexts, mergedSingleCharRunChars, mergedSingleCharRunLengths, i); continue; } mergedTexts[i] = joinTextParts(mergedTextParts[i]); } for (let i = 1; i < mergedLen; i++) { if (mergedKinds[i] === "text" && !mergedWordLike[i] && isEscapedQuoteClusterSegment(mergedTexts[i]) && mergedKinds[i - 1] === "text" && !mergedContainsCJK[i - 1]) { mergedTexts[i - 1] += mergedTexts[i]; mergedWordLike[i - 1] = mergedWordLike[i - 1] || mergedWordLike[i]; mergedTexts[i] = ""; } } const forwardStickyPrefixParts = Array.from({ length: mergedLen }, () => null); let nextLiveIndex = -1; for (let i = mergedLen - 1; i >= 0; i--) { const text = mergedTexts[i]; if (text.length === 0) continue; if (mergedKinds[i] === "text" && !mergedWordLike[i] && isForwardStickyClusterSegment(text) && nextLiveIndex >= 0 && mergedKinds[nextLiveIndex] === "text") { const prefixParts = forwardStickyPrefixParts[nextLiveIndex] ?? []; prefixParts.push(text); forwardStickyPrefixParts[nextLiveIndex] = prefixParts; mergedStarts[nextLiveIndex] = mergedStarts[i]; mergedTexts[i] = ""; continue; } nextLiveIndex = i; } for (let i = 0; i < mergedLen; i++) { const prefixParts = forwardStickyPrefixParts[i]; if (prefixParts == null) continue; mergedTexts[i] = joinReversedPrefixParts(prefixParts, mergedTexts[i]); } let compactLen = 0; for (let read = 0; read < mergedLen; read++) { const text = mergedTexts[read]; if (text.length === 0) continue; if (compactLen !== read) { mergedTexts[compactLen] = text; mergedWordLike[compactLen] = mergedWordLike[read]; mergedKinds[compactLen] = mergedKinds[read]; mergedStarts[compactLen] = mergedStarts[read]; } compactLen++; } mergedTexts.length = compactLen; mergedWordLike.length = compactLen; mergedKinds.length = compactLen; mergedStarts.length = compactLen; const compacted = mergeGlueConnectedTextRuns({ len: compactLen, texts: mergedTexts, isWordLike: mergedWordLike, kinds: mergedKinds, starts: mergedStarts }); const withMergedUrls = carryTrailingForwardStickyAcrossCJKBoundary(mergeAsciiPunctuationChains(splitHyphenatedNumericRuns(mergeNumericRuns(mergeUrlQueryRuns(mergeUrlLikeRuns(compacted)))))); for (let i = 0; i < withMergedUrls.len - 1; i++) { const split = splitLeadingSpaceAndMarks(withMergedUrls.texts[i]); if (split === null) continue; if (withMergedUrls.kinds[i] !== "space" && withMergedUrls.kinds[i] !== "preserved-space" || withMergedUrls.kinds[i + 1] !== "text" || !containsArabicScript(withMergedUrls.texts[i + 1])) { continue; } withMergedUrls.texts[i] = split.space; withMergedUrls.isWordLike[i] = false; withMergedUrls.kinds[i] = withMergedUrls.kinds[i] === "preserved-space" ? "preserved-space" : "space"; withMergedUrls.texts[i + 1] = split.marks + withMergedUrls.texts[i + 1]; withMergedUrls.starts[i + 1] = withMergedUrls.starts[i] + split.space.length; } return withMergedUrls; } function compileAnalysisChunks(segmentation, whiteSpaceProfile) { if (segmentation.len === 0) return []; if (!whiteSpaceProfile.preserveHardBreaks) { return [{ startSegmentIndex: 0, endSegmentIndex: segmentation.len, consumedEndSegmentIndex: segmentation.len }]; } const chunks = []; let startSegmentIndex = 0; for (let i = 0; i < segmentation.len; i++) { if (segmentation.kinds[i] !== "hard-break") continue; chunks.push({ startSegmentIndex, endSegmentIndex: i, consumedEndSegmentIndex: i + 1 }); startSegmentIndex = i + 1; } if (startSegmentIndex < segmentation.len) { chunks.push({ startSegmentIndex, endSegmentIndex: segmentation.len, consumedEndSegmentIndex: segmentation.len }); } return chunks; } function mergeKeepAllTextSegments(segmentation) { if (segmentation.len <= 1) return segmentation; const texts = []; const isWordLike = []; const kinds = []; const starts = []; let pendingTextParts = null; let pendingWordLike = false; let pendingStart = 0; let pendingContainsCJK = false; let pendingCanContinue = false; function flushPendingText() { if (pendingTextParts === null) return; texts.push(joinTextParts(pendingTextParts)); isWordLike.push(pendingWordLike); kinds.push("text"); starts.push(pendingStart); pendingTextParts = null; } for (let i = 0; i < segmentation.len; i++) { const text = segmentation.texts[i]; const kind = segmentation.kinds[i]; const wordLike = segmentation.isWordLike[i]; const start = segmentation.starts[i]; if (kind === "text") { const textContainsCJK = containsCJKText(text); const textCanContinue = canContinueKeepAllTextRun(text); if (pendingTextParts !== null && pendingContainsCJK && pendingCanContinue) { pendingTextParts.push(text); pendingWordLike = pendingWordLike || wordLike; pendingContainsCJK = pendingContainsCJK || textContainsCJK; pendingCanContinue = textCanContinue; continue; } flushPendingText(); pendingTextParts = [text]; pendingWordLike = wordLike; pendingStart = start; pendingContainsCJK = textContainsCJK; pendingCanContinue = textCanContinue; continue; } flushPendingText(); texts.push(text); isWordLike.push(wordLike); kinds.push(kind); starts.push(start); } flushPendingText(); return { len: texts.length, texts, isWordLike, kinds, starts }; } function analyzeText(text, profile, whiteSpace = "normal", wordBreak = "normal") { const whiteSpaceProfile = getWhiteSpaceProfile(whiteSpace); const normalized = whiteSpaceProfile.mode === "pre-wrap" ? normalizeWhitespacePreWrap(text) : normalizeWhitespaceNormal(text); if (normalized.length === 0) { return { normalized, chunks: [], len: 0, texts: [], isWordLike: [], kinds: [], starts: [] }; } const segmentation = wordBreak === "keep-all" ? mergeKeepAllTextSegments(buildMergedSegmentation(normalized, profile, whiteSpaceProfile)) : buildMergedSegmentation(normalized, profile, whiteSpaceProfile); return { normalized, chunks: compileAnalysisChunks(segmentation, whiteSpaceProfile), ...segmentation }; } let measureContext = null; const segmentMetricCaches = /* @__PURE__ */ new Map(); let cachedEngineProfile = null; const MAX_PREFIX_FIT_GRAPHEMES = 96; const emojiPresentationRe = /\p{Emoji_Presentation}/u; const maybeEmojiRe = /[\p{Emoji_Presentation}\p{Extended_Pictographic}\p{Regional_Indicator}\uFE0F\u20E3]/u; let sharedGraphemeSegmenter$1 = null; const emojiCorrectionCache = /* @__PURE__ */ new Map(); function getMeasureContext() { if (measureContext !== null) return measureContext; if (typeof OffscreenCanvas !== "undefined") { measureContext = new OffscreenCanvas(1, 1).getContext("2d"); return measureContext; } if (typeof document !== "undefined") { measureContext = document.createElement("canvas").getContext("2d"); return measureContext; } throw new Error("Text measurement requires OffscreenCanvas or a DOM canvas context."); } function getSegmentMetricCache(font) { let cache = segmentMetricCaches.get(font); if (!cache) { cache = /* @__PURE__ */ new Map(); segmentMetricCaches.set(font, cache); } return cache; } function getSegmentMetrics(seg, cache) { let metrics = cache.get(seg); if (metrics === void 0) { const ctx = getMeasureContext(); metrics = { width: ctx.measureText(seg).width, containsCJK: isCJK(seg) }; cache.set(seg, metrics); } return metrics; } function getEngineProfile() { if (cachedEngineProfile !== null) return cachedEngineProfile; if (typeof navigator === "undefined") { cachedEngineProfile = { lineFitEpsilon: 5e-3, carryCJKAfterClosingQuote: false, preferPrefixWidthsForBreakableRuns: false, preferEarlySoftHyphenBreak: false }; return cachedEngineProfile; } const ua = navigator.userAgent; const vendor = navigator.vendor; const isSafari = vendor === "Apple Computer, Inc." && ua.includes("Safari/") && !ua.includes("Chrome/") && !ua.includes("Chromium/") && !ua.includes("CriOS/") && !ua.includes("FxiOS/") && !ua.includes("EdgiOS/"); const isChromium = ua.includes("Chrome/") || ua.includes("Chromium/") || ua.includes("CriOS/") || ua.includes("Edg/"); cachedEngineProfile = { lineFitEpsilon: isSafari ? 1 / 64 : 5e-3, carryCJKAfterClosingQuote: isChromium, preferPrefixWidthsForBreakableRuns: isSafari, preferEarlySoftHyphenBreak: isSafari }; return cachedEngineProfile; } function parseFontSize(font) { const m = font.match(/(\d+(?:\.\d+)?)\s*px/); return m ? parseFloat(m[1]) : 16; } function getSharedGraphemeSegmenter$1() { if (sharedGraphemeSegmenter$1 === null) { sharedGraphemeSegmenter$1 = new Intl.Segmenter(void 0, { granularity: "grapheme" }); } return sharedGraphemeSegmenter$1; } function isEmojiGrapheme(g) { return emojiPresentationRe.test(g) || g.includes("\uFE0F"); } function textMayContainEmoji(text) { return maybeEmojiRe.test(text); } function getEmojiCorrection(font, fontSize) { let correction = emojiCorrectionCache.get(font); if (correction !== void 0) return correction; const ctx = getMeasureContext(); ctx.font = font; const canvasW = ctx.measureText("\u{1F600}").width; correction = 0; if (canvasW > fontSize + 0.5 && typeof document !== "undefined" && document.body !== null) { const span = document.createElement("span"); span.style.font = font; span.style.display = "inline-block"; span.style.visibility = "hidden"; span.style.position = "absolute"; span.textContent = "\u{1F600}"; document.body.appendChild(span); const domW = span.getBoundingClientRect().width; document.body.removeChild(span); if (canvasW - domW > 0.5) { correction = canvasW - domW; } } emojiCorrectionCache.set(font, correction); return correction; } function countEmojiGraphemes(text) { let count = 0; const graphemeSegmenter = getSharedGraphemeSegmenter$1(); for (const g of graphemeSegmenter.segment(text)) { if (isEmojiGrapheme(g.segment)) count++; } return count; } function getEmojiCount(seg, metrics) { if (metrics.emojiCount === void 0) { metrics.emojiCount = countEmojiGraphemes(seg); } return metrics.emojiCount; } function getCorrectedSegmentWidth(seg, metrics, emojiCorrection) { if (emojiCorrection === 0) return metrics.width; return metrics.width - getEmojiCount(seg, metrics) * emojiCorrection; } function getSegmentBreakableFitAdvances(seg, metrics, cache, emojiCorrection, mode) { if (metrics.breakableFitAdvances !== void 0 && metrics.breakableFitMode === mode) { return metrics.breakableFitAdvances; } metrics.breakableFitMode = mode; const graphemeSegmenter = getSharedGraphemeSegmenter$1(); const graphemes = []; for (const gs of graphemeSegmenter.segment(seg)) { graphemes.push(gs.segment); } if (graphemes.length <= 1) { metrics.breakableFitAdvances = null; return metrics.breakableFitAdvances; } if (mode === "sum-graphemes") { const advances2 = []; for (const grapheme of graphemes) { const graphemeMetrics = getSegmentMetrics(grapheme, cache); advances2.push(getCorrectedSegmentWidth(grapheme, graphemeMetrics, emojiCorrection)); } metrics.breakableFitAdvances = advances2; return metrics.breakableFitAdvances; } if (mode === "pair-context" || graphemes.length > MAX_PREFIX_FIT_GRAPHEMES) { const advances2 = []; let previousGrapheme = null; let previousWidth = 0; for (const grapheme of graphemes) { const graphemeMetrics = getSegmentMetrics(grapheme, cache); const currentWidth = getCorrectedSegmentWidth(grapheme, graphemeMetrics, emojiCorrection); if (previousGrapheme === null) { advances2.push(currentWidth); } else { const pair = previousGrapheme + grapheme; const pairMetrics = getSegmentMetrics(pair, cache); advances2.push(getCorrectedSegmentWidth(pair, pairMetrics, emojiCorrection) - previousWidth); } previousGrapheme = grapheme; previousWidth = currentWidth; } metrics.breakableFitAdvances = advances2; return metrics.breakableFitAdvances; } const advances = []; let prefix = ""; let prefixWidth = 0; for (const grapheme of graphemes) { prefix += grapheme; const prefixMetrics = getSegmentMetrics(prefix, cache); const nextPrefixWidth = getCorrectedSegmentWidth(prefix, prefixMetrics, emojiCorrection); advances.push(nextPrefixWidth - prefixWidth); prefixWidth = nextPrefixWidth; } metrics.breakableFitAdvances = advances; return metrics.breakableFitAdvances; } function getFontMeasurementState(font, needsEmojiCorrection) { const ctx = getMeasureContext(); ctx.font = font; const cache = getSegmentMetricCache(font); const fontSize = parseFontSize(font); const emojiCorrection = needsEmojiCorrection ? getEmojiCorrection(font, fontSize) : 0; return { cache, fontSize, emojiCorrection }; } function consumesAtLineStart(kind) { return kind === "space" || kind === "zero-width-break" || kind === "soft-hyphen"; } function breaksAfter(kind) { return kind === "space" || kind === "preserved-space" || kind === "tab" || kind === "zero-width-break" || kind === "soft-hyphen"; } function normalizeLineStartSegmentIndex(prepared, segmentIndex, endSegmentIndex = prepared.widths.length) { while (segmentIndex < endSegmentIndex) { const kind = prepared.kinds[segmentIndex]; if (!consumesAtLineStart(kind)) break; segmentIndex++; } return segmentIndex; } function getTabAdvance(lineWidth, tabStopAdvance) { if (tabStopAdvance <= 0) return 0; const remainder = lineWidth % tabStopAdvance; if (Math.abs(remainder) <= 1e-6) return tabStopAdvance; return tabStopAdvance - remainder; } function getLeadingLetterSpacing(prepared, hasContent, segmentIndex) { return prepared.letterSpacing !== 0 && hasContent && prepared.spacingGraphemeCounts[segmentIndex] > 0 ? prepared.letterSpacing : 0; } function getLineEndContribution(leadingSpacing, segmentContribution) { return segmentContribution === 0 ? 0 : leadingSpacing + segmentContribution; } function getTabTrailingLetterSpacing(prepared, segmentIndex) { return prepared.letterSpacing !== 0 && prepared.spacingGraphemeCounts[segmentIndex] > 0 ? prepared.letterSpacing : 0; } function getWholeSegmentFitContribution(prepared, kind, segmentIndex, leadingSpacing, segmentWidth) { const segmentContribution = kind === "tab" ? segmentWidth + getTabTrailingLetterSpacing(prepared, segmentIndex) : prepared.lineEndFitAdvances[segmentIndex]; return getLineEndContribution(leadingSpacing, segmentContribution); } function getBreakOpportunityFitContribution(prepared, kind, segmentIndex, leadingSpacing) { const segmentContribution = kind === "tab" ? 0 : prepared.lineEndFitAdvances[segmentIndex]; return getLineEndContribution(leadingSpacing, segmentContribution); } function getLineEndPaintContribution(prepared, kind, segmentIndex, leadingSpacing, segmentWidth) { const segmentContribution = kind === "tab" ? segmentWidth : prepared.lineEndPaintAdvances[segmentIndex]; return getLineEndContribution(leadingSpacing, segmentContribution); } function getBreakableGraphemeAdvance(prepared, hasContent, baseAdvance) { return prepared.letterSpacing !== 0 && hasContent ? baseAdvance + prepared.letterSpacing : baseAdvance; } function getBreakableCandidateFitWidth(prepared, candidatePaintWidth) { return prepared.letterSpacing === 0 ? candidatePaintWidth : candidatePaintWidth + prepared.letterSpacing; } function fitSoftHyphenBreak(graphemeFitAdvances, initialWidth, maxWidth, lineFitEpsilon, discretionaryHyphenWidth, letterSpacing) { let fitCount = 0; let fittedWidth = initialWidth; while (fitCount < graphemeFitAdvances.length) { const nextWidth = fittedWidth + graphemeFitAdvances[fitCount] + letterSpacing; const nextLineWidth = fitCount + 1 < graphemeFitAdvances.length ? nextWidth + discretionaryHyphenWidth : nextWidth; if (nextLineWidth > maxWidth + lineFitEpsilon) break; fittedWidth = nextWidth; fitCount++; } return { fitCount, fittedWidth }; } function countPreparedLines(prepared, maxWidth) { return walkPreparedLinesRaw(prepared, maxWidth); } function walkPreparedLinesSimple(prepared, maxWidth, onLine) { const { widths, kinds, breakableFitAdvances } = prepared; if (widths.length === 0) return 0; const engineProfile = getEngineProfile(); const lineFitEpsilon = engineProfile.lineFitEpsilon; const fitLimit = maxWidth + lineFitEpsilon; let lineCount = 0; let lineW = 0; let hasContent = false; let lineEndSegmentIndex = 0; let lineEndGraphemeIndex = 0; let pendingBreakSegmentIndex = -1; let pendingBreakPaintWidth = 0; function clearPendingBreak() { pendingBreakSegmentIndex = -1; pendingBreakPaintWidth = 0; } function emitCurrentLine(endSegmentIndex = lineEndSegmentIndex, endGraphemeIndex = lineEndGraphemeIndex, width = lineW) { lineCount++; lineW = 0; hasContent = false; clearPendingBreak(); } function startLineAtSegment(segmentIndex, width) { hasContent = true; lineEndSegmentIndex = segmentIndex + 1; lineEndGraphemeIndex = 0; lineW = width; } function startLineAtGrapheme(segmentIndex, graphemeIndex, width) { hasContent = true; lineEndSegmentIndex = segmentIndex; lineEndGraphemeIndex = graphemeIndex + 1; lineW = width; } function appendWholeSegment(segmentIndex, width) { if (!hasContent) { startLineAtSegment(segmentIndex, width); return; } lineW += width; lineEndSegmentIndex = segmentIndex + 1; lineEndGraphemeIndex = 0; } function appendBreakableSegmentFrom(segmentIndex, startGraphemeIndex) { const fitAdvances = breakableFitAdvances[segmentIndex]; for (let g = startGraphemeIndex; g < fitAdvances.length; g++) { const gw = fitAdvances[g]; if (!hasContent) { startLineAtGrapheme(segmentIndex, g, gw); } else if (lineW + gw > fitLimit) { emitCurrentLine(); startLineAtGrapheme(segmentIndex, g, gw); } else { lineW += gw; lineEndSegmentIndex = segmentIndex; lineEndGraphemeIndex = g + 1; } } if (hasContent && lineEndSegmentIndex === segmentIndex && lineEndGraphemeIndex === fitAdvances.length) { lineEndSegmentIndex = segmentIndex + 1; lineEndGraphemeIndex = 0; } } let i = 0; while (i < widths.length) { if (!hasContent) { i = normalizeLineStartSegmentIndex(prepared, i); if (i >= widths.length) break; } const w = widths[i]; const kind = kinds[i]; const breakAfter = breaksAfter(kind); if (!hasContent) { if (w > fitLimit && breakableFitAdvances[i] !== null) { appendBreakableSegmentFrom(i, 0); } else { startLineAtSegment(i, w); } if (breakAfter) { pendingBreakSegmentIndex = i + 1; pendingBreakPaintWidth = lineW - w; } i++; continue; } const newW = lineW + w; if (newW > fitLimit) { if (breakAfter) { appendWholeSegment(i, w); emitCurrentLine(i + 1, 0, lineW - w); i++; continue; } if (pendingBreakSegmentIndex >= 0) { if (lineEndSegmentIndex > pendingBreakSegmentIndex || lineEndSegmentIndex === pendingBreakSegmentIndex && lineEndGraphemeIndex > 0) { emitCurrentLine(); continue; } emitCurrentLine(pendingBreakSegmentIndex, 0, pendingBreakPaintWidth); continue; } if (w > fitLimit && breakableFitAdvances[i] !== null) { emitCurrentLine(); appendBreakableSegmentFrom(i, 0); i++; continue; } emitCurrentLine(); continue; } appendWholeSegment(i, w); if (breakAfter) { pendingBreakSegmentIndex = i + 1; pendingBreakPaintWidth = lineW - w; } i++; } if (hasContent) emitCurrentLine(); return lineCount; } function walkPreparedLinesRaw(prepared, maxWidth, onLine) { if (prepared.simpleLineWalkFastPath) { return walkPreparedLinesSimple(prepared, maxWidth); } const { widths, kinds, breakableFitAdvances, discretionaryHyphenWidth, chunks } = prepared; if (widths.length === 0 || chunks.length === 0) return 0; const engineProfile = getEngineProfile(); const lineFitEpsilon = engineProfile.lineFitEpsilon; const fitLimit = maxWidth + lineFitEpsilon; let lineCount = 0; let lineW = 0; let hasContent = false; let lineEndSegmentIndex = 0; let lineEndGraphemeIndex = 0; let pendingBreakSegmentIndex = -1; let pendingBreakFitWidth = 0; let pendingBreakPaintWidth = 0; let pendingBreakKind = null; function clearPendingBreak() { pendingBreakSegmentIndex = -1; pendingBreakFitWidth = 0; pendingBreakPaintWidth = 0; pendingBreakKind = null; } function emitCurrentLine(endSegmentIndex = lineEndSegmentIndex, endGraphemeIndex = lineEndGraphemeIndex, width = lineW) { lineCount++; lineW = 0; hasContent = false; clearPendingBreak(); } function startLineAtSegment(segmentIndex, width) { hasContent = true; lineEndSegmentIndex = segmentIndex + 1; lineEndGraphemeIndex = 0; lineW = width; } function startLineAtGrapheme(segmentIndex, graphemeIndex, width) { hasContent = true; lineEndSegmentIndex = segmentIndex; lineEndGraphemeIndex = graphemeIndex + 1; lineW = width; } function appendWholeSegment(segmentIndex, advance) { if (!hasContent) { startLineAtSegment(segmentIndex, advance); return; } lineW += advance; lineEndSegmentIndex = segmentIndex + 1; lineEndGraphemeIndex = 0; } function updatePendingBreakForWholeSegment(kind, breakAfter, segmentIndex, segmentWidth, leadingSpacing, advance) { if (!breakAfter) return; const fitAdvance = getBreakOpportunityFitContribution(prepared, kind, segmentIndex, leadingSpacing); const paintAdvance = getLineEndPaintContribution(prepared, kind, segmentIndex, leadingSpacing, segmentWidth); pendingBreakSegmentIndex = segmentIndex + 1; pendingBreakFitWidth = lineW - advance + fitAdvance; pendingBreakPaintWidth = lineW - advance + paintAdvance; pendingBreakKind = kind; } function appendBreakableSegmentFrom(segmentIndex, startGraphemeIndex) { const fitAdvances = breakableFitAdvances[segmentIndex]; for (let g = startGraphemeIndex; g < fitAdvances.length; g++) { const baseGw = fitAdvances[g]; if (!hasContent) { startLineAtGrapheme(segmentIndex, g, baseGw); } else { const gw = getBreakableGraphemeAdvance(prepared, true, baseGw); const candidatePaintWidth = lineW + gw; if (getBreakableCandidateFitWidth(prepared, candidatePaintWidth) > fitLimit) { emitCurrentLine(); startLineAtGrapheme(segmentIndex, g, baseGw); } else { lineW = candidatePaintWidth; lineEndSegmentIndex = segmentIndex; lineEndGraphemeIndex = g + 1; } } } if (hasContent && lineEndSegmentIndex === segmentIndex && lineEndGraphemeIndex === fitAdvances.length) { lineEndSegmentIndex = segmentIndex + 1; lineEndGraphemeIndex = 0; } } function continueSoftHyphenBreakableSegment(segmentIndex) { if (pendingBreakKind !== "soft-hyphen") return false; const fitWidths = breakableFitAdvances[segmentIndex]; if (fitWidths == null) return false; const { fitCount, fittedWidth } = fitSoftHyphenBreak(fitWidths, lineW, maxWidth, lineFitEpsilon, discretionaryHyphenWidth, prepared.letterSpacing); if (fitCount === 0) return false; lineW = fittedWidth; lineEndSegmentIndex = segmentIndex; lineEndGraphemeIndex = fitCount; clearPendingBreak(); if (fitCount === fitWidths.length) { lineEndSegmentIndex = segmentIndex + 1; lineEndGraphemeIndex = 0; return true; } emitCurrentLine(segmentIndex, fitCount, fittedWidth + discretionaryHyphenWidth); appendBreakableSegmentFrom(segmentIndex, fitCount); return true; } function emitEmptyChunk(chunk) { lineCount++; clearPendingBreak(); } for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { const chunk = chunks[chunkIndex]; if (chunk.startSegmentIndex === chunk.endSegmentIndex) { emitEmptyChunk(); continue; } hasContent = false; lineW = 0; chunk.startSegmentIndex; lineEndSegmentIndex = chunk.startSegmentIndex; lineEndGraphemeIndex = 0; clearPendingBreak(); let i = chunk.startSegmentIndex; while (i < chunk.endSegmentIndex) { if (!hasContent) { i = normalizeLineStartSegmentIndex(prepared, i, chunk.endSegmentIndex); if (i >= chunk.endSegmentIndex) break; } const kind = kinds[i]; const breakAfter = breaksAfter(kind); const leadingSpacing = getLeadingLetterSpacing(prepared, hasContent, i); const w = kind === "tab" ? getTabAdvance(lineW + leadingSpacing, prepared.tabStopAdvance) : widths[i]; const advance = leadingSpacing + w; const fitAdvance = getWholeSegmentFitContribution(prepared, kind, i, leadingSpacing, w); if (kind === "soft-hyphen") { if (hasContent) { lineEndSegmentIndex = i + 1; lineEndGraphemeIndex = 0; pendingBreakSegmentIndex = i + 1; pendingBreakFitWidth = lineW + discretionaryHyphenWidth; pendingBreakPaintWidth = lineW + discretionaryHyphenWidth; pendingBreakKind = kind; } i++; continue; } if (!hasContent) { if (fitAdvance > fitLimit && breakableFitAdvances[i] !== null) { appendBreakableSegmentFrom(i, 0); } else { startLineAtSegment(i, w); } updatePendingBreakForWholeSegment(kind, breakAfter, i, w, leadingSpacing, advance); i++; continue; } const newFitW = lineW + fitAdvance; if (newFitW > fitLimit) { const currentBreakFitWidth = lineW + getBreakOpportunityFitContribution(prepared, kind, i, leadingSpacing); const currentBreakPaintWidth = lineW + getLineEndPaintContribution(prepared, kind, i, leadingSpacing, w); if (pendingBreakKind === "soft-hyphen" && engineProfile.preferEarlySoftHyphenBreak && pendingBreakFitWidth <= fitLimit) { emitCurrentLine(pendingBreakSegmentIndex, 0, pendingBreakPaintWidth); continue; } if (pendingBreakKind === "soft-hyphen" && continueSoftHyphenBreakableSegment(i)) { i++; continue; } if (breakAfter && currentBreakFitWidth <= fitLimit) { appendWholeSegment(i, advance); emitCurrentLine(i + 1, 0, currentBreakPaintWidth); i++; continue; } if (pendingBreakSegmentIndex >= 0 && pendingBreakFitWidth <= fitLimit) { if (lineEndSegmentIndex > pendingBreakSegmentIndex || lineEndSegmentIndex === pendingBreakSegmentIndex && lineEndGraphemeIndex > 0) { emitCurrentLine(); continue; } const nextSegmentIndex = pendingBreakSegmentIndex; emitCurrentLine(nextSegmentIndex, 0, pendingBreakPaintWidth); i = nextSegmentIndex; continue; } if (fitAdvance > fitLimit && breakableFitAdvances[i] !== null) { emitCurrentLine(); appendBreakableSegmentFrom(i, 0); i++; continue; } emitCurrentLine(); continue; } appendWholeSegment(i, advance); updatePendingBreakForWholeSegment(kind, breakAfter, i, w, leadingSpacing, advance); i++; } if (hasContent) { const finalPaintWidth = pendingBreakSegmentIndex === chunk.consumedEndSegmentIndex ? pendingBreakPaintWidth : lineW; emitCurrentLine(chunk.consumedEndSegmentIndex, 0, finalPaintWidth); } } return lineCount; } let sharedGraphemeSegmenter = null; function getSharedGraphemeSegmenter() { if (sharedGraphemeSegmenter === null) { sharedGraphemeSegmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" }); } return sharedGraphemeSegmenter; } function createEmptyPrepared(includeSegments) { return { widths: [], lineEndFitAdvances: [], lineEndPaintAdvances: [], kinds: [], simpleLineWalkFastPath: true, segLevels: null, breakableFitAdvances: [], letterSpacing: 0, spacingGraphemeCounts: [], discretionaryHyphenWidth: 0, tabStopAdvance: 0, chunks: [] }; } function buildBaseCjkUnits(segText, engineProfile) { const units = []; let unitParts = []; let unitStart = 0; let unitContainsCJK = false; let unitEndsWithClosingQuote = false; let unitIsSingleKinsokuEnd = false; function pushUnit() { if (unitParts.length === 0) return; units.push({ text: unitParts.length === 1 ? unitParts[0] : unitParts.join(""), start: unitStart }); unitParts = []; unitContainsCJK = false; unitEndsWithClosingQuote = false; unitIsSingleKinsokuEnd = false; } function startUnit(grapheme, start, graphemeContainsCJK) { unitParts = [grapheme]; unitStart = start; unitContainsCJK = graphemeContainsCJK; unitEndsWithClosingQuote = endsWithClosingQuote(grapheme); unitIsSingleKinsokuEnd = kinsokuEnd.has(grapheme); } function appendToUnit(grapheme, graphemeContainsCJK) { unitParts.push(grapheme); unitContainsCJK = unitContainsCJK || graphemeContainsCJK; const graphemeEndsWithClosingQuote = endsWithClosingQuote(grapheme); if (grapheme.length === 1 && leftStickyPunctuation.has(grapheme)) { unitEndsWithClosingQuote = unitEndsWithClosingQuote || graphemeEndsWithClosingQuote; } else { unitEndsWithClosingQuote = graphemeEndsWithClosingQuote; } unitIsSingleKinsokuEnd = false; } for (const gs of getSharedGraphemeSegmenter().segment(segText)) { const grapheme = gs.segment; const graphemeContainsCJK = isCJK(grapheme); if (unitParts.length === 0) { startUnit(grapheme, gs.index, graphemeContainsCJK); continue; } if (unitIsSingleKinsokuEnd || kinsokuStart.has(grapheme) || leftStickyPunctuation.has(grapheme) || engineProfile.carryCJKAfterClosingQuote && graphemeContainsCJK && unitEndsWithClosingQuote) { appendToUnit(grapheme, graphemeContainsCJK); continue; } if (!unitContainsCJK && !graphemeContainsCJK) { appendToUnit(grapheme, graphemeContainsCJK); continue; } pushUnit(); startUnit(grapheme, gs.index, graphemeContainsCJK); } pushUnit(); return units; } function mergeKeepAllTextUnits(units) { if (units.length <= 1) return units; const merged = []; let currentTextParts = [units[0].text]; let currentStart = units[0].start; let currentContainsCJK = isCJK(units[0].text); let currentCanContinue = canContinueKeepAllTextRun(units[0].text); function flushCurrent() { merged.push({ text: currentTextParts.length === 1 ? currentTextParts[0] : currentTextParts.join(""), start: currentStart }); } for (let i = 1; i < units.length; i++) { const next = units[i]; const nextContainsCJK = isCJK(next.text); const nextCanContinue = canContinueKeepAllTextRun(next.text); if (currentContainsCJK && currentCanContinue) { currentTextParts.push(next.text); currentContainsCJK = currentContainsCJK || nextContainsCJK; currentCanContinue = nextCanContinue; continue; } flushCurrent(); currentTextParts = [next.text]; currentStart = next.start; currentContainsCJK = nextContainsCJK; currentCanContinue = nextCanContinue; } flushCurrent(); return merged; } function countRenderedSpacingGraphemes(text, kind) { if (kind === "zero-width-break" || kind === "soft-hyphen" || kind === "hard-break") { return 0; } if (kind === "tab") return 1; let count = 0; const graphemeSegmenter = getSharedGraphemeSegmenter(); for (const _ of graphemeSegmenter.segment(text)) count++; return count; } function addInternalLetterSpacing(width, graphemeCount, letterSpacing) { return graphemeCount > 1 ? width + (graphemeCount - 1) * letterSpacing : width; } function measureAnalysis(analysis, font, includeSegments, wordBreak, letterSpacing) { const engineProfile = getEngineProfile(); const { cache, emojiCorrection } = getFontMeasurementState(font, textMayContainEmoji(analysis.normalized)); const discretionaryHyphenWidth = getCorrectedSegmentWidth("-", getSegmentMetrics("-", cache), emojiCorrection) + (letterSpacing === 0 ? 0 : letterSpacing); const spaceWidth = getCorrectedSegmentWidth(" ", getSegmentMetrics(" ", cache), emojiCorrection); const tabStopAdvance = spaceWidth * 8; const hasLetterSpacing = letterSpacing !== 0; if (analysis.len === 0) return createEmptyPrepared(); const widths = []; const lineEndFitAdvances = []; const lineEndPaintAdvances = []; const kinds = []; let simpleLineWalkFastPath = analysis.chunks.length <= 1 && !hasLetterSpacing; const segStarts = null; const breakableFitAdvances = []; const spacingGraphemeCounts = []; const segments = includeSegments ? [] : null; const preparedStartByAnalysisIndex = Array.from({ length: analysis.len }); function pushMeasuredSegment(text, width, lineEndFitAdvance, lineEndPaintAdvance, kind, start, breakableFitAdvance, spacingGraphemeCount) { if (kind !== "text" && kind !== "space" && kind !== "zero-width-break") { simpleLineWalkFastPath = false; } widths.push(width); lineEndFitAdvances.push(lineEndFitAdvance); lineEndPaintAdvances.push(lineEndPaintAdvance); kinds.push(kind); breakableFitAdvances.push(breakableFitAdvance); if (hasLetterSpacing) spacingGraphemeCounts.push(spacingGraphemeCount); if (segments !== null) segments.push(text); } function pushMeasuredTextSegment(text, kind, start, wordLike, allowOverflowBreaks) { const textMetrics = getSegmentMetrics(text, cache); const spacingGraphemeCount = hasLetterSpacing ? countRenderedSpacingGraphemes(text, kind) : 0; const width = addInternalLetterSpacing(getCorrectedSegmentWidth(text, textMetrics, emojiCorrection), spacingGraphemeCount, letterSpacing); const baseLineEndFitAdvance = kind === "space" || kind === "preserved-space" || kind === "zero-width-break" ? 0 : width; const lineEndFitAdvance = baseLineEndFitAdvance === 0 ? 0 : baseLineEndFitAdvance + (spacingGraphemeCount > 0 ? letterSpacing : 0); const lineEndPaintAdvance = kind === "space" || kind === "zero-width-break" ? 0 : width; if (allowOverflowBreaks && wordLike && text.length > 1) { let fitMode = "sum-graphemes"; if (letterSpacing !== 0) { fitMode = "segment-prefixes"; } else if (isNumericRunSegment(text)) { fitMode = "pair-context"; } else if (engineProfile.preferPrefixWidthsForBreakableRuns) { fitMode = "segment-prefixes"; } const fitAdvances = getSegmentBreakableFitAdvances(text, textMetrics, cache, emojiCorrection, fitMode); pushMeasuredSegment(text, width, lineEndFitAdvance, lineEndPaintAdvance, kind, start, fitAdvances, spacingGraphemeCount); return; } pushMeasuredSegment(text, width, lineEndFitAdvance, lineEndPaintAdvance, kind, start, null, spacingGraphemeCount); } for (let mi = 0; mi < analysis.len; mi++) { preparedStartByAnalysisIndex[mi] = widths.length; const segText = analysis.texts[mi]; const segWordLike = analysis.isWordLike[mi]; const segKind = analysis.kinds[mi]; const segStart = analysis.starts[mi]; if (segKind === "soft-hyphen") { pushMeasuredSegment(segText, 0, discretionaryHyphenWidth, discretionaryHyphenWidth, segKind, segStart, null, 0); continue; } if (segKind === "hard-break") { pushMeasuredSegment(segText, 0, 0, 0, segKind, segStart, null, 0); continue; } if (segKind === "tab") { pushMeasuredSegment(segText, 0, 0, 0, segKind, segStart, null, hasLetterSpacing ? countRenderedSpacingGraphemes(segText, segKind) : 0); continue; } const segMetrics = getSegmentMetrics(segText, cache); if (segKind === "text" && segMetrics.containsCJK) { const baseUnits = buildBaseCjkUnits(segText, engineProfile); const measuredUnits = wordBreak === "keep-all" ? mergeKeepAllTextUnits(baseUnits) : baseUnits; for (let i = 0; i < measuredUnits.length; i++) { const unit = measuredUnits[i]; pushMeasuredTextSegment(unit.text, "text", segStart + unit.start, segWordLike, wordBreak === "keep-all" || !isCJK(unit.text)); } continue; } pushMeasuredTextSegment(segText, segKind, segStart, segWordLike, true); } const chunks = mapAnalysisChunksToPreparedChunks(analysis.chunks, preparedStartByAnalysisIndex, widths.length); const segLevels = segStarts === null ? null : computeSegmentLevels(analysis.normalized, segStarts); if (segments !== null) { return { widths, lineEndFitAdvances, lineEndPaintAdvances, kinds, simpleLineWalkFastPath, segLevels, breakableFitAdvances, letterSpacing, spacingGraphemeCounts, discretionaryHyphenWidth, tabStopAdvance, chunks, segments }; } return { widths, lineEndFitAdvances, lineEndPaintAdvances, kinds, simpleLineWalkFastPath, segLevels, breakableFitAdvances, letterSpacing, spacingGraphemeCounts, discretionaryHyphenWidth, tabStopAdvance, chunks }; } function mapAnalysisChunksToPreparedChunks(chunks, preparedStartByAnalysisIndex, preparedEndSegmentIndex) { const preparedChunks = []; for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i]; const startSegmentIndex = chunk.startSegmentIndex < preparedStartByAnalysisIndex.length ? preparedStartByAnalysisIndex[chunk.startSegmentIndex] : preparedEndSegmentIndex; const endSegmentIndex = chunk.endSegmentIndex < preparedStartByAnalysisIndex.length ? preparedStartByAnalysisIndex[chunk.endSegmentIndex] : preparedEndSegmentIndex; const consumedEndSegmentIndex = chunk.consumedEndSegmentIndex < preparedStartByAnalysisIndex.length ? preparedStartByAnalysisIndex[chunk.consumedEndSegmentIndex] : preparedEndSegmentIndex; preparedChunks.push({ startSegmentIndex, endSegmentIndex, consumedEndSegmentIndex }); } return preparedChunks; } function prepareInternal(text, font, includeSegments, options) { const wordBreak = options?.wordBreak ?? "normal"; const letterSpacing = options?.letterSpacing ?? 0; const analysis = analyzeText(text, getEngineProfile(), options?.whiteSpace, wordBreak); return measureAnalysis(analysis, font, includeSegments, wordBreak, letterSpacing); } function prepare(text, font, options) { return prepareInternal(text, font, false, options); } function getInternalPrepared(prepared) { return prepared; } function layout(prepared, maxWidth, lineHeight) { const lineCount = countPreparedLines(getInternalPrepared(prepared), maxWidth); return { lineCount, height: lineCount * lineHeight }; } class HeightCalculator { constructor(styleConfig) { this.preparedCache = /* @__PURE__ */ new Map(); this.styleConfig = styleConfig; this.contentWidth = this.calcContentWidth(); } static { this.HORIZONTAL_PADDING = 32; } static { this.CONTENT_BORDER_LEFT = 14; } static { this.CONTENT_GAP_ONLY = 8; } static { this.TIME_ICON_WIDTH_FACTOR = 6.43; } static { this.TIME_NO_ICON_WIDTH_FACTOR = 5.42; } static { this.TIME_ICON_OFFSET = 4.4; } static { this.SCROLLBAR_WIDTH = 10; } static { this.ITEM_PADDING = 8; } calcContentWidth() { const { normalContainerWidth, showEndTime, showTimeIcon, timeFontSize } = this.styleConfig; const borderLeft = showEndTime ? HeightCalculator.CONTENT_BORDER_LEFT : HeightCalculator.CONTENT_GAP_ONLY; const timeItemWidth = showTimeIcon ? Math.ceil( HeightCalculator.TIME_ICON_OFFSET + timeFontSize * HeightCalculator.TIME_ICON_WIDTH_FACTOR ) : Math.ceil( timeFontSize * HeightCalculator.TIME_NO_ICON_WIDTH_FACTOR ); return normalContainerWidth - HeightCalculator.HORIZONTAL_PADDING - borderLeft - HeightCalculator.SCROLLBAR_WIDTH - timeItemWidth; } compute(data) { const { contentFontSize, timeFontSize, showEndTime } = this.styleConfig; const lineHeight = contentFontSize + 6; const timeItemHeight = showEndTime ? timeFontSize * 2 + 6 : timeFontSize + 6; const { ITEM_PADDING } = HeightCalculator; const cw = this.contentWidth; const font = `${contentFontSize}px system-ui`; return data.map((item) => { const cached = this.preparedCache.get(item.content); if (cached) { const { height: height2 } = layout(cached, cw, lineHeight); return { prepared: cached, height: Math.max( height2 + ITEM_PADDING, timeItemHeight + ITEM_PADDING ) }; } const prepared = prepare(item.content, font, { whiteSpace: "pre-wrap" }); this.preparedCache.set(item.content, prepared); const { height } = layout(prepared, cw, lineHeight); return { prepared, height: Math.ceil( Math.max( height + ITEM_PADDING, timeItemHeight + ITEM_PADDING ) ) }; }); } static buildCumulated(heightCache) { const cumulated = [0]; for (let i = 0; i < heightCache.length; i++) { cumulated.push(cumulated[i] + heightCache[i].height); } return { cumulated, total: cumulated[cumulated.length - 1] }; } } function pad(num, len) { return String(num).padStart(len, "0"); } function secondsToSrtTime(totalSeconds) { const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor(totalSeconds % 3600 / 60); const seconds = Math.floor(totalSeconds % 60); const ms = Math.round( (totalSeconds - Math.floor(totalSeconds)) * 1e3 ); return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)},${pad(ms, 3)}`; } function buildSrtContent(data) { const lines = []; for (let i = 0; i < data.length; i++) { const item = data[i]; lines.push(String(i + 1)); lines.push( `${secondsToSrtTime(item.from)} --> ${secondsToSrtTime(item.to)}` ); lines.push(item.content); lines.push(""); } return lines.join("\n"); } function exportSrt(data, title) { const content = buildSrtContent(data); const filename = title ? `${title}.srt` : "subtitle.srt"; gmDownload.text( content, filename, "application/x-srt;charset=utf-8" ); } const ASS_HEADER = `[Script Info] Title: Default Aegisub file ScriptType: v4.00+ WrapStyle: 0 ScaledBorderAndShadow: yes YCbCr Matrix: None [Aegisub Project Garbage] Last Style Storage: Default [V4+ Styles] Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding Style: Default,Arial,80,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 [Events] Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text `; function escapeAssText(text) { return text.replace(/\n/g, "\\N"); } function buildAssContent(data) { const dialogueLines = []; for (const item of data) { const text = escapeAssText(item.content); dialogueLines.push( `Dialogue: 0,${item.startTime},${item.endTime},Default,,0,0,0,,${text}` ); } return ASS_HEADER + dialogueLines.join("\n"); } function exportAss(data, title) { const content = buildAssContent(data); const filename = title ? `${title}.ass` : "subtitle.ass"; gmDownload.text(content, filename, "text/x-ssa;charset=utf-8"); } class Tooltip { constructor(target, options) { this.tooltipEl = null; this.targetEl = null; this.showTimer = null; this.hideTimer = null; this.targetEl = target; this.options = { placement: "top", offset: 8, delay: 200, zIndex: 999999, ...options }; this.handleMouseEnter = () => this.scheduleShow(); this.handleMouseLeave = () => this.scheduleHide(); this.init(); } init() { if (!this.targetEl) return; this.createTooltipElement(); this.targetEl.addEventListener( "mouseenter", this.handleMouseEnter ); this.targetEl.addEventListener( "mouseleave", this.handleMouseLeave ); this.targetEl.addEventListener( "focus", this.handleMouseEnter ); this.targetEl.addEventListener("blur", this.handleMouseLeave); } createTooltipElement() { const div = document.createElement("div"); div.className = "tm-tooltip-box"; div.textContent = this.options.content; Object.assign(div.style, { position: "fixed", // 关键:使用 fixed 脱离所有父级 overflow 限制 padding: "6px 10px", backgroundColor: "rgba(0, 0, 0, 0.8)", color: "#fff", borderRadius: "4px", fontSize: "12px", lineHeight: "1.5", whiteSpace: "nowrap", pointerEvents: "none", // 防止遮挡鼠标事件 opacity: "0", visibility: "hidden", transition: "opacity 0.2s, visibility 0.2s", zIndex: String(this.options.zIndex), boxShadow: "0 2px 8px rgba(0,0,0,0.15)" }); document.body.appendChild(div); this.tooltipEl = div; } scheduleShow() { if (this.hideTimer) { clearTimeout(this.hideTimer); this.hideTimer = null; } if (this.tooltipEl?.style.visibility === "visible") { this.updatePosition(); return; } this.showTimer = window.setTimeout(() => { this.show(); }, this.options.delay); } scheduleHide() { if (this.showTimer) { clearTimeout(this.showTimer); this.showTimer = null; } this.hideTimer = window.setTimeout(() => { this.hide(); }, 100); } show() { if (!this.tooltipEl || !this.targetEl) return; this.updatePosition(); requestAnimationFrame(() => { if (this.tooltipEl) { this.tooltipEl.style.visibility = "visible"; this.tooltipEl.style.opacity = "1"; } }); } hide() { if (!this.tooltipEl) return; this.tooltipEl.style.opacity = "0"; this.tooltipEl.style.visibility = "hidden"; } /** * 核心逻辑:计算位置并处理边界碰撞 */ updatePosition() { if (!this.tooltipEl || !this.targetEl) return; const targetRect = this.targetEl.getBoundingClientRect(); const tooltipRect = this.tooltipEl.getBoundingClientRect(); const { placement, offset } = this.options; let top = 0; let left = 0; switch (placement) { case "top": top = targetRect.top - tooltipRect.height - offset; left = targetRect.left + (targetRect.width - tooltipRect.width) / 2; break; case "bottom": top = targetRect.bottom + offset; left = targetRect.left + (targetRect.width - tooltipRect.width) / 2; break; case "left": top = targetRect.top + (targetRect.height - tooltipRect.height) / 2; left = targetRect.left - tooltipRect.width - offset; break; case "right": top = targetRect.top + (targetRect.height - tooltipRect.height) / 2; left = targetRect.right + offset; break; } const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; if (left < 0) left = 0; if (left + tooltipRect.width > viewportWidth) { left = viewportWidth - tooltipRect.width; } if (placement === "top" && top < 0) { top = targetRect.bottom + offset; } else if (placement === "bottom" && top + tooltipRect.height > viewportHeight) { top = targetRect.top - tooltipRect.height - offset; } if (placement === "left" || placement === "right") { if (top < 0) top = 0; if (top + tooltipRect.height > viewportHeight) top = viewportHeight - tooltipRect.height; } this.tooltipEl.style.top = `${top}px`; this.tooltipEl.style.left = `${left}px`; } /** * 更新内容 */ setContent(content) { this.options.content = content; if (this.tooltipEl) { this.tooltipEl.textContent = content; if (this.tooltipEl.style.visibility === "visible") { this.updatePosition(); } } } /** * 销毁实例,清理事件和 DOM */ destroy() { if (this.showTimer) clearTimeout(this.showTimer); if (this.hideTimer) clearTimeout(this.hideTimer); if (this.targetEl) { this.targetEl.removeEventListener( "mouseenter", this.handleMouseEnter ); this.targetEl.removeEventListener( "mouseleave", this.handleMouseLeave ); this.targetEl.removeEventListener( "focus", this.handleMouseEnter ); this.targetEl.removeEventListener( "blur", this.handleMouseLeave ); } if (this.tooltipEl && this.tooltipEl.parentNode) { this.tooltipEl.parentNode.removeChild(this.tooltipEl); } this.tooltipEl = null; this.targetEl = null; } } class MoreMenu { constructor(button, items, scrollContainer) { this.button = button; this.items = items; this.isOpen = false; this.onButtonClick = (e) => { e.stopPropagation(); this.toggle(); }; this.onDocumentClick = () => { if (this.isOpen) this.close(); }; this.onScroll = () => { if (this.isOpen) this.close(); }; this.scrollContainer = scrollContainer ?? null; this.menuEl = this.render(); this.menuEl.style.position = "fixed"; document.body.appendChild(this.menuEl); this.button.addEventListener("click", this.onButtonClick); document.addEventListener("click", this.onDocumentClick); if (this.scrollContainer) { this.scrollContainer.addEventListener( "scroll", this.onScroll, { passive: true } ); } button.__moreMenu = this; } toggle() { this.isOpen ? this.close() : this.open(); } open() { this.isOpen = true; this.updatePosition(); this.menuEl.classList.add("open"); } close() { this.isOpen = false; this.menuEl.classList.remove("open"); } updatePosition() { const rect = this.button.getBoundingClientRect(); this.menuEl.style.top = `${rect.bottom + 4}px`; this.menuEl.style.right = `${window.innerWidth - rect.right - 75}px`; } destroy() { this.close(); this.button.removeEventListener("click", this.onButtonClick); document.removeEventListener("click", this.onDocumentClick); if (this.scrollContainer) { this.scrollContainer.removeEventListener( "scroll", this.onScroll ); } this.menuEl.remove(); delete this.button.__moreMenu; } render() { const el = document.createElement("div"); el.className = "more-menu"; el.addEventListener("click", (e) => e.stopPropagation()); this.items.forEach((item) => { const itemEl = document.createElement("div"); itemEl.className = "more-menu-item"; itemEl.textContent = item.label; if (item.onClick) { itemEl.addEventListener("click", (e) => { e.stopPropagation(); item.onClick(); this.close(); }); } el.appendChild(itemEl); }); return el; } } const ICON_LOCK = ` `; const ICON_SKIP_EMPTY = ` `; const ICON_IGNORE_MUSIC = ` `; const ICON_MORE = ` `; function createToggleButton(id, icon, defaultStatus, tip = "", disabled = false, onClick) { const button = document.createElement( "button" ); button.classList.add("toggle-button"); if (defaultStatus) button.classList.add("active"); button.dataset.id = id; button.dataset.tip = tip; button.disabled = disabled; tip && !disabled && (button.__tooltip = new Tooltip(button, { content: tip })); button.innerHTML = icon; if (onClick) { button.addEventListener("click", () => onClick(button, id)); } return button; } function renderHeader(config, state, callbacks, container, moreMenuItems, moreMenuScrollContainer, skipEmptyTip) { const header = document.createElement("header"); header.classList.add("timeline-header"); const { style, meta } = config; if (!style.showSubtitleId && !style.showSubtitleButton && !style.showTitle) { header.classList.add("hide"); container.style.setProperty("--header-height", "0px"); return header; } const title = document.createElement("h2"); title.classList.add("timeline-title"); if (!style.showTitle) title.classList.add("hide"); title.textContent = meta.title || "\u5B57\u5E55\u65F6\u95F4\u8F74"; header.appendChild(title); const metaSection = document.createElement("section"); metaSection.classList.add("timeline-meta"); const buttonGroup = document.createElement("section"); buttonGroup.classList.add("timeline-button-group"); if (!style.showSubtitleButton) buttonGroup.classList.add("hide"); const onToggle = (id, button) => { if (button.disabled) return; const active = button.classList.toggle("active"); if (id === "lock-time") callbacks.onLockTime(active); if (id === "skip-empty") callbacks.onSkipEmpty(active); if (id === "ignore-music") callbacks.onIgnoreMusic(active); }; const makeToggle = (id, icon, active, tip, disabled = false) => createToggleButton( id, icon, active, tip, disabled, (btn) => onToggle(id, btn) ); const ignoreMusicButton = makeToggle( "ignore-music", ICON_IGNORE_MUSIC, state.ignoreMusic, "\u8FC7\u6EE4\u97F3\u4E50\u5B57\u5E55", !meta.isAi ); buttonGroup.appendChild( makeToggle( "lock-time", ICON_LOCK, state.lockHighlight, "\u9501\u5B9A\u65F6\u95F4\u8F74" ) ); buttonGroup.appendChild( makeToggle( "skip-empty", ICON_SKIP_EMPTY, state.skipEmpty, skipEmptyTip ?? "\u8DF3\u8FC7\u5B57\u5E55\u95F4\u9694" ) ); buttonGroup.appendChild(ignoreMusicButton); const moreButton = createToggleButton("more", ICON_MORE, false); buttonGroup.appendChild(moreButton); if (moreMenuItems && moreMenuItems.length > 0) { new MoreMenu( moreButton, moreMenuItems, moreMenuScrollContainer ); } metaSection.appendChild(buttonGroup); const langTag = document.createElement("span"); langTag.classList.add("timeline-meta-tag"); if (!style.showSubtitleId) langTag.classList.add("hide"); langTag.dataset.ai = String(meta.isAi); langTag.textContent = `${meta.lan || "\u4E2D\u6587"}`; metaSection.appendChild(langTag); const aid = meta.aid; const part = meta.part; if (aid) { const idTag = document.createElement("span"); idTag.classList.add("timeline-meta-id"); if (!style.showSubtitleId) idTag.classList.add("hide"); idTag.textContent = `av${aid}${part ? ":p" + part : ""}`; metaSection.appendChild(idTag); } if (!style.showSubtitleButton && !style.showSubtitleId) { metaSection.classList.add("hide"); } header.appendChild(metaSection); if (style.showTitle && !style.showSubtitleButton && !style.showSubtitleId || !style.showTitle && (style.showSubtitleButton || style.showSubtitleId)) { container.style.setProperty("--header-height", "47px"); } return header; } function renderCloseButton(onClose) { const container = document.createElement("aside"); container.classList.add("timeline-close-button-container"); const closeButton = document.createElement("i"); closeButton.classList.add("timeline-close-button"); container.appendChild(closeButton); container.addEventListener("click", onClose); return container; } function destroyTooltips(container) { const buttons = container.querySelectorAll( ".toggle-button" ); buttons.forEach((btn) => { const tooltip = btn.__tooltip; if (tooltip) tooltip.destroy(); }); } function destroyMoreMenus(container) { const buttons = container.querySelectorAll( ".toggle-button" ); buttons.forEach((btn) => { const menu = btn.__moreMenu; if (menu) menu.destroy(); }); } class TimelineContainer { constructor(options) { this.startIndex = 0; this.endIndex = 0; this.scrollRAF = null; this.BUFFER_COUNT = 5; this.activeSubtitleIndex = -1; this.activeDomElement = null; this.skipEmptyTooltip = null; this.toggleCallbacks = { onLockTime: (active) => { this.storeConfig.lockTime.set(active); this.isLockHighlight = active; if (active) { this.scrollToLockHighlightRow(); } }, onSkipEmpty: (active) => { this.storeConfig.skipEmptyTime.set(active); this.isSkipEmptyTime = active; }, onIgnoreMusic: (active) => { this.storeConfig.ignoreMusic.set(active); if (!this.musicFilter.hasDifference) return; const prevEnabled = this.musicFilter.enabled; this.musicFilter.enabled = active; this.subtitleIndex = new SubtitleIndex( this.musicFilter.currentData ); this.activeSubtitleIndex = this.musicFilter.mapIndexAfterToggle( this.activeSubtitleIndex, prevEnabled ); this.renderVisibleItems(); this.scrollToLockHighlightRow(); if (this.skipEmptyTooltip) { const newEmptyTime = this.musicFilter.currentEmptyTime; const newTip = newEmptyTime > 0 ? `\u8DF3\u8FC7\u5B57\u5E55\u95F4\u9694\uFF08\u7A7A\u767D\u65F6\u95F4\u603B\u8BA1 ${formatTime(newEmptyTime)}\uFF09` : "\u8DF3\u8FC7\u5B57\u5E55\u95F4\u9694"; this.skipEmptyTooltip.setContent(newTip); } } }; this.onScroll = (e) => { const target = e.target; const scrollTop = target.scrollTop; if (this.scrollRAF !== null) { cancelAnimationFrame(this.scrollRAF); } this.scrollRAF = requestAnimationFrame(() => { this.handleScroll(scrollTop); }); }; this.handleVideoStep = (e) => { const customEvent = e; const { currentTime } = customEvent.detail; const activeSubtitle = this.subtitleIndex.getSubtitleAt(currentTime); if (!activeSubtitle) { if (this.isSkipEmptyTime && this.activeSubtitleIndex < this.musicFilter.currentData.length - 2) { const data = this.musicFilter.currentData; const currentSubtitle = data[this.activeSubtitleIndex]; const nextSubtitle = data[this.activeSubtitleIndex + 1]; if (currentTime > currentSubtitle.to) { this.container.dispatchEvent( new CustomEvent("videoJump", { detail: { currentTime: nextSubtitle.from } }) ); } } return; } const newActiveIndex = this.musicFilter.mapSidToCurrentIndex( activeSubtitle.sid ); if (newActiveIndex === -1 || newActiveIndex === this.activeSubtitleIndex) return; if (this.activeDomElement) { this.activeDomElement.classList.remove("active"); this.activeDomElement = null; } if (newActiveIndex >= this.startIndex && newActiveIndex <= this.endIndex) { const el = this.listContent?.querySelector( `[data-sid="${activeSubtitle.sid}"]` ); if (el) { el.classList.add("active"); this.activeDomElement = el; } } this.activeSubtitleIndex = newActiveIndex; if (this.isLockHighlight) { this.scrollToLockHighlightRow(); } }; this.metaInfo = options.metaInfo; this.styleConfig = options.styleConfig; this.buttonConfig = options.buttonConfig; this.storeConfig = options.storeConfig; this.heightCalculator = new HeightCalculator( this.styleConfig ); this.isLockHighlight = this.storeConfig.lockTime.get(); this.isSkipEmptyTime = this.storeConfig.skipEmptyTime.get(); const initialIgnoreMusic = this.storeConfig.ignoreMusic?.get() ?? false; this.musicFilter = new MusicFilterManager( options.subtitleData, initialIgnoreMusic ); this.subtitleIndex = new SubtitleIndex( this.musicFilter.currentData ); } // ============================================================ // 生命周期 // ============================================================ init() { this.container = document.createElement("section"); this.container.classList.add("timeline-container"); } render() { this.init(); const headerState = { lockHighlight: this.isLockHighlight, skipEmpty: this.isSkipEmptyTime, ignoreMusic: this.musicFilter.enabled }; const headerConfig = { meta: this.metaInfo, style: this.styleConfig }; const { aid, part, isAi, lan, title } = this.metaInfo; const aiSign = isAi ? "_AI" : ""; const filenamePrefix = `av${aid}_part${part}__${lan}${aiSign}__${title}`; const moreMenuItems = [ { label: "\u4E0B\u8F7D\u5B57\u5E55 (srt)", onClick: () => exportSrt( this.musicFilter.allData, filenamePrefix ) }, { label: "\u4E0B\u8F7D\u5B57\u5E55 (ass)", onClick: () => exportAss( this.musicFilter.allData, filenamePrefix ) } ]; const emptyTimeSeconds = this.musicFilter.currentEmptyTime; const skipEmptyTip = emptyTimeSeconds > 0 ? `\u8DF3\u8FC7\u5B57\u5E55\u95F4\u9694\uFF08\u7A7A\u767D\u65F6\u95F4\u603B\u8BA1 ${formatTime(emptyTimeSeconds)}\uFF09` : "\u8DF3\u8FC7\u5B57\u5E55\u95F4\u9694"; this.container.appendChild( renderHeader( headerConfig, headerState, this.toggleCallbacks, this.container, moreMenuItems, this.listContainer, skipEmptyTip ) ); const skipEmptyBtn = this.container.querySelector( '[data-id="skip-empty"]' ); this.skipEmptyTooltip = skipEmptyBtn?.__tooltip ?? null; this.container.appendChild( renderCloseButton(() => this.destroy()) ); this.container.appendChild(this.renderList()); this.container.addEventListener( "videoStep", this.handleVideoStep ); this.bindEvents(); return this.container; } destroy() { if (this.scrollRAF !== null) { cancelAnimationFrame(this.scrollRAF); } this.container.removeEventListener( "videoStep", this.handleVideoStep ); destroyTooltips(this.container); destroyMoreMenus(this.container); this.container.remove(); } // ============================================================ // 虚拟列表 // ============================================================ renderList() { const virtualList = document.createElement("main"); virtualList.className = "virtual-list"; const { timeFontSize, contentFontSize, normalContainerWidth, normalContainerHeightPercent, showInWebScreen } = this.styleConfig; timeFontSize && this.container.style.setProperty( "--time-font-size", `${timeFontSize}px` ); contentFontSize && this.container.style.setProperty( "--content-font-size", `${contentFontSize}px` ); normalContainerWidth && this.container.style.setProperty( "--normal-container-width", `${normalContainerWidth}px` ); normalContainerHeightPercent && this.container.style.setProperty( "--normal-container-height-percent", `${normalContainerHeightPercent}vh` ); this.container.dataset.showInWebScreen = String(showInWebScreen); this.phantom = document.createElement("aside"); this.phantom.className = "phantom"; this.listContent = document.createElement("section"); this.listContent.className = "list-content"; virtualList.appendChild(this.phantom); virtualList.appendChild(this.listContent); this.listContainer = virtualList; const normalCache = this.heightCalculator.compute( this.musicFilter.allData ); const normalResult = HeightCalculator.buildCumulated(normalCache); this.musicFilter.setNormalCache( normalCache, normalResult.cumulated, normalResult.total ); if (this.musicFilter.hasDifference) { const filteredCache = this.heightCalculator.compute( this.musicFilter.filteredData ); const filteredResult = HeightCalculator.buildCumulated(filteredCache); this.musicFilter.setFilteredCache( filteredCache, filteredResult.cumulated, filteredResult.total ); this.musicFilter.buildSidMap(); } const targetViewHeight = window.innerHeight * (this.styleConfig.normalContainerHeightPercent / 100); const viewItemCount = this.musicFilter.currentCumulatedHeights.findIndex( (h) => h >= targetViewHeight ); this.startIndex = 0; this.endIndex = Math.min( Math.max(10, viewItemCount), this.musicFilter.currentData.length ); virtualList.addEventListener("scroll", this.onScroll, { passive: true }); this.renderVisibleItems(); return virtualList; } createListItem(data, index) { const item = document.createElement("section"); item.className = "list-item timeline-item"; item.dataset.sid = String(data.sid); item.dataset.from = String(data.from); item.dataset.to = String(data.to); item.dataset.music = String(data.music || 0); const itemHeight = this.musicFilter.currentHeightCache[index].height; itemHeight && item.style.setProperty("height", `${itemHeight}px`); if (this.activeSubtitleIndex === index) { item.classList.add("active"); } const timeContainer = document.createElement("section"); timeContainer.className = "timeline-time-container"; const { showEndTime, showTimeIcon, disableSelectContent, disableSelectTime } = this.styleConfig; timeContainer.dataset.showEndTime = String(showEndTime); timeContainer.dataset.showIcon = String(showTimeIcon); timeContainer.dataset.disableSelectTime = String(disableSelectTime); const startTime = document.createElement("span"); startTime.classList.add( "timeline-time", "timeline-start-time" ); startTime.dataset.startTime = String(data.startTime); startTime.textContent = data.startTime; const endTime = document.createElement("span"); endTime.classList.add("timeline-time", "timeline-end-time"); endTime.dataset.endTime = String(data.endTime); endTime.textContent = data.endTime; timeContainer.appendChild(startTime); timeContainer.appendChild(endTime); const content = document.createElement("span"); content.className = "timeline-content"; content.textContent = data.content; content.dataset.content = String(data.content); content.dataset.disableSelectContent = String( disableSelectContent ); item.appendChild(timeContainer); item.appendChild(content); return item; } renderVisibleItems() { if (!this.phantom || !this.listContent) return; const data = this.musicFilter.currentData; const cumulated = this.musicFilter.currentCumulatedHeights; this.phantom.style.height = `${this.musicFilter.currentTotalHeight}px`; const actualStart = Math.max( 0, this.startIndex - this.BUFFER_COUNT ); const actualEnd = Math.min( data.length, this.endIndex + this.BUFFER_COUNT ); const visibleSids = /* @__PURE__ */ new Set(); for (let i = actualStart; i < actualEnd; i++) { const sid = data[i].sid; visibleSids.add(sid); let node = this.listContent.querySelector( `[data-sid="${sid}"]` ); if (!node) { node = this.createListItem(data[i], i); this.listContent.appendChild(node); } node.style.top = `${cumulated[i]}px`; node.style.width = "100%"; node.style.position = "absolute"; if (i === this.activeSubtitleIndex) { this.activeDomElement = node; } } for (const child of [...this.listContent.children]) { const el = child; const sid = Number(el.dataset.sid); if (!visibleSids.has(sid)) { el.remove(); } } } // ============================================================ // 滚动处理 // ============================================================ findStartIndex(scrollTop) { const cumulated = this.musicFilter.currentCumulatedHeights; let low = 0; let high = cumulated.length; while (low < high) { const mid = low + high >>> 1; if (cumulated[mid] <= scrollTop) { low = mid + 1; } else { high = mid; } } return Math.max(0, low - 1); } findEndIndex(scrollTop, viewportHeight) { const bottomEdge = scrollTop + viewportHeight; const cumulated = this.musicFilter.currentCumulatedHeights; const data = this.musicFilter.currentData; let index = this.startIndex; while (index < data.length && cumulated[index + 1] < bottomEdge) { index++; } return index; } handleScroll(scrollTop) { const viewportHeight = this.listContainer.clientHeight; const newStartIndex = this.findStartIndex(scrollTop); const newEndIndex = this.findEndIndex( scrollTop, viewportHeight ); if (newStartIndex !== this.startIndex || newEndIndex !== this.endIndex) { this.startIndex = newStartIndex; this.endIndex = newEndIndex; this.renderVisibleItems(); } } // ============================================================ // 视频同步 // ============================================================ scrollToLockHighlightRow() { const { lockHighlightCol } = this.buttonConfig; if (lockHighlightCol < 1) return; const data = this.musicFilter.currentData; const targetIndex = Math.max( 0, this.activeSubtitleIndex - (lockHighlightCol - 1) ); if (targetIndex < 0 || targetIndex >= data.length) return; const targetOffsetY = this.musicFilter.currentCumulatedHeights[targetIndex]; if (this.scrollRAF !== null) { cancelAnimationFrame(this.scrollRAF); } this.scrollRAF = requestAnimationFrame(() => { if (this.listContainer) { this.listContainer.scrollTop = targetOffsetY; } }); } // ============================================================ // 点击事件 // ============================================================ bindEvents() { const { jumpTimeMode, isCopyTime, isCopyContent } = this.buttonConfig; const isClickTimeContainer = (target) => target.closest(".timeline-time-container"); const isClickContent = (target) => target.closest(".timeline-content"); const isClickStartTime = (target) => target.closest(".timeline-start-time"); const isClickEndTime = (target) => target.closest(".timeline-end-time"); const handleJumpVideoTimeMode = (target) => { if (jumpTimeMode.length === 0) return; const itemContainer = target.closest(".timeline-item"); if (!itemContainer) return; const from = Number(itemContainer.dataset.from); const dispatchVideoJumpEvent = () => this.container.dispatchEvent( new CustomEvent("videoJump", { detail: { currentTime: from } }) ); const isJumpTime = Boolean( jumpTimeMode.includes("\u65F6\u95F4\u8DF3\u8F6C") && isClickTimeContainer(target) ); const isJumpContent = Boolean( jumpTimeMode.includes("\u6587\u672C\u8DF3\u8F6C") && isClickContent(target) ); if (isJumpTime || isJumpContent) { dispatchVideoJumpEvent(); } }; const handleCopyContent = (target) => { if (!isCopyTime && !isCopyContent) return; if (isCopyTime) { const startTimeElement = isClickStartTime(target); if (startTimeElement) { GM_setClipboard( startTimeElement.dataset.startTime || "" ); return; } const endTimeElement = isClickEndTime(target); if (endTimeElement) { GM_setClipboard( endTimeElement.dataset.endTime || "" ); return; } } const contentElement = isClickContent(target); if (isCopyContent && contentElement) { GM_setClipboard(contentElement.dataset.content || ""); } }; this.container.addEventListener("click", (e) => { const target = e.target; if (!target) return; handleJumpVideoTimeMode(target); handleCopyContent(target); }); } } const storeConfig = { \u65F6\u95F4\u8F74\u5B9E\u65F6\u914D\u7F6E: { lockTime: { title: "\u9501\u5B9A\u65F6\u95F4\u8F74\u5230\u56FA\u5B9A\u4F4D\u7F6E", type: "checkbox", default: true }, skipEmptyTime: { title: "\u8DF3\u8FC7\u7A7A\u767D\u65F6\u95F4", type: "checkbox", default: false }, ignoreMusic: { title: "\u5FFD\u7565\u97F3\u4E50", type: "checkbox", default: false } } }; const { lockTimeStore, skipEmptyTimeStore, ignoreMusicStore } = createUserConfigStorage(storeConfig); const UserConfig = { \u65F6\u95F4\u8F74\u914D\u7F6E: { alwaysLoad: { title: "\u81EA\u52A8\u52A0\u8F7D\u65F6\u95F4\u8F74", description: "\u9875\u9762\u8F7D\u5165\u65F6, \u81EA\u52A8\u52A0\u8F7D\u65F6\u95F4\u8F74\u5230\u9875\u9762\u4E2D", type: "checkbox", default: true }, jumpTimeMode: { title: "\u70B9\u51FB\u65F6\u95F4\u8F74\u8DF3\u8F6C\u89C6\u9891\u7684\u6A21\u5F0F", description: "\u70B9\u51FB\u67D0\u4E00\u884C\u5B57\u5E55\u7684\u4F4D\u7F6E, \u4F1A\u5C06\u89C6\u9891\u8DF3\u8F6C\u5230\u5BF9\u5E94\u7684\u5F00\u59CB\u65F6\u95F4", type: "mult-select", values: ["\u65F6\u95F4\u8DF3\u8F6C", "\u6587\u672C\u8DF3\u8F6C"], default: ["\u65F6\u95F4\u8DF3\u8F6C"] }, lockHighlightCol: { title: "\u9AD8\u4EAE\u65F6\u95F4\u8F74\u9501\u5B9A\u4F4D\u7F6E (\u884C) ", description: "\u9AD8\u4EAE\u65F6\u95F4\u8F74\u9501\u5B9A\u4F4D\u7F6E", type: "number", default: 2, min: 0 }, showInWebScreen: { title: "\u7F51\u9875\u5168\u5C4F\u663E\u793A\u65F6\u95F4\u8F74", description: "\u7F51\u9875\u5168\u5C4F\u663E\u793A\u5C06\u65F6\u95F4\u8F74", type: "checkbox", default: false }, isCopyTime: { title: "\u81EA\u52A8\u590D\u5236\u65F6\u95F4", description: "\u70B9\u51FB\u65F6\u95F4\u7684\u65F6\u5019, \u81EA\u52A8\u590D\u5236\u65F6\u95F4\u5230\u7C98\u8D34\u677F", type: "checkbox", default: false }, isCopyContent: { title: "\u81EA\u52A8\u590D\u5236\u6587\u672C", description: "\u70B9\u51FB\u6587\u672C\u7684\u65F6\u5019, \u81EA\u52A8\u590D\u5236\u6587\u672C\u5230\u7C98\u8D34\u677F", type: "checkbox", default: false } }, \u65F6\u95F4\u8F74\u6837\u5F0F: { showEndTime: { title: "\u663E\u793A\u65F6\u95F4\u8F74\u7ED3\u675F\u65F6\u95F4", description: "\u663E\u793A\u65F6\u95F4\u8F74\u7ED3\u675F\u65F6\u95F4", type: "checkbox", default: false }, disableSelectTime: { title: "\u7981\u6B62\u9009\u4E2D\u65F6\u95F4\u6587\u672C", description: "\u5B57\u5E55\u7684\u65F6\u95F4\u5C06\u65E0\u6CD5\u9009\u4E2D\u548C\u590D\u5236", type: "checkbox", default: true }, disableSelectContent: { title: "\u7981\u6B62\u9009\u4E2D\u5B57\u5E55\u6587\u672C", description: "\u5B57\u5E55\u7684\u5185\u5BB9\u5C06\u65E0\u6CD5\u9009\u4E2D\u548C\u590D\u5236", type: "checkbox", default: false }, showTitle: { title: "\u663E\u793A\u5B57\u5E55\u6807\u9898", description: "\u663E\u793A\u5B57\u5E55\u6807\u9898", type: "checkbox", default: true }, showSubtitleId: { title: "\u663E\u793A\u5B50\u6807\u9898", description: "\u89C6\u9891\u7684 av \u53F7\u548C bv \u53F7", type: "checkbox", default: true }, showSubtitleButton: { title: "\u663E\u793A\u5BB9\u5668\u6309\u94AE", description: '"\u65F6\u95F4\u8F74\u9501\u5B9A" \u548C "\u8DF3\u8FC7\u7A7A\u767D"', type: "checkbox", default: true }, timeFontSize: { title: "\u65F6\u95F4\u5B57\u4F53\u5927\u5C0F (px)", description: "", type: "number", default: 12, min: 0 }, showTimeIcon: { title: "\u5728\u65F6\u95F4\u524D\u9762\u663E\u793A\u56FE\u6807", description: "\u5728\u65F6\u95F4\u524D\u9762\u663E\u793A\u56FE\u6807, \u4FBF\u4E8E\u8FA8\u8BA4\u65F6\u95F4\u662F\u5F00\u59CB\u65F6\u95F4\u8FD8\u662F\u7ED3\u675F\u65F6\u95F4", type: "checkbox", default: true }, contentFontSize: { title: "\u6587\u672C\u5185\u5BB9\u5B57\u4F53\u5927\u5C0F (px)", description: "", type: "number", default: 14, min: 0 }, normalContainerWidth: { title: "\u5E38\u89C4\u6A21\u5F0F\u4E0B\u7684\u65F6\u95F4\u8F74\u5BB9\u5668\u5BBD\u5EA6 (px)", description: "", type: "number", default: 411, min: 0 }, normalContainerHeightPercent: { title: "\u5E38\u89C4\u6A21\u5F0F\u4E0B\u7684\u65F6\u95F4\u8F74\u5BB9\u5668\u9AD8\u5EA6 (\u9875\u9762\u9AD8\u5EA6\u7684\u767E\u5206\u6BD4)", description: "", type: "number", default: 70, min: 0, max: 100 }, webScreenContainerWidth: { title: "\u7F51\u9875\u5168\u5C4F\u6A21\u5F0F\u4E0B\u7684\u65F6\u95F4\u8F74\u5BB9\u5668\u5BBD\u5EA6 (px)", description: "", type: "number", default: 411, min: 0 } } }; const { // 配置项 alwaysLoadStore, jumpTimeModeStore, lockHighlightColStore, showInWebScreenStore, isCopyTimeStore, isCopyContentStore, // 网页样式 showEndTimeStore, disableSelectTimeStore, disableSelectContentStore, showTitleStore, showSubtitleIdStore, showSubtitleButtonStore, timeFontSizeStore, showTimeIconStore, contentFontSizeStore, normalContainerWidthStore, normalContainerHeightPercentStore, webScreenContainerWidthStore } = createUserConfigStorage(UserConfig); const TimelineStyle = `:root { --header-height: 79px; --time-font-size: 11px; --content-font-size: 14px; --normal-container-width: 411px; --normal-container-height-percent: 70vh; --webScreen-container-width: 411px; } /* ============ TimelineContainer \u6837\u5F0F ============ */ .timeline-container { position: relative; width: var(--normal-container-width); height: var(--normal-container-height-percent); min-height: 300px; box-shadow: #d8d8d8 0 0 10px; pointer-events: all; margin-bottom: 24px; border-radius: 4px; background-color: #ffffff; scrollbar-width: thin !important; scrollbar-color: #aaa transparent; } /* \u5BBD\u5C4F\u6A21\u5F0F\u4E0D\u663E\u793A\u65F6\u95F4\u8F74 */ [class^="video-container"]:has(.bpx-player-container[data-screen="wide"]) .timeline-container { display: none; } /* \u7F51\u9875\u5168\u5C4F\u6A21\u5F0F\u4E0B\u7684\u65F6\u95F4\u8F74\u5BB9\u5668\u6837\u5F0F */ [class^="video-container"]:has( .timeline-container[data-show-in-web-screen="true"] ):has(#bilibili-player.mode-webscreen) { & #bilibili-player.mode-webscreen { width: calc(100vw - var(--webScreen-container-width)); } & .timeline-container { position: fixed; top: 0; right: 0; height: 100vh; width: var(--webScreen-container-width); z-index: 2000; & > .virtual-list { height: calc(100vh - var(--header-height)); } } } .timeline-item { display: flex; gap: 8px; padding: 4px 16px; border-radius: 4px; font-size: var(--content-font-size); line-height: calc(var(--content-font-size) + 6px); align-items: center; pointer-events: all; } .timeline-item.active { background-color: #ccffff; padding: 4px 16px; font-size: var(--content-font-size); } .timeline-item:hover { background: #ddffff; } .timeline-time-container { display: flex; flex-flow: column; color: #aaa; align-items: center; flex-direction: column; gap: 2px; justify-content: center; height: 100%; pointer-events: all; } .timeline-time { font-size: var(--time-font-size); line-height: var(--time-font-size); display: flex; align-items: center; gap: 4px; color: #aaa; width: fit-content; } .timeline-end-time { border-top: 1px solid #ccc; color: #9cc8c8; padding-top: 2px; } .timeline-time-container[data-show-end-time="false"] { & > .timeline-end-time { display: none; } & + .timeline-content { border-left: none; padding: 0; } } .timeline-time-container[data-disable-select-time="false"] { user-select: none; } .timeline-time-container[data-show-icon="true"] > .timeline-time::before { display: block; text-align: center; vertical-align: middle; padding: 1px; width: calc(var(--time-font-size) - 3px); height: calc(var(--time-font-size) - 3px); font-size: calc(var(--time-font-size) - 3px); line-height: calc(var(--time-font-size) - 3px); border-radius: 4px; border: 1px solid #ccc; } .timeline-start-time::before { content: "S"; } .timeline-end-time::before { content: "E"; border-color: #9cc8c8; } .timeline-content { flex: 1; color: #333; border-left: 2px solid #ddd; padding-left: 4px; white-space: pre-line; } .timeline-content[data-disable-select-content="false"] { user-select: none; } /* ============ TimelineHeader \u7EC4\u4EF6 ============ */ .timeline-header { padding: 12px; box-sizing: border-box; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); display: flex; flex-direction: column; gap: 10px; position: relative; overflow: hidden; } .timeline-header .hide, .timeline-header.hide { display: none; } .timeline-title { font-size: 16px; font-weight: 600; line-height: 1.4; color: #1f2937; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .timeline-meta { display: flex; gap: 8px; justify-content: left; align-items: center; font-size: 12px; height: 22px; color: #6b7280; overflow: hidden; text-overflow: ellipsis; } .timeline-button-group { display: flex; gap: 4px; } .timeline-meta-tag { margin-left: auto; background-color: #f3f4f6; padding: 3px 8px; border-radius: 4px; user-select: none; text-wrap: nowrap; } .timeline-meta-tag[data-ai="true"]::after { content: "ai"; font-size: 8px; vertical-align: top; padding-left: 2px; } .timeline-meta-id { font-family: monospace; background-color: #eff6ff; color: #2563eb; padding: 3px 8px; border-radius: 4px; } .timeline-close-button-container { position: absolute; top: 0; right: 10px; opacity: 0; transition: opacity 0.15s; z-index: 9; pointer-events: all; & > .timeline-close-button::after { content: "\xD7"; color: #ccc; } } .timeline-header:not(.hide):hover + .timeline-close-button-container { opacity: 1; } .timeline-container:has(.timeline-header.hide):hover .timeline-close-button-container { opacity: 1; } /* ============ ToggleButton \u7EC4\u4EF6 ============ */ .toggle-button-group { display: flex; align-items: center; gap: 8px; } .toggle-button { padding: 0; width: 22px; height: 22px; position: relative; display: inline-flex; align-items: center; justify-content: center; border: none; border-radius: 6px; background: transparent; cursor: pointer; transition: background-color 0.2s ease, transform 0.1s ease; color: inherit; } .toggle-button:hover { background-color: rgba(0, 0, 0, 0.08); } .toggle-button.active { color: #00caca; } .toggle-button:disabled { opacity: 0.4; cursor: not-allowed; } .toggle-button:focus-visible { outline: 2px solid #4f46e5; outline-offset: 2px; } .toggle-button svg { width: 18px !important; height: 18px !important; } .toggle-button__tooltip { position: absolute; top: 100%; left: 50%; transform: translateX(-50%) translateY(8px); padding: 4px 8px; font-size: 12px; font-weight: 500; color: #fff; background-color: #1f2937; border-radius: 4px; white-space: nowrap; pointer-events: none; z-index: 10; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); } /* ============ MoreMenu \u7EC4\u4EF6 ============ */ .more-menu { position: fixed; min-width: 150px; background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); z-index: 10000; display: none; overflow: hidden; } .more-menu.open { display: block; } .more-menu-item { padding: 8px 16px; font-size: 13px; color: #374151; cursor: pointer; white-space: nowrap; user-select: none; transition: background-color 0.15s; } .more-menu-item:hover { background-color: #f3f4f6; } .more-menu-item:not(:last-child) { border-bottom: 1px solid #f3f4f6; } /* ============ TimelineContentList \u7EC4\u4EF6 ============ */ .virtual-list { height: calc( max(var(--normal-container-height-percent), 300px) - var(--header-height) ); overflow-y: auto; position: relative; scrollbar-width: thin; } .phantom { position: absolute; top: 0; left: 0; right: 0; z-index: -1; pointer-events: none; } .list-content { position: absolute; left: 0; top: 0; right: 0; } .list-item { box-sizing: border-box; } `; class Logger { constructor(prefix) { this.prefix = prefix; } log(msg) { /* @__PURE__ */ (() => { })(`${this.prefix} ${msg}`); } info(msg) { console.info(`${this.prefix} ${msg}`); } warn(msg) { /* @__PURE__ */ (() => { })(`${this.prefix} ${msg}`); } error(msg) { console.error(`${this.prefix} ${msg}`); } } const logger = new Logger("[bilibili timeline]"); let loadedStyle = false; let loadedTimelineContainer = null; let handleRemoveVideoEventListener = null; const injectTimelineContainer = async (timelineContainer) => { if (!loadedStyle) { GM_addStyle(TimelineStyle); loadedStyle = true; } const rightContainer = await elementWaiter(".right-container"); const container = await elementGetter( ".right-container-inner.scroll-sticky", { parent: rightContainer } ); const danmakuBox = container.querySelector(".danmaku-box"); if (!danmakuBox) { logger.warn("\u65E0\u6CD5\u627E\u5230\u5F39\u5E55\u5217\u8868\u5BB9\u5668, \u8BF7\u91CD\u8BD5"); return; } if (loadedTimelineContainer) { loadedTimelineContainer.destroy(); handleRemoveVideoEventListener?.(); } loadedTimelineContainer = timelineContainer; const timeline = timelineContainer.render(); container.insertBefore(timeline, danmakuBox); const video = document.querySelector( ".bpx-player-video-wrap video" ); if (!video) { logger.warn("\u672A\u68C0\u6D4B\u5230\u89C6\u9891\u5BB9\u5668..."); return; } const handleTimeUpdate = () => { timeline.dispatchEvent( new CustomEvent("videoStep", { detail: { currentTime: video.currentTime } }) ); }; video.addEventListener("timeupdate", handleTimeUpdate); handleRemoveVideoEventListener = () => { video.removeEventListener("timeupdate", handleTimeUpdate); }; timeline.addEventListener("videoJump", (e) => { const { currentTime } = e.detail; video.currentTime = currentTime; }); }; const createTimelineBaseConfig = () => ({ styleConfig: { showTitle: showTitleStore.value, showSubtitleId: showSubtitleIdStore.value, showSubtitleButton: showSubtitleButtonStore.value, timeFontSize: timeFontSizeStore.value, showTimeIcon: showTimeIconStore.value, contentFontSize: contentFontSizeStore.value, normalContainerWidth: normalContainerWidthStore.value, normalContainerHeightPercent: normalContainerHeightPercentStore.value, webScreenContainerWidth: webScreenContainerWidthStore.value, showEndTime: showEndTimeStore.value, showInWebScreen: showInWebScreenStore.value, disableSelectTime: disableSelectTimeStore.value, disableSelectContent: disableSelectContentStore.value }, buttonConfig: { isCopyTime: isCopyTimeStore.get(), isCopyContent: isCopyContentStore.get(), lockHighlightCol: lockHighlightColStore.get(), jumpTimeMode: jumpTimeModeStore.get() }, storeConfig: { lockTime: { get: lockTimeStore.get.bind(lockTimeStore), set: lockTimeStore.set.bind(lockTimeStore) }, skipEmptyTime: { get: skipEmptyTimeStore.get.bind(skipEmptyTimeStore), set: skipEmptyTimeStore.set.bind(skipEmptyTimeStore) }, ignoreMusic: { get: ignoreMusicStore.get.bind(ignoreMusicStore), set: ignoreMusicStore.set.bind(ignoreMusicStore) } } }); const createTimelineFromData = async (subtitleData, metaInfo) => { logger.info("\u624B\u52A8\u5BFC\u5165\u5B57\u5E55\u6570\u636E"); const timelineContainer = new TimelineContainer({ metaInfo: { aid: 0, lan: metaInfo?.lan ?? "\u624B\u52A8\u5BFC\u5165", isAi: false, part: 1, title: metaInfo?.title ?? "\u624B\u52A8\u5BFC\u5165\u5B57\u5E55" }, subtitleData, ...createTimelineBaseConfig() }); await injectTimelineContainer(timelineContainer); }; const createTimeline = async (subtitle, videoSubtitleInfo) => { const subtitleResponse = await subtitle.getContent(); const subtitleData = parseSubtitleResponse(subtitleResponse); logger.info("\u5DF2\u83B7\u53D6\u5B57\u5E55\u6570\u636E"); const timelineContainer = new TimelineContainer({ metaInfo: { aid: videoSubtitleInfo.avid, lan: subtitle.lan_doc, isAi: subtitle.ai_status !== 0, part: videoSubtitleInfo.part, title: videoSubtitleInfo.partTitle }, subtitleData, ...createTimelineBaseConfig() }); await injectTimelineContainer(timelineContainer); }; const cleanText = (text) => { return text.replace(/\{[^}]*\}/g, "").replace(/\\N/g, "\n").replace(/<[^>]*>/g, "").trim(); }; const timeToSeconds = (timeStr) => { const [h, m, sPart] = timeStr.replace(",", ".").split(":"); const [s, ms] = sPart.split("."); return parseInt(h) * 3600 + parseInt(m) * 60 + parseInt(s) + parseInt(ms || "0") * 0.01; }; const parseSRT = (content) => { const blocks = content.trim().split(/\n\s*\n/); return blocks.map((block) => { const lines = block.split("\n"); const timeLine = lines[1]; const [fromStr, toStr] = timeLine.split(" --> "); const text = lines.slice(2).join("\n").trim(); return { sid: parseInt(lines[0]), from: timeToSeconds(fromStr.trim()), to: timeToSeconds(toStr.trim()), content: cleanText(text) }; }); }; const parseASS = (content) => { const eventsMatch = content.match(/\[Events]/i); if (!eventsMatch) { return []; } return content.split(/\r?\n/).filter((line) => line.startsWith("Dialogue:")).map((line, index) => { const parts = line.split(/,\s*/g); const [_0, start, end, _3, _4, _5, _6, _7, _8, ...text] = parts; return { sid: index + 1, from: timeToSeconds(start), to: timeToSeconds(end), content: cleanText(text.join("\n")) }; }); }; const parseSubtitleFile = (callback) => { const input = document.createElement("input"); input.type = "file"; input.accept = ".srt,.ass"; input.style.display = "none"; const handleChange = async (event) => { try { if (!event.target.files?.length) { handleClean(); return; } const file = event.target.files[0]; const content = await file.text(); const parsedLines = file.name.endsWith(".srt") ? parseSRT(content) : parseASS(content); const filename = file.name.slice(0, -4); const result = parsedLines.map( (line, index) => ({ sid: line.sid ?? index + 1, from: line.from, to: line.to, startTime: formatTime(line.from), endTime: formatTime(line.to), content: line.content }) ); callback(result, filename); } catch (error) { logger.error(`\u5B57\u5E55\u6587\u4EF6\u89E3\u6790\u5931\u8D25: ${error}`); } finally { handleClean(); } }; const handleClean = () => { input.removeEventListener("change", handleChange); input.removeEventListener("cancel", handleClean); input.remove(); }; document.body.appendChild(input); input.addEventListener("change", handleChange); input.addEventListener("cancel", handleClean); input.click(); }; const generateSubtitleButton = async (url) => { const videoId = getVideoId(url); if (!videoId) { logger.warn("\u65E0\u6CD5\u83B7\u53D6\u89C6\u9891ID"); return; } const videoSubtitleInfo = await getVideoSubtitlesList( videoId.avId, videoId.part ); gmMenuCommand.batch(() => { gmMenuCommand.reset(); if (videoSubtitleInfo.subtitles.length) { /* @__PURE__ */ (() => { })("subtitles", videoSubtitleInfo.subtitles); videoSubtitleInfo.subtitles.forEach((subtitle) => { const isAiSubtitle = subtitle.ai_status !== 0; const aiContent = isAiSubtitle ? "_AI" : ""; gmMenuCommand.create( `\u751F\u6210\u65F6\u95F4\u8F74 (${subtitle.lan_doc}${aiContent})`, createTimeline.bind( null, subtitle, videoSubtitleInfo ) ); }); } else { gmMenuCommand.create("\u5F53\u524D\u89C6\u9891\u4E0D\u5B58\u5728\u5B57\u5E55", () => { }); } gmMenuCommand.create(`\u5237\u65B0`, generateSubtitleButton); gmMenuCommand.create(`\u624B\u52A8\u5BFC\u5165\u5B57\u5E55`, () => { parseSubtitleFile((subtitleData, filename) => { createTimelineFromData(subtitleData, { title: filename }); }); }); }); }; const handleLoadPage = async (targetUrl) => { await generateSubtitleButton(targetUrl); const isAutoLoadTimeContainer = alwaysLoadStore.get(); if (isAutoLoadTimeContainer) { gmMenuCommand.list[0].onClick(); } }; const main = async () => { handleLoadPage(); onRouteChange(async ({ to, type }) => { if (type !== "push") { return; } handleLoadPage(to); }); }; main().catch(console.error); })();