下書き(未公開)
縦書き原稿用紙

ソースコード / src/chrome/editor.js

src/chrome/editor.js

(() => {  try {  window.genkoBoot.stage('画面と描画機能の準備');  // Each review edition has its own sample storage. Never migrate real manuscripts here.  const CELL = 28, GAP = 12, CENTER_GAP = 16, ROWS = 20, COLS = 20, LIBRARY_KEY = 'tategaki-genko-chrome-library-v2';  const ATTACHED_MARK_CENTER = CELL - 3;  // The overview never shrinks a sheet below a quarter of its size (221px wide).  const OVERVIEW_MIN_SCALE = .25;  // Widest Windows IME candidate list allowed for (the prediction list is about 240px).  const IME_LIST_WIDTH = 320;  const FONT_OPTIONS = {  "mincho": {    "label": "游明朝",    "css": "\"Yu Mincho\", \"Hiragino Mincho ProN\", \"Noto Serif CJK JP\", serif"  },  "gothic": {    "label": "游ゴシック",    "css": "\"Yu Gothic\", \"Hiragino Kaku Gothic ProN\", \"Noto Sans CJK JP\", sans-serif"  },  "biz-mincho": {    "label": "BIZ UD明朝",    "css": "\"BIZ UDMincho Medium\", \"BIZ UDMincho\", \"Yu Mincho\", serif"  },  "kyokasho": {    "label": "UDデジタル教科書体",    "css": "\"UD Digi Kyokasho N\", \"Yu Mincho\", serif"  },  "shippori-b1": {    "label": "しっぽり明朝B1",    "css": "\"Shippori Mincho B1\", \"Yu Mincho\", serif"  },  "klee-one": {    "label": "クレー One",    "css": "\"Klee One\", \"Yu Mincho\", serif"  },  "yomogi": {    "label": "よもぎ",    "css": "\"Yomogi\", \"Yu Mincho\", serif"  }};  const normalizeFont = value => Object.prototype.hasOwnProperty.call(FONT_OPTIONS, value) ? value : value === 'biz-gothic' ? 'gothic' : 'mincho';  let COMMA_FONT = '20px ' + FONT_OPTIONS.mincho.css;  const commaMeasureContext = document.createElement('canvas').getContext('2d');  const columnRight = column => column * (CELL + GAP) + (column >= COLS / 2 ? CENTER_GAP : 0);  const input = document.getElementById('input');  const pages = document.getElementById('pages');  const name = document.getElementById('name');  const fontSelect = document.getElementById('manuscript-font');  const main = document.getElementById('main');  const panelSide = document.getElementById('panel-side');  const projectSelect = document.getElementById('project-select');  const versionsSelect = document.getElementById('versions');  const countEl = document.getElementById('count');  const status = document.getElementById('status');  const overviewToggle = document.getElementById('overview-toggle');  const pageIndicator = document.getElementById('page-indicator');  const initialPixelRatio = window.devicePixelRatio || 1;  const segmenter = typeof Intl.Segmenter === 'function' ? new Intl.Segmenter('ja', { granularity: 'grapheme' }) : null;  const opening = new Set([... '「『(〔[{〈《【([{「']);  const closing = new Set([... '」』)〕]}〉》】)]}」']);  const unbreakableMarks = new Set(['…', '⋯', '‥', '—', '―']);  const punctuation = new Set(['。', '、']);  const westernPunctuation = new Set(['.', ',']);  const verticalKana = new Set([...'ーーぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶ']);  const rotatedSymbols = new Set(['=', '=', '-', '‐', '‑', '−', '-', '―', '—', '~', '〜', '<', '>', '<', '>']);  const verticalForm = new Map([    ['「', '﹁'], ['」', '﹂'], ['『', '﹃'], ['』', '﹄'],    ['(', '︵'], [')', '︶'], ['〔', '︹'], ['〕', '︺'],    ['[', '﹇'], [']', '﹈'], ['{', '︷'], ['}', '︸'],    ['〈', '︿'], ['〉', '﹀'], ['《', '︽'], ['》', '︾'],    ['【', '︻'], ['】', '︼'], ['、', '︑'], ['。', '︒'],    ['(', '︵'], [')', '︶'], ['[', '﹇'], [']', '﹈'],    ['{', '︷'], ['}', '︸'], ['「', '﹁'], ['」', '﹂']  ]);  let stops = [];  let findMatches = [], activeFindIndex = -1;  const findQuery = document.getElementById('find-query');  const findCount = document.getElementById('find-count');  let library;  // Filled at build time from scripts/manuscript-samples.cjs (data/literature).  const SAMPLE_CATALOG = /* build: SAMPLE_CATALOG */[];  // Unedited samples of earlier editions, recognised so they can be retired.  // Filled at build time from data/chrome/legacy-samples.json.  const LEGACY_SAMPLES = /* build: LEGACY_SAMPLES */[];  // Samples withdrawn from the catalog for 1.0.0: 「去年の木」, 「蜘蛛の糸」 and the layout check.  const WITHDRAWN_SAMPLES = ['aozora-4719', 'aozora-92', 'check-layout'];  const WITHDRAWN_TITLES = ['去年の木 — 新美南吉', '蜘蛛の糸 — 芥川龍之介', '確認用:原稿用紙とふりがな', '確認用:句読点・かっこ', '確認用:ふりがな'];  let sampleMigrationPending = false;  function sampleProject(example) {    const project = newProject(example.title, example.text);    project.rubies = example.rubies.map(item => ({ ...item }));    project.font = normalizeFont(example.font);    project.sampleId = example.sampleId; project.sampleProtected = true;    return project;  }  function isOriginalSample(project) {    return !!project?.sampleProtected && SAMPLE_CATALOG.some(sample => sample.sampleId === project.sampleId);  }  function isProtectedProject(project) {    return isOriginalSample(project) || project?.protected === true;  }  // A protected original cannot be edited, so it follows the bundled readings.  // The read-only guide 「はじめに」 also follows the bundled text, so a new  // version's instructions reach existing users. Drafts and edited copies keep  // their own text and readings.  function refreshOriginalSampleRubies(stored) {    for (const project of stored.projects) {      const source = project.sampleProtected && SAMPLE_CATALOG.find(sample => sample.sampleId === project.sampleId);      if (source?.sampleId === 'guide' && (project.text !== source.text || project.title !== source.title)) {        project.title = source.title; project.text = source.text;        project.rubies = source.rubies.map(ruby => ({ ...ruby }));        sampleMigrationPending = true;        continue;      }      if (!source || project.text !== source.text || JSON.stringify(project.rubies) === JSON.stringify(source.rubies)) continue;      project.rubies = source.rubies.map(ruby => ({ ...ruby }));      sampleMigrationPending = true;    }    return stored;  }  function protectSampleLibrary(stored) {    if (stored.sampleProtectionVersion >= 1) return refreshOriginalSampleRubies(stored);    const originals = new Set();    for (const project of stored.projects) {      const source = SAMPLE_CATALOG.find(sample => sample.sampleId === project.sampleId);      if (!source) continue;      const same = project.title === source.title && project.text === source.text &&        JSON.stringify(project.rubies || []) === JSON.stringify(source.rubies);      if (same) project.sampleProtected = true;      else {        // An edited sample becomes an ordinary draft, never an overwritten original.        delete project.sampleId; delete project.sampleProtected;        originals.add(source.sampleId);      }      sampleMigrationPending = true;    }    for (const id of originals) {      if (stored.projects.some(project => project.sampleId === id) || stored.projects.length >= SAFETY.newProjects) continue;      const original = sampleProject(SAMPLE_CATALOG.find(sample => sample.sampleId === id));      try { archiveBudget([...stored.projects, original]); } catch { continue; }      stored.projects.push(original);    }    stored.sampleProtectionVersion = 1;    return stored;  }  function updateSampleProtection() {    const project = activeProject(), original = isOriginalSample(project), locked = isProtectedProject(project);    input.readOnly = locked; name.readOnly = locked;    document.getElementById('sample-protection').hidden = !locked;    document.getElementById('sample-protection').textContent = '保護中(書き換えられません)';    document.getElementById('copy-sample').hidden = !locked;    document.getElementById('ruby-open').disabled = locked;    document.getElementById('restore-version').disabled = locked || !activeProject().history.length;    const button = document.getElementById('toggle-protection');    button.hidden = original;    button.textContent = locked ? '保護を解除' : '保護する';    button.setAttribute('aria-pressed', String(locked));    button.title = locked ? 'この原稿を編集できるようにします' : '本文・原稿名・ふりがなの編集を止めます';  }  function toggleProjectProtection() {    if (isOriginalSample(activeProject())) return;    save();    const project = activeProject();    project.protected = project.protected !== true;    updateSampleProtection(); refreshProjectOptions(); persist();  }  function createEditableCopy() {    const source = activeProject();    if (!isProtectedProject(source) || !canAddProjects(1)) return;    const copy = newProject(source.title + '(編集用)', source.text);    copy.font = source.font;    copy.rubies = source.rubies.map(ruby => ({ ...ruby }));    copy.history = copyHistory(source);    try { archiveBudget([...library.projects, copy]); }    catch (error) { operationProblem(error.message); return; }    library.projects.push(copy); library.activeId = copy.id;    showProject(); persist();    pages.children[pages.children.length - 1]?.scrollIntoView({ block: 'center' });    render();    input.focus({ preventScroll: true });  }  function prepareImportDraft() {    if (!isProtectedProject(activeProject())) return true;    if (!canAddProjects(1)) return false;    const project = newProject();    library.projects.push(project); library.activeId = project.id;    showProject();    return true;  }  // The first start opens a blank manuscript; the samples wait in the switcher.  function createSampleLibrary() {    const projects = [newProject(), ...SAMPLE_CATALOG.map(sampleProject)];    return { version: 2, sampleCatalogVersion: 6, sampleProtectionVersion: 1, activeId: projects[0].id, panelSide: 'right', panelSideVersion: 1, projects };  }  function unchangedLegacySample(project) {    if (project.history.length || project.driveLink || project.protected === true) return false;    return LEGACY_SAMPLES.some(example => project.title === example.title && project.text === example.text &&      normalizeFont(project.font) === normalizeFont(example.font) &&      JSON.stringify(project.rubies || []) === JSON.stringify(example.rubies || []));  }  function curateSampleLibrary(stored) {    if (stored.sampleCatalogVersion >= 6) return stored;    if (stored.sampleCatalogVersion >= 1) {      const previousCatalog = stored.sampleCatalogVersion;      stored.sampleCatalogVersion = 6;      // Append only the new work; never replace drafts or switch selection.      const retired = ['check-punctuation', 'check-ruby'];      if (!stored.projects.some(project => retired.includes(project.sampleId) || WITHDRAWN_SAMPLES.includes(project.sampleId) ||        SAMPLE_CATALOG.some(sample => sample.sampleId === project.sampleId))) return stored;      stored.projects = stored.projects.filter(project => {        if (!retired.includes(project.sampleId)) return true;        if (unchangedLegacySample(project)) return false;        delete project.sampleId; delete project.sampleProtected;        return true;      });      for (const [id, introduced] of [['aozora-43754', 3], ['guide', 5], ['aozora-624', 6]]) {        if (previousCatalog >= introduced || stored.projects.some(project => project.sampleId === id) || stored.projects.length >= SAFETY.newProjects) continue;        const candidate = sampleProject(SAMPLE_CATALOG.find(sample => sample.sampleId === id));        try { archiveBudget([...stored.projects, candidate]); } catch { continue; }        stored.projects.push(candidate);        sampleMigrationPending = true;      }      stored.projects = orderStandardSamples(stored.projects);      if (!stored.projects.some(project => project.id === stored.activeId)) {        stored.activeId = (stored.projects.find(project => project.sampleId === 'check-layout') || stored.projects[0]).id;      }      sampleMigrationPending = true;      return stored;    }    // Legacy samples predate sampleId. Recognize their exact title AND complete    // content; a title-only match must never authorize removal of edited text.    if (!stored.projects.some(project => LEGACY_SAMPLES.some(example => project.title === example.title))) {      // Mark this in memory too, so an intentionally imported old sample is not      // mistaken for pre-update clutter at the following restart.      stored.sampleCatalogVersion = 6;      return stored;    }    const kept = stored.projects.filter(project => !unchangedLegacySample(project));    for (const example of SAMPLE_CATALOG) {      if (kept.some(project => project.sampleId === example.sampleId) || kept.length >= SAFETY.newProjects) continue;      const candidate = sampleProject(example);      try { archiveBudget([...kept, candidate]); }      catch { continue; } // User manuscripts and their histories take priority.      kept.push(candidate);    }    stored.projects = orderStandardSamples(kept);    stored.sampleCatalogVersion = 6;    if (!kept.some(project => project.id === stored.activeId)) stored.activeId = kept[0].id;    sampleMigrationPending = true;    return stored;  }  // The withdrawn samples and the check manuscripts shipped only in pre-release  // builds, so every copy goes, edited or not. Those builds also kept the panel  // on the left; 1.0.0 moves it to the right once, saved with the next change.  function withdrawSamples(stored) {    if (stored.panelSideVersion !== 1) { stored.panelSide = 'right'; stored.panelSideVersion = 1; }    const kept = stored.projects.filter(project => !WITHDRAWN_SAMPLES.includes(project.sampleId) && !WITHDRAWN_TITLES.includes(project.title));    if (kept.length === stored.projects.length) return stored;    stored.projects = kept.length ? kept : [newProject()];    if (!stored.projects.some(project => project.id === stored.activeId)) stored.activeId = stored.projects[0].id;    sampleMigrationPending = true;    return stored;  }  function orderStandardSamples(projects) {    return [...SAMPLE_CATALOG.flatMap(sample => projects.filter(project => project.sampleId === sample.sampleId)),      ...projects.filter(project => !SAMPLE_CATALOG.some(sample => sample.sampleId === project.sampleId))];  }  // These are provisional resource bounds, not manuscript formatting rules.  // See test-results/chrome-quality-02.md for the Node/DOM measurements.  // `projects` only validates stored and imported data, so an existing library of  // up to 200 still loads and saves; new manuscripts (samples included) stop at  // `newProjects`. Ruby-heavy prose (e.g. Izumi Kyoka) has about 25 rubies a page,  // so 100 pages need room for well over 2,000.  // Automatic history: one version per 30 minutes of writing, 48 per manuscript  // (a whole day of writing). Older files with up to 50 versions still open.  const HISTORY_LIMIT = 48, AUTO_VERSION_INTERVAL = 30 * 60 * 1000;  const SAFETY = Object.freeze({ fileBytes: 10000000, textUnits: 40000, pages: 100,    projects: 200, newProjects: 30, history: 50, rubies: 5000, archiveUnits: 1000000 });  let originalStored = null, lastStored = null, storageProtected = false;  let unsavedChanges = false, libraryRevision = 0, readTicket = 0, pendingRead = null;  let composing = false, renderBlocked = false;  let boundedLayout = null;  const recoveryPanel = document.getElementById('recovery-panel');  const recoveryMessage = document.getElementById('recovery-message');  const operationStatus = document.getElementById('operation-status');  function operationProblem(message) {    operationStatus.textContent = message;    operationStatus.hidden = !message;    if (message) document.getElementById('file-status').textContent = '';  }  function storageProblem(message) {    unsavedChanges = true;    status.hidden = false;    status.textContent = message;    recoveryMessage.textContent = message;    recoveryPanel.hidden = false;    document.getElementById('recover-original').hidden = originalStored === null;  }  function textWithinBounds(text) {    if (typeof text !== 'string') throw Error('本文が文字列ではありません。');    if (text.length > SAFETY.textUnits) throw Error('本文が長すぎます。通常の日本語で約4万字までを目安に、原稿を分けてください。');    if (!boundedLayout || boundedLayout.text !== text) boundedLayout = { text, result: layout(text) };    if (boundedLayout.result.pageCount > SAFETY.pages) throw Error('改行・空行を含む紙面が100ページを超えます。原稿を分けてください。');  }  function archiveBudget(projects) {    if (!Array.isArray(projects) || !projects.length || projects.length > SAFETY.projects) throw Error('原稿数は1〜200件にしてください。');    let total = 0;    for (const project of projects) {      if (project?.protected !== undefined && typeof project.protected !== 'boolean') throw Error('保護設定のデータが不正です。');      if (!project || typeof project.title !== 'string' || typeof project.text !== 'string' ||          !Array.isArray(project.history) || project.history.length > SAFETY.history) throw Error('原稿または編集履歴のデータが不正です(履歴は50件まで)。');      for (const version of [project, ...project.history]) {        if (!version || typeof version.title !== 'string' || typeof version.text !== 'string') throw Error('編集履歴のデータが不正です。');        for (const key of ['font', 'kind', 'id']) if (version[key] != null && typeof version[key] !== 'string') throw Error('原稿の書体・ID・履歴名が不正です。');        for (const key of ['at', 'updatedAt', 'lastVersionAt']) if (version[key] != null &&            (typeof version[key] !== 'number' || !Number.isFinite(version[key]))) throw Error('原稿の日時データが不正です。');        if (version.title.length > 1000) throw Error('原稿名が長すぎます。');        total += version.title.length + version.text.length;        const rubies = version.rubies || [];        if (!Array.isArray(rubies) || rubies.length > SAFETY.rubies) throw Error('ふりがなの件数が多すぎるか、形式が不正です。');        for (const ruby of rubies) {          if (!ruby || typeof ruby.text !== 'string' || ruby.text.length > 200) throw Error('ふりがなのデータが大きすぎるか、形式が不正です。');          total += ruby.text.length;        }        if (total > SAFETY.archiveUnits) throw Error('本文・履歴の合計が大きすぎます。原稿を分けて読み込んでください。');      }    }  }  function validateArchiveProjects(projects, displayLimit = true) {    archiveBudget(projects);    // First count all input, then do the more expensive Unicode/layout work.    for (const project of projects) for (const version of [project, ...project.history]) {      if (displayLimit) textWithinBounds(version.text);      normalizeRubies(version.text, version.rubies || []);    }    const ids = projects.filter(p => typeof p.id === 'string' && p.id).map(p => p.id);    if (new Set(ids).size !== ids.length) throw Error('同じIDの原稿が重複しています。');    return projects;  }  function checkFileSize(file, limit = SAFETY.fileBytes) {    if (!Number.isFinite(file.size) || file.size < 0 || file.size > limit) throw Error(limit / 1000000 + 'MB以下のファイルを選んでください。');  }  function rubyBoundaries(text) {    // A long text asks the segmenter about each offset instead of splitting it all.    if (text.length <= 2000 || !segmenter) return new Set([0, text.length, ...segments(text).map(part => part.offset)]);    const iterator = segmenter.segment(text);    return { has(offset) { return offset === text.length || iterator.containing(offset)?.index === offset; } };  }  function beginRead(file, limit) {    checkFileSize(file, limit);    return { ticket: ++readTicket, revision: libraryRevision };  }  function checkRead(read) {    if (read.ticket !== readTicket || read.revision !== libraryRevision) throw Error('読み込み中に原稿を編集・切替したか、別のファイルを選びました。現在の原稿を保護するため読み込みを中止しました。');  }  function canAddProjects(count) {    if (library.projects.length + count <= SAFETY.newProjects) return true;    operationProblem('原稿は30件までです。必要な原稿をJSONで保存してから、不要な原稿を削除してください。');    return false;  }  function protectOversizedRender(error) {    renderBlocked = true;    document.body.classList.add('plain-recovery');    pages.replaceChildren();    pageViews.length = 0; plan = null;    stops = [];    pageIndicator.textContent = '紙面表示を停止';    operationProblem(error.message + ' 入力内容は切り捨てていません。本文欄で分割するか、JSONで保存してください。');  }  window.addEventListener('beforeunload', event => {    if (unsavedChanges || composing && input.value !== activeProject()?.text) { event.preventDefault(); event.returnValue = ''; }  });  window.addEventListener('storage', event => {    if (event.key === LIBRARY_KEY && event.newValue !== lastStored) {      storageProtected = true;      storageProblem('別の原稿タブで保存先が変更されました。上書きを停止しています。このタブの原稿をJSONで保存してください。');    }  });  input.addEventListener('compositionstart', () => { composing = true; });  function finishComposition(event) { composing = false; save(event); }  input.addEventListener('compositionend', finishComposition);  input.addEventListener('blur', event => { if (composing) finishComposition(event); });  function manuscriptInput(event) {    if (event.isComposing) composing = true;    else if (composing && event.isComposing === false) composing = false;    save(event);  }  function guardInsertion(event, inserted) {    if (!event.cancelable || composing || event.isComposing || typeof inserted !== 'string') return;    const candidate = input.value.slice(0, input.selectionStart) + inserted + input.value.slice(input.selectionEnd);    try { textWithinBounds(candidate); }    catch (error) { event.preventDefault(); operationProblem(error.message + ' 現在の原稿は変更していません。'); }  }  input.addEventListener('beforeinput', event => {    if (event.inputType?.startsWith('insert')) guardInsertion(event, event.data ?? (event.inputType === 'insertLineBreak' ? '\n' : null));  });  input.addEventListener('paste', event => guardInsertion(event, event.clipboardData?.getData('text/plain')));  document.getElementById('recover-current').addEventListener('click', () => {    try { saveBackup(); }    catch (error) { operationProblem('JSON保存を開始できませんでした。' + error.message); }  });  document.getElementById('recover-all').addEventListener('click', () => {    try { saveLibraryBackup(); }    catch (error) { operationProblem('全原稿のJSON保存を開始できませんでした。' + error.message); }  });  document.getElementById('recover-original').addEventListener('click', () => {    if (originalStored === null) return;    try { downloadBlob(new Blob([originalStored], { type: 'application/json;charset=utf-8' }), '保存先の元データ_' + backupStamp() + '.json'); }    catch (error) { operationProblem('元データの保存を開始できませんでした。' + error.message); }  });  document.getElementById('retry-storage').addEventListener('click', () => {    if (storageProtected) {      try {        const now = localStorage.getItem(LIBRARY_KEY);        if (now !== originalStored) throw Error('保存先が別のタブで変わっています。このタブをJSON保存してから、原稿タブを開き直してください。');        if (originalStored !== null) downloadBlob(new Blob([originalStored], { type: 'application/json;charset=utf-8' }), '保存先の元データ_' + backupStamp() + '.json');        saveLibraryBackup();        if (!confirm('元データと現在の全原稿のJSONを、実際にファイルへ保存できたことを確認してください。保存済みなら「OK」で、ブラウザ内の保存先を現在の原稿に置き換えて記録を再開します。')) return;        lastStored = now; storageProtected = false;        persist();      } catch (error) { operationProblem('記録を再開できませんでした。' + error.message); }    } else persist();  });  let overviewMode = false, returnScrollY = 0;  // VIEWPORT_LAYOUT_20_START: all sizes here are CSS pixels, without DPR compensation.  function sheetViewportSize(width, height, panelHidden, paperClientWidth = 0) {    const stacked = width <= 650;    const panel = panelHidden || stacked ? 0 : 266;    const innerWidth = paperClientWidth > 0 ? paperClientWidth - 24 : width - 24 - panel - 26;    const availableWidth = Math.max(1, innerWidth - 2);    const availableHeight = Math.max(1, height - 52);    return { scale: Math.max(.001, Math.min(2, availableWidth / 884, availableHeight / 630)), availableWidth };  }  // VIEWPORT_LAYOUT_20_END  function fitPaper() {    // The overview spreads the paper across the window; the sheet size waits for the normal view.    if (overviewMode) return;    const width = document.documentElement.clientWidth || window.innerWidth;    const height = document.documentElement.clientHeight || window.innerHeight;    const paper = document.getElementById('paper-scroll');    const { scale } = sheetViewportSize(width, height, library?.panelHidden, paper?.clientWidth || 0);    document.documentElement.style.setProperty('--paper-scale', String(scale));    document.documentElement.style.setProperty('--paper-height', (630 * scale + 26) + 'px');  }  function updatePageIndicator(range) {    if (!pages.children.length) return;    // Reading every page's position would lay out every page; two suffice.    if (!overviewMode) visiblePage = (range || visiblePages()).center;    pageIndicator.textContent = `${visiblePage + 1} / ${pages.children.length}`;  }  let visiblePage = 0, indicatorQueued = false;  function updateOverviewLayout() {    if (!overviewMode) return;    const n = Math.max(1, pages.children.length), gap = 12;    const width = Math.max(1, (document.documentElement.clientWidth || window.innerWidth) - 48), height = Math.max(1, window.innerHeight - 124);    let bestColumns = 1, bestScale = 0;    for (let columns = 1; columns <= n; columns++) {      const rows = Math.ceil(n / columns);      const scale = Math.min(1, (width - (columns - 1) * gap) / (columns * 884),        (height - (rows - 1) * gap) / (rows * 640));      if (scale > bestScale) { bestScale = scale; bestColumns = columns; }    }    // Smaller than this, the pages of a long manuscript cannot be told apart: the    // overview keeps this size, fits as many across as the window allows, and scrolls.    if (bestScale < OVERVIEW_MIN_SCALE) {      bestScale = OVERVIEW_MIN_SCALE;      bestColumns = Math.max(1, Math.min(n, Math.floor((width + gap) / (884 * bestScale + gap))));    }    document.documentElement.style.setProperty('--overview-cols', String(bestColumns));    document.documentElement.style.setProperty('--overview-scale', String(Math.max(0.01, bestScale * .96)));  }  function setOverview(show, targetPage = null) {    if (show === overviewMode) return;    if (show) returnScrollY = window.scrollY;    const current = show ? visiblePages().center : null;    overviewMode = show;    if (show) input.blur();    document.body.classList.toggle('overview-mode', show);    for (const button of [overviewToggle]) {      button.textContent = show ? '戻る' : '一覧';      button.title = show ? '通常表示へ戻る(Esc)' : '全ページを一覧表示';      button.setAttribute('aria-label', button.title);    }    if (show) {      buildAllPages(); updateOverviewLayout();      // A long overview scrolls; it opens at the page being written.      requestAnimationFrame(() => pages.children[current]?.scrollIntoView({ block: 'center' }));    }    else {      // Size the sheets before scrolling, or the page chosen would drift as they settle.      fitPaper();      render();      requestAnimationFrame(() => {        if (targetPage === null) window.scrollTo(0, returnScrollY);        else pages.children[targetPage]?.scrollIntoView({ block: 'center' });      });    }  }  // One keystroke splits the same manuscript several times (layout, ruby checks,  // the character count), so the last few long texts keep their split. Callers  // never change the returned array.  const segments = (cache => value => {    const cached = cache.get(value);    if (cached) return cached;    const result = segmenter ? [...segmenter.segment(value)].map(x => ({ text: x.segment, offset: x.index })) : [...value].map((text, i) => ({ text, offset: i }));    if (value.length > 200) {      cache.set(value, result);      if (cache.size > 4) cache.delete(cache.keys().next().value);    }    return result;  })(new Map());  // RUBY_MODEL_START: UTF-16 offsets match the textarea selection.  function normalizeRubies(text, items = []) {    if (!Array.isArray(items)) throw new Error('ルビのデータが不正です。');    if (items.length > SAFETY.rubies || items.some(item => typeof item?.text !== 'string' || item.text.length > 200) ||        items.reduce((sum, item) => sum + item.text.length, 0) > SAFETY.textUnits) throw Error('ふりがなのデータ量が上限を超えます。原稿を分けてください。');    if (!items.length) return [];    const boundaries = rubyBoundaries(text);    const result = items.map(item => {      if (!item || !Number.isInteger(item.start) || !Number.isInteger(item.end) ||          item.start < 0 || item.end <= item.start || item.end > text.length ||          !boundaries.has(item.start) || !boundaries.has(item.end) ||          typeof item.text !== 'string' || !item.text.trim() || /[\r\n]/u.test(item.text) ||          /[\r\n]/u.test(text.slice(item.start, item.end)) ||          segments(text.slice(item.start, item.end)).length > ROWS) throw new Error('ルビの範囲・読みが不正です。');      return { start: item.start, end: item.end, text: item.text.trim() };    }).sort((a, b) => a.start - b.start);    if (result.some((item, i) => i > 0 && result[i - 1].end > item.start)) throw new Error('ルビの範囲が重複しています。');    return result;  }  function rebaseRubies(before, after, rubies) {    if (before === after) return normalizeRubies(after, rubies);    let start = 0, oldEnd = before.length, newEnd = after.length;    while (start < oldEnd && start < newEnd && before[start] === after[start]) start++;    while (oldEnd > start && newEnd > start && before[oldEnd - 1] === after[newEnd - 1]) { oldEnd--; newEnd--; }    const delta = newEnd - oldEnd;    const afterBoundaries = rubyBoundaries(after);    return normalizeRubies(after, rubies.flatMap(item => {      if (item.end <= start) return [{ ...item }];      if (item.start >= oldEnd) return [{ ...item, start: item.start + delta, end: item.end + delta }];      return []; // Editing the base removes its reading rather than assigning it to different text.    }).filter(item => afterBoundaries.has(item.start) && afterBoundaries.has(item.end)));  }  // RUBY_MODEL_END  // Lays out a run of text from the top of a fresh column.  function layoutRun(value) {    let page = 0, column = 0, row = 0, wrapped = false;    const glyphs = [];    const positions = [{ offset: 0, page, column, row }];    const nextColumn = automatic => { column++; row = 0; wrapped = automatic; if (column === COLS) { page++; column = 0; } };    const moveBeforeGlyph = () => {      nextColumn(false);      Object.assign(positions[positions.length - 1], { page, column, row });    };    let lastCell = null, previous = '';    const parts = segments(value);    // Count each run once, rather than repeatedly scanning a long run ahead.    const runLengths = new Array(parts.length).fill(1);    for (let i = parts.length - 2; i >= 0; i--) {      if (unbreakableMarks.has(parts[i].text) && parts[i].text === parts[i + 1].text) {        runLengths[i] = runLengths[i + 1] + 1;      }    }    for (let i = 0; i < parts.length; i++) {      const part = parts[i];      const char = part.text;      if (char === '\n' || char === '\r\n') {        if (!wrapped) nextColumn(false);        wrapped = false;        lastCell = null;      } else {        const westernMark = westernPunctuation.has(char) && /^[A-Za-z]$/u.test(previous);        // Each closing bracket keeps its own cell. Reserve the preceding base        // with a following bracket run instead of squeezing them into one cell.        if (!closing.has(char)) {          const followingIndex = i + 1;          const punctuationCell = punctuation.has(parts[followingIndex]?.text) ? 1 : 0;          let trailing = 0;          while (closing.has(parts[followingIndex + punctuationCell + trailing]?.text)) trailing++;          const needed = 1 + punctuationCell + trailing - (punctuationCell && trailing === 1 ? 1 : 0);          const punctuationPair = punctuation.has(char) && trailing === 1;          if (trailing > 1 && !punctuationPair && row > 0 && needed <= ROWS && needed > ROWS - row) moveBeforeGlyph();        }        if (unbreakableMarks.has(char) && char !== previous && row > 0 && runLengths[i] > ROWS - row) {          moveBeforeGlyph();        }        if (row === ROWS - 1 && opening.has(char)) moveBeforeGlyph();        const shareWithPrevious = closing.has(char) && punctuation.has(previous) && lastCell && !closing.has(parts[i + 1]?.text);        const avoidLineHead = row === 0 && wrapped && lastCell && (punctuation.has(char) || westernMark || closing.has(char) && !closing.has(parts[i + 1]?.text));        if (shareWithPrevious || avoidLineHead) {          const cornerAttached = !!(avoidLineHead && (closing.has(char) || punctuation.has(char) && closing.has(parts[i + 1]?.text) && !closing.has(parts[i + 2]?.text)) || closing.has(char) && glyphs.at(-1)?.cornerAttached);          glyphs.push({ text: char, ...lastCell, attached: true, westernMark, start: part.offset, end: part.offset + char.length, ...(cornerAttached ? { cornerAttached: true } : {}) });        } else {          lastCell = { page, column, row };          glyphs.push({ text: char, ...lastCell, attached: false, westernMark, start: part.offset, end: part.offset + char.length });          wrapped = false;          row++;          if (row === ROWS) nextColumn(true);        }      }      previous = char;      positions.push({ offset: part.offset + part.text.length, page, column, row });    }    // The character count shown in the panel: every cell but spaces.    const characters = glyphs.reduce((sum, item) => sum + (/^\s+$/u.test(item.text) ? 0 : 1), 0);    return { glyphs, positions, page, column, row, wrapped, characters };  }  function finishLayout({ glyphs, positions, page, column, wrapped, characters }) {    // A cursor just after the last cell does not require an empty manuscript page.    // Explicit line breaks still retain their intended blank columns/pages.    const pageCount = Math.max(1, page + 1 - (wrapped && column === 0 ? 1 : 0));    for (const position of positions) {      if (position.page >= pageCount) {        Object.assign(position, { page: pageCount - 1, column: COLS - 1, row: ROWS });      }    }    return { glyphs, positions, pageCount, characters };  }  // After a line break the next paragraph starts at the top of a fresh column with  // nothing carried over, so each paragraph's run is kept and only edited  // paragraphs are laid out again; the runs are joined by column number. The  // result is the same as laying out the whole text at once (see the tests).  const paragraphRuns = new Map();  function layout(value) {    if (value.length < 2000 || value.includes('\r')) return finishLayout(layoutRun(value));    const glyphs = [], positions = [{ offset: 0, page: 0, column: 0, row: 0 }], used = new Map();    let base = 0, offset = 0, row = 0, wrapped = false, characters = 0;    value.split('\n').forEach((text, index) => {      if (index) {        if (!wrapped) base++;        row = 0; wrapped = false; offset++;        positions.push({ offset, page: Math.floor(base / COLS), column: base % COLS, row: 0 });      }      const run = used.get(text) || paragraphRuns.get(text) || layoutRun(text);      used.set(text, run);      for (const item of run.glyphs) {        const at = base + item.page * COLS + item.column;        glyphs.push({ ...item, page: Math.floor(at / COLS), column: at % COLS, start: item.start + offset, end: item.end + offset });      }      for (let i = 1; i < run.positions.length; i++) {        const position = run.positions[i], at = base + position.page * COLS + position.column;        positions.push({ offset: position.offset + offset, page: Math.floor(at / COLS), column: at % COLS, row: position.row });      }      base += run.page * COLS + run.column; row = run.row; wrapped = run.wrapped; characters += run.characters;      offset += text.length;    });    paragraphRuns.clear();    for (const [text, run] of used) paragraphRuns.set(text, run);    return finishLayout({ glyphs, positions, page: Math.floor(base / COLS), column: base % COLS, wrapped, characters });  }  // Ruby annotates the established body layout; it must never move body cells.  // A separator is an explicit user-supplied reading boundary, never guessed.  function readingAnnotations(selection, reading) {    const parts = String(reading).split(/[||]/u).map(part => part.trim());    if (parts.length === 1) return normalizeRubies(selection.base, [{ start: 0, end: selection.base.length, text: parts[0] }])      .map(item => ({ ...item, start: item.start + selection.start, end: item.end + selection.start }));    const bases = segments(selection.base);    if (parts.length !== bases.length || parts.some(part => !part)) throw new Error('文字ごとに読みを指定してください。例:京都 → きょう|と(2文字に2つの読み)');    return normalizeRubies(selection.base, bases.map((base, i) => ({ start: base.offset, end: base.offset + base.text.length, text: parts[i] })))      .map(item => ({ ...item, start: item.start + selection.start, end: item.end + selection.start }));  }  function readingForSelection(selection, annotations) {    if (!annotations.length || annotations[0].start !== selection.start || annotations.at(-1).end !== selection.end) return '';    if (annotations.some((item, i) => i > 0 && annotations[i - 1].end !== item.start)) return '';    if (annotations.length === 1) return annotations[0].text;    if (annotations.every(item => segments(selection.base.slice(item.start - selection.start, item.end - selection.start)).length === 1)) {      return annotations.map(item => item.text).join('|');    }    return ''; // Don't silently erase mixed group/character annotation boundaries.  }  function buildRubyRuns(value, rubies, glyphs) {    const indexByStart = new Map(glyphs.map((glyph, index) => [glyph.start, index]));    const runs = [];    for (const ruby of rubies) {      const start = indexByStart.get(ruby.start);      if (start === undefined) continue;      const bases = [];      for (let i = start; i < glyphs.length && glyphs[i].start < ruby.end; i++) bases.push(glyphs[i]);      if (!bases.length) continue;      const first = bases[0], last = bases.at(-1);      const needsSplit = first.page !== last.page || first.column !== last.column;      const letters = segments(ruby.text);      const height = needsSplit ? CELL : (last.row - first.row + 1) * CELL;      runs.push({ page: first.page, column: first.column, row: first.row, height,        text: ruby.text, size: Math.min(10, height / letters.length),        baseStart: first.start, baseEnd: last.end, rubyStart: ruby.start, needsSplit,        base: value.slice(ruby.start, ruby.end) });    }    return runs;  }  function centerAttachedComma(comma) {    comma.setAttribute('x', '0');    comma.setAttribute('y', '0');    if (commaMeasureContext) {      commaMeasureContext.font = COMMA_FONT;      const metrics = alternateInk(comma, parseFloat(COMMA_FONT)) || commaMeasureContext.measureText(comma.textContent);      if (metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight > 0 &&          metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent > 0) {        comma.setAttribute('x', String(ATTACHED_MARK_CENTER + (metrics.actualBoundingBoxLeft - metrics.actualBoundingBoxRight) / 2));        comma.setAttribute('y', String(ATTACHED_MARK_CENTER + (metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2));        return;      }    }    const bounds = comma.getBBox();    if (!bounds.width || !bounds.height) return;    comma.setAttribute('x', String(ATTACHED_MARK_CENTER - bounds.x - bounds.width / 2));    comma.setAttribute('y', String(ATTACHED_MARK_CENTER - bounds.y - bounds.height / 2));  }  function centerAllAttachedCommas(root = pages) {    for (const comma of root.querySelectorAll('.comma-text')) centerAttachedComma(comma);  }  // Some families (BIZ UDMincho, UD Digi Kyokasho, Yomogi) have no U+FE10-FE4F  // code points, only 'vert' alternates of the ordinary characters. Chrome  // applies 'vert' only in vertical writing mode, so those marks are drawn  // vertically: an ordinary cell like the small kana, a shared cell as vertical  // SVG text placed by the same ink-based insets from this font-file table.  // Vertical-layout ink boxes per em (left, right from the centre line; top,  // bottom below the vertical origin), from scripts/measure-vertical-alternates.py.  const VERTICAL_ALTERNATES = /* build: VERTICAL_ALTERNATES */{};  const verticalAlternateFamilies = new Map();  function ownFamilyRendered(font) {    // A missing family falls back to Yu Mincho; then keep the old code points.    if (!verticalAlternateFamilies.has(font)) {      let rendered = false;      try {        const ink = css => {          commaMeasureContext.font = '20px ' + css;          const m = commaMeasureContext.measureText('永あ');          return [m.actualBoundingBoxLeft, m.actualBoundingBoxRight, m.actualBoundingBoxAscent, m.actualBoundingBoxDescent].join();        };        rendered = !!commaMeasureContext && ink(FONT_OPTIONS[font].css) !== ink('"Yu Mincho", serif');      } catch { rendered = false; }      verticalAlternateFamilies.set(font, rendered);    }    return verticalAlternateFamilies.get(font);  }  function setVerticalMark(element, form) {    const font = normalizeFont(activeProject()?.font), own = VERTICAL_ALTERNATES[font];    const char = own && [...verticalForm].find(([key, value]) => value === form && own[key])?.[0];    if (!char || !ownFamilyRendered(font)) element.textContent = form;    else if (element.namespaceURI === 'http://www.w3.org/2000/svg') {      element.textContent = char;      element.classList.add('vertical-alternate');      element.dataset.inkEm = own[char].join(' ');    } else {      const upright = document.createElement('span');      upright.className = 'vertical-japanese';      upright.textContent = char;      element.textContent = '';      element.classList.add('vertical-alternate');      element.append(upright);    }    return element;  }  function alternateInk(element, size) {    if (!element.dataset.inkEm) return null;    // As canvas metrics around the text position, which in vertical SVG text    // is the glyph's vertical origin on its centre line.    const [left, right, top, bottom] = element.dataset.inkEm.split(' ').map(value => Number(value) * size);    return { actualBoundingBoxLeft: -left, actualBoundingBoxRight: right, actualBoundingBoxAscent: -top, actualBoundingBoxDescent: bottom };  }  function makeSharedCellText(value, { x, y, width, height, size = 20, rotate = false }) {    const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');    text.classList.add('shared-cell-text');    setVerticalMark(text, value);    Object.assign(text.dataset, { centerX: x, centerY: y, maxWidth: width, maxHeight: height, fontSize: size, rotate });    text.style.fill = 'var(--ink)'; text.style.stroke = 'none';    return text;  }  function positionAllSharedCellText(root = pages) {    const family = FONT_OPTIONS[normalizeFont(activeProject()?.font)].css;    for (const text of root.querySelectorAll('.shared-cell-text')) {      const x = Number(text.dataset.centerX), y = Number(text.dataset.centerY);      const maxWidth = Number(text.dataset.maxWidth), maxHeight = Number(text.dataset.maxHeight);      const rotate = text.dataset.rotate === 'true';      let size = Number(text.dataset.fontSize), metrics;      // Fit by font size only. Never squeeze a bracket along one axis.      for (let attempt = 0; attempt < 3; attempt++) {        text.style.font = `${size}px ${family}`;        if (commaMeasureContext) {          commaMeasureContext.font = text.style.font;          metrics = alternateInk(text, size) || commaMeasureContext.measureText(text.textContent);        } else {          const box = text.getBBox();          metrics = { actualBoundingBoxLeft: -box.x, actualBoundingBoxRight: box.x + box.width,            actualBoundingBoxAscent: -box.y, actualBoundingBoxDescent: box.y + box.height };        }        const inkWidth = metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight;        const inkHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;        const ratio = Math.min(1, maxWidth / ((rotate ? inkHeight : inkWidth) || 1),          maxHeight / ((rotate ? inkWidth : inkHeight) || 1));        if (ratio >= 1 || attempt === 2) break;        size *= ratio * .98;      }      const fittedInkHeight = rotate ? metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight        : metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;      const centerY = text.dataset.topInset !== undefined ? Number(text.dataset.topInset) + fittedInkHeight / 2        : text.dataset.bottomInset === undefined ? y : CELL - Number(text.dataset.bottomInset) - fittedInkHeight / 2;      const fittedInkWidth = rotate ? metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent        : metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight;      const centerX = text.dataset.rightInset === undefined ? x : CELL - Number(text.dataset.rightInset) - fittedInkWidth / 2;      text.setAttribute('x', String(centerX + (metrics.actualBoundingBoxLeft - metrics.actualBoundingBoxRight) / 2));      text.setAttribute('y', String(centerY + (metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2));      if (rotate) text.setAttribute('transform', `rotate(90 ${centerX} ${centerY})`);      else text.removeAttribute('transform');    }  }  function centerWesternMark(mark) {    const target = mark.closest('svg').dataset.attached === 'true' ? ATTACHED_MARK_CENTER : null;    const x = target ?? 23, y = target ?? 6;    mark.setAttribute('x', '0');    mark.setAttribute('y', '0');    if (commaMeasureContext) {      commaMeasureContext.font = COMMA_FONT;      const metrics = commaMeasureContext.measureText(mark.textContent);      if (metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight > 0 &&          metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent > 0) {        mark.setAttribute('x', String(x + (metrics.actualBoundingBoxLeft - metrics.actualBoundingBoxRight) / 2));        mark.setAttribute('y', String(y + (metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2));        return;      }    }    const bounds = mark.getBBox();    if (!bounds.width || !bounds.height) return;    mark.setAttribute('x', String(x - bounds.x - bounds.width / 2));    mark.setAttribute('y', String(y - bounds.y - bounds.height / 2));  }  function centerAllWesternMarks(root = pages) {    for (const mark of root.querySelectorAll('.western-mark-text')) centerWesternMark(mark);  }  function searchRanges(value, query) {    if (!query) return [];    const matches = [];    let from = 0, at;    while ((at = value.indexOf(query, from)) !== -1) {      matches.push({ start: at, end: at + query.length });      from = at + query.length;    }    return matches;  }  function refreshFindMatches() {    const previousStart = findMatches[activeFindIndex]?.start;    findMatches = findQuery.value ? searchRanges(input.value, findQuery.value) : [];    if (!findMatches.length) activeFindIndex = -1;    else if (previousStart === undefined) activeFindIndex = 0;    else {      const same = findMatches.findIndex(match => match.start === previousStart);      activeFindIndex = same === -1 ? 0 : same;    }    findCount.textContent = findMatches.length ? `${activeFindIndex + 1} / ${findMatches.length}` : '0 / 0';  }  // Pages persist between renders. Each render plans what every page shows (one  // key per page) and rebuilds only pages whose key changed. Only the pages near  // the screen (NEAR_PAGES either side) hold their content: the browser lays out  // every attached element on each change, so a far page is an empty sheet until  // it comes near, when its kept content goes back in. Pages on screen and the  // caret's page are brought up to date at once, the rest of the window while the  // browser is idle, so a keystroke costs about the same on page 1 and on page 100.  // Printing and the overview show every page. Without requestIdleCallback (the  // test DOM model) every page is shown and up to date at once.  const NEAR_PAGES = 10;  const pageViews = [];  let plan = null, idleTask = null, showAllPages = false;  function pageShell(index) {    const page = document.createElement('div');    page.className = 'page';    const sheet = document.createElement('div');    sheet.className = 'sheet';    sheet.dataset.page = String(index);    const rules = [];    for (let column = 0; column < COLS; column++) {      const rule = document.createElement('div');      rule.className = 'column-rule';      rule.style.right = columnRight(column) + 'px';      rules.push(rule);    }    const footer = document.createElement('div');    footer.className = 'page-footer';    const dimensions = document.createElement('span');    dimensions.textContent = '20×20';    const number = document.createElement('span');    number.textContent = String(index + 1);    footer.append(dimensions, number);    page.append(sheet, footer);    return { page, sheet, rules, key: null, nodes: [], shown: false };  }  function matchPageCount(count) {    for (const view of pageViews.splice(count)) view.page.remove();    while (pageViews.length < count) {      const view = pageShell(pageViews.length);      pageViews.push(view);      pages.append(view.page);    }  }  function invalidatePages() { for (const view of pageViews) view.key = null; }  // First index at or after each page, for items listed in page order.  function pageStarts(items, pageCount) {    const starts = new Array(pageCount + 1);    let at = 0;    for (let page = 0; page <= pageCount; page++) {      while (at < items.length && items[at].page < page) at++;      starts[page] = at;    }    return starts;  }  const cellKey = item => item.column * ROWS + item.row;  function pageOverlays(index) {    const { glyphs } = plan.result, from = plan.glyphStarts[index], to = plan.glyphStarts[index + 1];    const selected = new Set(), found = new Map();    if (plan.selectionStart !== plan.selectionEnd) {      for (let i = from; i < to; i++) {        const item = glyphs[i];        if (item.start < plan.selectionEnd && item.end > plan.selectionStart) selected.add(cellKey(item));      }    }    const matches = plan.findMatches;    if (matches.length && from < to) {      let low = 0, high = matches.length;      while (low < high) { const mid = (low + high) >> 1; if (matches[mid].end <= glyphs[from].start) low = mid + 1; else high = mid; }      let matchIndex = low;      for (let i = from; i < to; i++) {        const item = glyphs[i];        while (matchIndex < matches.length && matches[matchIndex].end <= item.start) matchIndex++;        for (let m = matchIndex; m < matches.length && matches[m].start < item.end; m++) {          if (matches[m].end <= item.start) continue;          const key = cellKey(item), active = m === plan.activeFindIndex;          if (!found.has(key) || active) found.set(key, active);        }      }    }    return { selected, found };  }  function pageKey(index) {    const { glyphs } = plan.result, parts = [plan.font];    for (let i = plan.glyphStarts[index]; i < plan.glyphStarts[index + 1]; i++) {      const item = glyphs[i];      parts.push(item.text, cellKey(item), item.attached ? item.cornerAttached ? 2 : 1 : 0, item.westernMark ? 1 : 0);    }    for (let i = plan.rubyStarts[index]; i < plan.rubyStarts[index + 1]; i++) {      const ruby = plan.rubyRuns[i];      parts.push('r', cellKey(ruby), ruby.height, ruby.size, ruby.text, ruby.needsSplit ? ruby.base : '');    }    const { selected, found } = pageOverlays(index);    parts.push('s', ...selected, 'f', ...[...found].flat());    if (plan.caret && plan.caret.page === index) parts.push('c', cellKey(plan.caret));    return parts.join('\u0001');  }  function hidePage(index) {    const view = pageViews[index];    if (!view.shown) return;    view.sheet.replaceChildren();    view.shown = false;  }  function buildPage(index) {    const view = pageViews[index], key = pageKey(index);    if (view.key === key) {      if (!view.shown) { view.sheet.replaceChildren(...view.nodes); view.shown = true; }      return false;    }    const { glyphs } = plan.result, from = plan.glyphStarts[index], to = plan.glyphStarts[index + 1];    const nodes = [...view.rules];    const { selected, found } = pageOverlays(index);    const cell = (className, key) => {      const highlight = document.createElement('span');      highlight.className = className;      highlight.style.right = columnRight(Math.floor(key / ROWS)) + 'px';      highlight.style.top = (key % ROWS * CELL) + 'px';      return highlight;    };    for (const key of selected) nodes.push(cell('selection-cell', key));    for (const [key, active] of found) nodes.push(cell(active ? 'find-cell find-active' : 'find-cell', key));    const sharedBodyCells = new Set();    for (let i = from; i < to; i++) {      const item = glyphs[i];      if (!item.attached && !punctuation.has(item.text) && !closing.has(item.text)) sharedBodyCells.add(cellKey(item));    }    for (let i = from; i < to; i++) {      const item = glyphs[i];      // One renderer and one set of sizes for shared marks at every row.      // The body character always keeps its ordinary renderer and position.      if (item.attached && (punctuation.has(item.text) || closing.has(item.text))) {        // A punctuation-only final cell is still an ordinary cell. Only marks        // forced into a body character's cell need the line-end placement.        const sharesBody = sharedBodyCells.has(cellKey(item));        const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');        svg.classList.add('attached-mark');        if (item.cornerAttached) svg.classList.add('corner-mark');        svg.setAttribute('viewBox', '0 0 28 28');        svg.style.right = columnRight(item.column) + 'px';        svg.style.top = (item.row * CELL) + 'px';        if (item.text === '。') {          // Use the same font glyph as an ordinary punctuation cell. A drawn          // circle with an independent stroke makes this period look heavier.          const period = makeSharedCellText(verticalForm.get(item.text) || item.text, {            x: ATTACHED_MARK_CENTER, y: ATTACHED_MARK_CENTER, width: CELL, height: CELL, size: 20          });          period.classList.add('shared-period-text');          period.dataset.rightInset = 1;          if (sharesBody) period.dataset.bottomInset = 1;          else period.dataset.topInset = 1;          svg.append(period);        } else if (item.text === '、') {          const comma = document.createElementNS(svg.namespaceURI, 'text');          comma.classList.add('comma-text');          comma.style.font = COMMA_FONT;          setVerticalMark(comma, verticalForm.get('、'));          svg.append(comma);        } else {          const bracket = makeSharedCellText(verticalForm.get(item.text) || item.text, {            x: 8, y: 8, width: 14, height: 14, size: 20          });          bracket.classList.add('attached-bracket-text');          // A key sharing a period's cell stays at the top left, including the          // final row. A key with its own cell keeps the font's ordinary glyph.          if (sharesBody) bracket.dataset.bottomInset = 1;          svg.append(bracket);        }        nodes.push(svg);        continue;      }      if (item.westernMark) {        const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');        svg.classList.add('attached-mark');        svg.setAttribute('viewBox', '0 0 28 28');        svg.dataset.attached = String(item.attached);        svg.style.right = columnRight(item.column) + 'px';        svg.style.top = (item.row * CELL) + 'px';        const mark = document.createElementNS(svg.namespaceURI, 'text');        mark.classList.add('western-mark-text');        mark.style.font = COMMA_FONT;        mark.textContent = item.text;        svg.append(mark);        nodes.push(svg);        continue;      }      const glyph = document.createElement('span');      glyph.className = 'glyph';      if (punctuation.has(item.text)) glyph.classList.add('punctuation');      if (opening.has(item.text) || closing.has(item.text)) glyph.classList.add('bracket');      if (item.attached) glyph.classList.add('attached');      if (item.text === '…' || item.text === '⋯') {        const dots = document.createElementNS('http://www.w3.org/2000/svg', 'svg');        dots.classList.add('vertical-ellipsis');        dots.setAttribute('viewBox', '0 0 28 28');        dots.setAttribute('aria-hidden', 'true');        for (const y of [7, 14, 21]) {          const dot = document.createElementNS(dots.namespaceURI, 'circle');          dot.setAttribute('cx', '14'); dot.setAttribute('cy', String(y)); dot.setAttribute('r', '1.5');          dots.append(dot);        }        glyph.append(dots);      } else if (verticalKana.has(item.text) || rotatedSymbols.has(item.text)) {        const stroke = document.createElement('span');        stroke.className = verticalKana.has(item.text) ? 'vertical-japanese' : 'rotated-symbol';        stroke.textContent = item.text === 'ー' ? 'ー' : item.text;        glyph.append(stroke);      } else setVerticalMark(glyph, verticalForm.get(item.text) || item.text);      if (item.attached) {        glyph.style.width = (CELL / 2) + 'px';        glyph.style.height = (CELL / 2) + 'px';        glyph.style.right = (columnRight(item.column) + CELL / 2) + 'px';        glyph.style.top = (item.row * CELL + CELL / 2) + 'px';      } else {        glyph.style.right = columnRight(item.column) + 'px';        glyph.style.top = (item.row * CELL) + 'px';      }      nodes.push(glyph);    }    for (let i = plan.rubyStarts[index]; i < plan.rubyStarts[index + 1]; i++) {      const ruby = plan.rubyRuns[i];      if (ruby.needsSplit) {        const marker = document.createElement('span');        marker.className = 'ruby-run ruby-warning';        marker.textContent = '※';        marker.title = ruby.base + '(' + ruby.text + '):読みの区切りを指定してください';        marker.style.right = (columnRight(ruby.column) - 11) + 'px';        marker.style.top = (ruby.row * CELL) + 'px';        nodes.push(marker);        continue;      }      const run = document.createElement('span');      run.className = 'ruby-run';      run.setAttribute('aria-label', ruby.text);      run.style.right = (columnRight(ruby.column) - 11) + 'px';      run.style.top = (ruby.row * CELL) + 'px';      run.style.height = ruby.height + 'px';      const letters = segments(ruby.text);      letters.forEach((letter, i) => {        const span = document.createElement('span');        span.className = 'ruby-letter';        span.textContent = letter.text;        span.style.fontSize = ruby.size + 'px';        span.style.height = ruby.size + 'px';        span.style.top = ((i + .5) * ruby.height / letters.length - ruby.size / 2) + 'px';        run.append(span);      });      nodes.push(run);    }    if (plan.caret && plan.caret.page === index) {      const caret = document.createElement('span');      caret.className = 'cursor';      caret.style.right = (columnRight(plan.caret.column) + 3) + 'px';      caret.style.top = (plan.caret.row * CELL + 1) + 'px';      nodes.push(caret);    }    view.sheet.replaceChildren(...nodes);    centerAllAttachedCommas(view.sheet);    positionAllSharedCellText(view.sheet);    centerAllWesternMarks(view.sheet);    Object.assign(view, { key, nodes, shown: true });    return true;  }  // Pages on screen, from the first page's position and the page pitch.  function visiblePages() {    const count = pageViews.length;    if (!count) return { first: 0, last: -1, center: 0 };    const top = pageViews[0].page.getBoundingClientRect().top;    const pitch = count > 1 ? pageViews[1].page.getBoundingClientRect().top - top : 0;    if (!(pitch > 0)) return { first: 0, last: count - 1, center: 0 };    const clamp = value => Math.max(0, Math.min(count - 1, Math.floor(value)));    return { first: clamp(-top / pitch), last: clamp((window.innerHeight - top) / pitch), center: clamp((window.innerHeight / 2 - top) / pitch) };  }  // Shows every page, up to date: for printing and the overview.  function buildAllPages() {    if (idleTask !== null) { window.cancelIdleCallback?.(idleTask); idleTask = null; }    if (plan) for (let i = 0; i < pageViews.length; i++) buildPage(i);  }  function scheduleIdlePages(order) {    if (idleTask !== null) window.cancelIdleCallback(idleTask);    let next = 0;    const work = deadline => {      idleTask = null;      while (next < order.length && (deadline.didTimeout || deadline.timeRemaining() > 2)) buildPage(order[next++]);      if (next < order.length) idleTask = window.requestIdleCallback(work, { timeout: 200 });    };    if (order.length) idleTask = window.requestIdleCallback(work, { timeout: 200 });  }  function bringPagesUpToDate(range) {    if (!plan) return;    if (typeof window.requestIdleCallback !== 'function' || overviewMode || showAllPages) { buildAllPages(); return; }    const { first, last, center } = range || visiblePages(), caretPage = plan.caret?.page;    const from = Math.max(0, first - NEAR_PAGES), to = Math.min(pageViews.length - 1, last + NEAR_PAGES);    for (let i = 0; i < pageViews.length; i++) if ((i < from || i > to) && i !== caretPage) hidePage(i);    for (let i = first; i <= last; i++) buildPage(i);    if (caretPage !== undefined) buildPage(caretPage);    const rest = [];    for (let i = from; i <= to; i++) if (i < first || i > last) rest.push(i);    scheduleIdlePages(rest.sort((a, b) => Math.abs(a - center) - Math.abs(b - center) || a - b));  }  function render() {    try { textWithinBounds(input.value); }    catch (error) { protectOversizedRender(error); return; }    if (renderBlocked) { renderBlocked = false; document.body.classList.remove('plain-recovery'); operationProblem(''); }    const selectedFont = FONT_OPTIONS[normalizeFont(activeProject()?.font)];    // Setting the font again, even unchanged, makes the browser restyle every page.    if (pages.style.getPropertyValue?.('--manuscript-font') !== selectedFont.css) pages.style.setProperty('--manuscript-font', selectedFont.css);    COMMA_FONT = '20px ' + selectedFont.css;    const title = name.value.trim() || '縦書き原稿用紙';    if (document.title !== title) document.title = title;    const currentRubies = composing ? rebaseRubies(activeProject().text, input.value, activeProject().rubies || []) : normalizeRubies(input.value, activeProject()?.rubies || []);    const result = boundedLayout.result;    stops = result.positions;    refreshFindMatches();    const rubyRuns = buildRubyRuns(input.value, currentRubies, result.glyphs);    const unresolved = rubyRuns.filter(ruby => ruby.needsSplit);    const rubyStatus = document.getElementById('ruby-status');    if (unresolved.length) {      rubyStatus.dataset.splitWarning = 'true';      rubyStatus.textContent = unresolved.slice(0, 3).map(ruby => '「' + ruby.base + '」(' + ruby.text + ')').join('、') +        (unresolved.length > 3 ? ' ほか' : '') + 'は列をまたぎます。本文を選び、文字ごとの読みを「きょう|と」のように指定してください。※の位置の読みは未表示です。';    } else if (rubyStatus.dataset.splitWarning === 'true') {      delete rubyStatus.dataset.splitWarning;      rubyStatus.textContent = '本文を選択して、ふりがなを変更できます。';    }    const selectionStart = input.selectionStart, selectionEnd = input.selectionEnd;    const offset = input.selectionDirection === 'backward' ? selectionStart : selectionEnd;    let at = stops[0];    for (const stop of stops) { if (stop.offset > offset) break; at = stop; }    plan = { result, rubyRuns, font: selectedFont.css, text: input.value, direction: input.selectionDirection, selectionStart, selectionEnd, findMatches, activeFindIndex,      caret: document.activeElement === input && selectionStart === selectionEnd ? at : null,      glyphStarts: pageStarts(result.glyphs, result.pageCount), rubyStarts: pageStarts(rubyRuns, result.pageCount) };    matchPageCount(result.pageCount);    // Read positions before changing any page: a page's size never depends on its content.    const range = visiblePages(), sheetRect = pageViews[at.page].sheet.getBoundingClientRect();    bringPagesUpToDate(range);    updateOverviewLayout();    updatePageIndicator(range);    const displayScale = sheetRect.width / 804;    if (!composing) {      // Windows IME opens its candidate list below the caret, its frame starting      // left of it by a fixed amount at any paper scale. At +32 px the frame still      // hid the column's right rule in the user's screen; +36 leaves it visible.      // The list then covers columns already written, not the text being composed.      // Where the list has no room on the right (the first columns near the edge of      // the window, or the ためし書き frame), Windows pushes it back over the column,      // so it opens on the left instead, over the columns still to be written.      const columnRightX = sheetRect.right - columnRight(at.column) * displayScale;      const columnLeftX = columnRightX - CELL * displayScale;      const roomOnRight = columnRightX + 2 + IME_LIST_WIDTH <= innerWidth;      input.style.left = (roomOnRight || columnLeftX < IME_LIST_WIDTH ? columnRightX + 36 : columnLeftX - IME_LIST_WIDTH) + 'px';      input.style.top = (sheetRect.top + at.row * CELL * displayScale) + 'px';    }    countEl.textContent = result.characters.toLocaleString('ja-JP');  }  const newId = () => Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);  const newProject = (title = '', text = '') => ({ id: newId(), title, text, font: 'mincho', rubies: [], history: [], updatedAt: Date.now(), lastVersionAt: text ? 0 : Date.now() });  const activeProject = () => library.projects.find(project => project.id === library.activeId);  function addVersion(project, kind) {    if (!project.text && !project.title) return;    project.history.push({ id: newId(), at: Date.now(), kind, title: project.title, text: project.text, font: normalizeFont(project.font), rubies: normalizeRubies(project.text, project.rubies || []) });    if (project.history.length > HISTORY_LIMIT) project.history.splice(0, project.history.length - HISTORY_LIMIT);    project.lastVersionAt = Date.now();  }  function refreshProjectOptions() {    projectSelect.replaceChildren(...library.projects.map(project => {      const option = document.createElement('option');      option.value = project.id;      option.textContent = (project.title.trim() || '無題') + (isProtectedProject(project) ? '(保護)' : '');      return option;    }));    projectSelect.value = library.activeId;  }  function refreshVersions() {    const history = activeProject().history;    document.getElementById('restore-version').disabled = isProtectedProject(activeProject()) || !history.length;    if (!history.length) {      versionsSelect.replaceChildren(new Option('まだ履歴はありません', ''));      return;    }    versionsSelect.replaceChildren(...[...history].reverse().map(version =>      new Option(new Date(version.at).toLocaleString('ja-JP') + ' · ' + (version.kind === '復元前' ? '戻す前の原稿' : version.kind), version.id)));  }  // New writing comes before old versions: when the library would outgrow the  // storage budget, the oldest history versions (of any manuscript) are dropped  // first, so saving never stops while history remains. Current text is never cut.  function trimHistoryToBudget(projects) {    const size = version => version.title.length + version.text.length + (version.rubies || []).reduce((sum, ruby) => sum + ruby.text.length, 0);    let total = projects.reduce((sum, project) => sum + size(project) + project.history.reduce((all, version) => all + size(version), 0), 0);    let trimmed = false;    while (total > SAFETY.archiveUnits) {      let oldest = null;      for (const project of projects) if (project.history.length && (!oldest || project.history[0].at < oldest.history[0].at)) oldest = project;      if (!oldest) break;      total -= size(oldest.history.shift());      trimmed = true;    }    return trimmed;  }  function persist() {    libraryRevision++;    library.panelSide = panelSide.value;    try {      if (storageProtected) throw Error('元の保存先の保護中');      if (localStorage.getItem(LIBRARY_KEY) !== lastStored) {        storageProtected = true; throw Error('別のタブで保存先が変更されました');      }      if (trimHistoryToBudget(library.projects)) refreshVersions();      archiveBudget(library.projects);      const serialized = JSON.stringify(library);      localStorage.setItem(LIBRARY_KEY, serialized);      lastStored = serialized;      unsavedChanges = false;      status.hidden = true; status.textContent = ''; recoveryPanel.hidden = true;    } catch (error) {      storageProblem(storageProtected        ? '元の保存先を保護するため上書きを停止しています。このタブの原稿をJSONで保存してください。'        : 'ブラウザ内に記録できていません。画面の原稿は保持しています。閉じる前にJSONで保存してください。 ' + error.message);    }    refreshDriveStatus();  }  const rubyUndoStates = new Map();  function save(event) {    if (isProtectedProject(activeProject())) {      composing = false; input.value = activeProject().text; name.value = activeProject().title; persist(); render(); return;    }    if (composing && !event) composing = false;    if (composing) { render(); return; }    const project = activeProject();    if (project) {      const changedText = project.text !== input.value;      if (changedText && project.text && Date.now() - project.lastVersionAt >= AUTO_VERSION_INTERVAL) {        addVersion(project, '自動');        refreshVersions();      }      const changedTitle = project.title !== name.value;      if (changedText) {        const cache = rubyUndoStates.get(project.id) || new Map();        cache.set(project.text, normalizeRubies(project.text, project.rubies || []));        const oldCount = (project.rubies || []).length;        project.rubies = /^history(Undo|Redo)$/u.test(event?.inputType || '') && cache.has(input.value)          ? normalizeRubies(input.value, cache.get(input.value))          : rebaseRubies(project.text, input.value, project.rubies || []);        if (project.rubies.length < oldCount) document.getElementById('ruby-status').textContent = '本文を変更した範囲のふりがなを外しました。';        while (cache.size > 50) cache.delete(cache.keys().next().value);        rubyUndoStates.set(project.id, cache);      }      project.title = name.value;      project.text = input.value;      project.updatedAt = Date.now();      if (changedTitle) refreshProjectOptions();      persist();    }    if (!composing) render();  }  function showProject() {    const project = activeProject();    document.getElementById('file-status').textContent = '';    document.getElementById('ruby-status').textContent = '';    delete document.getElementById('ruby-status').dataset.splitWarning;    name.value = project.title;    fontSelect.value = normalizeFont(project.font);    input.value = project.text;    if (project.text !== input.value) {      project.rubies = rebaseRubies(project.text, input.value, project.rubies || []);      project.text = input.value;    }    input.setSelectionRange(input.value.length, input.value.length);    refreshProjectOptions();    refreshVersions();    updateSampleProtection();    render();    refreshDriveStatus();  }  function loadLibrary() {    try {      originalStored = localStorage.getItem(LIBRARY_KEY);      lastStored = originalStored;      if (originalStored !== null) {        if (originalStored.length > SAFETY.fileBytes) throw Error('保存データが大きすぎます');        const stored = JSON.parse(originalStored);        if (!stored || ![1, 2].includes(stored.version)) throw Error('保存形式が不正です');        validateArchiveProjects(stored.projects, false);        if (stored.projects.some(p => typeof p.id !== 'string' || !p.id)) throw Error('保存IDが不正です');        for (const project of stored.projects) {          project.font = normalizeFont(project.font);          project.rubies = normalizeRubies(project.text, project.rubies || []);          for (const version of project.history) {            version.font = normalizeFont(version.font);            version.rubies = normalizeRubies(version.text, version.rubies || []);          }        }        if (!stored.projects.some(p => p.id === stored.activeId)) stored.activeId = stored.projects[0].id;        stored.version = 2; return withdrawSamples(protectSampleLibrary(curateSampleLibrary(stored)));      }    } catch (error) {      storageProtected = true;      storageProblem('保存データを読み込めませんでした。元の保存先は上書きしません。' + error.message + ' 現在の原稿と、取得できた元データをJSONで保存してください。');    }    return createSampleLibrary();  }  function offsetAtPoint(x, y) {    const element = document.elementFromPoint(x, y);    const sheet = element && element.closest('.sheet');    if (!sheet) return null;    const box = sheet.getBoundingClientRect();    const displayScale = box.width / 804;    const fromRight = (box.right - x) / displayScale;    let column = 0;    for (let i = 1; i < COLS; i++) {      if (Math.abs(fromRight - columnRight(i) - CELL / 2) < Math.abs(fromRight - columnRight(column) - CELL / 2)) column = i;    }    const localY = (y - box.top) / displayScale;    const row = Math.max(0, Math.min(ROWS - 1, Math.floor(localY / CELL)));    const lowerHalf = localY % CELL >= CELL / 2;    const target = Number(sheet.dataset.page) * COLS * ROWS + column * ROWS + row + (lowerHalf ? .75 : 0);    let closest = stops[0], distance = Infinity;    for (const stop of stops) {      const ordinal = stop.page * COLS * ROWS + stop.column * ROWS + stop.row;      const gap = Math.abs(ordinal - target);      if (gap < distance) { distance = gap; closest = stop; }    }    return closest.offset;  }  window.genkoBoot.stage('操作ボタンの準備');  let dragAnchor = null;  pages.addEventListener('mousedown', event => {    if (event.button !== 0) return;    if (overviewMode) {      const page = event.target.closest('.page');      if (page) {        event.preventDefault();        setOverview(false, [...pages.children].indexOf(page));      }      return;    }    const offset = offsetAtPoint(event.clientX, event.clientY);    if (offset === null) return;    event.preventDefault();    const anchor = event.shiftKey ? (input.selectionDirection === 'backward' ? input.selectionEnd : input.selectionStart) : offset;    dragAnchor = anchor;    input.style.left = event.clientX + 'px';    input.style.top = event.clientY + 'px';    input.focus({ preventScroll: true });    input.setSelectionRange(Math.min(anchor, offset), Math.max(anchor, offset), offset < anchor ? 'backward' : 'forward');    render();  });  document.addEventListener('mousemove', event => {    if (dragAnchor === null) return;    const offset = offsetAtPoint(event.clientX, event.clientY);    if (offset === null) return;    input.setSelectionRange(Math.min(dragAnchor, offset), Math.max(dragAnchor, offset),      offset < dragAnchor ? 'backward' : 'forward');    render();  });  document.addEventListener('mouseup', () => { dragAnchor = null; });  input.addEventListener('input', manuscriptInput);  input.addEventListener('keydown', event => {    if (renderBlocked) return;    if (composing || event.isComposing || event.keyCode === 229 || !['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) return;    event.preventDefault();    const forward = event.key === 'ArrowDown' || event.key === 'ArrowLeft';    const focus = input.selectionStart === input.selectionEnd ? input.selectionStart      : input.selectionDirection === 'backward' ? input.selectionStart : input.selectionEnd;    let index = 0;    while (index + 1 < stops.length && stops[index + 1].offset <= focus) index++;    const here = stops[index];    let target = here;    if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {      const step = event.key === 'ArrowDown' ? 1 : -1;      let next = index + step;      while (next >= 0 && next < stops.length &&        stops[next].page === here.page && stops[next].column === here.column && stops[next].row === here.row) next += step;      const candidate = stops[next];      if (candidate) target = candidate;    } else {      let page = here.page;      let column = here.column + (event.key === 'ArrowLeft' ? 1 : -1);      if (column === COLS) { page++; column = 0; }      if (column < 0) { page--; column = COLS - 1; }      const candidates = stops.filter(stop => stop.page === page && stop.column === column);      if (candidates.length) target = candidates.reduce((best, stop) =>        Math.abs(stop.row - here.row) < Math.abs(best.row - here.row) ? stop : best);    }    if (event.shiftKey) {      const anchor = input.selectionStart === input.selectionEnd ? focus        : input.selectionDirection === 'backward' ? input.selectionEnd : input.selectionStart;      input.setSelectionRange(Math.min(anchor, target.offset), Math.max(anchor, target.offset),        target.offset < anchor ? 'backward' : 'forward');    } else if (input.selectionStart !== input.selectionEnd) {      const edge = forward ? input.selectionEnd : input.selectionStart;      input.setSelectionRange(edge, edge);    } else input.setSelectionRange(target.offset, target.offset);    render();  });  // A key's input event has already rendered; key-up only matters when the key  // moved the selection without changing the text (Home, End, Shift+arrows...).  input.addEventListener('keyup', () => {    if (plan && plan.text === input.value && plan.selectionStart === input.selectionStart && plan.selectionEnd === input.selectionEnd &&        plan.direction === input.selectionDirection) return;    render();  });  input.addEventListener('click', render);  input.addEventListener('blur', render);  document.addEventListener('mousedown', event => {    if (!event.target.closest('.sheet') && event.target !== input) input.blur();  });  name.addEventListener('input', save);  fontSelect.addEventListener('change', () => {    save();    const project = activeProject(), selected = normalizeFont(fontSelect.value);    if (normalizeFont(project.font) === selected) return;    addVersion(project, 'フォント変更前');    project.font = selected; project.updatedAt = Date.now();    driveMessage = null;    persist(); refreshVersions(); render();  });  const rubyDialog = document.getElementById('ruby-dialog');  const rubyReading = document.getElementById('ruby-reading');  let pendingRubySelection = null;  function openRubyEditor() {    if (isProtectedProject(activeProject())) return;    save();    const existing = activeProject().rubies || [];    let start = input.selectionStart, end = input.selectionEnd;    const atCaret = existing.find(item => item.start <= start && item.end > start);    if (start === end && atCaret) { start = atCaret.start; end = atCaret.end; }    const base = input.value.slice(start, end);    if (!base) { document.getElementById('ruby-status').textContent = '読みを付ける本文をドラッグで選択してください。'; input.focus({ preventScroll: true }); return; }    if (/[\r\n]/u.test(base) || segments(base).length > ROWS) {      document.getElementById('ruby-status').textContent = '改行を含まない20字以内の範囲を選択してください。'; return;    }    const overlaps = existing.filter(item => item.start < end && item.end > start);    pendingRubySelection = { start, end, base };    document.getElementById('ruby-base').textContent = base;    rubyReading.value = readingForSelection(pendingRubySelection, overlaps);    document.getElementById('ruby-apply').textContent = rubyReading.value ? '変更する' : '付ける';    document.getElementById('ruby-remove').disabled = overlaps.length === 0;    document.getElementById('ruby-error').textContent = '';    rubyDialog.showModal(); rubyReading.focus();  }  document.getElementById('ruby-open').addEventListener('mousedown', event => event.preventDefault());  document.getElementById('ruby-open').addEventListener('click', openRubyEditor);  function commitRuby(remove = false) {    if (isProtectedProject(activeProject())) return;    const selection = pendingRubySelection;    if (!selection || input.value.slice(selection.start, selection.end) !== selection.base) return;    const project = activeProject();    try {      const next = (project.rubies || []).filter(item => item.end <= selection.start || item.start >= selection.end);      if (!remove) next.push(...readingAnnotations(selection, rubyReading.value));      const normalized = normalizeRubies(project.text, next);      addVersion(project, remove ? 'ルビ削除前' : 'ルビ変更前');      project.rubies = normalized;      project.updatedAt = Date.now();      persist(); refreshVersions(); rubyDialog.close();      input.focus({ preventScroll: true }); input.setSelectionRange(selection.start, selection.end); render();      if (document.getElementById('ruby-status').dataset.splitWarning !== 'true') document.getElementById('ruby-status').textContent = remove ? 'ふりがなを外しました。' : 'ふりがなを反映しました。';    } catch (error) { document.getElementById('ruby-error').textContent = error.message; }  }  document.getElementById('ruby-form').addEventListener('submit', event => { event.preventDefault(); commitRuby(); });  document.getElementById('ruby-remove').addEventListener('click', () => commitRuby(true));  document.getElementById('ruby-cancel').addEventListener('click', () => rubyDialog.close());  function applyPanelSide() {    const onRight = panelSide.value === 'right';    main.classList.toggle('panel-right', onRight);    document.body.classList.toggle('panel-on-right', onRight);  }  document.getElementById('flip-panel').addEventListener('click', () => {    panelSide.value = panelSide.value === 'left' ? 'right' : 'left';    applyPanelSide();    save();  });  document.getElementById('hide-panel').addEventListener('click', () => {    library.panelHidden = true;    document.body.classList.add('panel-hidden');    fitPaper();    persist();    render();  });  document.getElementById('show-panel').addEventListener('click', () => {    library.panelHidden = false;    document.body.classList.remove('panel-hidden');    fitPaper();    persist();    render();  });  overviewToggle.addEventListener('click', () => setOverview(!overviewMode));  function jumpToPage(index) {    if (overviewMode) setOverview(false, index);    else pages.children[index]?.scrollIntoView({ block: 'center', behavior: 'smooth' });  }  document.getElementById('first-page').addEventListener('click', () => jumpToPage(0));  document.getElementById('last-page').addEventListener('click', () => jumpToPage(pages.children.length - 1));  function scrollToFindMatch() {    const match = findMatches[activeFindIndex];    if (!match || !plan) return;    const glyph = plan.result.glyphs.find(item => item.end > match.start);    if (!glyph) return;    buildPage(glyph.page);    showFindPage(glyph.page);  }  // The search box is always in the side panel. Searching never moves the paper  // unless the match to show is off screen, and then only as far as needed.  function showFindPage(index) {    const page = pages.children[Math.max(0, Math.min(index, pages.children.length - 1))];    if (!page) return;    const box = page.getBoundingClientRect();    if (box.top >= 0 && box.bottom <= window.innerHeight) return;    page.scrollIntoView({ block: 'nearest' });  }  const matchPage = match => plan?.result.glyphs.find(item => item.end > match.start)?.page ?? 0;  function openFind() {    if (library.panelHidden) document.getElementById('show-panel').click();    findQuery.focus({ preventScroll: true });    findQuery.select();  }  function clearFind() {    findQuery.value = '';    render();  }  function moveFind(direction) {    if (!findMatches.length) return;    if (overviewMode) setOverview(false);    activeFindIndex = (activeFindIndex + direction + findMatches.length) % findMatches.length;    render();    scrollToFindMatch();  }  document.getElementById('find-close').addEventListener('click', () => { clearFind(); findQuery.focus({ preventScroll: true }); });  document.getElementById('find-prev').addEventListener('click', () => moveFind(-1));  document.getElementById('find-next').addEventListener('click', () => moveFind(1));  // Typing a query marks every match and makes the first one on or after the page  // in view the current one, so the paper stays where it is when it can.  findQuery.addEventListener('input', () => {    activeFindIndex = -1;    render();    if (!findMatches.length || !plan) return;    const boxes = [...pages.children].map(page => page.getBoundingClientRect());    const whole = boxes.findIndex(box => box.top >= 0 && box.bottom <= window.innerHeight);    const inView = whole >= 0 ? whole : Math.max(0, boxes.findIndex(box => box.bottom > 0));    const next = findMatches.findIndex(match => matchPage(match) >= inView);    if (next > 0) { activeFindIndex = next; render(); }    scrollToFindMatch();  });  findQuery.addEventListener('keydown', event => {    if (event.isComposing) return;    if (event.key === 'Escape') {      event.preventDefault(); event.stopPropagation();      clearFind(); input.focus({ preventScroll: true });      return;    }    if (event.key !== 'Enter') return;    event.preventDefault();    moveFind(event.shiftKey ? -1 : 1);  });  window.addEventListener('scroll', () => {    if (indicatorQueued) return;    indicatorQueued = true;    requestAnimationFrame(() => { indicatorQueued = false; if (plan && !overviewMode && !renderBlocked) bringPagesUpToDate(); updatePageIndicator(); });  }, { passive: true });  window.addEventListener('resize', () => { fitPaper(); render(); });  const printPages = document.getElementById('print-pages');  function buildPrintPages() {    showAllPages = true;    buildAllPages();    printPages.style.setProperty('--manuscript-font', FONT_OPTIONS[normalizeFont(activeProject().font)].css);    printPages.replaceChildren(...[...pages.children].map(page => {      const frame = document.createElement('div');      frame.className = 'print-page';      frame.append(page.cloneNode(true));      return frame;    }));  }  let printRequestPending = false;  function requestManuscriptPrint() {    if (printRequestPending) return;    printRequestPending = true;    const start = () => {      try {        if (renderBlocked) throw Error('紙面表示が停止しているため印刷できません。');        if (overviewMode) setOverview(false);        centerAllAttachedCommas(); positionAllSharedCellText(); centerAllWesternMarks();        buildPrintPages();        window.print();      } catch (error) { operationProblem('印刷を開始できませんでした。' + error.message); }      finally { printRequestPending = false; }    };    if (document.fonts?.ready) document.fonts.ready.then(start, () => { printRequestPending = false; operationProblem('書体を読み込めませんでした。'); });    else start();  }  window.addEventListener('afterprint', () => { printPages.replaceChildren(); showAllPages = false; bringPagesUpToDate(); });  window.addEventListener('beforeprint', () => { if (overviewMode) setOverview(false); centerAllAttachedCommas(); positionAllSharedCellText(); centerAllWesternMarks(); });  window.addEventListener('beforeprint', buildPrintPages);  projectSelect.addEventListener('change', () => {    library.activeId = projectSelect.value;    showProject();    persist();  });  document.getElementById('new-project').addEventListener('click', () => {    if (!canAddProjects(1)) return;    const project = newProject();    library.projects.push(project);    library.activeId = project.id;    showProject();    persist();    name.focus();  });  const noticesDialog = document.getElementById('notices-dialog');  document.getElementById('notices-open').addEventListener('click', () => noticesDialog.showModal());  document.getElementById('notices-close').addEventListener('click', () => noticesDialog.close());  // A click outside the box (on the backdrop, which targets the dialog itself) closes it.  noticesDialog.addEventListener('click', event => { if (event.target === noticesDialog) noticesDialog.close(); });  const deleteDialog = document.getElementById('delete-dialog');  let deleteTargetId = null;  document.getElementById('delete-project').addEventListener('click', () => {    save();    const project = activeProject();    deleteTargetId = project.id;    document.getElementById('delete-project-name').textContent = '「' + (project.title.trim() || '無題') + '」';    deleteDialog.showModal();    document.getElementById('cancel-delete').focus({ preventScroll: true });  });  document.getElementById('cancel-delete').addEventListener('click', () => {    deleteTargetId = null;    deleteDialog.close();  });  deleteDialog.addEventListener('cancel', () => { deleteTargetId = null; });  deleteDialog.addEventListener('close', () => { deleteTargetId = null; });  document.getElementById('confirm-delete').addEventListener('click', () => {    if (!deleteDialog.open || !deleteTargetId) return;    const index = library.projects.findIndex(project => project.id === deleteTargetId);    if (index < 0) { deleteTargetId = null; deleteDialog.close(); return; }    const wasActive = library.activeId === deleteTargetId;    library.projects.splice(index, 1);    if (!library.projects.length) library.projects.push(newProject());    if (wasActive) library.activeId = library.projects[Math.min(index, library.projects.length - 1)].id;    deleteTargetId = null;    deleteDialog.close();    showProject();    persist();  });  document.getElementById('checkpoint').addEventListener('click', () => {    save();    addVersion(activeProject(), '手動');    persist();    refreshVersions();  });  document.getElementById('toggle-protection').addEventListener('click', toggleProjectProtection);  document.getElementById('copy-sample').addEventListener('click', createEditableCopy);  document.getElementById('restore-version').addEventListener('click', () => {    if (isProtectedProject(activeProject())) return;    const project = activeProject();    const version = project.history.find(item => item.id === versionsSelect.value);    if (!version) return;    addVersion(project, '復元前');    project.title = version.title;    project.text = version.text;    project.font = normalizeFont(version.font);    project.rubies = normalizeRubies(version.text, version.rubies || []);    project.updatedAt = Date.now();    showProject();    persist();  });  function downloadBlob(blob, filename) {    const url = URL.createObjectURL(blob);    const link = document.createElement('a');    link.href = url;    link.download = filename;    link.click();    setTimeout(() => URL.revokeObjectURL(url), 1000);  }  const backupStamp = () => {    const date = new Date();    const two = n => String(n).padStart(2, '0');    return date.getFullYear() + '-' + two(date.getMonth() + 1) + '-' + two(date.getDate()) +      '_' + two(date.getHours()) + '-' + two(date.getMinutes()) + '-' + two(date.getSeconds());  };  function saveBackup() {    save();    const project = activeProject();    const data = { format: 'tategaki-manuscript', version: 2, savedAt: Date.now(), recovery: 'plain-text', project: manuscriptSnapshot(project) };    const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json;charset=utf-8' });    downloadBlob(blob, safeFilename(project.title.trim() || '無題') + '_' + backupStamp() + '.json');    return true;  }  function saveLibraryBackup() {    save();    const data = { ...library, recovery: 'plain-text', projects: library.projects.map(manuscriptSnapshot) };    const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json;charset=utf-8' });    downloadBlob(blob, '全原稿_' + backupStamp() + '.json');    document.getElementById('file-status').textContent = '全原稿のJSON保存を開始しました。';  }  document.addEventListener('keydown', event => {    if (event.key === 'Escape') {      if (composing || event.isComposing || event.keyCode === 229 || document.querySelector('dialog[open]')) return;      if (overviewMode) { event.preventDefault(); setOverview(false); return; }    }    if ((event.ctrlKey || event.metaKey) && ['s', 'f'].includes(event.key.toLowerCase()) && document.querySelector('dialog[open]')) {      event.preventDefault(); return;    }    if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'f') {      event.preventDefault();      openFind();    }    if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {      event.preventDefault();      saveSelectedFile();    }  });  function parseArchive(data) {    const single = data && (data.format === 'tategaki-manuscript' && [1, 2].includes(data.version) || data.format === 'tategaki-drive-manuscript' && data.version === 2);    if (single) return validateArchiveProjects([data.project], data.recovery !== 'plain-text');    if (data && [1, 2].includes(data.version)) return validateArchiveProjects(data.projects, data.recovery !== 'plain-text');    throw Error('このアプリの原稿JSONではありません。');  }  function copyHistory(source) {    return source.history.filter(v => v && typeof v.title === 'string' && typeof v.text === 'string')      .slice(-HISTORY_LIMIT).map(v => ({ id: newId(), at: Number(v.at) || Date.now(), kind: String(v.kind || '以前の版'), title: v.title, text: v.text, font: normalizeFont(v.font), rubies: normalizeRubies(v.text, v.rubies || []) }));  }  function restoreProject(source, allowOversize = false) {    validateArchiveProjects([source], !allowOversize);    if (!library.projects.some(p => p.id === source.id) && !canAddProjects(1)) return;    save();    const current = activeProject();    let project = library.projects.find(p => typeof source.id === 'string' && p.id === source.id);    if (isProtectedProject(project)) project = null;    if (!project && !canAddProjects(1)) return;    if (project && (project.title !== source.title || project.text !== source.text || normalizeFont(project.font) !== normalizeFont(source.font) || JSON.stringify(project.rubies || []) !== JSON.stringify(source.rubies || []))) {      if (!confirm(`同じ原稿「${project.title.trim() || '無題'}」があります。JSONの内容を開きますか? 現在の版は編集履歴に残します。`)) return;      addVersion(project, '読込前');    }    if (!project) {      if (!isProtectedProject(current) && !current.title && !current.text && !current.history.length) project = current;      else { project = newProject(); library.projects.push(project); }      if (typeof source.id === 'string' && source.id && !library.projects.some(p => p !== project && p.id === source.id)) project.id = source.id;    }    const oldHistory = project.history;    project.title = source.title;    project.text = source.text;    project.font = normalizeFont(source.font);    project.rubies = normalizeRubies(source.text, source.rubies || []);    const mergedHistory = new Map([...copyHistory(source), ...oldHistory].map(v => [JSON.stringify([v.at, v.title, v.text, normalizeFont(v.font), v.rubies || [], v.kind]), v]));    project.history = [...mergedHistory.values()].sort((a, b) => a.at - b.at).slice(-HISTORY_LIMIT);    project.updatedAt = Date.now();    project.lastVersionAt = Date.now();    if (source.protected === true) project.protected = true; else delete project.protected;    library.activeId = project.id;    showProject();    persist();    document.getElementById('file-status').textContent = '原稿(JSON)を開きました。';    window.scrollTo(0, 0);  }  let pendingSources = [];  const jsonPicker = document.getElementById('json-picker');  const jsonSelect = document.getElementById('json-project-select');  async function openJsonFile(file) {    try {      const read = beginRead(file);      const data = JSON.parse((await file.text()).replace(/^\uFEFF/, ''));      checkRead(read);      const sources = parseArchive(data);      read.allowOversize = data.recovery === 'plain-text';      if (sources.length === 1) {        jsonPicker.hidden = true;        restoreProject(sources[0], read.allowOversize);        pendingSources = []; pendingRead = null;      } else {        pendingSources = sources; pendingRead = read;        jsonSelect.replaceChildren(new Option('開く原稿を選んでください', ''), ...sources.map((p, i) => new Option(p.title.trim() || '無題', String(i))));        jsonSelect.value = '';        jsonPicker.hidden = false;      }    } catch (error) { operationProblem('原稿を開けませんでした。' + error.message); }  }  document.getElementById('import-backup').addEventListener('change', event => {    const file = event.target.files[0]; event.target.value = '';    if (file) return openJsonFile(file);  });  document.getElementById('open-json-project').addEventListener('click', () => {    if (jsonSelect.value === '') { jsonSelect.focus(); return; }    try { checkRead(pendingRead); }    catch (error) { operationProblem(error.message); return; }    const source = pendingSources[Number(jsonSelect.value)];    if (!source) return;    restoreProject(source, pendingRead.allowOversize);    pendingSources = [];    jsonPicker.hidden = true;  });  document.getElementById('cancel-json-project').addEventListener('click', () => {    pendingSources = [];    jsonPicker.hidden = true;  });  function downloadTitle() {    const title = name.value.trim();    if (!title) { alert('原稿名を入力してください。'); name.focus(); return null; }    return title;  }  const safeFilename = title => title.replace(/[\\/:*?"<>|]/g, '_');  function saveText() {    const title = downloadTitle();    if (!title) return;    const filename = safeFilename(title) + '.txt';    const body = input.value.replace(/\r\n?/g, '\n').replace(/\n/g, '\r\n');    const blob = new Blob(['\uFEFF', body], { type: 'text/plain;charset=utf-8' });    if (blob.size > 2000000) { alert('ファイルが2MBを超えています。'); return; }    downloadBlob(blob, filename);    return true;  }  let saveFeedbackEpoch = 0;  function clearSaveFeedback() {    saveFeedbackEpoch++;    for (const format of ['json', 'txt', 'pdf']) document.getElementById('save-' + format).classList.remove('save-started');    document.getElementById('save-feedback').textContent = '';  }  function signalSaveStart(format) {    const button = document.getElementById('save-' + format), epoch = saveFeedbackEpoch;    button.classList.add('save-started');    document.getElementById('save-feedback').textContent = ({ json: 'JSON', txt: 'TXT' }[format]) + 'のダウンロードを開始しました。';    setTimeout(() => { if (epoch === saveFeedbackEpoch) clearSaveFeedback(); }, 1200);  }  function saveSelectedFile(format = 'json') {    clearSaveFeedback();    document.getElementById('file-status').textContent = '';    try {    save();    if (format === 'pdf') {      if (renderBlocked) { operationProblem('紙面表示が停止しているため印刷できません。JSONで保存するか、本文を分けてください。'); return; }      requestManuscriptPrint(); return;    }    const saveFile = { json: saveBackup, txt: saveText }[format];    if (saveFile?.()) signalSaveStart(format);    } catch (error) { operationProblem('ファイル保存を開始できませんでした。' + error.message); }  }  for (const format of ['json', 'txt', 'pdf']) {    document.getElementById('save-' + format).addEventListener('click', () => saveSelectedFile(format));  }  document.getElementById('open-file').addEventListener('click', () => document.getElementById('import-file').click());  async function openTextFile(file) {    try {      const read = beginRead(file);      const text = (await file.text()).replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');      checkRead(read);      textWithinBounds(text);      if (!prepareImportDraft()) return;      if (input.value && !confirm('現在の原稿を読み込んだテキストで置き換えますか? 現在の版は履歴に残します。')) return;      addVersion(activeProject(), '読込前');      activeProject().text = text; activeProject().rubies = [];      input.value = text; input.setSelectionRange(text.length, text.length);      save(); refreshVersions(); operationProblem('');      document.getElementById('file-status').textContent = 'テキストを開きました。';    } catch (error) { operationProblem('テキストを開けませんでした。' + error.message); }  }  document.getElementById('import').addEventListener('change', event => {    const file = event.target.files[0]; event.target.value = '';    if (file) return openTextFile(file);  });  document.getElementById('import-file').addEventListener('change', event => {    const file = event.target.files[0]; event.target.value = '';    if (!file) return;    const extension = file.name.toLowerCase().split('.').pop();    if (extension === 'json') return openJsonFile(file);    if (extension === 'txt') return openTextFile(file);    alert('原稿(.json)・テキスト(.txt)を選んでください。');  });  // Only the Apps Script host provides this bridge. Local HTML never claims a cloud save.  window.genkoBoot.stage('保存機能の確認');  const driveAvailable = !!window.google?.script?.run;  document.getElementById('drive-actions').hidden = !driveAvailable;  const driveStatus = document.getElementById('drive-status');  const driveDialog = document.getElementById('drive-dialog');  const driveFiles = document.getElementById('drive-files');  const driveListStatus = document.getElementById('drive-list-status');  let driveBusy = false, driveNextPage = '', driveMessage = null;  function manuscriptSnapshot(project) {    return {      id: project.id, title: project.title, text: project.text, font: normalizeFont(project.font),      rubies: normalizeRubies(project.text, project.rubies || []),      history: project.history.slice(-HISTORY_LIMIT).map(item => ({ id: item.id, at: item.at, kind: item.kind,        title: item.title, text: item.text, font: normalizeFont(item.font), rubies: normalizeRubies(item.text, item.rubies || []) })),      updatedAt: project.updatedAt, lastVersionAt: project.lastVersionAt, ...(project.protected === true ? { protected: true } : {})    };  }  function manuscriptFingerprint(project) {    const snapshot = manuscriptSnapshot(project);    // Local timestamps change when focus is saved, even without a content change.    return JSON.stringify([snapshot.title, snapshot.text, snapshot.font, snapshot.rubies, snapshot.history]);  }  function refreshDriveStatus() {    for (const id of ['drive-save', 'drive-save-as', 'drive-open', 'drive-refresh']) {      document.getElementById(id).disabled = !driveAvailable || driveBusy;    }    document.getElementById('drive-load').disabled = driveBusy || !driveFiles.value;    document.getElementById('drive-more').disabled = driveBusy;    driveFiles.disabled = driveBusy;    if (!driveAvailable) return;    const project = activeProject();    if (!project) return;    const binding = project.driveLink;    let text;    if (driveBusy) text = 'Google Driveと通信中です…';    else if (driveMessage?.projectId === project.id) text = driveMessage.text;    else if (!binding) text = 'この原稿はまだDriveに保存していません。';    else text = binding.fingerprint === manuscriptFingerprint(project)      ? 'Driveに保存済み · ' + new Date(binding.savedAt).toLocaleString('ja-JP')      : 'Driveに未保存の変更があります。';    driveStatus.textContent = text;    driveStatus.dataset.error = String(!driveBusy && driveMessage?.projectId === project.id && driveMessage.error === true);  }  function driveCall(method, argument) {    return new Promise((resolve, reject) => {      if (!driveAvailable) return reject(new Error('Googleドキュメント版で利用できます。'));      const runner = window.google.script.run.withSuccessHandler(resolve).withFailureHandler(reject);      if (method === 'save') runner.genkoDriveSave(argument);      else if (method === 'list') runner.genkoDriveList(argument);      else if (method === 'open') runner.genkoDriveOpen(argument);      else reject(new Error('未対応の操作です。'));    });  }  function driveError(error) { return error?.message || String(error || '通信に失敗しました。'); }  async function saveToDrive(asCopy = false) {    if (driveBusy || !driveAvailable) return;    save();    const project = activeProject(), projectId = project.id;    const snapshot = manuscriptSnapshot(project), fingerprint = manuscriptFingerprint(project);    const binding = asCopy ? null : project.driveLink;    const request = { project: snapshot };    if (binding) Object.assign(request, { fileId: binding.id, revision: binding.revision, fileVersion: binding.version });    driveBusy = true; driveMessage = null; refreshDriveStatus();    try {      const result = await driveCall('save', request);      if (!result?.file?.id || !result.file.version || !result.revision) throw new Error('保存結果を確認できませんでした。Driveの一覧を確認してください。');      // The user can keep editing or switch projects during the request.      const original = library.projects.find(item => item.id === projectId);      if (original) original.driveLink = { id: result.file.id, version: result.file.version, revision: result.revision,        savedAt: Date.now(), fingerprint };      persist();    } catch (error) {      driveMessage = { projectId, error: true, text: 'Driveに保存できませんでした。' + driveError(error) };    } finally { driveBusy = false; refreshDriveStatus(); }  }  async function listDriveFiles(append = false) {    if (driveBusy || !driveAvailable) return;    driveBusy = true; driveMessage = null;    if (!append) { driveFiles.replaceChildren(); driveNextPage = ''; }    driveListStatus.textContent = '原稿の一覧を読み込み中です…'; driveListStatus.dataset.error = 'false';    refreshDriveStatus();    try {      const result = await driveCall('list', append ? driveNextPage : '');      if (!Array.isArray(result?.files)) throw new Error('一覧の内容を確認できませんでした。');      for (const file of result.files) {        if (!file?.id || [...driveFiles.options].some(option => option.value === file.id)) continue;        const modified = file.modifiedTime ? new Date(file.modifiedTime).toLocaleString('ja-JP') : '';        driveFiles.add(new Option(file.name + (modified ? ' · ' + modified : ''), file.id));      }      driveNextPage = result.nextPageToken || '';      if (!driveFiles.value && driveFiles.options.length) driveFiles.selectedIndex = 0;      driveListStatus.textContent = driveFiles.options.length ? driveFiles.options.length + '件の原稿' : '保存済みの原稿はありません。';    } catch (error) {      driveListStatus.textContent = driveError(error); driveListStatus.dataset.error = 'true';    } finally {      document.getElementById('drive-more').hidden = !driveNextPage;      driveBusy = false; refreshDriveStatus();    }  }  async function openFromDrive() {    if (driveBusy || !driveFiles.value) return;    const fileId = driveFiles.value;    driveBusy = true; driveMessage = null; refreshDriveStatus();    driveListStatus.textContent = '原稿を読み込み中です…'; driveListStatus.dataset.error = 'false';    try {      const result = await driveCall('open', fileId);      const source = result?.project;      if (result?.file?.id !== fileId || !result.file.version || !result.revision ||          typeof source?.title !== 'string' || typeof source.text !== 'string' || !Array.isArray(source.history)) {        throw new Error('原稿の内容を確認できませんでした。');      }      const rubies = normalizeRubies(source.text, source.rubies || []);      const history = copyHistory(source);      save(); // Keep the current manuscript, including edits made while waiting.      const project = newProject(source.title, source.text);      Object.assign(project, { rubies, history, font: normalizeFont(source.font), lastVersionAt: Number(source.lastVersionAt) || Date.now() });      project.driveLink = { id: fileId, version: result.file.version, revision: result.revision,        savedAt: Date.now(), fingerprint: manuscriptFingerprint(project) };      library.projects.push(project); library.activeId = project.id;      persist(); showProject(); driveDialog.close(); window.scrollTo(0, 0);    } catch (error) {      driveListStatus.textContent = '開けませんでした。' + driveError(error); driveListStatus.dataset.error = 'true';    } finally { driveBusy = false; refreshDriveStatus(); }  }  document.getElementById('drive-save').addEventListener('click', () => saveToDrive());  document.getElementById('drive-save-as').addEventListener('click', () => saveToDrive(true));  document.getElementById('drive-open').addEventListener('click', () => {    save(); driveDialog.showModal(); listDriveFiles();  });  document.getElementById('drive-refresh').addEventListener('click', () => listDriveFiles());  document.getElementById('drive-more').addEventListener('click', () => listDriveFiles(true));  document.getElementById('drive-load').addEventListener('click', openFromDrive);  document.getElementById('drive-cancel').addEventListener('click', () => { if (!driveBusy) driveDialog.close(); });  driveDialog.addEventListener('cancel', event => { if (driveBusy) event.preventDefault(); });  driveFiles.addEventListener('change', refreshDriveStatus);  for (const element of [input, name]) element.addEventListener('input', () => { driveMessage = null; refreshDriveStatus(); });  window.genkoBoot.stage('保存データと見本の読み込み');  if (typeof ResizeObserver === 'function') {    let observedWidth = 0, queued = false;    const observer = new ResizeObserver(entries => {      const width = entries[0]?.contentRect.width || 0;      if (Math.abs(width - observedWidth) < .5 || queued) return;      observedWidth = width; queued = true;      requestAnimationFrame(() => { queued = false; fitPaper(); render(); });    });    observer.observe(document.getElementById('paper-scroll'));  }  library = loadLibrary();  panelSide.value = library.panelSide === 'left' ? 'left' : 'right';  applyPanelSide();  document.body.classList.toggle('panel-hidden', library.panelHidden === true);  fitPaper();  window.genkoBoot.stage('原稿一覧と用紙の描画');  // Opening the app means writing: a sample left open last time gives way to an  // empty manuscript, an existing one if there is, else a new one.  if (isOriginalSample(activeProject())) {    let blank = library.projects.find(project => !isProtectedProject(project) && !project.text && !project.title);    if (!blank && library.projects.length < SAFETY.newProjects) { blank = newProject(); library.projects.push(blank); }    if (blank) library.activeId = blank.id;  }  showProject();  if (sampleMigrationPending) {    persist();  }  // Ready to write at once: the caret waits in the manuscript, in the first cell when it is blank.  if (!isProtectedProject(activeProject())) { input.focus({ preventScroll: true }); render(); }  window.genkoBoot.ready();  document.fonts?.addEventListener?.('loadingdone', () => { verticalAlternateFamilies.clear(); invalidatePages(); render(); });  document.fonts?.ready.then(() => { centerAllAttachedCommas(); positionAllSharedCellText(); });  } catch (error) { window.genkoBoot.fail(error); }})();