// ==UserScript== // @name Bilibili 视频时间轴 // @description 根据视频字幕, 生成视频时间轴. // @version 2.0.2 // @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 // @downloadURL https://update.greasyfork.icu/scripts/522993/Bilibili%20%E8%A7%86%E9%A2%91%E6%97%B6%E9%97%B4%E8%BD%B4.user.js // @updateURL https://update.greasyfork.icu/scripts/522993/Bilibili%20%E8%A7%86%E9%A2%91%E6%97%B6%E9%97%B4%E8%BD%B4.meta.js // ==/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: true isCopyTime: title: 自动复制时间 description: '点击时间的时候, 自动复制时间到粘贴板' type: checkbox default: false isCopyContent: title: 自动复制文本 description: '点击文本的时候, 自动复制文本到粘贴板' type: checkbox default: false isSmoothScroll: 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.smoothHebavior = 'auto'; 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(); this.smoothHebavior = this.buttonConfig.isSmoothScroll ? 'smooth' : 'auto'; 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) { return; } this.listContainer.scrollTo({ top: targetOffsetY, behavior: this.smoothHebavior, }); }); } // ============================================================ // 点击事件 // ============================================================ 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: true, }, 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, }, isSmoothScroll: { title: '\u5E73\u6ED1\u6EDA\u52A8', description: '\u811A\u672C\u6EDA\u52A8\u4E0D\u518D\u662F\u76F4\u63A5\u6E32\u67D3, \u800C\u662F\u6709\u4E00\u4E2A\u6EDA\u52A8\u8FC7\u7A0B\u624D\u6EDA\u52A8\u5230\u76EE\u6807\u4F4D\u7F6E', 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, isSmoothScrollStore, // 网页样式 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-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-time-container[data-disable-select-time="true"], .timeline-content[data-disable-select-content="true"] { 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; } /* ============ \u7279\u6B8A\u5904\u7406 ============ */ /* \u5E38\u89C4\u6A21\u5F0F\u4E0B, \u8BA9\u8054\u5408\u6295\u7A3F\u7684\u89C6\u9891\u5BB9\u5668\u548C\u65F6\u95F4\u8F74\u9F50\u5E73 */ [class^="video-container"]:has(.members-info-container):has(.timeline-container) .left-container:has(.bpx-player-container[data-screen="normal"]) { padding-top: 70px; } `; 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.value, isCopyContent: isCopyContentStore.value, lockHighlightCol: lockHighlightColStore.value, jumpTimeMode: jumpTimeModeStore.value, isSmoothScroll: isSmoothScrollStore.value, }, 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); })();