ExHentai Viewer

manage your favorite tags, enhance searching, improve comic page

ของเมื่อวันที่ 08-10-2021 ดู เวอร์ชันล่าสุด

// ==UserScript==
// @name ExHentai Viewer
// @namespace Violentmonkey Scripts
// @description manage your favorite tags, enhance searching, improve comic page
// @match *://exhentai.org/*
// @match *://e-hentai.org/*
// @grant       GM_setValue
// @grant       GM_getValue
// @version 0.53
// ==/UserScript==

const currentVersion = '0.53';
const $ = selector => document.querySelector(selector);
const $$ = selector => document.querySelectorAll(selector);
const create = tag => document.createElement(tag);
let data = {
    custom_filter: GM_getValue('custom_filter', {}),
    script_config: GM_getValue('script_config', {})
}

init();

function init() {
    // upgrade user data if not compatible
    upgradeScriptData();
    // router
    const url = window.location.href;
    if (url.includes('/s/')) {
        // comic page
        EHViewer('s');
    } else if (url.includes('/mpv')) {
        // multi page mode
        EHViewer('mpv');
    } else if (url.includes('/g/')) {
        // gallery page
        handleGallery();
    } else if ($('#searchbox')) {
        // add panel below the searchbox
        handleSearchBox();
    }

}

function upgradeScriptData() {
    if (data.script_config.currentVersion) {
        // correct wrong naming style in 0.41
        data.script_config.current_version = data.script_config.currentVersion;
        delete data.script_config.currentVersion;
    }
    switch (data.script_config.current_version) {
        // don't break, just go through the flow
        case undefined:
            if (data.custom_filter.length === undefined) {
                // just install
            } else {
                // 0.37 or ealier
                // backup old data
                data.script_config.old_version_data = [{
                    'version': '0.37-',
                    'data': data.custom_filter
                }];
                // migrate custom filters
                let new_custom_filter = {};
                for (let item of data.custom_filter) {
                    if (item && item.name && item.tag) {
                        new_custom_filter[item.name] = item.tag.split('+');
                    }
                }
                data.custom_filter = new_custom_filter;
            }
        default:
            data.script_config.current_version = currentVersion;
            GM_setValue('custom_filter', data.custom_filter);
            GM_setValue('script_config', data.script_config);
    }
}

function changeStyle(css, mode, id = 'ehv-style') {
    let cssEl = $('#' + id);
    if (!cssEl) {
        cssEl = create('style');
        cssEl.type = 'text/css';
        cssEl.id = id;
        document.head.append(cssEl);
    }
    switch (mode) {
        case 'add':
            cssEl.innerHTML += css;
            break;
        case 'replace':
            cssEl.innerHTML = css;
            break;
    }
}

function addBtnContainer() {
    let btnStyle = `
        #ehv-btn-c{
            text-align: center;
            list-style: none;
            position: fixed;
            bottom: 30px;
            right: 30px;
            z-index: 999;
        }
        .ehv-btn {
            line-height: 32px;
            font-size: 16px;
            padding: 2px;
            margin: 5px;
            color: #233;
            position: relative;
            width: 32px;
            height: 32px;
            border: none;
            border-radius: 50%;
            box-shadow: 0 0 3px 0 #0006;
            cursor: pointer;
            user-select: none;
            outline: none;
            background-color: #fff;
            background-repeat: no-repeat;
            background-position: center;
        }
        .ehv-btn:hover{
            box-shadow: 0 0 3px 1px #0005!important;
        }
        .ehv-btn:active {
            top: 1px;
            box-shadow: 0 0 1px 1px #0004!important;
        }`;
    let btnContainer = create('ul');
    btnContainer.id = 'ehv-btn-c';
    document.body.append(btnContainer);
    changeStyle(btnStyle, 'add', 'ehv-btn-c-style');
    return btnContainer;
}

function addBtn(btn, container) {
    let btnEl = create('li');
    if (btn.icon.startsWith('data:image')) {
        btnEl.style.backgroundImage = "url('" + btn.icon + "')";
    } else {
        btnEl.innerHTML = btn.icon;
    }
    btnEl.classList.add('ehv-btn');
    btnEl.addEventListener(btn.event, btn.cb);
    container.append(btnEl);
    return btnEl;
}

