プログラム / 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; const FONT_OPTIONS = { "mincho": { "label": "游明朝", "css": "\"Yu Mincho\", \"Hiragino Mincho ProN\", \"Noto Serif CJK JP\", serif", "word": "Yu Mincho" }, "gothic": { "label": "游ゴシック", "css": "\"Yu Gothic\", \"Hiragino Kaku Gothic ProN\", \"Noto Sans CJK JP\", sans-serif", "word": "Yu Gothic" }, "biz-mincho": { "label": "BIZ UD明朝", "css": "\"BIZ UDMincho Medium\", \"BIZ UDMincho\", \"Yu Mincho\", serif", "word": "BIZ UDMincho Medium" }, "kyokasho": { "label": "UDデジタル教科書体", "css": "\"UD Digi Kyokasho N\", \"Yu Mincho\", serif", "word": "UD Digi Kyokasho N" }, "shippori-b1": { "label": "しっぽり明朝B1", "css": "\"Shippori Mincho B1\", \"Yu Mincho\", serif", "word": "Shippori Mincho B1" }, "klee-one": { "label": "クレー One", "css": "\"Klee One\", \"Yu Mincho\", serif", "word": "Klee One" }, "yomogi": { "label": "よもぎ", "css": "\"Yomogi\", \"Yu Mincho\", serif", "word": "Yomogi" }}; const normalizeFont = value => Object.prototype.hasOwnProperty.call(FONT_OPTIONS, value) ? value : value === 'biz-gothic' ? 'gothic' : 'mincho'; function fontFromWordNames(names) { if (!names.length || !names.every(name => name === names[0])) return 'mincho'; return Object.keys(FONT_OPTIONS).find(key => FONT_OPTIONS[key].word === names[0]) || (names[0] === 'BIZ UDGothic' ? '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, findReturnFocus = null; const findBar = document.getElementById('find-bar'); 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 */[]; 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. // Drafts and edited copies keep their own readings. function refreshOriginalSampleRubies(stored) { for (const project of stored.projects) { const source = project.sampleProtected && SAMPLE_CATALOG.find(sample => sample.sampleId === project.sampleId); 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.projects) 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 = original ? '見本の原文(保護中)' : '保護中'; 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; } function createSampleLibrary() { const projects = SAMPLE_CATALOG.map(sampleProject); return { version: 2, sampleCatalogVersion: 4, sampleProtectionVersion: 1, activeId: projects[0].id, panelSide: 'left', 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 >= 4) return stored; if (stored.sampleCatalogVersion >= 1) { const previousCatalog = stored.sampleCatalogVersion; stored.sampleCatalogVersion = 4; // 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) || 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-92', 2], ['aozora-43754', 3], ['check-layout', 4]]) { if (previousCatalog >= introduced || stored.projects.some(project => project.sampleId === id) || stored.projects.length >= SAFETY.projects) 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 = 4; 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.projects) 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 = 4; if (!kept.some(project => project.id === stored.activeId)) stored.activeId = kept[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. const SAFETY = Object.freeze({ fileBytes: 10000000, textUnits: 40000, pages: 100, projects: 200, history: 50, rubies: 2000, 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) { if (!Number.isFinite(file.size) || file.size < 0 || file.size > SAFETY.fileBytes) throw Error('10MB以下のファイルを選んでください。'); } function rubyBoundaries(text) { if (text.length <= SAFETY.textUnits || !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) { checkFileSize(file); 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.projects) return true; operationProblem('原稿は200件までです。必要な原稿をJSONで保存してから、不要な原稿を削除してください。'); return false; } const wordCrcTable = Array.from({ length: 256 }, (_, n) => { for (let bit = 0; bit < 8; bit++) n = n & 1 ? 0xedb88320 ^ n >>> 1 : n >>> 1; return n >>> 0; }); function verifyWordBytes(bytes, size, expectedCrc) { if (bytes.length !== size) throw Error('Word本文の展開サイズが一致しません。'); let crc = 0xffffffff; for (const byte of bytes) crc = wordCrcTable[(crc ^ byte) & 255] ^ crc >>> 8; if (((crc ^ 0xffffffff) >>> 0) !== expectedCrc) throw Error('Word本文のチェックサムが一致しません。'); return bytes; } function protectOversizedRender(error) { renderBlocked = true; document.body.classList.add('plain-recovery'); pages.replaceChildren(); 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() { 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() { if (!pages.children.length) return; if (!overviewMode) { let closest = Infinity; for (let i = 0; i < pages.children.length; i++) { const rect = pages.children[i].getBoundingClientRect(); const distance = Math.abs((rect.top + rect.bottom) / 2 - window.innerHeight / 2); if (distance < closest) { closest = distance; visiblePage = i; } } } 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 - 89); 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; } } 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; 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) updateOverviewLayout(); else { render(); requestAnimationFrame(() => { if (targetPage === null) window.scrollTo(0, returnScrollY); else pages.children[targetPage]?.scrollIntoView({ block: 'center' }); }); } } const segments = value => segmenter ? [...segmenter.segment(value)].map(x => ({ text: x.segment, offset: x.index })) : [...value].map((text, i) => ({ text, offset: i })); // RUBY_MODEL_START: UTF-16 offsets match textarea selection and DOCX text. 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 function layout(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 }); } // 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 }; } // 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() { for (const comma of pages.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() { const family = FONT_OPTIONS[normalizeFont(activeProject()?.font)].css; for (const text of pages.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() { for (const mark of pages.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 = findBar.hidden ? [] : 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'; } 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)]; pages.style.setProperty('--manuscript-font', selectedFont.css); COMMA_FONT = '20px ' + selectedFont.css; document.title = name.value.trim() || '縦書き原稿'; const currentRubies = composing ? rebaseRubies(activeProject().text, input.value, activeProject().rubies || []) : normalizeRubies(input.value, activeProject()?.rubies || []); const result = boundedLayout.result; stops = result.positions; refreshFindMatches(); const fragment = document.createDocumentFragment(); const sheets = []; for (let i = 0; i < result.pageCount; i++) { const page = document.createElement('div'); page.className = 'page'; const sheet = document.createElement('div'); sheet.className = 'sheet'; sheet.dataset.page = String(i); for (let column = 0; column < COLS; column++) { const rule = document.createElement('div'); rule.className = 'column-rule'; rule.style.right = columnRight(column) + 'px'; sheet.append(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(i + 1); footer.append(dimensions, number); page.append(sheet, footer); fragment.append(page); sheets.push(sheet); } const selectionStart = input.selectionStart, selectionEnd = input.selectionEnd; if (selectionStart !== selectionEnd) { const seen = new Set(); for (const item of result.glyphs) { if (item.start >= selectionEnd || item.end <= selectionStart) continue; const key = `${item.page}:${item.column}:${item.row}`; if (seen.has(key)) continue; seen.add(key); const highlight = document.createElement('span'); highlight.className = 'selection-cell'; highlight.style.right = columnRight(item.column) + 'px'; highlight.style.top = (item.row * CELL) + 'px'; sheets[item.page].append(highlight); } } if (findMatches.length) { const foundCells = new Map(); let matchIndex = 0; for (const item of result.glyphs) { while (matchIndex < findMatches.length && findMatches[matchIndex].end <= item.start) matchIndex++; for (let i = matchIndex; i < findMatches.length && findMatches[i].start < item.end; i++) { if (findMatches[i].end <= item.start) continue; const key = `${item.page}:${item.column}:${item.row}`; const active = i === activeFindIndex; if (!foundCells.has(key) || active) foundCells.set(key, { item, active }); } } for (const { item, active } of foundCells.values()) { const highlight = document.createElement('span'); highlight.className = active ? 'find-cell find-active' : 'find-cell'; highlight.style.right = columnRight(item.column) + 'px'; highlight.style.top = (item.row * CELL) + 'px'; sheets[item.page].append(highlight); } } const sharedBodyCells = new Set(result.glyphs .filter(item => !item.attached && !punctuation.has(item.text) && !closing.has(item.text)) .map(item => `${item.page}:${item.column}:${item.row}`)); for (const item of result.glyphs) { // 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 = item.attached && sharedBodyCells.has(`${item.page}:${item.column}:${item.row}`); 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); } sheets[item.page].append(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); sheets[item.page].append(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'; } sheets[item.page].append(glyph); } 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 = '本文を選択して、ふりがなを変更できます。'; } for (const ruby of rubyRuns) { 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'; sheets[ruby.page].append(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); }); sheets[ruby.page].append(run); } const offset = input.selectionDirection === 'backward' ? selectionStart : selectionEnd; let at = stops[0]; for (const stop of stops) { if (stop.offset > offset) break; at = stop; } const caret = document.createElement('span'); caret.className = 'cursor'; caret.style.right = (columnRight(at.column) + 3) + 'px'; caret.style.top = (at.row * CELL + 1) + 'px'; if (document.activeElement === input && selectionStart === selectionEnd) sheets[at.page].append(caret); pages.replaceChildren(fragment); centerAllAttachedCommas(); positionAllSharedCellText(); centerAllWesternMarks(); updateOverviewLayout(); updatePageIndicator(); const sheetRect = sheets[at.page].getBoundingClientRect(); 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. input.style.left = (sheetRect.right - columnRight(at.column) * displayScale + 36) + 'px'; input.style.top = (sheetRect.top + at.row * CELL * displayScale) + 'px'; } countEl.textContent = segments(input.value).filter(part => !/^\s+$/u.test(part.text)).length.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 > 50) project.history.splice(0, project.history.length - 50); 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() || '無題') + (isOriginalSample(project) ? '(原文)' : 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))); } function persist() { libraryRevision++; library.panelSide = panelSide.value; try { if (storageProtected) throw Error('元の保存先の保護中'); if (localStorage.getItem(LIBRARY_KEY) !== lastStored) { storageProtected = true; throw Error('別のタブで保存先が変更されました'); } 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 >= 60 * 60 * 1000) { 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 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(); }); input.addEventListener('keyup', 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 openStandardSample(sampleId) { save(); let project = library.projects.find(item => item.sampleId === sampleId); if (!project) { if (!canAddProjects(1)) return; project = sampleProject(SAMPLE_CATALOG.find(item => item.sampleId === sampleId)); library.projects.push(project); } library.activeId = project.id; persist(); showProject(); window.scrollTo(0, 0); } document.getElementById('ruby-sample').addEventListener('click', () => openStandardSample('check-layout')); document.getElementById('literature-sample').addEventListener('click', () => openStandardSample('aozora-92')); document.getElementById('chumon-sample').addEventListener('click', () => openStandardSample('aozora-43754')); 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 = pages.querySelector('.find-active'); const page = match?.closest('.page'); if (page) showFindPage([...pages.children].indexOf(page)); } let findPageIndex = 0; function positionFindBar() { if (findBar.hidden) return; const page = pages.children[Math.min(findPageIndex, pages.children.length - 1)]; if (!page) return; const box = page.getBoundingClientRect(), scale = box.width / 884; const controlScale = Math.min(1, scale); const rightSide = document.body.classList.contains('panel-on-right'); findBar.style.left = (rightSide ? box.right - 40 * scale - 250 * controlScale : box.left + 40 * scale) + 'px'; findBar.style.right = 'auto'; findBar.style.top = box.top + 3 * scale + 'px'; findBar.style.transform = 'scale(' + controlScale + ')'; } function showFindPage(index) { findPageIndex = Math.max(0, Math.min(index, pages.children.length - 1)); pages.children[findPageIndex]?.scrollIntoView({ block: 'start' }); positionFindBar(); } window.addEventListener('resize', positionFindBar); window.addEventListener('scroll', positionFindBar, { passive: true }); for (const id of ['flip-panel', 'hide-panel', 'show-panel']) { document.getElementById(id).addEventListener('click', () => requestAnimationFrame(positionFindBar)); } function openFind() { if (findBar.hidden) findReturnFocus = document.activeElement; if (overviewMode) setOverview(false); const visiblePage = [...pages.children].findIndex(page => page.getBoundingClientRect().bottom > 0); findBar.hidden = false; render(); showFindPage(Math.max(0, visiblePage)); findQuery.focus({ preventScroll: true }); findQuery.select(); } function closeFind() { findBar.hidden = true; findQuery.value = ''; if (findReturnFocus?.isConnected) findReturnFocus.focus({ preventScroll: true }); findReturnFocus = null; render(); } function moveFind(direction) { if (!findMatches.length) return; activeFindIndex = (activeFindIndex + direction + findMatches.length) % findMatches.length; render(); scrollToFindMatch(); } document.getElementById('find-open').addEventListener('click', openFind); document.getElementById('find-close').addEventListener('click', closeFind); document.getElementById('find-prev').addEventListener('click', () => moveFind(-1)); document.getElementById('find-next').addEventListener('click', () => moveFind(1)); findQuery.addEventListener('input', () => { activeFindIndex = -1; render(); scrollToFindMatch(); }); findQuery.addEventListener('keydown', event => { if (event.key !== 'Enter' || event.isComposing) return; event.preventDefault(); moveFind(event.shiftKey ? -1 : 1); }); window.addEventListener('scroll', () => { if (indicatorQueued) return; indicatorQueued = true; requestAnimationFrame(() => { indicatorQueued = false; updatePageIndicator(); }); }, { passive: true }); window.addEventListener('resize', () => { fitPaper(); render(); }); const printPages = document.getElementById('print-pages'); function buildPrintPages() { 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()); 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 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(); } else if (event.key === 'Escape' && !findBar.hidden) { event.preventDefault(); closeFind(); } 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(-50).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(-50); 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, '_'); const xmlEscape = value => value.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '') .replace(/&/g, '&').replace(/</g, '<') .replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); function wordDocument(body, rubies = [], font = 'mincho') { const plain = body.replace(/\r\n?/g, '\n'); const annotations = rebaseRubies(body, plain, rubies); const family = FONT_OPTIONS[normalizeFont(font)].word; const run = (value, size = 24) => '<w:r><w:rPr><w:rFonts w:eastAsia="' + family + '" w:ascii="' + family + '" w:hAnsi="' + family + '"/>' + '<w:sz w:val="' + size + '"/><w:szCs w:val="' + size + '"/></w:rPr><w:t xml:space="preserve">' + xmlEscape(value) + '</w:t></w:r>'; const ruby = (base, reading) => '<w:r><w:ruby><w:rubyPr><w:rubyAlign w:val="distributeSpace"/>' + '<w:hps w:val="12"/><w:hpsRaise w:val="24"/><w:hpsBaseText w:val="24"/><w:lid w:val="ja-JP"/>' + '</w:rubyPr><w:rt>' + run(reading, 12) + '</w:rt><w:rubyBase>' + run(base) + '</w:rubyBase></w:ruby></w:r>'; let offset = 0, annotationIndex = 0; const content = plain.split('\n').map(line => { let cursor = 0, xml = ''; while (annotations[annotationIndex] && annotations[annotationIndex].start < offset + line.length) { const item = annotations[annotationIndex++]; xml += run(line.slice(cursor, item.start - offset)); xml += ruby(plain.slice(item.start, item.end), item.text); cursor = item.end - offset; } xml += run(line.slice(cursor)); offset += line.length + 1; return '<w:p><w:pPr><w:spacing w:after="0"/></w:pPr>' + xml + '</w:p>'; }).join(''); return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' + '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' + '<w:body>' + content + '<w:sectPr><w:pgSz w:w="16838" w:h="11906" w:orient="landscape"/>' + '<w:pgMar w:top="850" w:right="850" w:bottom="850" w:left="850" w:header="420" w:footer="420" w:gutter="0"/>' + '<w:textDirection w:val="tbRl"/><w:docGrid w:type="linesAndChars" w:linePitch="480"/></w:sectPr></w:body></w:document>'; } function zipStore(files) { const encoder = new TextEncoder(); const chunks = [], central = []; let offset = 0; const crcTable = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; crcTable[n] = c >>> 0; } const crc32 = bytes => { let c = 0xffffffff; for (const byte of bytes) c = crcTable[(c ^ byte) & 255] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; }; for (const [path, content] of files) { const filename = encoder.encode(path), data = encoder.encode(content), crc = crc32(data); const local = new Uint8Array(30 + filename.length); const l = new DataView(local.buffer); l.setUint32(0, 0x04034b50, true); l.setUint16(4, 20, true); l.setUint16(6, 0x0800, true); l.setUint32(14, crc, true); l.setUint32(18, data.length, true); l.setUint32(22, data.length, true); l.setUint16(26, filename.length, true); local.set(filename, 30); chunks.push(local, data); const record = new Uint8Array(46 + filename.length); const v = new DataView(record.buffer); v.setUint32(0, 0x02014b50, true); v.setUint16(4, 20, true); v.setUint16(6, 20, true); v.setUint16(8, 0x0800, true); v.setUint32(16, crc, true); v.setUint32(20, data.length, true); v.setUint32(24, data.length, true); v.setUint16(28, filename.length, true); v.setUint32(42, offset, true); record.set(filename, 46); central.push(record); offset += local.length + data.length; } const centralLength = central.reduce((sum, item) => sum + item.length, 0); const end = new Uint8Array(22), e = new DataView(end.buffer); e.setUint32(0, 0x06054b50, true); e.setUint16(8, central.length, true); e.setUint16(10, central.length, true); e.setUint32(12, centralLength, true); e.setUint32(16, offset, true); return new Blob([...chunks, ...central, end], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }); } function makeDocx(body, rubies = [], font = 'mincho') { return zipStore([ ['[Content_Types].xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>'], ['_rels/.rels', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>'], ['word/document.xml', wordDocument(body, rubies, font)] ]); } 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; } function saveWord() { const title = downloadTitle(); if (!title) return; const blob = makeDocx(input.value, activeProject().rubies || [], activeProject().font); if (blob.size > 2000000) { alert('ファイルが2MBを超えています。'); return; } downloadBlob(blob, safeFilename(title) + '.docx'); 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', docx: 'Word' }[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, docx: saveWord }[format]; if (saveFile?.()) signalSaveStart(format); } catch (error) { operationProblem('ファイル保存を開始できませんでした。' + error.message); } } // Word output is not offered: its layout in Word cannot be verified. 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); }); async function readWordXml(file) { if (file.size > 10000000) throw new Error('10MB以下のWordファイルを選んでください。'); const bytes = new Uint8Array(await file.arrayBuffer()); const view = new DataView(bytes.buffer); let end = -1; for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 65557); i--) { if (view.getUint32(i, true) === 0x06054b50 && i + 22 + view.getUint16(i + 20, true) === bytes.length) { end = i; break; } } if (end < 0) throw new Error('有効な.docxファイルではありません。'); const count = view.getUint16(end + 10, true); let offset = view.getUint32(end + 16, true); const decoder = new TextDecoder(); for (let i = 0; i < count; i++) { if (offset + 46 > bytes.length || view.getUint32(offset, true) !== 0x02014b50) throw new Error('Wordファイルが破損しています。'); const flags = view.getUint16(offset + 8, true), method = view.getUint16(offset + 10, true); const expectedCrc = view.getUint32(offset + 16, true); const size = view.getUint32(offset + 20, true), expandedSize = view.getUint32(offset + 24, true); const length = view.getUint16(offset + 28, true); const next = offset + 46 + length + view.getUint16(offset + 30, true) + view.getUint16(offset + 32, true); if (next > bytes.length) throw new Error('Wordファイルが破損しています。'); const path = decoder.decode(bytes.subarray(offset + 46, offset + 46 + length)); if (path === 'word/document.xml') { if (flags & 1) throw new Error('パスワードで保護されたWordは読み込めません。'); if (expandedSize > 20000000) throw new Error('本文データが大きすぎます。'); const local = view.getUint32(offset + 42, true); if (local + 30 > bytes.length || view.getUint32(local, true) !== 0x04034b50) throw new Error('Wordファイルが破損しています。'); const dataStart = local + 30 + view.getUint16(local + 26, true) + view.getUint16(local + 28, true); if (dataStart + size > bytes.length) throw new Error('Wordファイルが破損しています。'); const data = bytes.subarray(dataStart, dataStart + size); if (method === 0) return decoder.decode(verifyWordBytes(data, expandedSize, expectedCrc)); if (method !== 8) throw new Error('このWordの圧縮形式には対応していません。'); const reader = new Blob([data]).stream().pipeThrough(new DecompressionStream('deflate-raw')).getReader(); const chunks = []; let total = 0; try { while (true) { const { value, done } = await reader.read(); if (done) break; total += value.length; if (total > 20000000) { await reader.cancel(); throw new Error('本文データが大きすぎます。'); } chunks.push(value); } } finally { reader.releaseLock(); } const expanded = new Uint8Array(await new Blob(chunks).arrayBuffer()); return decoder.decode(verifyWordBytes(expanded, expandedSize, expectedCrc)); } offset = next; } throw new Error('Wordの本文が見つかりません。'); } function wordPlainText(xml, filename) { if (/<!DOCTYPE|<!ENTITY/i.test(xml)) throw Error('外部定義を含むWord本文は読み込めません。'); const doc = new DOMParser().parseFromString(xml, 'application/xml'); if (doc.getElementsByTagName('parsererror').length) throw new Error('Wordの本文を解析できません。'); const ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; const body = doc.getElementsByTagNameNS(ns, 'body')[0]; if (!body) throw new Error('Wordの本文が見つかりません。'); const paragraphs = [...body.getElementsByTagNameNS(ns, 'p')]; const rubies = []; let paragraphOffset = 0; const lines = paragraphs.map(p => { let text = ''; const walk = node => { if (node.namespaceURI === ns) { if (node.localName === 'del') return; if (node.localName === 'ruby') { const base = [...node.childNodes].find(child => child.namespaceURI === ns && child.localName === 'rubyBase'); const rt = [...node.childNodes].find(child => child.namespaceURI === ns && child.localName === 'rt'); if (!base) throw new Error('Wordのルビの親文字が見つかりません。'); const start = paragraphOffset + text.length; walk(base); const reading = rt ? [...rt.getElementsByTagNameNS(ns, 't')].map(item => item.textContent).join('') : ''; if (reading.trim() && paragraphOffset + text.length > start) rubies.push({ start, end: paragraphOffset + text.length, text: reading }); return; } if (node.localName === 't') { text += node.textContent; return; } if (node.localName === 'tab') { text += '\t'; return; } if (node.localName === 'br' || node.localName === 'cr') { text += '\n'; return; } } for (const child of node.childNodes) walk(child); }; walk(p); paragraphOffset += text.length + 1; return text; }); let title = filename.replace(/\.docx$/i, ''); const first = paragraphs[0]; const style = first?.getElementsByTagNameNS(ns, 'pStyle')[0]?.getAttributeNS(ns, 'val'); const alignment = first?.getElementsByTagNameNS(ns, 'jc')[0]?.getAttributeNS(ns, 'val'); const isTitle = /^(title|タイトル)$/i.test(style || '') || (alignment === 'center' && first?.getElementsByTagNameNS(ns, 'b').length); let removed = 0; if (lines[0]?.trim() && isTitle) { removed = lines[0].length + 1; title = lines.shift().trim(); } const text = lines.join('\n'); const retained = rubies.filter(item => item.start >= removed).map(item => ({ ...item, start: item.start - removed, end: item.end - removed })); // Preserve our document-wide font on round trip; mixed Word formatting is not imported. const fontNames = [...body.getElementsByTagNameNS(ns, 'rFonts')].map(node => node.getAttributeNS(ns, 'eastAsia')); const font = fontFromWordNames(fontNames); return { title, text, rubies: normalizeRubies(text, retained), font }; } async function openWordFile(file) { try { const read = beginRead(file); const loaded = wordPlainText(await readWordXml(file), file.name); checkRead(read); textWithinBounds(loaded.text); if (!prepareImportDraft()) return; if (input.value && !confirm('現在の原稿をWordの本文で置き換えますか? 現在の版は履歴に残します。')) return; addVersion(activeProject(), '読込前'); name.value = loaded.title; Object.assign(activeProject(), { text: loaded.text, rubies: loaded.rubies, font: loaded.font }); fontSelect.value = loaded.font; input.value = loaded.text; input.setSelectionRange(0, 0); save(); refreshVersions(); operationProblem(''); document.getElementById('file-status').textContent = 'Wordを開きました。'; window.scrollTo(0, 0); } catch (error) { operationProblem('Wordを開けませんでした。' + error.message); } } document.getElementById('import-word').addEventListener('change', event => { const file = event.target.files[0]; event.target.value = ''; if (file) return openWordFile(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 === 'docx') return openWordFile(file); if (extension === 'txt') return openTextFile(file); alert('原稿(.json)・Word(.docx)・テキスト(.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(-50).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 === 'right' ? 'right' : 'left'; applyPanelSide(); document.body.classList.toggle('panel-hidden', library.panelHidden === true); fitPaper(); window.genkoBoot.stage('原稿一覧と用紙の描画'); showProject(); if (sampleMigrationPending) { persist(); } window.genkoBoot.ready(); document.fonts?.addEventListener?.('loadingdone', () => { verticalAlternateFamilies.clear(); render(); }); document.fonts?.ready.then(() => { centerAllAttachedCommas(); positionAllSharedCellText(); }); } catch (error) { window.genkoBoot.fail(error); }})();