naro-scroll

全体をジャンプさせつつ、.p-novelの枠内も20pxスクロールさせます

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         naro-scroll
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  全体をジャンプさせつつ、.p-novelの枠内も20pxスクロールさせます
// @author       You
// @match        https://ncode.syosetu.com/*
// @grant        none
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    // --- 設定値:ここを書き換えて調整してください ---
    let SCROLL_AMOUNT = 652; // 上下ボタンのスクロール量
    let JUMP_OFFSET_RATIO = 0.14; // 全体ジャンプ時の沈み込み量(画面の20%)
    let INNER_EXTRA_SCROLL = 47; // エリア内の追加スクロール量(20px)
    let NOVEL_COLUMN_WIDTH_EM = null;
    // ------------------------------------------

    const STORAGE_KEY = `naro-scroll:${location.hostname}`;

    const readSavedSettings = () => {
        try {
            const rawValue = localStorage.getItem(STORAGE_KEY);
            if (!rawValue) return null;
            const parsed = JSON.parse(rawValue);
            return parsed && typeof parsed === 'object' ? parsed : null;
        } catch (error) {
            return null;
        }
    };

    const saveCurrentSettings = () => {
        const payload = {
            scrollAmount: SCROLL_AMOUNT,
            jumpOffsetRatio: JUMP_OFFSET_RATIO,
            innerExtraScroll: INNER_EXTRA_SCROLL,
            novelColumnWidthEm: NOVEL_COLUMN_WIDTH_EM
        };
        localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
    };

    const applySavedSettingsToState = (settings) => {
        if (!settings) return;

        const scrollAmount = Number(settings.scrollAmount);
        const jumpOffsetRatio = Number(settings.jumpOffsetRatio);
        const innerExtraScroll = Number(settings.innerExtraScroll);
        const novelColumnWidthEm = Number(settings.novelColumnWidthEm);

        if (Number.isFinite(scrollAmount)) {
            SCROLL_AMOUNT = scrollAmount;
        }
        if (Number.isFinite(jumpOffsetRatio)) {
            JUMP_OFFSET_RATIO = jumpOffsetRatio;
        }
        if (Number.isFinite(innerExtraScroll)) {
            INNER_EXTRA_SCROLL = innerExtraScroll;
        }
        if (Number.isFinite(novelColumnWidthEm)) {
            NOVEL_COLUMN_WIDTH_EM = novelColumnWidthEm;
        }
    };

    applySavedSettingsToState(readSavedSettings());

    const waitForElement = (selector, callback) => {
        const interval = setInterval(() => {
            const el = document.querySelector(selector);
            if (el) { clearInterval(interval); callback(el); }
        }, 300);
    };

    let updateScrollAmountButtonText = () => {};

    // 1. 本文エリア (.p-novel) の上部横並びボタン
    waitForElement('.p-novel', (novelArea) => {
        const btnContainer = document.createElement('div');
        let downBtn = null;
        Object.assign(btnContainer.style, {
            position: 'sticky',
            top: '0',
            left: '0',
            width: '100%',
            zIndex: '99999',
            display: 'flex',
            justifyContent: 'center',
            gap: '0px',
            pointerEvents: 'none'
        });

        const createBtn = (text, direction) => {
            const btn = document.createElement('button');
            btn.innerText = text;
            Object.assign(btn.style, {
                flex: '1',
                maxWidth: '120px',
                height: '35px',
                lineHeight: '23px',
                backgroundColor: 'rgba(0, 0, 0, 0.3)',
                color: '#fff',
                border: '1px solid rgba(255, 255, 255, 0.3)',
                cursor: 'pointer',
                fontSize: '12px',
                fontWeight: 'bold',
                textShadow: '1px 1px 1px #000',
                pointerEvents: 'auto',
                padding: '0'
            });

            btn.onmouseover = () => btn.style.backgroundColor = 'rgba(255, 255, 255, 0.2)';
            btn.onmouseout = () => btn.style.backgroundColor = 'rgba(0, 0, 0, 0.3)';
            btn.onclick = () => novelArea.scrollBy({ top: SCROLL_AMOUNT * direction, behavior: 'smooth' });
            return btn;
        };

        btnContainer.appendChild(createBtn('▲ UP', -1));
        downBtn = createBtn('▼ DOWN ' + SCROLL_AMOUNT, 1);
        btnContainer.appendChild(downBtn);
        novelArea.prepend(btnContainer);

        updateScrollAmountButtonText = () => {
            if (downBtn) {
                downBtn.innerText = `▼ DOWN ${SCROLL_AMOUNT}`;
            }
        };

        // 左内側の縦ボタン
        const sideBtnContainer = document.createElement('div');
        Object.assign(sideBtnContainer.style, {
            position: 'sticky',
            top: '50%',
            left: '0',
            transform: 'translateY(-50%)',
            height: '100vh',
            width: '0',
            zIndex: '99999',
            display: 'flex',
            flexDirection: 'column',
            gap: '4px',
            pointerEvents: 'none'
        });

        const createSideBtn = (text, direction) => {
            const btn = document.createElement('button');
            btn.innerText = text;
            Object.assign(btn.style, {
                marginLeft: '4px',
                width: '28px',
                height: '100vh',
                backgroundColor: 'rgba(0, 0, 0, 0.3)',
                color: '#fff',
                border: '1px solid rgba(255, 255, 255, 0.3)',
                cursor: 'pointer',
                fontSize: '14px',
                fontWeight: 'bold',
                textShadow: '1px 1px 1px #000',
                pointerEvents: 'auto',
                padding: '0'
            });

            btn.onmouseover = () => btn.style.backgroundColor = 'rgba(255, 255, 255, 0.2)';
            btn.onmouseout = () => btn.style.backgroundColor = 'rgba(0, 0, 0, 0.3)';
            btn.onclick = () => novelArea.scrollBy({ top: SCROLL_AMOUNT * direction, behavior: 'smooth' });
            return btn;
        };

        sideBtnContainer.appendChild(createSideBtn(' ▽ ', 1));
        // sideBtnContainer.appendChild(createSideBtn('△', -1));
        novelArea.appendChild(sideBtnContainer);
    });

    // 2. ヘッダー内ジャンプリンク(全体スクロール + エリア内微調整)
    waitForElement('header.c-menu', (header) => {
        let controlsVisible = false;
        const jumpLinkRow = document.createElement('div');
        Object.assign(jumpLinkRow.style, {
            position: 'relative'
        });

        const jumpLink = document.createElement('a');
        jumpLink.href = 'javascript:void(0);';
        Object.assign(jumpLink.style, {
            display: 'block', width: '100%', backgroundColor: '#111', color: '#fff',
            textAlign: 'center', padding: '10px 0', fontSize: '14px', textDecoration: 'none',
            fontWeight: 'bold', borderBottom: '1px solid #333'
        });

        const formatRatio = (ratio) => ratio.toFixed(2).replace(/\.00$/, '').replace(/0$/, '');
        const updateJumpLinkText = () => {
            const jumpOffsetPercent = (JUMP_OFFSET_RATIO * 100).toFixed(1).replace(/\.0$/, '');
            jumpLink.innerText = `📖 本文(${jumpOffsetPercent}%下)へジャンプ`;
        };

        const controlRow = document.createElement('div');
        Object.assign(controlRow.style, {
            position: 'relative',
            display: 'flex',
            justifyContent: 'center',
            alignItems: 'center',
            backgroundColor: '#111',
            borderBottom: '1px solid #333',
            padding: '8px 0'
        });

        const createCenteredControlRow = () => {
            const row = document.createElement('div');
            Object.assign(row.style, {
                display: 'flex',
                justifyContent: 'center',
                alignItems: 'center',
                gap: '16px'
            });
            return row;
        };

        const createControlGroup = () => {
            const group = document.createElement('div');
            Object.assign(group.style, {
                display: 'flex',
                alignItems: 'center',
                gap: '6px'
            });
            return group;
        };

        const createLabeledControl = (labelText, controlGroup) => {
            const wrapper = document.createElement('div');
            Object.assign(wrapper.style, {
                display: 'flex',
                flexDirection: 'row',
                alignItems: 'center',
                gap: '4px'
            });

            const label = document.createElement('div');
            label.innerText = labelText;
            Object.assign(label.style, {
                color: '#ccc',
                fontSize: '11px',
                fontWeight: 'bold',
                lineHeight: '1',
                minWidth: '72px',
                textAlign: 'right'
            });

            wrapper.appendChild(label);
            wrapper.appendChild(controlGroup);
            return wrapper;
        };

        const createActionButton = (text) => {
            const button = document.createElement('button');
            button.innerText = text;
            Object.assign(button.style, {
                minWidth: '72px',
                height: '28px',
                padding: '0 10px',
                fontSize: '12px',
                fontWeight: 'bold',
                color: '#fff',
                backgroundColor: '#222',
                border: '1px solid #555',
                cursor: 'pointer'
            });
            button.onmouseover = () => button.style.backgroundColor = '#333';
            button.onmouseout = () => button.style.backgroundColor = '#222';
            return button;
        };

        const toggleControlsButton = createActionButton('非表示');
        Object.assign(toggleControlsButton.style, {
            position: 'absolute',
            right: '16px',
            top: '50%',
            transform: 'translateY(-50%)'
        });

        const ratioInput = document.createElement('input');
        ratioInput.type = 'text';
        ratioInput.value = formatRatio(JUMP_OFFSET_RATIO);
        Object.assign(ratioInput.style, {
            width: '72px',
            textAlign: 'center',
            fontSize: '14px',
            padding: '4px',
            color: '#fff',
            backgroundColor: '#222',
            border: '1px solid #555'
        });

        const createRatioBtn = (text) => {
            const btn = document.createElement('button');
            btn.innerText = text;
            Object.assign(btn.style, {
                width: '28px',
                height: '28px',
                lineHeight: '24px',
                fontSize: '16px',
                fontWeight: 'bold',
                color: '#fff',
                backgroundColor: '#222',
                border: '1px solid #555',
                cursor: 'pointer'
            });
            btn.onmouseover = () => btn.style.backgroundColor = '#333';
            btn.onmouseout = () => btn.style.backgroundColor = '#222';
            return btn;
        };

        const clampRatio = (value) => Math.max(0, Math.min(1, value));
        const setRatio = (value) => {
            if (!Number.isFinite(value)) return;
            JUMP_OFFSET_RATIO = clampRatio(value);
            ratioInput.value = formatRatio(JUMP_OFFSET_RATIO);
            updateJumpLinkText();
        };

        const minusBtn = createRatioBtn('-');
        const plusBtn = createRatioBtn('+');

        minusBtn.onclick = () => setRatio(JUMP_OFFSET_RATIO - 0.01);
        plusBtn.onclick = () => setRatio(JUMP_OFFSET_RATIO + 0.01);

        ratioInput.onchange = () => {
            const parsed = parseFloat(ratioInput.value);
            if (Number.isFinite(parsed)) {
                setRatio(parsed);
            } else {
                ratioInput.value = formatRatio(JUMP_OFFSET_RATIO);
            }
        };

        const formatInteger = (value) => String(Math.round(value));
        const makeIntegerControl = (initialValue, minValue, maxValue, stepValue, onChange) => {
            let currentValue = initialValue;
            const input = document.createElement('input');
            input.type = 'text';
            input.value = formatInteger(currentValue);
            Object.assign(input.style, {
                width: '72px',
                textAlign: 'center',
                fontSize: '14px',
                padding: '4px',
                color: '#fff',
                backgroundColor: '#222',
                border: '1px solid #555'
            });

            const clampValue = (value) => Math.max(minValue, Math.min(maxValue, value));
            const applyValue = (value) => {
                if (!Number.isFinite(value)) return;
                currentValue = clampValue(value);
                input.value = formatInteger(currentValue);
                onChange(currentValue);
            };

            const minus = createRatioBtn('-');
            const plus = createRatioBtn('+');

            minus.onclick = () => applyValue(currentValue - stepValue);
            plus.onclick = () => applyValue(currentValue + stepValue);

            input.onchange = () => {
                const parsed = parseFloat(input.value);
                if (Number.isFinite(parsed)) {
                    applyValue(parsed);
                } else {
                    input.value = formatInteger(currentValue);
                }
            };

            applyValue(currentValue);
            return { input, minus, plus, applyValue };
        };

        const rootStyle = document.documentElement.style;
        const formatWidth = (value) => value.toFixed(1).replace(/\.0$/, '');
        const getCurrentColumnWidthEm = () => {
            const rawValue = getComputedStyle(document.documentElement)
                .getPropertyValue('--novel-column-width')
                .trim();
            const parsed = parseFloat(rawValue);
            return Number.isFinite(parsed) ? parsed : 50;
        };

        if (!Number.isFinite(NOVEL_COLUMN_WIDTH_EM)) {
            NOVEL_COLUMN_WIDTH_EM = getCurrentColumnWidthEm();
        }
        const widthInput = document.createElement('input');
        widthInput.type = 'text';
        Object.assign(widthInput.style, {
            width: '72px',
            textAlign: 'center',
            fontSize: '14px',
            padding: '4px',
            color: '#fff',
            backgroundColor: '#222',
            border: '1px solid #555'
        });

        const clampWidth = (value) => Math.max(20, Math.min(120, value));
        const setColumnWidth = (value) => {
            if (!Number.isFinite(value)) return;
            NOVEL_COLUMN_WIDTH_EM = clampWidth(value);
            widthInput.value = formatWidth(NOVEL_COLUMN_WIDTH_EM);
            rootStyle.setProperty('--novel-column-width', `${formatWidth(NOVEL_COLUMN_WIDTH_EM)}em`);
        };

        const widthMinusBtn = createRatioBtn('-');
        const widthPlusBtn = createRatioBtn('+');

        widthMinusBtn.onclick = () => setColumnWidth(NOVEL_COLUMN_WIDTH_EM - 1);
        widthPlusBtn.onclick = () => setColumnWidth(NOVEL_COLUMN_WIDTH_EM + 1);

        widthInput.onchange = () => {
            const parsed = parseFloat(widthInput.value);
            if (Number.isFinite(parsed)) {
                setColumnWidth(parsed);
            } else {
                widthInput.value = formatWidth(NOVEL_COLUMN_WIDTH_EM);
            }
        };

        const scrollAmountControl = makeIntegerControl(SCROLL_AMOUNT, 1, 5000, 10, (value) => {
            SCROLL_AMOUNT = value;
            updateScrollAmountButtonText();
        });

        const innerExtraScrollControl = makeIntegerControl(INNER_EXTRA_SCROLL, 0, 1000, 1, (value) => {
            INNER_EXTRA_SCROLL = value;
        });

        const ratioControl = createControlGroup();
        ratioControl.appendChild(minusBtn);
        ratioControl.appendChild(ratioInput);
        ratioControl.appendChild(plusBtn);

        const widthControl = createControlGroup();
        widthControl.appendChild(widthMinusBtn);
        widthControl.appendChild(widthInput);
        widthControl.appendChild(widthPlusBtn);

        const scrollAmountControlGroup = createControlGroup();
        scrollAmountControlGroup.appendChild(scrollAmountControl.minus);
        scrollAmountControlGroup.appendChild(scrollAmountControl.input);
        scrollAmountControlGroup.appendChild(scrollAmountControl.plus);

        const innerExtraScrollControlGroup = createControlGroup();
        innerExtraScrollControlGroup.appendChild(innerExtraScrollControl.minus);
        innerExtraScrollControlGroup.appendChild(innerExtraScrollControl.input);
        innerExtraScrollControlGroup.appendChild(innerExtraScrollControl.plus);

        const scrollSettingsRow = document.createElement('div');
        Object.assign(scrollSettingsRow.style, {
            backgroundColor: '#111',
            borderBottom: '1px solid #333',
            padding: '8px 0'
        });

        const scrollControlRow = createCenteredControlRow();

        scrollControlRow.appendChild(createLabeledControl('上下量', scrollAmountControlGroup));
        scrollControlRow.appendChild(createLabeledControl('内スクロール', innerExtraScrollControlGroup));
        scrollSettingsRow.appendChild(scrollControlRow);

        const saveButton = createActionButton('保存');
        Object.assign(saveButton.style, {
            position: 'absolute',
            right: '16px',
            top: '50%',
            transform: 'translateY(-50%)'
        });

        saveButton.onclick = () => {
            saveCurrentSettings();
        };

        const topControlGroupRow = createCenteredControlRow();
        topControlGroupRow.appendChild(createLabeledControl('ジャンプ率', ratioControl));
        topControlGroupRow.appendChild(createLabeledControl('本文幅', widthControl));
        controlRow.appendChild(topControlGroupRow);
        controlRow.appendChild(saveButton);

        const updateControlsVisibility = () => {
            controlRow.style.display = controlsVisible ? 'flex' : 'none';
            scrollSettingsRow.style.display = controlsVisible ? 'block' : 'none';
            toggleControlsButton.innerText = controlsVisible ? '非表示' : '表示';
        };

        toggleControlsButton.onclick = () => {
            controlsVisible = !controlsVisible;
            updateControlsVisibility();
        };

        jumpLinkRow.appendChild(jumpLink);
        jumpLinkRow.appendChild(toggleControlsButton);
        updateJumpLinkText();
        setColumnWidth(NOVEL_COLUMN_WIDTH_EM);
        updateScrollAmountButtonText();
        updateControlsVisibility();

        jumpLink.onclick = (e) => {
            e.preventDefault();
            const target = document.querySelector('.p-novel__body');
            const novelArea = document.querySelector('.p-novel');

            if (target && novelArea) {
                // A. まず、枠内の位置をリセットしてから指定分(20px)だけスクロール
                novelArea.scrollTo({ top: INNER_EXTRA_SCROLL, behavior: 'smooth' });

                // B. 次に、ページ全体の位置を計算して移動
                const headerHeight = header.offsetHeight;
                const extraOffset = window.innerHeight * JUMP_OFFSET_RATIO;
                const targetPosition = target.getBoundingClientRect().top + window.pageYOffset - headerHeight + extraOffset;

                window.scrollTo({
                    top: targetPosition,
                    behavior: 'smooth'
                });
            }
        };
        header.prepend(jumpLinkRow);
        header.prepend(controlRow);
        header.prepend(scrollSettingsRow);
    });
})();