function EHViewer(mode) {
    const host = window.location.host;
    const svgIcons = {
        zoomIn: 'data:image/svg+xml;utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="rgb(120,120,120)" d="M14 7H9V2a1 1 0 0 0-2 0v5H2a1 1 0 0 0 0 2h5v5a1 1 0 0 0 2 0V9h5a1 1 0 0 0 0-2z"></path></svg>',
        zoomOut: 'data:image/svg+xml;utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><rect fill="rgb(120,120,120)" x="2" y="7" width="12" height="2" rx="1"></rect></svg>',
        prevPage: 'data:image/svg+xml;utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="rgb(120,120,120)" d="M15 7H3.414l4.293-4.293a1 1 0 0 0-1.414-1.414l-6 6a1 1 0 0 0 0 1.414l6 6a1 1 0 0 0 1.414-1.414L3.414 9H15a1 1 0 0 0 0-2z"></path></svg>',
        nextPage: 'data:image/svg+xml;utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="rgb(120,120,120)" d="M15.707 7.293l-6-6a1 1 0 0 0-1.414 1.414L12.586 7H1a1 1 0 0 0 0 2h11.586l-4.293 4.293a1 1 0 1 0 1.414 1.414l6-6a1 1 0 0 0 0-1.414z"></path></svg>',
        reload: 'data:image/svg+xml;utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="rgb(120,120,120)" d="M15 1a1 1 0 0 0-1 1v2.418A6.995 6.995 0 1 0 8 15a6.954 6.954 0 0 0 4.95-2.05 1 1 0 0 0-1.414-1.414A5.019 5.019 0 1 1 12.549 6H10a1 1 0 0 0 0 2h5a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1z"></path></svg>',
        gallery: 'data:image/svg+xml;utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><g fill="rgb(120,120,120)"><rect x="1" y="1" width="6" height="6" rx="1" ry="1"></rect><rect x="9" y="1" width="6" height="6" rx="1" ry="1"></rect><rect x="1" y="9" width="6" height="6" rx="1" ry="1"></rect><rect x="9" y="9" width="6" height="6" rx="1" ry="1"></rect></g></svg>'
    }
    let css = '';
    let btnContainer = addBtnContainer();
    let currentScale;

    switch (mode) {
        case 's':
            currentScale = 0.45;
            css += `
                #i1{
                    width:98%!important;
                    max-width:98%!important;
                    min-width:800px;
                    background-color: inherit!important;
                    border: none!important
                }
                #img{
                    max-width:none!important;
                    max-height:none!important;
                    background-color: #4f535b!important;
                    padding: 8px;
                    border: 1px solid #000;
                    border-radius: 2px;
                }
                h1, #i2, #i5, #i6, #i7, .ip, .sn{
                    display:none!important;
                }
                ::-webkit-scrollbar{
                    display:none;
                }
                html{
                    scrollbar-width: none;
                }`;
            // add btns
            [
                { icon: svgIcons.zoomIn, event: 'mousedown', cb: e => zoomer(zoomInS) },
                { icon: svgIcons.zoomOut, event: 'mousedown', cb: e => zoomer(zoomOutS) },
                { icon: svgIcons.prevPage, event: 'click', cb: prevPage },
                { icon: svgIcons.nextPage, event: 'click', cb: nextPage },
                { icon: svgIcons.reload, event: 'click', cb: e => $('#loadfail').click() },
                { icon: svgIcons.gallery, event: 'click', cb: e => $('div.sb>a').click() }
            ].forEach(v => addBtn(v, btnContainer));
            // handle key events
            document.body.addEventListener('keydown', e => {
                switch (e.key) {
                    case "=":
                        zoomInS(0.1);
                        break;
                    case "-":
                        zoomOutS(0.1);
                        break;
                    case ",":
                        window.scroll(0, 0);
                        break;
                    case ".":
                        window.scroll(0, window.innerHeight);
                        break;
                    case "[":
                        window.scrollBy(0, -window.innerHeight * 0.3);
                        break;
                    case "]":
                        window.scrollBy(0, window.innerHeight * 0.3);
                        break;
                }
            });
            // set scale on page load
            setNewPage();
            // set scale on img load
            let observer = new MutationObserver(setNewPage);
            observer.observe($('#i3'), { attributes: false, childList: true, subtree: false });
            break;
        case 'mpv':
            currentScale = 1;
            css += '#pane_images_inner>div{margin:auto;}';
            // add btns
            [
                { icon: svgIcons.zoomIn, event: 'mousedown', cb: e => zoomer(zoomInMpv) },
                { icon: svgIcons.zoomOut, event: 'mousedown', cb: e => zoomer(zoomOutMpv) }
            ].forEach(v => addBtn(v, btnContainer));
            document.body.addEventListener('keydown', e => {
                switch (e.key) {
                    case "=":
                        zoomInMpv(0.1);
                        break;
                    case "-":
                        zoomOutMpv(0.1);
                        break;
                }
            });
            break;
    }

    // add style
    if(data.script_config.hide_button){
        css += `
            #ehv-btn-c{
                padding: 80px 30px 30px 80px;
                bottom: 0!important;
                right: -80px!important;
                transition-duration: 300ms;
            }
            #ehv-btn-c:hover{
                padding: 80px 30px 30px 20px!important;
                right: 0!important;
            }`;
    }
    css += '.ehv-btn{background-color: #44454B!important;}';
    if (host === 'e-hentai.org') {
        // btn color
        css = css.replaceAll('#44454B', '#D3D0D1');
        // page bg & border (for mode 's')
        css = css.replace('#4f535b', '#EDEBDF').replace('1px solid #000', '1px solid #5C0D12');
    }
    changeStyle(css, 'replace', 'ehv-style');

    // === functions ===
    function zoomer(cb) {
        // long press to trigger continuous scaling(zoomTimeout > zoomInterval)
        let zoomInterval = -1;
        let zoomTimeout = setTimeout(function () {
            zoomInterval = setInterval(cb, 100);
        }, 800);
        document.addEventListener('mouseup', e=>{
            if (zoomInterval === -1) {
                cb(0.1);
                clearTimeout(zoomTimeout);
            } else {
                clearInterval(zoomInterval);
            }
        }, {once: true});
    }
    function prevPage() {
        const currentPage = $('.sn>span:first-child').innerText
        currentPage !== '1' ? $('#prev').click() : alert('This is first page (⊙_⊙)');
    }
    function nextPage() {
        const currentPage = $('.sn span:first-child').innerText
        const lastPage = $('.sn span:last-child').innerText;
        currentPage !== lastPage ? $('#next').click() : alert('This is last page (⊙ω⊙)');
    }
    function setNewPage() {
        // add page indicator
        let footer = $('#i4 > div:first-child');
        const currentPage = $('.sn span:first-child').innerText;
        const totalPage = $('.sn span:last-child').innerText;
        footer.innerHTML = currentPage + 'P / ' + totalPage + 'P :: ' + footer.innerText + ' :: ';
        // add origin source
        let imgLink = $('#i7>a');
        imgLink ? footer.append(imgLink) : footer.innerHTML += 'No download';
        // inherit the zoom scale of previous page
        zoom4S();
    }
    function zoomInS(pace = 0.02) {
        const targetScale = currentScale * (1 + pace);
        currentScale = targetScale > 1 ? 1 : targetScale;
        zoom4S();
    }
    function zoomOutS(pace = 0.02) {
        const img = $('#img');
        const targetScale = currentScale * (1 - pace);
        currentScale = img.height > window.innerHeight ? targetScale : currentScale;
        zoom4S();
    }
    function zoom4S() {
        const img = $('#img');
        img.style.width = currentScale * 100 + '%';
        img.style.height = 'auto';
    }
    function zoomInMpv(pace = 0.02) {
        const maxWidth = Number($('#pane_images').style.width.replace("px", "")) - 20;
        const originalWidth = Number($('#image_1').style.maxWidth.replace("px", ""));
        currentScale = originalWidth * (1 + pace) < maxWidth ? currentScale + pace : currentScale;
        zoom4Mpv(originalWidth * currentScale);
    }
    function zoomOutMpv(pace = 0.02) {
        const minWidth = 200;
        const originalWidth = Number($('#image_1').style.maxWidth.replace("px", ""));
        currentScale = originalWidth * (currentScale - pace) > minWidth ? currentScale - pace : currentScale;
        zoom4Mpv(originalWidth * currentScale);
    }
    function zoom4Mpv(width) {
        let mpvStyle = 'img[id^="imgsrc"], div[id^="image"]{width:mpvWidth!important;height:auto!important; max-width:100%!important;min-width:200px!important}"';
        mpvStyle = mpvStyle.replace('mpvWidth', width + 'px');
        changeStyle(mpvStyle, 'replace', 'custom-width');
    }
}

