Bionic Reading Neo

Bionic Reading User Script ⌘ + B

Verze ze dne 12. 01. 2025. Zobrazit nejnovější verzi.

// ==UserScript==
// @name              Bionic Reading Neo
// @namespace         http://tampermonkey.net/
// @version           0.1.0
// @description       Bionic Reading User Script ⌘ + B
// @author            RoCry
// @match             *://*/*
// @icon              data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAIMSURBVHgB7ZfvtcFAEMU377zvlEAHOkAFqAAdUAEqoAM6QAV0QAeogA68/W1M3ubPShbn5It7zpDE7OzNzJ3ZI7hrqBLxo0rGl8AvH+fzWbXb7VznWq2mqtWqajQaqt/vm/t3ESBCCNTrdeWLwWCg5vO5IQVWq5XTF7IQF9+PEAAEPRwOYbAgyNwEHI9HdbvdDOnJZPKfPQicTidaMWY6yF07RcZ90kdMv/n90c4mlguaaBRP/JwEptNpKgB+3W435dtqtQoREOAfrfEhAK7XayobvJEPgd1uZ3yJ5d2G1Dep/qyaP4N+e7Nmu92+NgcQrQ2E5wt5CS8CqHg4HJpvOxCq9gUxKpVKOIiyQE/v9/vYM2kle/P1eu09kMggZta5RJhniBQR2VAFRahnQX4XFDHUb3dLHgEIs7k9B5wl0P2uOp1OdE/qL5eL2mw2kQj51gTMtehgPB47JyFGB+g2jMrmJICyGZtJsBEHF8EEkGg2m+ZaDqwsAsRk8xhcJXANInuQ2DYajZ6WgDHM76y18dIcII1JJGdDEpLR2WwWe/4SATv9Ars9XaB8rLXb25sAAXq9Xup5kWmIPnSpzDCL4NIArSKnlhjPlKMlJUbeHJDDbLFYhK3rIuBjOrVhsIKDCIFDAjJvE5DNfQhIFiDiTYCFut5m4+RmRQmA5XJpYgWPhaXh+8fkS6B0An855cAlu9FutwAAAABJRU5ErkJggg==
// @exclude           /\.(js|java|c|cpp|h|py|css|less|scss|json|yaml|yml|xml)(?:\?.+)$/
// @license           MIT
// @run-at            document-end
// @grant            GM_getValue
// @grant            GM_setValue
// ==/UserScript==

/*
To change config, open browser console and run:
1. Get current config:
   > GM_getValue('config')

2. Set new config (example):
   > const newConfig = GM_getValue('config')
   > newConfig.scale = 0.7  // change any option you want
   > GM_setValue('config', newConfig)

3. Refresh the page to apply changes

Available config options are listed below in defaultConfig
*/

const defaultConfig = {
    // Whether to automatically apply bionic reading when page loads
    autoBionic: false,

    // Whether to skip processing text inside <a> tags that contain URLs
    skipLinks: true,

    // The ratio of characters to bold at the start of each word (0.0 to 1.0)
    scale: 0.5,

    // Maximum number of characters to bold in any word (null for no limit)
    maxBionicLength: null,

    // Opacity of the bold text (0.0 to 1.0)
    opacity: 1,

    // Number of words to skip between processed words (0 for no skipping)
    // Higher values create a "saccade" effect, mimicking natural eye movement
    saccade: 0,

    // Whether to use special Unicode characters instead of bold text
    symbolMode: false,

    // Whether to skip processing words in the excludeWords list
    skipWords: true,
    // List of common words to skip when skipWords is true
    excludeWords: ['is','and','as','if','the','of','to','be','for','this'],

    // Minimum word length to apply bionic reading (shorter words are skipped)
    minWordLength: 3,

    // Minimum ratio of ASCII characters required to consider content as English
    // (0.0 to 1.0) - Higher values mean stricter English detection
    minAsciiRatio: 0.9,
    // Number of characters to analyze for language detection
    charsToCheck: 300
};

