ExHentai Viewer

manage your favorite tags, enhance searching, improve comic page

As of 2023-04-21. See the latest version.

// ==UserScript==
// @name ExHentai Viewer
// @namespace Violentmonkey Scripts
// @description manage your favorite tags, enhance searching, improve comic page
// @description:zh-CN 管理标签,增强搜索,改进漫画页面
// @match *://exhentai.org/*
// @match *://e-hentai.org/*
// @grant       GM_setValue
// @grant       GM_getValue
// @version 0.63
// ==/UserScript==
/*
 * To-do:
 *   1. preload images?
 */

const currentVersion = '0.62';
const sortFilters = false;
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 and load script config
    loadScriptData();
    // 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 loadScriptData() {
    switch (data.script_config.current_version) {
        // don't break, just go through the flow to upgrade script data by version
        case undefined:
            // just install
        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="Search">
          </p>`;
    searchBox.setAttribute(
        'style',
        'display: none; width: 720px; 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('+', ' ');
            filter = `"${tag}" `;
            // add tailing space
            if(searchInput.value.length > 0 && searchInput.value.endsWith(' ') === false) searchInput.value += ' ';
            searchInput.value.includes(filter)
                ? (searchInput.value = searchInput.value.replace(filter, ''))
                : (searchInput.value += filter);
        }
    }
}

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]';
            }
        });
        $$('#searchbox>form>div')[1].append(a);
    } else {
        $('#ehv-panel-btn') && $('#ehv-panel-btn').remove();
    }
}

function setPanel() {
    const container = $('#f_search').parentNode;
    const searchInput = $('#f_search');

    // set style
    const panelCss = `#ehv-panel > input[type="button"]{ margin: 2px; }`;
    let panelStyle = $('#panel-style');
    if (!panelStyle) {
        panelStyle = create('style');
        panelStyle.id = 'panel-style';
        panelStyle.textContent = panelCss;
        document.head.append(panelStyle);
    }

    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 data.custom_filter) {
        let filterBtn = create('input');
        filterBtn.setAttribute('type', 'button');
        filterBtn.setAttribute('value', key);
        filterBtn.setAttribute('title', data.custom_filter[key].toString());
        filterBtn.addEventListener('click', applyFilter);
        filterBtn.addEventListener('contextmenu', removeFilter);
        if (isExist(data.custom_filter[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);

    // enhace apply filter button
    const searchBtn = $('#f_search+input');
    searchBtn.addEventListener('contextmenu', newtabSearch);
    searchBtn.title = 'right click to search in new page';

    // === 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 = data.custom_filter[key];

        // add tailing space
        if(searchInput.value.length > 0 && searchInput.value.endsWith(' ') === false) searchInput.value += ' ';

        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() {
        data.custom_filter = GM_getValue('custom_filter', {}); // get latest filter data
        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('+');
            data.custom_filter[key] = value;
        } else if (filterStr.length === 1 && filterStr[0] !== '') {
            key = value = filterStr[0];
            data.custom_filter[key] = [value];
        } else {
            alert('Invalid input...:(');
            return;
        }
        // sort filters by char codepoint
        if(sortFilters === true){
            const newFilters = {};
            const sortedKeys = Object.keys(data.custom_filter).sort();
            for (let key of sortedKeys) {
                newFilters[key] = data.custom_filter[key];
            }
            data.custom_filter = newFilters;
        }
        GM_setValue('custom_filter', data.custom_filter);
        setPanel();
    }
    function ehvSetting(e) {
        e.preventDefault();
        data.script_config = GM_getValue('script_config', {}); // sync latest config data
        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();
        data.custom_filter = GM_getValue('custom_filter', {}); // get latest filter data
        if (confirm('Delete this tag?')) {
            let key = e.target.value;
            delete data.custom_filter[key];
            GM_setValue('custom_filter', data.custom_filter);
            setPanel();
        }
    }
    function newtabSearch(e) {
        e.preventDefault();
        const keyword = e.target.previousElementSibling.value;
        const url = `https://${location.host}/?f_search=${keyword}`;
        open(url);
    }
}