function handleGallery() {
    // add searchbox
    let searchBox = create('form');
    searchBox.innerHTML = `
        <p class="nopm">
            <input type="text" id="f_search" name="f_search" placeholder="Search Keywords" value="" size="60">
            <input type="submit" name="f_apply" value="Apply Filter">
        </p>`;
    searchBox.setAttribute('style', 'display: none; width: 600px; margin: 10px auto; border: 2px ridge black; padding: 10px;');
    searchBox.setAttribute('action', 'https://' + window.location.host + '/');
    searchBox.setAttribute('method', 'get');
    $('.gm').before(searchBox);

    // add btn to show/hide searchbox
    let tbody = $('#taglist > table > tbody');
    tbody.innerHTML += `
        <tr>
            <td class="tc">EHV:</td>
            <td>
                <div id="ehv-panel-btn" class="gt" style="cursor:pointer">show panel</div>
            </td>
        </tr>`;
    $('#ehv-panel-btn').addEventListener('click', e => {
        const t = e.target;
        if (t.innerText == 'show panel') {
            searchBox.style.display = "block";
            t.innerText = 'hide panel';
        } else {
            searchBox.style.display = "none";
            t.innerText = 'show panel';
        }
    });

    // add panel
    setPanel();

    // add gallery tag to searchbox by right-click
    tbody.addEventListener('contextmenu', applyGalleryTag);

    function applyGalleryTag(e) {
        const t = e.target;
        if (t.tagName === 'A') {
            e.preventDefault();
            const searchInput = $('#f_search');
            let tag = t.href.split('/').pop().replaceAll('+', " ");
            tag = `"${tag}"`;
            searchInput.value.includes(tag) ? searchInput.value = searchInput.value.replace(tag, '') : searchInput.value += tag;
        }
    }
}

