app/src/main/assets/reader/content.js
raw · 37033 bytes
(async () => {
'use strict';
if (document.getElementById('narou-tategaki-host')) return;
const sources = [...document.querySelectorAll('.p-novel__text')];
if (!sources.length) sources.push(...document.querySelectorAll('#novel_p, #novel_honbun, #novel_a'));
if (!sources.length) return; // Leave table-of-contents and other pages alone.
const i18n = await NarouI18n.create();
const t = (key, values) => i18n.text(key, values);
const bindings = [];
const motionDefaults = {acceleration: 900, speed: 120, deadzone: .08, brake: .15, reverse: false};
let motionSettings = {...motionDefaults};
const defaults = {font: 20, spacing: 1.9, paragraph: 0.3, theme: 'paper', auto: true, normalColor: null, highlightColor: null, backgroundColor: null, language: i18n.language};
const textColors = {paper: ['#2d332f', '#17694f', '#e3e3df'], white: ['#222222', '#17694f', '#e3e3df'], dark: ['#e2e4de', '#79dfb5', '#000000']};
let settings = {...defaults}, opened = false, lastFocus;
let nativeBackgroundColor = null;
let controlsVisible = false, savedPosition = 0, restoring = false, saveTimer;
const positionKey = `narouPosition:${location.pathname}`;
const notify = data => browser.runtime.sendNativeMessage('narou', data).catch(() => {});
function setControls(visible) {
controlsVisible = visible;
calibrationRunning = false;
panel.classList.toggle('chrome-hidden', !visible);
header.inert = !visible;
resetMotion();
footer.inert = !visible;
}
function savePosition() {
if (!opened || restoring) return;
savedPosition = fraction();
browser.storage.local.set({[positionKey]: {position: savedPosition, updated: Date.now()}}).catch(() => {});
}
const inertBefore = new Map();
const host = document.createElement('div');
host.id = 'narou-tategaki-host';
host.style.setProperty('all', 'initial', 'important');
host.style.setProperty('position', 'fixed', 'important');
host.style.setProperty('inset', '0', 'important');
host.style.setProperty('z-index', '2147483647', 'important');
host.style.setProperty('pointer-events', 'none', 'important');
const shadow = host.attachShadow({mode: 'open'});
const style = document.createElement('style');
style.textContent = `
:host { color-scheme: light; }
* { box-sizing: border-box; }
[hidden], [inert] { display: none !important; }
button, select, input { font: inherit; }
button, select { border: 1px solid var(--border); background: var(--bg); color: var(--fg); border-radius: 7px; padding: 7px 10px; }
button, a, select, input { touch-action: manipulation; }
button, a { cursor: pointer; }
button:hover { background: var(--hover); }
:focus-visible { outline: 3px solid #459a83; outline-offset: 2px; }
#launch { pointer-events: auto; position: fixed; right: 20px; bottom: 20px; background: #25483f; color: #fff6df; border: 0; font: 16px sans-serif; box-shadow: 0 2px 12px #0004; padding: 12px 18px; }
#panel { --bg: #f6f0e3; --fg: #2d332f; --border: #cfc8b9; --hover: #e8dfce; pointer-events: auto; position: fixed; inset: 0; height: 100vh; height: 100dvh; display: grid; grid-template-rows: minmax(0, 1fr); background: var(--bg); color: var(--fg); font: 14px/1.5 system-ui, sans-serif; }
#panel[data-theme="white"] { --bg: #fff; --fg: #222; --border: #ddd; --hover: #eee; }
#panel[data-theme="dark"] { --bg: #18201e; --fg: #e2e4de; --border: #45504b; --hover: #303b35; color-scheme: dark; }
header, footer { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 10px 18px; }
header { max-height: 70dvh; overflow-y: auto; position: absolute; top: 0; left: 0; right: 0; z-index: 2; background: var(--bg); box-shadow: 0 4px 18px #0002; border-bottom: 1px solid var(--border); }
footer { position: absolute; bottom: 0; left: 0; right: 0; z-index: 2; background: var(--bg); box-shadow: 0 -4px 18px #0002; border-top: 1px solid var(--border); }
#motion-settings { flex-basis: 100%; } #motion-settings label { display: flex; flex-wrap: wrap; margin: 5px 0; } #motion-settings summary { cursor: pointer; padding: 8px 0; }
#motion-settings input[type=range] { flex: 1; min-width: 100px; }
#motion-settings output { min-width: 7ch; font-variant-numeric: tabular-nums; }
#motion-gauge { position: relative; height: 48px; margin: 12px 8px 0; border: 1px solid var(--border); border-radius: 6px; background: var(--hover); overflow: hidden; }
#motion-band { position: absolute; height: 100%; background: #39866e55; border-left: 2px solid #39866e; border-right: 2px solid #39866e; }
#motion-anchor { position: absolute; height: 100%; border-left: 1px dashed var(--fg); }
#motion-marker { position: absolute; top: 0; bottom: 0; width: 4px; background: var(--fg); transform: translateX(-50%); }
#motion-scale { display: flex; justify-content: space-between; margin: 0 8px; font-variant-numeric: tabular-nums; }
#motion-boundaries, #motion-status { margin: 6px 0; font-variant-numeric: tabular-nums; }
header strong { margin-right: auto; }
label { display: inline-flex; align-items: center; gap: 6px; }
input[type=range] { width: 85px; accent-color: #39866e; }
input[type=checkbox] { accent-color: #39866e; }
#viewport { grid-row: 1; min-height: 0; touch-action: pan-x pinch-zoom; scrollbar-width: none; overflow-x: auto; overflow-y: hidden; direction: rtl; overscroll-behavior: contain; scrollbar-color: #849589 var(--bg); }
article { writing-mode: vertical-rl; text-orientation: mixed; direction: ltr; height: 100%; width: max-content; min-width: 100%; padding: 30px 40px; font: var(--font, 22px)/var(--spacing, 1.9) "Yu Mincho", "Hiragino Mincho ProN", "Noto Serif CJK JP", "Noto Serif JP", serif; overflow-wrap: break-word; line-break: strict; }
h1 { font-size: 1.1em; font-weight: 600; line-height: 1.8; margin: 0 0 0 1.8em; }
section { margin: 0 0 0 2em; }
section:last-child { margin: 0; }
h2 { font-size: .7em; font-weight: normal; opacity: .7; margin: 0 0 0 1em; }
p { margin: 0 0 0 var(--paragraph, .3em); padding: 0; }
article { color: var(--normal-text, var(--fg)); }
article p.reading-focus { color: var(--highlight-text); }
ruby { ruby-position: over; }
rt { font-size: .5em; }
img { max-height: 100%; max-width: 70vw; object-fit: contain; }
article a { color: inherit; text-decoration: underline; }
nav { display: flex; gap: 12px; }
nav a { color: var(--fg); text-decoration: none; border-bottom: 1px solid var(--border); }
#chapter-subtitle { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: .25em 1em; min-width: 0; overflow-wrap: anywhere; }
#progress { flex: 1; min-width: 80px; direction: rtl; }
#percent { font-variant-numeric: tabular-nums; min-width: 4ch; }
.chrome-hidden header, .chrome-hidden footer { visibility: hidden; pointer-events: none; }
button, select { min-height: 44px; }
label { min-height: 36px; }
.hint { opacity: .65; font-size: 12px; }
@media(max-width: 700px) { header, footer { gap: 7px; padding: 8px; } .hint { display: none; } article { padding: 22px; } #motion-settings { flex-basis: 100%; } #motion-settings label { display: flex; flex-wrap: wrap; margin: 5px 0; } #motion-settings summary { cursor: pointer; padding: 8px 0; }
header strong { display: none; } }
@media(prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } }
`;
shadow.append(style);
const el = (tag, text, attrs = {}) => {
const node = document.createElement(tag);
if (text) {
const textNode = document.createTextNode(text.startsWith('@') ? t(text.slice(1)) : text);
node.append(textNode);
if (text.startsWith('@')) bindings.push(() => { textNode.data = t(text.slice(1)); });
}
for (const [key, value] of Object.entries(attrs)) {
node.setAttribute(key, value.startsWith?.('@') ? t(value.slice(1)) : value);
if (value.startsWith?.('@')) bindings.push(() => node.setAttribute(key, t(value.slice(1))));
}
return node;
};
const launch = el('button', '@reader.launch', {id: 'launch', type: 'button', title: '@reader.shortcut'});
const panel = el('div', '', {id: 'panel', role: 'dialog', 'aria-modal': 'true', 'aria-label': '@reader.name'});
panel.hidden = true;
const header = el('header');
header.append(el('strong', '@reader.name'));
function slider(label, id, min, max, step) {
const wrap = el('label', label);
const input = el('input', '', {id, type: 'range', min, max, step});
const output = el('output');
wrap.append(input, output); header.append(wrap);
return [input, output];
}
const [font, fontOut] = slider('@font', 'font', 14, 40, 1);
const [spacing, spaceOut] = slider('@spacing', 'spacing', 1.4, 3, 0.1);
const [paragraph, paragraphOut] = slider('@paragraph', 'paragraph', 0, 2, 0.1);
const themeLabel = el('label', '@theme');
const theme = el('select', '', {'aria-label': '@theme'});
for (const [value, label] of [['paper', '@paper'], ['white', '@white'], ['dark', '@dark']]) theme.append(el('option', label, {value}));
themeLabel.append(theme); header.append(themeLabel);
const languageLabel = el('label', '@language');
const languageSelect = el('select', '', {id: 'reading-language', 'aria-label': '@language'});
for (const [code, name] of Object.entries(i18n.config.supportedLanguages)) languageSelect.append(el('option', name, {value: code}));
languageLabel.append(languageSelect); header.append(languageLabel);
let statusKey = 'tilt.hint', statusValues = {};
function setStatus(key, values = {}) { statusKey = key; statusValues = values; motionStatus.textContent = t(key, values); }
languageSelect.addEventListener('change', async () => {
languageSelect.disabled = true;
try {
await i18n.setLanguage(languageSelect.value);
settings.language = i18n.language;
bindings.forEach(update => update());
panel.lang = i18n.language;
setStatus(statusKey, statusValues); updateMotionButton(); updateCalibration();
notify({type: 'language', language: settings.language}); save();
} finally { languageSelect.value = i18n.language; languageSelect.disabled = false; }
});
const autoLabel = el('label', '@auto');
const auto = el('input', '', {type: 'checkbox', title: '@auto.hint'});
autoLabel.prepend(auto); header.append(autoLabel);
const colorInputs = {};
for (const [key, label] of [['normalColor', '@normalColor'], ['highlightColor', '@highlightColor'], ['backgroundColor', '@backgroundColor']]) {
const wrap = el('label', label);
const input = el('input', '', {type: 'color', id: `reading-${key}`});
colorInputs[key] = input; wrap.append(input); header.append(wrap);
input.addEventListener('input', () => { settings[key] = input.value; applyTextColors(); save(); });
}
const resetColors = el('button', '@resetColors', {type: 'button', id: 'reading-reset-colors'});
resetColors.addEventListener('click', () => { settings.normalColor = settings.highlightColor = settings.backgroundColor = null; applyTextColors(); save(); });
header.append(resetColors);
const appMenu = el('button', '☰', {type: 'button', 'aria-label': '@appMenu'});
appMenu.addEventListener('click', () => notify({type: 'menu'}));
header.prepend(appMenu);
const close = el('button', '@original', {type: 'button'});
const hideControls = el('button', '@read', {type: 'button'});
hideControls.addEventListener('click', () => { setControls(false); viewport.focus({preventScroll: true}); });
header.append(close, hideControls);
const motionToggle = el('button', '@tilt.off', {id: 'motion-toggle', type: 'button', 'aria-pressed': 'false'});
header.append(motionToggle);
const motionDetails = el('details', '', {id: 'motion-settings'});
motionDetails.append(el('summary', '@tilt.settings'));
const motionStatus = el('p', '@tilt.hint', {id: 'motion-status'});
const motionGauge = el('div', '', {id: 'motion-gauge', role: 'img', 'aria-label': '@tilt.gauge'});
const motionBand = el('span', '', {id: 'motion-band'});
const motionAnchor = el('span', '', {id: 'motion-anchor'});
const motionMarker = el('span', '', {id: 'motion-marker'});
motionMarker.hidden = true;
motionGauge.append(motionBand, motionAnchor, motionMarker);
const motionScale = el('div', '', {id: 'motion-scale'});
for (const text of ['−1 g', '0', '+1 g']) motionScale.append(el('span', text));
const motionBoundaries = el('p', '', {id: 'motion-boundaries'});
const previewButton = el('button', '@tilt.resume', {id: 'motion-preview', type: 'button', 'aria-pressed': 'false'});
previewButton.disabled = true;
let calibrationRunning = false;
const neutralButton = el('button', '@tilt.neutral', {type: 'button', id: 'motion-neutral'});
neutralButton.disabled = true;
motionDetails.append(motionGauge, motionScale, motionBoundaries, motionStatus, neutralButton, previewButton);
const offsetLabel = el('label', '@tilt.offset');
const offsetInput = el('input', '', {id: 'motion-offset', type: 'range', min: -1, max: 1, step: .01, value: 0});
const offsetOutput = el('output', '0.00 g');
offsetLabel.append(offsetInput, offsetOutput); motionDetails.append(offsetLabel);
offsetInput.addEventListener('input', () => { neutral = Number(offsetInput.value); resetMotion(); updateCalibration(); });
const motionInputs = {};
for (const [key, label, min, max, step, suffix] of [
['acceleration', '@tilt.acceleration', 100, 3000, 100, ' px/s²'],
['speed', '@tilt.speed', 20, 600, 10, ' px/s'],
['deadzone', '@tilt.deadzone', .02, .4, .01, ' g'],
['brake', '@tilt.brake', .01, 1, .01, '']
]) {
const labelNode = el('label', label);
const input = el('input', '', {id: `motion-${key}`, type: 'range', min, max, step});
const output = el('output');
input.value = motionSettings[key]; output.textContent = input.value + suffix;
input.addEventListener('input', () => {
motionSettings[key] = Number(input.value); output.textContent = input.value + suffix;
resetMotion(); saveMotion();
updateCalibration();
});
motionInputs[key] = {input, output, suffix, min, max};
labelNode.append(input, output); motionDetails.append(labelNode);
}
const reverseLabel = el('label', '@tilt.reverse');
const reverseMotion = el('input', '', {id: 'motion-reverse', type: 'checkbox'});
reverseLabel.prepend(reverseMotion); motionDetails.append(reverseLabel);
reverseMotion.addEventListener('change', () => { motionSettings.reverse = reverseMotion.checked; resetMotion(); saveMotion(); });
motionDetails.append(el('p', '@tilt.help'));
header.append(motionDetails);
const viewport = el('div', '', {id: 'viewport', tabindex: '0', role: 'region', 'aria-label': '@viewport'});
const article = el('article', '', {lang: 'ja'});
const title = document.querySelector('.p-novel__title, .novel_subtitle');
article.append(el('h1', title ? title.textContent.trim() : document.title));
// Copy only passive reading markup. No scripts, page styles, IDs or event handlers.
const allowed = new Set(['P', 'DIV', 'BR', 'RUBY', 'RB', 'RT', 'RP', 'RTC', 'SPAN', 'EM', 'STRONG', 'B', 'I', 'A', 'IMG', 'SUP', 'SUB']);
const discard = new Set(['SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'FORM', 'INPUT', 'BUTTON']);
function safeURL(raw) {
if (!raw) return null;
try { const u = new URL(raw, document.baseURI); return ['http:', 'https:'].includes(u.protocol) ? u.href : null; }
catch (_) { return null; }
}
function copy(node) {
if (node.nodeType === Node.TEXT_NODE) return document.createTextNode(node.textContent);
if (node.nodeType !== Node.ELEMENT_NODE || discard.has(node.tagName)) return document.createDocumentFragment();
// Blank spacer paragraphs add entire empty columns in vertical layout.
if (node.tagName === 'P' && !node.textContent.trim() &&
[...node.querySelectorAll('*')].every(child => ['BR', 'SPAN', 'WBR'].includes(child.tagName))) {
return document.createDocumentFragment();
}
const clean = allowed.has(node.tagName) ? el(node.tagName.toLowerCase()) : document.createDocumentFragment();
if (node.tagName === 'A') {
const url = safeURL(node.getAttribute('href'));
if (url) clean.setAttribute('href', url);
}
if (node.tagName === 'IMG') {
const url = safeURL(node.getAttribute('src'));
if (url) clean.setAttribute('src', url);
clean.setAttribute('alt', node.getAttribute('alt') || '');
}
for (const child of node.childNodes) clean.append(copy(child));
return clean;
}
for (const source of sources) {
const section = el('section');
if (source.matches('.p-novel__text--preface, #novel_p')) section.append(el('h2', '@preface'));
if (source.matches('.p-novel__text--afterword, #novel_a')) section.append(el('h2', '@afterword'));
for (const child of source.childNodes) section.append(copy(child));
article.append(section);
}
viewport.append(article);
const paragraphs = [...article.querySelectorAll('p')];
let focusedParagraph = null, lastAutoFocus = 0;
for (const paragraph of paragraphs) paragraph.tabIndex = 0;
function focusParagraph(paragraph) {
if (!paragraph || paragraph === focusedParagraph) return;
focusedParagraph?.classList.remove('reading-focus');
focusedParagraph?.removeAttribute('aria-current');
focusedParagraph = paragraph;
paragraph.classList.add('reading-focus'); paragraph.setAttribute('aria-current', 'true');
}
function autoFocusParagraph(now) {
if (now - lastAutoFocus < 100) return;
lastAutoFocus = now;
const view = viewport.getBoundingClientRect(), center = (view.left + view.right) / 2;
let nearest = null, distance = Infinity;
for (const paragraph of paragraphs) {
const rect = paragraph.getBoundingClientRect();
if (rect.right <= view.left || rect.left >= view.right || !rect.width || !rect.height) continue;
const d = Math.max(rect.left - center, center - rect.right, 0);
if (d < distance) { nearest = paragraph; distance = d; }
}
focusParagraph(nearest);
}
article.addEventListener('keydown', event => {
if (event.target.matches('p') && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault(); event.stopPropagation(); focusParagraph(event.target);
}
});
const footer = el('footer');
const subtitle = document.querySelector('.p-novel__subtitle');
if (subtitle) {
const chapterSubtitle = el('div', '', {id: 'chapter-subtitle'});
for (const selector of ['.p-novel__subtitle-chapter', '.p-novel__subtitle-episode']) {
const text = subtitle.querySelector(selector)?.textContent.trim();
if (text) {
const part = el('span');
part.textContent = text;
chapterSubtitle.append(part);
}
}
if (chapterSubtitle.childElementCount) footer.append(chapterSubtitle);
}
const nav = el('nav', '', {'aria-label': '@nav'});
const pager = document.querySelector('.c-pager, .novel_bn');
if (pager) for (const link of pager.querySelectorAll('a[href]')) {
const url = safeURL(link.getAttribute('href'));
if (url) nav.append(el('a', link.textContent.trim(), {href: url}));
}
footer.append(nav);
const back = el('button', '→', {type: 'button', 'aria-label': '@back'});
const forward = el('button', '←', {type: 'button', 'aria-label': '@forward'});
footer.append(back, forward);
const progress = el('input', '', {id: 'progress', type: 'range', min: '0', max: '1000', value: '0', 'aria-label': '@progress'});
const percent = el('span', '0%', {id: 'percent'});
footer.append(progress, percent, el('span', '@hint', {class: 'hint'}));
panel.append(header, viewport, footer); shadow.append(launch, panel);
document.body.append(host);
const lock = el('style');
lock.textContent = 'html.narou-tategaki-open { overflow: hidden !important; }';
document.head.append(lock);
const motion = new NarouMotion();
let motionEnabled = false, motionSample = null, sampleAt = 0, lastFrame = 0;
let neutral = null, motionRotation = null, touching = false, motionTimer, motionFrame;
let motionGeneration = 0, polling = false, pageActive = true;
function resetMotion() { motion.reset(); lastFrame = 0; }
function updateCalibration(now = performance.now()) {
const ready = motionEnabled && motionSample?.active && Number.isFinite(motionSample.x) && now - sampleAt <= 350 && neutral !== null;
const percent = value => `${Math.min(100, Math.max(0, (value + 1) * 50))}%`;
const center = neutral ?? 0, low = center - motionSettings.deadzone, high = center + motionSettings.deadzone;
motionBand.style.left = percent(low);
motionBand.style.width = `${(Math.min(1, high) - Math.max(-1, low)) * 50}%`;
motionAnchor.style.left = percent(center);
motionMarker.hidden = !ready;
if (ready) motionMarker.style.left = percent(motionSample.x);
offsetInput.value = center; offsetOutput.textContent = `${center.toFixed(2)} g`;
const left = motionSettings.reverse ? t('tilt.forward') : t('tilt.back');
const right = motionSettings.reverse ? t('tilt.back') : t('tilt.forward');
motionBoundaries.textContent = t('tilt.boundaries', {left, right, low: low.toFixed(2), high: high.toFixed(2)});
previewButton.disabled = !ready;
previewButton.textContent = t(calibrationRunning ? 'tilt.pause' : 'tilt.resume');
previewButton.setAttribute('aria-pressed', String(calibrationRunning));
if (!ready) return;
const braking = Math.abs(motionSample.x - neutral) <= motionSettings.deadzone;
const forward = (motionSample.x - neutral) * (motionSettings.reverse ? -1 : 1) > 0;
const state = controlsVisible && !calibrationRunning ? t('paused') : touching ? t('touch') : braking ? t('braking') : forward ? t('accelerate.forward') : t('accelerate.back');
setStatus('tilt.status', {x: motionSample.x.toFixed(2), state, velocity: motion.velocity.toFixed(1)});
motionGauge.setAttribute('aria-label', t('tilt.range', {x: motionSample.x.toFixed(2), low: low.toFixed(2), high: high.toFixed(2), state}));
}
function saveMotion() { browser.storage.local.set({narouMotion: {...motionSettings, enabled: motionEnabled}}).catch(() => {}); }
function updateMotionButton() {
motionToggle.textContent = t(motionEnabled ? 'tilt.on' : 'tilt.off');
motionToggle.setAttribute('aria-pressed', String(motionEnabled));
}
function animateMotion(now) {
motionFrame = null;
if (!motionEnabled || !opened || !pageActive || document.hidden) { resetMotion(); return; }
const paused = (controlsVisible && !(motionDetails.open && calibrationRunning)) || touching || restoring || !motionSample?.active ||
now - sampleAt > 350 || neutral === null || String(window.getSelection()).trim();
if (paused) resetMotion();
else {
if (lastFrame) {
const delta = motion.step(motionSample.x, (now - lastFrame) / 1000, {...motionSettings, neutral});
const before = viewport.scrollLeft;
move(delta);
if (viewport.scrollLeft !== before && Math.abs(motionSample.x - neutral) > motionSettings.deadzone) autoFocusParagraph(now);
if ((delta > 0 && viewport.scrollLeft <= -range()) || (delta < 0 && viewport.scrollLeft >= 0)) motion.reset();
}
lastFrame = now;
}
updateCalibration(now);
motionFrame = requestAnimationFrame(animateMotion);
}
async function pollMotion() {
if (polling || !motionEnabled || !opened || !pageActive || document.hidden) return;
polling = true;
const generation = motionGeneration;
let requestTimeout;
try {
// A native reply can be lost when Gecko freezes a history document.
const sample = await Promise.race([
browser.runtime.sendNativeMessage('narou', {
type: 'motion', enabled: true, open: opened, moving: Math.abs(motion.velocity) > .1
}),
new Promise((_, reject) => {
requestTimeout = setTimeout(() => reject(new Error('Sensor request timed out')), 1000);
})
]);
if (generation !== motionGeneration) return;
if (!sample?.available) {
motionEnabled = false; updateMotionButton();
setStatus('sensor.missing');
stopMotion(); return;
}
motionSample = sample; sampleAt = performance.now();
neutralButton.disabled = !sample.active;
if (!sample.active || !Number.isFinite(sample.x)) {
motionSample = null; resetMotion(); setStatus('sensor.wait');
} else {
if (motionRotation !== sample.rotation) {
motionRotation = sample.rotation; neutral = sample.x; resetMotion();
}
updateCalibration();
}
} catch (error) {
if (generation !== motionGeneration) return;
motionSample = null;
resetMotion();
neutralButton.disabled = true;
const detail = String(
error?.message || error || 'Unknown error'
).slice(0, 240);
setStatus('sensor.error', {detail});
} finally {
clearTimeout(requestTimeout);
// A reply from before pagehide must not unlock or schedule the new poller.
if (generation === motionGeneration) {
polling = false;
if (motionEnabled && opened && pageActive && !document.hidden) motionTimer = setTimeout(pollMotion, 100);
}
}
}
function startMotion() {
if (!motionEnabled || !opened || !pageActive || document.hidden) return;
if (!motionFrame) motionFrame = requestAnimationFrame(animateMotion);
clearTimeout(motionTimer); pollMotion();
}
function stopMotion() {
calibrationRunning = false;
motionGeneration++; polling = false; clearTimeout(motionTimer); cancelAnimationFrame(motionFrame); motionFrame = null;
motionSample = null; neutralButton.disabled = true; resetMotion();
updateCalibration();
notify({type: 'motion', enabled: false});
}
motionToggle.addEventListener('click', () => {
motionEnabled = !motionEnabled; updateMotionButton(); saveMotion();
if (motionEnabled) { neutral = null; motionRotation = null; motionDetails.open = true; startMotion(); }
else { stopMotion(); setStatus('sensor.off'); }
});
neutralButton.addEventListener('click', () => {
if (!motionSample?.active || performance.now() - sampleAt > 350) return;
neutral = motionSample.x; resetMotion();
updateCalibration();
});
previewButton.addEventListener('click', () => {
if (previewButton.disabled || !motionDetails.open || !controlsVisible) return;
calibrationRunning = !calibrationRunning; resetMotion(); updateCalibration();
});
motionDetails.addEventListener('toggle', () => {
if (!motionDetails.open) { calibrationRunning = false; resetMotion(); }
updateCalibration();
});
viewport.addEventListener('pointerdown', () => { touching = true; resetMotion(); }, {passive: true});
window.addEventListener('pointerup', () => { touching = false; }, {passive: true});
window.addEventListener('pointercancel', () => { touching = false; }, {passive: true});
document.addEventListener('visibilitychange', () => {
touching = false;
if (document.hidden) stopMotion(); else startMotion();
});
window.addEventListener('pagehide', () => { pageActive = false; stopMotion(); });
window.addEventListener('pageshow', () => {
pageActive = true;
touching = false;
resetMotion();
if (opened) { notify({type: 'reader', open: true}); notify({type: 'background', color: settings.backgroundColor || textColors[settings.theme][2]}); }
startMotion();
});
// Continuous scrolling must checkpoint periodically, not only after scrolling stops.
setInterval(() => { if (opened && motionEnabled && Math.abs(motion.velocity) > .1) savePosition(); }, 2000);
browser.storage.local.get('narouMotion').then(result => {
const stored = result.narouMotion || {};
for (const [key, control] of Object.entries(motionInputs)) {
if (Number.isFinite(stored[key])) motionSettings[key] = Math.min(control.max, Math.max(control.min, stored[key]));
control.input.value = motionSettings[key]; control.output.textContent = control.input.value + control.suffix;
}
motionSettings.reverse = stored.reverse === true; reverseMotion.checked = motionSettings.reverse;
motionEnabled = stored.enabled === true; updateMotionButton(); startMotion();
updateCalibration();
}).catch(() => {});
const range = () => Math.max(0, viewport.scrollWidth - viewport.clientWidth);
const fraction = () => range() ? Math.min(1, Math.max(0, -viewport.scrollLeft / range())) : 0;
function updateProgress() {
progress.value = String(Math.round(fraction() * 1000));
percent.textContent = `${Math.round(fraction() * 100)}%`;
}
function apply(position = opened ? fraction() : savedPosition) {
restoring = true;
panel.dataset.theme = settings.theme;
applyTextColors();
article.style.setProperty('--font', `${settings.font}px`);
article.style.setProperty('--spacing', settings.spacing);
article.style.setProperty('--paragraph', `${settings.paragraph}em`);
font.value = settings.font; spacing.value = settings.spacing; paragraph.value = settings.paragraph;
fontOut.textContent = `${settings.font}px`; spaceOut.textContent = `${settings.spacing.toFixed(1)}`;
paragraphOut.textContent = `${settings.paragraph.toFixed(1)}em`;
languageSelect.value = i18n.language; panel.lang = i18n.language;
theme.value = settings.theme; auto.checked = settings.auto;
requestAnimationFrame(() => {
if (opened) viewport.scrollLeft = -position * range();
savedPosition = position;
updateProgress();
requestAnimationFrame(() => { restoring = false; });
});
}
async function save() {
try { await browser.storage.local.set({narouTategaki: settings}); }
catch (err) { console.warn('縦書き: settings could not be saved', err); }
}
function applyTextColors() {
const [normal, highlight, background] = textColors[settings.theme];
article.style.setProperty('--normal-text', settings.normalColor || normal);
article.style.setProperty('--highlight-text', settings.highlightColor || highlight);
const backgroundColor = settings.backgroundColor || background;
panel.style.setProperty('--bg', backgroundColor);
colorInputs.normalColor.value = settings.normalColor || normal;
colorInputs.highlightColor.value = settings.highlightColor || highlight;
colorInputs.backgroundColor.value = backgroundColor;
if (nativeBackgroundColor !== backgroundColor) {
nativeBackgroundColor = backgroundColor;
notify({type: 'background', color: backgroundColor});
}
}
function open() {
if (opened) return;
opened = true; lastFocus = document.activeElement;
for (const child of document.body.children) if (child !== host) { inertBefore.set(child, child.inert); child.inert = true; }
document.documentElement.classList.add('narou-tategaki-open');
launch.hidden = true; panel.hidden = false;
setControls(false); apply(savedPosition); viewport.focus({preventScroll: true});
notify({type: 'reader', open: true});
startMotion();
}
function shut() {
if (!opened) return;
savePosition(); stopMotion(); notify({type: 'reader', open: false});
opened = false; panel.hidden = true; launch.hidden = false;
document.documentElement.classList.remove('narou-tategaki-open');
for (const [node, inert] of inertBefore) node.inert = inert;
inertBefore.clear();
if (lastFocus && lastFocus !== document.body && lastFocus.isConnected) lastFocus.focus({preventScroll: true});
else launch.focus({preventScroll: true});
}
const toggle = () => opened ? shut() : open();
launch.addEventListener('click', open); close.addEventListener('click', shut);
function move(amount) { viewport.scrollLeft = Math.max(-range(), Math.min(0, viewport.scrollLeft - amount)); }
const page = () => viewport.clientWidth * .85;
back.addEventListener('click', () => move(-page()));
forward.addEventListener('click', () => move(page()));
viewport.addEventListener('wheel', event => {
if (event.ctrlKey || event.shiftKey || Math.abs(event.deltaX) >= Math.abs(event.deltaY)) return;
const scale = event.deltaMode === 1 ? settings.font : event.deltaMode === 2 ? page() : 1;
move(event.deltaY * scale); event.preventDefault();
}, {passive: false});
viewport.addEventListener('scroll', () => {
updateProgress();
if (opened && !restoring) {
savedPosition = fraction();
clearTimeout(saveTimer);
saveTimer = setTimeout(savePosition, 250);
}
}, {passive: true});
// Ignore swipes, links and long presses; preserve native selection and touch scrolling.
let pointerStart = null;
viewport.addEventListener('pointerdown', event => {
pointerStart = {x: event.clientX, y: event.clientY, time: performance.now()};
}, {passive: true});
viewport.addEventListener('pointercancel', () => { pointerStart = null; });
viewport.addEventListener('pointerup', event => {
const start = pointerStart; pointerStart = null;
if (!start || Math.hypot(event.clientX - start.x, event.clientY - start.y) > 10 ||
performance.now() - start.time > 450 || event.target.closest('a') ||
String(window.getSelection()).trim()) return;
const paragraph = event.target.closest('article p');
if (paragraph && paragraph !== focusedParagraph) { focusParagraph(paragraph); return; }
setControls(!controlsVisible);
}, {passive: true});
document.addEventListener('visibilitychange', () => { if (document.hidden) savePosition(); });
window.addEventListener('pagehide', savePosition);
progress.addEventListener('input', () => { viewport.scrollLeft = -Number(progress.value) / 1000 * range(); updateProgress(); });
panel.addEventListener('keydown', event => {
if (event.key === 'Escape') { event.preventDefault(); shut(); return; }
// Native controls and chapter links keep their normal keyboard behavior.
if (event.target !== viewport && event.target !== panel) return;
if (event.altKey || event.ctrlKey || event.metaKey) return;
const actions = {ArrowLeft: () => move(settings.font * settings.spacing * 3), ArrowRight: () => move(-settings.font * settings.spacing * 3), PageDown: () => move(page()), PageUp: () => move(-page()), ' ': () => move(event.shiftKey ? -page() : page()), Home: () => { viewport.scrollLeft = 0; }, End: () => { viewport.scrollLeft = -range(); }};
if (actions[event.key]) { event.preventDefault(); actions[event.key](); updateProgress(); }
});
font.addEventListener('input', () => { settings.font = Number(font.value); apply(); save(); });
spacing.addEventListener('input', () => { settings.spacing = Number(spacing.value); apply(); save(); });
paragraph.addEventListener('input', () => { settings.paragraph = Number(paragraph.value); apply(); save(); });
theme.addEventListener('change', () => { settings.theme = theme.value; apply(); save(); });
auto.addEventListener('change', () => { settings.auto = auto.checked; save(); });
let lastWidth = 0, lastHeight = 0;
new ResizeObserver(() => {
if (!opened) return;
const width = viewport.clientWidth, height = viewport.clientHeight;
if (width !== lastWidth || height !== lastHeight) {
lastWidth = width; lastHeight = height;
apply(savedPosition);
} else updateProgress();
}).observe(viewport);
browser.runtime.onMessage.addListener(message => { if (message.type === 'narou-tategaki-toggle') toggle(); });
browser.storage.local.get(['narouTategaki', positionKey]).then(result => {
const position = result[positionKey]?.position;
savedPosition = Number.isFinite(position) ? Math.min(1, Math.max(0, position)) : 0;
const stored = result.narouTategaki || {};
settings.font = Number.isFinite(stored.font) ? Math.min(40, Math.max(14, stored.font)) : defaults.font;
settings.spacing = Number.isFinite(stored.spacing) ? Math.min(3, Math.max(1.4, stored.spacing)) : defaults.spacing;
settings.paragraph = Number.isFinite(stored.paragraph) ? Math.min(2, Math.max(0, stored.paragraph)) : defaults.paragraph;
settings.theme = ['paper', 'white', 'dark'].includes(stored.theme) ? stored.theme : defaults.theme;
settings.auto = typeof stored.auto === 'boolean' ? stored.auto : defaults.auto;
for (const key of ['normalColor', 'highlightColor', 'backgroundColor']) settings[key] = /^#[0-9a-f]{6}$/i.test(stored[key]) ? stored[key] : null;
if (settings.auto && !opened) open(); else apply(savedPosition);
}).catch(() => { open(); });
notify({type: 'language', language: i18n.language});
// Keep a bounded local history; no network requests or reading telemetry.
browser.storage.local.get(null).then(all => {
const old = Object.keys(all).filter(key => key.startsWith('narouPosition:'))
.sort((a, b) => (all[b]?.updated || 0) - (all[a]?.updated || 0)).slice(200);
if (old.length) return browser.storage.local.remove(old);
}).catch(() => {});
})();