全体をジャンプさせつつ、.p-novelの枠内も20pxスクロールさせます
// ==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);
});
})();