function handleSearchBox() {
    setPanel();
    const ehvPanel = $('#ehv-panel');
    if (data.script_config.hide_panel) {
        ehvPanel.style.display = 'none';
        const a = create('a');
        a.id = 'ehv-panel-btn';
        a.innerText = 'Show EHV Panel';
        a.setAttribute('href', '#');
        a.setAttribute('style', 'margin-left: 1em;');
        a.addEventListener('click', function () {
            if (ehvPanel.style.display == 'none') {
                ehvPanel.style.display = 'block';
                a.innerText = 'Hide EHV Panel';
            } else {
                ehvPanel.style.display = 'none';
                a.innerText = 'Show EHV Panel';
            }
        });
        $$('p.nopm')[1].append(a);
    }else{
        $('#ehv-panel-btn') && $('#ehv-panel-btn').remove();
    }
}

function setPanel() {
    const filters = data.custom_filter;
    const container = $('.nopm');
    const searchInput = $('#f_search');
    let ehvPanel = $('#ehv-panel');

    if (ehvPanel) ehvPanel.remove();

    ehvPanel = document.createElement('div');
    ehvPanel.setAttribute('class', 'nopm');
    ehvPanel.setAttribute('id', 'ehv-panel');
    ehvPanel.setAttribute('style', 'padding-top:8px;');
    container.append(ehvPanel);
    // filter buttons
    for (let key in filters) {
        let filterBtn = create('input');
        filterBtn.setAttribute('type', 'button');
        filterBtn.setAttribute('value', key);
        filterBtn.setAttribute('title', filters[key].toString());
        filterBtn.addEventListener('click', applyFilter);
        filterBtn.addEventListener('contextmenu', removeFilter);
        if (isExist(filters[key])) {
            filterBtn.setAttribute('style', 'filter: invert(20%);');
        } else {
            filterBtn.removeAttribute('style');
        }
        ehvPanel.append(filterBtn);
    }
    //new filter button
    let addFilterBtn = create('input');
    addFilterBtn.setAttribute('type', 'button');
    addFilterBtn.setAttribute('value', '+');
    addFilterBtn.addEventListener('click', addFilter);
    addFilterBtn.addEventListener('contextmenu', ehvSetting);
    ehvPanel.append(addFilterBtn);

    // 

    // === function ===
    function isExist(values) {
        const inputValue = searchInput.value;
        return values.every(v => inputValue.includes(v));
    }
    function applyFilter(e) {
        let t = e.target;
        let key = t.value;
        let values = filters[key];

        if (isExist(values)) {
            values.forEach(v => searchInput.value = searchInput.value.replaceAll(`"${v}"`, ''));
            t.removeAttribute('style');
        } else {
            values.forEach(v => searchInput.value += `"${v}"`);
            t.setAttribute('style', 'filter: invert(20%);');
        }
    }
    function addFilter() {
        const input = prompt('Add filter like format below', '[tag] or [name@tag] or [name@tag+tag+tag+tag]');
        const filterStr = input ? input.split('@') : '';
        let key, value;
        if(filterStr.length === 2){
            key = filterStr[0];
            value = filterStr[1].split('+');
            filters[key] = value;
        }else if(filterStr.length === 1 && filterStr[0] !== ''){
            key = value = filterStr[0];
            filters[key] = [value];
        }else{
            alert('Invalid input...:(');
            return;
        }
        GM_setValue('custom_filter', filters);
        setPanel();
    }
    function ehvSetting(e){
        e.preventDefault();
        data.script_config.hide_panel = confirm('hide EHV panel by default?');
        data.script_config.hide_button = confirm('hide comic page buttons by default? (hover lower right corner to show)');
        GM_setValue('script_config', data.script_config);
        handleSearchBox();
    }
    function removeFilter(e){
        e.preventDefault();
        if(confirm('Delete this tag?')){
            let key = e.target.value;
            delete filters[key];
            GM_setValue('custom_filter', filters);
            setPanel();
        }
    }
}