penguin/narou-android

Simple 縦書き reader for narou

app/src/main/assets/reader/content.js

raw · 35279 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, language: i18n.language};
  const textColors = {paper: ['#2d332f', '#17694f'], white: ['#222222', '#17694f'], dark: ['#e2e4de', '#79dfb5']};
  let settings = {...defaults}, opened = false, lastFocus;
  let nativeTheme = 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] { 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); }
    #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']]) {
    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 = 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 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;
  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 || 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 || document.hidden) return;
    polling = true;
    const generation = motionGeneration;
    try {
      const sample = await browser.runtime.sendNativeMessage('narou', {
        type: 'motion', enabled: true, moving: Math.abs(motion.velocity) > .1
      });
      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 {
      polling = false;
      if (motionEnabled && opened && !document.hidden) motionTimer = setTimeout(pollMotion, 100);
    }
  }
  function startMotion() {
    if (!motionEnabled || !opened || document.hidden) return;
    if (!motionFrame) motionFrame = requestAnimationFrame(animateMotion);
    clearTimeout(motionTimer); pollMotion();
  }
  function stopMotion() {
    calibrationRunning = false;
    motionGeneration++; 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', stopMotion);
  window.addEventListener('pageshow', () => {
    if (opened) { notify({type: 'reader', open: true}); notify({type: 'theme', theme: settings.theme}); }
    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();
    if (nativeTheme !== settings.theme) {
      nativeTheme = settings.theme;
      notify({type: 'theme', theme: settings.theme});
    }
    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] = textColors[settings.theme];
    article.style.setProperty('--normal-text', settings.normalColor || normal);
    article.style.setProperty('--highlight-text', settings.highlightColor || highlight);
    colorInputs.normalColor.value = settings.normalColor || normal;
    colorInputs.highlightColor.value = settings.highlightColor || highlight;
  }
  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']) 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(() => {});
})();