Greasy Fork is available in English.
快速阅读脚本。支持 RSVP 与高亮追踪。适用于有阅读障碍的用户。
// ==UserScript== // @name 跳跳眼 // @namespace http://greasyfork.icu/zh-CN/users/1535852-severedline // @version 2.1.1 // @description 快速阅读脚本。支持 RSVP 与高亮追踪。适用于有阅读障碍的用户。 // @author qwerty // @license Unlicense // @match *://*/* // @grant GM_registerMenuCommand // @grant GM_getResourceText // @grant GM_getResourceURL // @resource jiebaWasmJs https://cdn.jsdelivr.net/npm/[email protected]/pkg/web/jieba_rs_wasm.js // @resource jiebaWasmBg https://cdn.jsdelivr.net/npm/[email protected]/pkg/web/jieba_rs_wasm_bg.wasm // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/purify.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/hotkeys-js.min.js // @require https://cdn.jsdelivr.net/npm/@mozilla/[email protected]/Readability.min.js // ==/UserScript== /* global Readability, DOMPurify, hotkeys */ (function () { 'use strict'; let overlayHost = null; let wasmEngine = null; const icons = { play: `<svg viewBox="0 0 24 24" width="30" height="30" fill="currentColor"><path d="M8 5.14v14l11-7-11-7z"/></svg>`, pause: `<svg viewBox="0 0 24 24" width="30" height="30" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>`, prev: `<svg viewBox="0 0 24 24" width="28" height="28" fill="currentColor"><path d="M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z"/></svg>`, next: `<svg viewBox="0 0 24 24" width="28" height="28" fill="currentColor"><path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z"/></svg>`, close: `<svg viewBox="0 0 24 24" width="26" height="26" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>`, theme: `<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M12 3a9 9 0 109 9c0-.46-.04-.92-.1-1.36a5.389 5.389 0 01-4.4 2.26 5.403 5.403 0 01-3.14-9.8c-.44-.06-.9-.1-1.36-.1z"/></svg>`, mode: `<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z"/></svg>`, book: `<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM9 4h2v5l-1-.75L9 9V4zm9 16H6V4h1v9l3-2.25L13 13V4h5v16z"/></svg>`, tts: `<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>`, ttsOff: `<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>`, }; async function initWasmEngine() { if (wasmEngine) return; showToast('🚀 正在加载分词引擎...'); try { let jsCode = GM_getResourceText('jiebaWasmJs'); if (!jsCode) throw new Error('无法获取 WASM JS'); jsCode = jsCode .replace(/export function ([a-zA-Z0-9_]+)/g, 'wasmSandbox.$1 = function $1') .replace(/export\s*\{[^}]+\}\s*;/g, '') .replace(/export default ([a-zA-Z0-9_]+);?/g, 'wasmSandbox.__wbg_init = $1;') .replace(/import\.meta\.url/g, 'location.href') .replace(/import\.meta/g, '{}'); const wasmSandbox = {}; const runSandbox = new Function('wasmSandbox', jsCode); runSandbox(wasmSandbox); const wasmUrl = GM_getResourceURL('jiebaWasmBg'); if (!wasmUrl) throw new Error('无法获取 WASM 二进制文件'); const wasmRes = await fetch(wasmUrl); const wasmBuffer = await wasmRes.arrayBuffer(); await wasmSandbox.__wbg_init(wasmBuffer); wasmEngine = wasmSandbox; removeToast(); } catch (error) { removeToast(); console.warn('WASM 分词加载失败,回退到浏览器内置分词器', error); wasmEngine = 'fallback'; } } document.addEventListener('keydown', (e) => { if (e.altKey && e.code === 'KeyR') { e.preventDefault(); launch(); } }); if (typeof GM_registerMenuCommand !== 'undefined') { GM_registerMenuCommand('📖 启动速读 (选中文本/全文)[Alt+R]', launch); } function createMobileFab() { if (document.getElementById('sr-mobile-fab')) return; const fab = document.createElement('div'); fab.id = 'sr-mobile-fab'; fab.innerHTML = icons.book; Object.assign(fab.style, { position: 'fixed', bottom: '30px', right: '30px', zIndex: '2147483646', width: '50px', height: '50px', borderRadius: '25px', backgroundColor: 'rgba(28, 28, 30, 0.65)', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(10px)', boxShadow: '0 8px 24px rgba(0,0,0,0.15)', cursor: 'pointer', transition: 'all 0.3s ease', opacity: '0.4', }); let scrollTimeout; window.addEventListener( 'scroll', () => { fab.style.opacity = '1'; fab.style.pointerEvents = 'auto'; clearTimeout(scrollTimeout); scrollTimeout = setTimeout(() => { fab.style.opacity = '0.3'; fab.style.pointerEvents = 'none'; }, 2000); }, { passive: true }, ); fab.style.pointerEvents = 'none'; setTimeout(() => (fab.style.pointerEvents = 'auto'), 500); fab.onclick = () => launch(); document.body.appendChild(fab); } if (/Mobi|Android|iPhone/i.test(navigator.userAgent)) { createMobileFab(); } async function launch() { if (overlayHost) return; try { await initWasmEngine(); const selection = window.getSelection(); const selectionText = selection.toString().trim(); let articleTitle = document.title; let articleHTML = ''; if (selectionText) { articleTitle = '选中文本阅读'; const range = selection.getRangeAt(0); const container = document.createElement('div'); container.appendChild(range.cloneContents()); articleHTML = DOMPurify.sanitize( container.innerHTML || selectionText .split('\n') .filter((p) => p.trim()) .map((p) => `<p>${p}</p>`) .join(''), ); } else { const clone = document.cloneNode(true); const elementsToRemove = clone.querySelectorAll( 'script, style, noscript, iframe, svg, canvas, video, audio', ); elementsToRemove.forEach((el) => el.remove()); const reader = new Readability(clone); const article = reader.parse(); if (article && article.content && article.content.trim() !== '') { articleTitle = article.title; articleHTML = DOMPurify.sanitize(article.content); } else { articleHTML = document.body.innerText .split('\n') .filter((p) => p.trim()) .map((p) => `<p>${DOMPurify.sanitize(p)}</p>`) .join(''); } } buildUI(articleTitle, articleHTML); } catch (error) { console.error('启动失败流程终止', error); } } function buildUI(title, htmlContent) { const originalOverflow = document.body.style.overflow; overlayHost = document.createElement('div'); const shadow = overlayHost.attachShadow({ mode: 'open' }); shadow.innerHTML = ` <style> :host { --bg: rgba(250, 250, 252, 0.95); --text-main: #1d1d1f; --text-secondary: #86868b; --accent: #FF3B30; --hl-bg: #FFD60A; --hl-text: #000; --panel-bg: rgba(255, 255, 255, 0.7); --border: rgba(0, 0, 0, 0.08); --shadow-btn: 0 8px 16px rgba(0,0,0,0.1); --shadow-panel: 0 16px 64px rgba(0,0,0,0.15); } .dark-mode { --bg: rgba(28, 28, 30, 0.95); --text-main: #f5f5f7; --text-secondary: #a1a1a6; --accent: #FF453A; --hl-bg: #E5A300; --hl-text: #fff; --panel-bg: rgba(44, 44, 46, 0.7); --border: rgba(255, 255, 255, 0.1); --shadow-btn: 0 8px 16px rgba(0,0,0,0.4); --shadow-panel: 0 16px 64px rgba(0,0,0,0.6); } #overlay { position: fixed; inset: 0; z-index: 2147483647; background: var(--bg); color: var(--text-main); backdrop-filter: blur(40px) saturate(200%); -webkit-backdrop-filter: blur(40px) saturate(200%); display: flex; flex-direction: column; font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", "WenQuanYi Micro Hei", sans-serif !important; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; letter-spacing: 0.015em; font-weight: 400; transition: background 0.4s ease, color 0.4s ease; opacity: 0; animation: fadeIn 0.3s forwards; } @keyframes fadeIn { to { opacity: 1; } } #top-bar { position: absolute; top: 0; left: 0; right: 0; z-index: 10; display: flex; justify-content: space-between; align-items: center; padding: 24px 40px; transition: opacity 0.4s ease, transform 0.4s ease; } #top-bar.playing { opacity: 0; transform: translateY(-10px); pointer-events: none; } #top-bar h2 { margin: 0; font-size: 18px; font-weight: 600; opacity: 0.85; max-width: 60vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .icon-btn { background: transparent; border: none; color: var(--text-main); display: inline-flex; align-items: center; justify-content: center; cursor: pointer; border-radius: 14px; padding: 10px; transition: 0.2s ease; } .icon-btn:hover { background: var(--border); transform: scale(1.05); } .actions { display: flex; gap: 12px; } #content-area { flex: 1; position: relative; display: flex; align-items: center; justify-content: center; } #rsvp-view { height: 100%; width: 100%; display: flex; align-items: center; justify-content: center; position: absolute; inset: 0; padding: 0 20px;} .rsvp-box { display: flex; width: 100%; font-size: clamp(40px, 8vw, 86px); font-family: "SF Mono", "Courier New", Courier, monospace !important; font-weight: 600; letter-spacing: 0.05em; } .rsvp-box .prefix { flex: 1; text-align: right; opacity: 0.9; } .rsvp-box .focal { flex: 0 0 auto; color: var(--accent); text-align: center; position: relative; } .rsvp-box .suffix { flex: 1; text-align: left; opacity: 0.9; } .guideline-top, .guideline-bottom { position: absolute; left: 50%; width: 4px; height: 16px; background: var(--accent); transform: translateX(-50%); border-radius: 2px; opacity: 0.8;} .guideline-top { top: -15%; } .guideline-bottom { bottom: -15%; } #highlight-view { height: 100%; width: 100%; overflow-y: auto; position: absolute; inset: 0; display: none; scroll-behavior: smooth; } #highlight-view::-webkit-scrollbar { width: 6px; } #highlight-view::-webkit-scrollbar-track { background: transparent; } #highlight-view::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } #highlight-view::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); } .hl-container { max-width: 760px; margin: 0 auto; padding: 120px 32px 240px; font-size: 28px; line-height: 1.8; color: var(--text-secondary); font-weight: 400;} .sr-word { transition: all 0.1s ease-out; border-radius: 6px; padding: 2px 3px; } .sr-word.active { background-color: var(--hl-bg); color: var(--hl-text); font-weight: 600; box-shadow: 0 2px 10px rgba(0,0,0,0.15); transform: scale(1.05); display: inline-block; position: relative; z-index: 2;} #bottom-island { position: absolute; bottom: 40px; left: 50%; transform: translateX(-50%); background: var(--panel-bg); padding: 24px 40px; border-radius: 36px; box-shadow: var(--shadow-panel); border: 1px solid var(--border); display: flex; flex-direction: column; gap: 24px; width: 90%; max-width: 680px; backdrop-filter: blur(40px) saturate(200%); -webkit-backdrop-filter: blur(40px) saturate(200%); transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s ease; z-index: 10; } #bottom-island.playing { opacity: 0.15; transform: translateX(-50%) scale(0.95) translateY(20px); } #bottom-island.playing:hover { opacity: 1; transform: translateX(-50%) scale(1) translateY(0); } .slider-row, .controls-row { display: flex; align-items: center; justify-content: space-between; gap: 24px; font-size: 15px; font-weight: 500; opacity: 0.85;} .wpm-control { display: flex; align-items: center; gap: 16px; flex: 1; } .playback-controls { display: flex; align-items: center; gap: 16px; } .play-btn { background: var(--text-main); color: var(--bg); border-radius: 50%; width: 64px; height: 64px; box-shadow: var(--shadow-btn); } .play-btn:hover { transform: scale(1.08); background: var(--text-main); } input[type=range] { flex: 1; -webkit-appearance: none; background: transparent; cursor: pointer; height: 24px; margin: 0; } input[type=range]::-webkit-slider-runnable-track { width: 100%; height: 6px; background: var(--border); border-radius: 3px; } input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; height: 22px; width: 22px; border-radius: 50%; background: var(--text-main); margin-top: -8px; box-shadow: 0 2px 8px rgba(0,0,0,0.3); transition: 0.2s; } input[type=range]::-webkit-slider-thumb:hover { transform: scale(1.2); } @media (max-width: 768px) { #top-bar { padding: 16px 20px; } #top-bar h2 { font-size: 15px; } .rsvp-box { font-size: 46px; } .hl-container { font-size: 20px; padding: 100px 20px 200px; } #bottom-island { width: 95%; padding: 20px 24px; bottom: 20px; border-radius: 28px; gap: 16px;} .controls-row { flex-direction: column; gap: 20px; } .wpm-control { width: 100%; } .playback-controls { width: 100%; justify-content: center; gap: 30px; } .play-btn { width: 56px; height: 56px; } .icon-btn svg { width: 26px; height: 26px; } } </style> <div id="overlay" tabindex="0" style="outline: none;"> <div id="top-bar"> <h2>${title}</h2> <div class="actions"> <button class="icon-btn" id="btn-tts" title="切换朗读">${icons.ttsOff}</button> <button class="icon-btn" id="btn-theme" title="切换深色/浅色">${icons.theme}</button> <button class="icon-btn" id="btn-mode" title="切换模式">${icons.mode}</button> <button class="icon-btn" id="btn-close" title="退出 (Esc)">${icons.close}</button> </div> </div> <div id="content-area"> <div id="rsvp-view"> <div class="rsvp-box" id="rsvp-box"> <span class="prefix"></span> <span class="focal"><div class="guideline-top"></div>准<div class="guideline-bottom"></div></span> <span class="suffix">备...</span> </div> </div> <div id="highlight-view"> <div class="hl-container" id="hl-container"></div> </div> </div> <div id="bottom-island"> <div class="slider-row"> <span id="progress-text" style="min-width: 110px;">0 / 0 (0%)</span> <input type="range" id="progress-slider" min="0" value="0"> </div> <div class="controls-row"> <div class="wpm-control"> <span id="wpm-text" style="min-width: 85px;">350 WPM</span> <input type="range" id="wpm-slider" min="100" max="1000" step="25" value="350"> </div> <div class="playback-controls"> <button class="icon-btn" id="btn-prev">${icons.prev}</button> <button class="icon-btn play-btn" id="btn-play">${icons.play}</button> <button class="icon-btn" id="btn-next">${icons.next}</button> </div> </div> </div> </div> `; document.documentElement.appendChild(overlayHost); document.body.style.overflow = 'hidden'; const hlContainer = shadow.getElementById('hl-container'); hlContainer.innerHTML = htmlContent; const playableWords = []; function traverseAndWrap(node) { if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent; if (!text.trim()) return; let words; if (wasmEngine && wasmEngine !== 'fallback') { words = wasmEngine.cut(text, false); } else { if (typeof Intl.Segmenter === 'function') { if (!window.__sr_segmenter) { window.__sr_segmenter = new Intl.Segmenter('zh-CN', { granularity: 'word' }); } words = [...window.__sr_segmenter.segment(text)] .map((s) => s.segment) .filter((w) => w.trim().length > 0 || /[\n\r]/.test(w)); } else { words = text.split('').filter((w) => w.trim().length > 0 || /[\n\r]/.test(w)); } } const fragment = document.createDocumentFragment(); words.forEach((w) => { if (w.trim().length === 0) { fragment.appendChild(document.createTextNode(w)); } else { const span = document.createElement('span'); span.textContent = w; span.className = 'sr-word'; span.dataset.index = playableWords.length; fragment.appendChild(span); playableWords.push({ word: w.trim(), el: span }); } }); node.parentNode.replaceChild(fragment, node); } else if (node.nodeType === Node.ELEMENT_NODE) { if (['SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME'].includes(node.tagName.toUpperCase())) return; Array.from(node.childNodes).forEach(traverseAndWrap); } } Array.from(hlContainer.childNodes).forEach(traverseAndWrap); let isPlaying = false, currentIndex = 0, timerId = null, lastActiveEl = null; let isTtsEnabled = false; let wpm = parseInt(localStorage.getItem('sr_wpm')) || 350; let isRsvpMode = localStorage.getItem('sr_mode') !== 'false'; let isDarkMode = localStorage.getItem('sr_theme') !== null ? localStorage.getItem('sr_theme') === 'true' : window.matchMedia('(prefers-color-scheme: dark)').matches; const ui = { topBar: shadow.getElementById('top-bar'), bottomIsland: shadow.getElementById('bottom-island'), btnPlay: shadow.getElementById('btn-play'), btnPrev: shadow.getElementById('btn-prev'), btnNext: shadow.getElementById('btn-next'), btnClose: shadow.getElementById('btn-close'), btnTts: shadow.getElementById('btn-tts'), btnTheme: shadow.getElementById('btn-theme'), btnMode: shadow.getElementById('btn-mode'), rsvpBox: shadow.getElementById('rsvp-box'), rsvpView: shadow.getElementById('rsvp-view'), hlView: shadow.getElementById('highlight-view'), progSlider: shadow.getElementById('progress-slider'), progText: shadow.getElementById('progress-text'), wpmSlider: shadow.getElementById('wpm-slider'), wpmText: shadow.getElementById('wpm-text'), overlay: shadow.getElementById('overlay'), }; ui.progSlider.max = Math.max(0, playableWords.length - 1); function getORPIndex(word) { const len = word.length; if (len <= 1) return 0; if (len >= 6) return Math.ceil(len / 3); return Math.floor(len / 2); } async function playTTS() { if (!isTtsEnabled) return; window.speechSynthesis.cancel(); let textToSpeak = ''; let charIndexMap = []; for (let i = currentIndex; i < playableWords.length; i++) { let w = playableWords[i].word; charIndexMap.push({ charStart: textToSpeak.length, wordIndex: i }); textToSpeak += w; if (textToSpeak.length > 100 && /[。!?.!?\n]/.test(w)) break; if (textToSpeak.length >= 150) break; } if (!textToSpeak) return; const utterance = new SpeechSynthesisUtterance(textToSpeak); let voices = window.speechSynthesis.getVoices(); if (voices.length === 0) { await Promise.race([ new Promise((resolve) => window.speechSynthesis.addEventListener('voiceschanged', resolve, { once: true }), ), new Promise((resolve) => setTimeout(resolve, 1000)), ]); voices = window.speechSynthesis.getVoices(); } const zhVoice = voices.find((v) => v.lang === 'zh-CN' && v.localService) || voices.find((v) => v.lang === 'zh-CN') || voices.find((v) => v.lang === 'zh-TW' && v.localService) || voices.find((v) => v.lang === 'zh-TW') || voices.find((v) => v.lang === 'zh-HK') || voices.find((v) => v.lang === 'zh') || voices.find((v) => v.lang.startsWith('cmn')) || voices.find((v) => v.lang.includes('zh')) || voices[0]; if (zhVoice) { utterance.voice = zhVoice; utterance.lang = zhVoice.lang; } else { utterance.lang = 'zh-CN'; } utterance.rate = Math.max(0.5, Math.min(3.5, 0.5 + wpm / 150)); utterance.volume = 1.0; let lastSyncedIndex = -1; utterance.onboundary = (e) => { if (!isPlaying || !isTtsEnabled) return; let matchedIndex = currentIndex; for (let i = 0; i < charIndexMap.length; i++) { if (charIndexMap[i].charStart <= e.charIndex) { matchedIndex = charIndexMap[i].wordIndex; } else { break; } } if (matchedIndex !== lastSyncedIndex) { lastSyncedIndex = matchedIndex; currentIndex = matchedIndex; requestAnimationFrame(() => updateDisplay()); } }; utterance.onend = () => { if (!isPlaying || !isTtsEnabled) return; if (charIndexMap.length === 0) { currentIndex++; } else { currentIndex = charIndexMap[charIndexMap.length - 1].wordIndex + 1; } updateDisplay(); if (currentIndex < playableWords.length && isPlaying && isTtsEnabled) { requestAnimationFrame(() => playTTS()); } else { if (currentIndex >= playableWords.length) { currentIndex = playableWords.length - 1; updateDisplay(); } pause(); } }; utterance.onerror = (e) => { if (e.error === 'interrupted' || e.error === 'canceled') return; console.warn('TTS 错误:', e.error, e); if (e.error === 'language-unavailable' || e.error === 'voice-unavailable') { console.warn('中文语音不可用,尝试使用默认语音'); const fallback = new SpeechSynthesisUtterance(textToSpeak); fallback.lang = 'zh-CN'; fallback.rate = utterance.rate; fallback.onboundary = utterance.onboundary; fallback.onend = utterance.onend; fallback.onerror = () => { console.warn('回退语音也失败了'); pause(); }; window.speechSynthesis.speak(fallback); return; } pause(); }; window.speechSynthesis.speak(utterance); } function updateDisplay() { if (playableWords.length === 0) return; const current = playableWords[currentIndex]; const wordText = current.word; const orp = getORPIndex(wordText); const prefix = wordText.substring(0, orp); const focal = wordText.substring(orp, orp + 1); const suffix = wordText.substring(orp + 1); ui.rsvpBox.innerHTML = `<span class="prefix">${prefix}</span><span class="focal"><div class="guideline-top"></div>${focal}<div class="guideline-bottom"></div></span><span class="suffix">${suffix}</span>`; if (lastActiveEl) { lastActiveEl.classList.remove('active'); } if (current.el) { current.el.classList.add('active'); lastActiveEl = current.el; if (!isRsvpMode) { const c = ui.hlView; const elTop = current.el.offsetTop; const cTop = c.scrollTop; const cHeight = c.clientHeight; if (elTop < cTop + cHeight * 0.2 || elTop > cTop + cHeight * 0.8) { current.el.scrollIntoView({ behavior: wpm > 300 ? 'auto' : 'smooth', block: 'center' }); } } } ui.progSlider.value = currentIndex; const percent = Math.floor(((currentIndex + 1) / playableWords.length) * 100) || 0; ui.progText.textContent = `${currentIndex + 1} / ${playableWords.length} (${percent}%)`; } function play() { if (isPlaying || currentIndex >= playableWords.length) return; isPlaying = true; ui.btnPlay.innerHTML = icons.pause; ui.bottomIsland.classList.add('playing'); ui.topBar.classList.add('playing'); if (isTtsEnabled) { playTTS(); } else { queueNextWord(); } } function pause() { isPlaying = false; ui.btnPlay.innerHTML = icons.play; ui.bottomIsland.classList.remove('playing'); ui.topBar.classList.remove('playing'); clearTimeout(timerId); window.speechSynthesis.cancel(); } function queueNextWord() { if (!isPlaying) return; updateDisplay(); const word = playableWords[currentIndex].word; currentIndex++; if (currentIndex >= playableWords.length) { pause(); currentIndex = playableWords.length - 1; updateDisplay(); return; } let delay = 60000 / wpm; if (/[.!?。!?…]/.test(word)) { delay *= Math.max(1.5, 2.5 - wpm / 500); } else if (/[,;:,;:、——]/.test(word)) { delay *= Math.max(1.2, 1.8 - wpm / 800); } else if (/[\n\r]/.test(word)) { delay *= Math.max(1.5, 2.2 - wpm / 600); } else if (word.length >= 4) { delay *= Math.min(1.4, 1 + (word.length - 3) * 0.05); } timerId = setTimeout(queueNextWord, delay); } function seek(offset) { currentIndex = Math.max(0, Math.min(playableWords.length - 1, currentIndex + offset)); updateDisplay(); if (isPlaying && isTtsEnabled) playTTS(); } function setWpm(val) { wpm = Math.max(100, Math.min(1000, val)); ui.wpmSlider.value = wpm; ui.wpmText.textContent = `${wpm} WPM`; if (isPlaying && isTtsEnabled) playTTS(); } hotkeys.setScope('swiftread'); hotkeys('space', 'swiftread', (e) => { e.preventDefault(); isPlaying ? pause() : play(); }); hotkeys('left', 'swiftread', (e) => { e.preventDefault(); seek(-1); }); hotkeys('right', 'swiftread', (e) => { e.preventDefault(); seek(1); }); hotkeys('up', 'swiftread', (e) => { e.preventDefault(); setWpm(wpm + 25); }); hotkeys('down', 'swiftread', (e) => { e.preventDefault(); setWpm(wpm - 25); }); hotkeys('esc', 'swiftread', (e) => { e.preventDefault(); ui.btnClose.click(); }); ui.btnPlay.onclick = () => (isPlaying ? pause() : play()); ui.btnPrev.onclick = () => seek(-10); ui.btnNext.onclick = () => seek(10); ui.btnClose.onclick = () => { pause(); clearTimeout(timerId); hotkeys.unbind('space,left,right,up,down,esc', 'swiftread'); hotkeys.deleteScope('swiftread'); playableWords.length = 0; lastActiveEl = null; if (hlContainer) { hlContainer.innerHTML = ''; } Object.keys(ui).forEach((key) => (ui[key] = null)); overlayHost.remove(); overlayHost = null; document.body.style.overflow = originalOverflow; }; if (isDarkMode) ui.overlay.classList.add('dark-mode'); ui.rsvpView.style.display = isRsvpMode ? 'flex' : 'none'; ui.hlView.style.display = isRsvpMode ? 'none' : 'block'; setWpm(wpm); ui.btnTts.innerHTML = isTtsEnabled ? icons.tts : icons.ttsOff; ui.btnTts.onclick = () => { isTtsEnabled = !isTtsEnabled; ui.btnTts.innerHTML = isTtsEnabled ? icons.tts : icons.ttsOff; if (!isTtsEnabled) window.speechSynthesis.cancel(); if (isPlaying) { pause(); play(); } }; ui.btnTheme.onclick = () => { const isDark = ui.overlay.classList.toggle('dark-mode'); localStorage.setItem('sr_theme', isDark); }; ui.btnMode.onclick = () => { isRsvpMode = !isRsvpMode; localStorage.setItem('sr_mode', isRsvpMode); ui.rsvpView.style.display = isRsvpMode ? 'flex' : 'none'; ui.hlView.style.display = isRsvpMode ? 'none' : 'block'; updateDisplay(); }; ui.wpmSlider.oninput = (e) => { setWpm(parseInt(e.target.value)); localStorage.setItem('sr_wpm', e.target.value); }; ui.progSlider.oninput = (e) => { if (isPlaying) pause(); currentIndex = parseInt(e.target.value); updateDisplay(); }; setTimeout(() => ui.overlay.focus(), 100); ui.rsvpView.onclick = () => (isPlaying ? pause() : play()); ui.hlView.onclick = (e) => { if (window.getSelection().toString().trim().length > 0) return; if (e.target.classList.contains('sr-word')) { if (isPlaying) pause(); currentIndex = parseInt(e.target.dataset.index, 10); updateDisplay(); return; } isPlaying ? pause() : play(); }; updateDisplay(); } let toastEl = null; function showToast(msg) { if (toastEl) return; toastEl = document.createElement('div'); toastEl.textContent = msg; Object.assign(toastEl.style, { position: 'fixed', top: '40px', left: '50%', transform: 'translateX(-50%)', background: 'rgba(28,28,30,0.85)', color: '#fff', padding: '14px 28px', borderRadius: '30px', zIndex: '2147483647', fontFamily: 'system-ui, sans-serif', fontSize: '15px', fontWeight: '500', backdropFilter: 'blur(10px)', boxShadow: '0 12px 32px rgba(0,0,0,0.2)', }); document.documentElement.appendChild(toastEl); } function removeToast() { if (toastEl) { toastEl.remove(); toastEl = null; } } })();