let config = defaultConfig;
try {
    config = (_=>{
        const _config = GM_getValue('config');
        if(!_config) return defaultConfig;
    
        for(let key in defaultConfig){
            if(_config[key] === undefined) _config[key] = defaultConfig[key];
        }
        return _config;
    })();
    
    GM_setValue('config',config);
} catch(e) {
    console.log('Failed to read default config')
}

let isBionic = false;
let body = document.body;

const styleEl = document.createElement('style');
styleEl.textContent = `bbb{font-weight:bold;opacity:${config.opacity}}html[data-site="greasyfork"] a bionic{pointer-events:none}`;

document.documentElement.setAttribute('data-site',location.hostname.replace(/\.\w+$|www\./ig,''))

const excludeNodeNames = [
    'script','style','xmp',
    'input','textarea','select',
    'pre','code',
    'h1','h2',
    'b','strong',
    'svg','embed',
    'img','audio','video',
    'canvas',
];

const excludeClasses = [
    'highlight',
    'katex',
    'editor',
]

const excludeClassesRegexi = new RegExp(excludeClasses.join('|'),'i');
const linkRegex = /^https?:\/\//;

function isEnglishContent() {
    try {
        const title = document.title || '';
        const firstParagraphs = Array.from(document.getElementsByTagName('p'))
            .slice(0, 3)
            .map(p => p.textContent)
            .join(' ');
        
        const textToAnalyze = (title + ' ' + firstParagraphs)
            .slice(0, config.charsToCheck)
            .replace(/\s+/g, ' ')
            .trim();

        if (!textToAnalyze) return true;

        const asciiChars = textToAnalyze.replace(/[\s\.,\-_'"!?()]/g, '')
            .split('')
            .filter(char => char.charCodeAt(0) <= 127).length;
        
        const totalChars = textToAnalyze.replace(/[\s\.,\-_'"!?()]/g, '').length;
        
        if (totalChars === 0) return true;
        
        const asciiRatio = asciiChars / totalChars;
        console.log('🈂️ ASCII Ratio:', asciiRatio.toFixed(2));
        
        return asciiRatio >= config.minAsciiRatio;
    } catch (e) {
        console.error('Error checking content language:', e);
        return true;
    }
}

const gather = el=>{
    let textEls = [];
    el.childNodes.forEach(el=>{
        if(el.isEnB) return;
        if(el.originEl) return;

        if(el.nodeType === 3){
            textEls.push(el);
        }else if(el.childNodes){
            const nodeName = el.nodeName.toLowerCase();
            if(excludeNodeNames.includes(nodeName)) return;
            if(config.skipLinks){
                if(nodeName === 'a'){
                    if(linkRegex.test(el.textContent)) return;
                }
            }
            if(el.getAttribute){
                if(el.getAttribute('class') && excludeClassesRegexi.test(el.getAttribute('class'))) return;
                if(el.getAttribute('contentEditable') === 'true') return;
            }

            textEls = textEls.concat(gather(el))
        }
    })
    return textEls;
};

const engRegex  = /[a-zA-Z][a-z]+/;
const engRegexg = new RegExp(engRegex,'g');

const getHalfLength = word=>{
    if (word.length < config.minWordLength) return 0;

    let halfLength;
    if(/ing$/.test(word)){
        halfLength = word.length - 3;
    }else if(word.length<5){
        halfLength = Math.floor(word.length * config.scale);
    }else{
        halfLength = Math.ceil(word.length * config.scale);
    }

    if(config.maxBionicLength){
        halfLength = Math.min(halfLength, config.maxBionicLength)
    }
    return halfLength;
}

let count = 0;
const saccadeRound = config.saccade + 1;
const saccadeCounter = _=>{
    return ++count % saccadeRound === 0;
};

const replaceTextByEl = el=>{
    const text = el.data;
    if(!engRegex.test(text))return;

    if(!el.replaceEl){
        const spanEl = document.createElement('bionic');
        spanEl.isEnB = true;

        // Split text into parts and create DOM elements instead of using innerHTML
        const parts = text.split(engRegexg);
        const matches = text.match(engRegexg) || [];
        let currentIndex = 0;

        // Add first text part if exists
        if (parts[0]) {
            spanEl.appendChild(document.createTextNode(parts[0]));
            currentIndex++;
        }

        // Process each matched word
        matches.forEach((word, i) => {
            if(word.length < config.minWordLength || 
               (config.skipWords && config.excludeWords.includes(word)) ||
               (config.saccade && !saccadeCounter())) {
                spanEl.appendChild(document.createTextNode(word));
            } else {
                const halfLength = getHalfLength(word);
                if (halfLength === 0) {
                    spanEl.appendChild(document.createTextNode(word));
                } else {
                    const boldPart = document.createElement('bbb');
                    boldPart.textContent = word.substr(0, halfLength);
                    spanEl.appendChild(boldPart);
                    spanEl.appendChild(document.createTextNode(word.substr(halfLength)));
                }
            }

            // Add text part after the word if exists
            if (parts[currentIndex]) {
                spanEl.appendChild(document.createTextNode(parts[currentIndex]));
            }
            currentIndex++;
        });

        spanEl.originEl = el;
        el.replaceEl = spanEl;
    }

    el.after(el.replaceEl);
    el.remove();
};

const replaceTextSymbolModeByEl = el=>{
    const text = el.data;
    if(!engRegex.test(text))return;

    // For symbol mode, we can still use textContent since we're not creating HTML
    el.data = text.replace(engRegexg,word=>{
        if(word.length < config.minWordLength) return word;
        if(config.skipWords && config.excludeWords.includes(word)) return word;
        if(config.saccade && !saccadeCounter()) return word;

        const halfLength = getHalfLength(word);
        if (halfLength === 0) return word;
        const a = word.substr(0,halfLength).
            replace(/[a-z]/g,w=>String.fromCharCode(55349,w.charCodeAt(0)+56717)).
            replace(/[A-Z]/g,w=>String.fromCharCode(55349,w.charCodeAt(0)+56723));
        const b = word.substr(halfLength).
            replace(/[a-z]/g,w=> String.fromCharCode(55349,w.charCodeAt(0)+56665)).
            replace(/[A-Z]/g,w=> String.fromCharCode(55349,w.charCodeAt(0)+56671));
        return a + b;
    })
}

const bionic = _=>{
    if (!isEnglishContent()) {
        console.log('🈂️ Non-English content detected, skipping bionic reading');
        return;
    }

    const textEls = gather(body);
    isBionic = true;
    count = 0;

    let replaceFunc = config.symbolMode ? replaceTextSymbolModeByEl : replaceTextByEl;
    textEls.forEach(replaceFunc);
    document.head.appendChild(styleEl);
}

const lazy = (func,ms = 15)=> {
    return _=>{
        clearTimeout(func.T)
        func.T = setTimeout(func,ms)
    }
};

const listenerFunc = lazy(_=>{
    if(!isBionic) return;
    bionic();
});

if(window.MutationObserver){
    (new MutationObserver(listenerFunc)).observe(body,{
        childList: true,
        subtree: true,
        attributes: true,
    });
}else{
    const {open,send} = XMLHttpRequest.prototype;
    XMLHttpRequest.prototype.open = function(){
        this.addEventListener('load',listenerFunc);
        return open.apply(this,arguments);
    };
    document.addEventListener('DOMContentLoaded',listenerFunc);
    document.addEventListener('DOMNodeInserted',listenerFunc);
}

if(config.autoBionic){
    window.addEventListener('load',bionic);
}

const revoke = _=>{
    const els = [...document.querySelectorAll('bionic')];
    els.forEach(el=>{
        const {originEl} = el;
        if(!originEl) return;
        el.after(originEl);
        el.remove();
    })
    isBionic = false;
};

document.addEventListener('keydown',e=>{
    const { ctrlKey , metaKey, key } = e;
    if( ctrlKey || metaKey ){
        if(key === 'b'){
            if(isBionic){
                revoke();
            }else{
                bionic();
            }
        }
    }
})