// ==UserScript==
// @name 跳跳眼
// @namespace https://greasyfork.org/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/jieba-wasm@2.4.0/pkg/web/jieba_rs_wasm.js
// @resource jiebaWasmBg https://cdn.jsdelivr.net/npm/jieba-wasm@2.4.0/pkg/web/jieba_rs_wasm_bg.wasm
// @require https://cdn.jsdelivr.net/npm/dompurify@3.4.1/dist/purify.min.js
// @require https://cdn.jsdelivr.net/npm/hotkeys-js@4.0.3/dist/hotkeys-js.min.js
// @require https://cdn.jsdelivr.net/npm/@mozilla/readability@0.6.0/Readability.min.js
// @downloadURL https://update.greasyfork.icu/scripts/575171/%E8%B7%B3%E8%B7%B3%E7%9C%BC.user.js
// @updateURL https://update.greasyfork.icu/scripts/575171/%E8%B7%B3%E8%B7%B3%E7%9C%BC.meta.js
// ==/UserScript==
/* global Readability, DOMPurify, hotkeys */
(function () {
'use strict';
let overlayHost = null;
let wasmEngine = null;
const icons = {
play: ``,
pause: ``,
prev: ``,
next: ``,
close: ``,
theme: ``,
mode: ``,
book: ``,
tts: ``,
ttsOff: ``,
};
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}
`)
.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) => `${DOMPurify.sanitize(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 = `
${title}
`;
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 = `${prefix}${focal}${suffix}`;
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;
}
}
})();