/* ObsiGate — Native mobile editor UX (ROADMAP #69) * * Tactile-optimised editing experience, split in three areas: * 1. Editor — floating Markdown toolbar (bold/italic/code/list/link), * persistent "Paste" button (iOS clipboard workaround), * pinch-zoom font size and adjustable editor height. * 2. Viewer — swipe shortcuts (left → backlinks, right → table of contents) * and a distraction-free reading mode with horizontal page * navigation. * * The pure helpers at the top are exported for unit testing (JSDOM). * All UI wiring happens in initMobileEditor(), called once from app.js. */ import { state } from './state.js'; import { t } from './i18n.js'; // ── Constants ────────────────────────────────────────────────────────────── export const FORMAT_ACTIONS = ['bold', 'italic', 'code', 'list', 'link']; export const SWIPE_THRESHOLD = 60; export const MIN_EDITOR_FONT = 10; export const MAX_EDITOR_FONT = 28; export const DEFAULT_EDITOR_FONT = 13.5; export const MIN_EDITOR_HEIGHT = 140; // ROADMAP #83 — every command the mobile ribbon can expose. export const RIBBON_COMMANDS = [ 'undo', 'redo', 'wikilink', 'files', 'tag', 'attachment', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'bold', 'italic', 'strike', 'highlight', 'code', 'codeblock', 'quote', 'link', 'bullet', 'ordered', 'checkbox', 'indent', 'dedent', 'paste', 'font_smaller', 'font_larger', ]; // Default ribbon layout (Obsidian Android-like). export const DEFAULT_RIBBON_COMMANDS = [ 'undo', 'redo', 'wikilink', 'files', 'tag', 'attachment', 'h1', 'h2', 'h3', 'bold', 'italic', 'strike', 'highlight', 'code', 'codeblock', 'quote', 'link', 'bullet', 'ordered', 'checkbox', 'indent', 'dedent', 'paste', 'font_smaller', 'font_larger', ]; const FONT_KEY = 'obsigate-editor-font-size'; const HEIGHT_KEY = 'obsigate-editor-height'; const READING_KEY = 'obsigate-reading-mode'; const RIBBON_KEY = 'obsigate-mobile-ribbon'; // Command glyphs (kept as plain text so they render with the mono font). const COMMAND_ICONS = { undo: '↶', redo: '↷', wikilink: '[[]]', files: '📁', tag: '#', attachment: '![[ ]]', h1: 'H1', h2: 'H2', h3: 'H3', h4: 'H4', h5: 'H5', h6: 'H6', bold: 'B', italic: 'I', strike: 'S̶', highlight: '==', code: '<>', codeblock: '{}', quote: '❝', link: '🔗', bullet: '•', ordered: '1.', checkbox: '☑', indent: '→', dedent: '←', paste: '📋', font_smaller: 'A−', font_larger: 'A+', }; // Commands reusing an existing i18n key (the rest use mobile_editor.cmd_). const COMMAND_LABEL_KEYS = { paste: 'mobile_editor.paste', font_smaller: 'mobile_editor.font_smaller', font_larger: 'mobile_editor.font_larger', }; // ── Pure helpers (unit tested) ───────────────────────────────────────────── /** Clamp a font size to the supported range; non-numeric falls back to 13.5. */ export function clampZoom(value, min = MIN_EDITOR_FONT, max = MAX_EDITOR_FONT) { const n = Number(value); if (!Number.isFinite(n)) return DEFAULT_EDITOR_FONT; return Math.min(max, Math.max(min, n)); } /** Detect a horizontal swipe from a delta. Returns 'left' | 'right' | null. */ export function detectSwipe(dx, dy, threshold = SWIPE_THRESHOLD) { if (Math.abs(dx) < threshold) return null; if (Math.abs(dy) > Math.abs(dx)) return null; return dx < 0 ? 'left' : 'right'; } /** * Compute the text change produced by a Markdown formatting action. * * Supports the legacy actions (bold, italic, code, list, link) plus the * ROADMAP #83 ribbon commands (headings, strike, highlight, code block, * quote, ordered list, checkbox, indent/dedent, wikilink, tag, attachment). * * @param {string} text Full document text. * @param {number} from Selection start. * @param {number} to Selection end. * @param {string} action One of RIBBON_COMMANDS / FORMAT_ACTIONS. * @returns {{changes:{from:number,to:number,insert:string},selection:{anchor:number,head:number}}|null} */ export function formatChange(text, from, to, action) { const selected = text.slice(from, to); // Wrap/unwrap the selection with a marker pair. const wrap = (open, close = open) => { if ( selected.length >= open.length + close.length && selected.startsWith(open) && selected.endsWith(close) ) { const inner = selected.slice(open.length, selected.length - close.length); return { changes: { from, to, insert: inner }, selection: { anchor: from, head: from + inner.length }, }; } const insert = open + selected + close; return { changes: { from, to, insert }, selection: { anchor: from + open.length, head: from + open.length + selected.length }, }; }; // Apply a per-line prefix, toggling it off when every line already has it. // ``pattern`` strips any competing prefix before adding the new one. const prefixLines = (prefix, pattern) => { const lineStart = text.lastIndexOf('\n', from - 1) + 1; let lineEnd = text.indexOf('\n', to); if (lineEnd === -1) lineEnd = text.length; const lines = text.slice(lineStart, lineEnd).split('\n'); const allHave = lines.every((l) => l.startsWith(prefix)); const out = lines .map((l) => { if (allHave) return l.slice(prefix.length); const base = pattern ? l.replace(pattern, '') : l; return prefix + base; }) .join('\n'); return { changes: { from: lineStart, to: lineEnd, insert: out }, selection: { anchor: lineStart, head: lineStart + out.length }, }; }; const listPattern = /^([-*+]\s+|\d+\.\s+|- \[[ xX]\]\s+)/; switch (action) { case 'bold': return wrap('**'); case 'italic': return wrap('*'); case 'code': return wrap('`'); case 'strike': return wrap('~~'); case 'highlight': return wrap('=='); case 'wikilink': return wrap('[[', ']]'); case 'attachment': return wrap('![[', ']]'); case 'tag': { const insert = '#' + selected; const anchor = from + insert.length; return { changes: { from, to, insert }, selection: { anchor, head: anchor } }; } case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': { const level = Number(action.slice(1)); return prefixLines('#'.repeat(level) + ' ', /^#{1,6}\s+/); } case 'quote': return prefixLines('> ', /^>\s?/); case 'bullet': case 'list': return prefixLines('- ', listPattern); case 'checkbox': return prefixLines('- [ ] ', listPattern); case 'ordered': { const lineStart = text.lastIndexOf('\n', from - 1) + 1; let lineEnd = text.indexOf('\n', to); if (lineEnd === -1) lineEnd = text.length; const lines = text.slice(lineStart, lineEnd).split('\n'); const allOrdered = lines.every((l) => /^\d+\.\s+/.test(l)); const out = lines .map((l, i) => { if (allOrdered) return l.replace(/^\d+\.\s+/, ''); return `${i + 1}. ` + l.replace(listPattern, ''); }) .join('\n'); return { changes: { from: lineStart, to: lineEnd, insert: out }, selection: { anchor: lineStart, head: lineStart + out.length }, }; } case 'codeblock': { const lineStart = text.lastIndexOf('\n', from - 1) + 1; let lineEnd = text.indexOf('\n', to); if (lineEnd === -1) lineEnd = text.length; const block = text.slice(lineStart, lineEnd); if (block.startsWith('```') && block.endsWith('```') && block.length >= 6) { const inner = block.slice(3, -3).replace(/^\n/, '').replace(/\n$/, ''); return { changes: { from: lineStart, to: lineEnd, insert: inner }, selection: { anchor: lineStart, head: lineStart + inner.length }, }; } const insert = '```\n' + block + '\n```'; return { changes: { from: lineStart, to: lineEnd, insert }, selection: { anchor: lineStart + 4, head: lineStart + 4 + block.length }, }; } case 'indent': { const lineStart = text.lastIndexOf('\n', from - 1) + 1; let lineEnd = text.indexOf('\n', to); if (lineEnd === -1) lineEnd = text.length; const lines = text.slice(lineStart, lineEnd).split('\n'); const out = lines.map((l) => ' ' + l).join('\n'); return { changes: { from: lineStart, to: lineEnd, insert: out }, selection: { anchor: lineStart, head: lineStart + out.length }, }; } case 'dedent': { const lineStart = text.lastIndexOf('\n', from - 1) + 1; let lineEnd = text.indexOf('\n', to); if (lineEnd === -1) lineEnd = text.length; const lines = text.slice(lineStart, lineEnd).split('\n'); const out = lines.map((l) => l.replace(/^(\t| {1,2})/, '')).join('\n'); return { changes: { from: lineStart, to: lineEnd, insert: out }, selection: { anchor: lineStart, head: lineStart + out.length }, }; } case 'link': { const insert = `[${selected}](url)`; const urlStart = from + 1 + selected.length + 2; return { changes: { from, to, insert }, selection: { anchor: urlStart, head: urlStart + 3 }, }; } default: return null; } } /** Keep only known ribbon command ids, de-duplicated, in order. */ export function normalizeRibbonCommands(list) { const valid = new Set(RIBBON_COMMANDS); const seen = new Set(); const out = []; (Array.isArray(list) ? list : []).forEach((id) => { if (valid.has(id) && !seen.has(id)) { seen.add(id); out.push(id); } }); return out.length ? out : [...DEFAULT_RIBBON_COMMANDS]; } /** Move a command by ``delta`` positions within the list (clamped). */ export function moveRibbonCommand(list, id, delta) { const arr = [...list]; const i = arr.indexOf(id); if (i === -1) return arr; const j = Math.max(0, Math.min(arr.length - 1, i + delta)); if (i === j) return arr; arr.splice(i, 1); arr.splice(j, 0, id); return arr; } /** Build a plain insertion change (used by the paste helper). */ export function insertText(from, to, insert) { return { changes: { from, to, insert }, selection: { anchor: from + insert.length, head: from + insert.length }, }; } /** Apply a formatting action to a CodeMirror EditorView. */ export function applyFormatToView(view, action) { if (!view || !view.state) return false; const { from, to } = view.state.selection.main; const change = formatChange(view.state.doc.toString(), from, to, action); if (!change) return false; view.dispatch({ changes: change.changes, selection: change.selection }); if (typeof view.focus === 'function') view.focus(); return true; } /** Apply a formatting action to a plain textarea fallback editor. */ export function applyFormatToTextarea(ta, action) { if (!ta) return false; const change = formatChange(ta.value, ta.selectionStart, ta.selectionEnd, action); if (!change) return false; const { from, to, insert } = change.changes; ta.value = ta.value.slice(0, from) + insert + ta.value.slice(to); if (typeof ta.focus === 'function') ta.focus(); ta.selectionStart = change.selection.anchor; ta.selectionEnd = change.selection.head; return true; } // ── Runtime helpers ──────────────────────────────────────────────────────── let _initialized = false; function isMobileViewport() { if (typeof window === 'undefined') return false; if (window.matchMedia) return window.matchMedia('(max-width: 768px)').matches; return window.innerWidth <= 768; } async function showToastSafe(message, type) { try { const ui = await import('./ui.js'); if (ui && typeof ui.showToast === 'function') ui.showToast(message, type); } catch (_) { /* UI module unavailable (tests) — silent */ } } function currentEditorFontSize() { try { const saved = parseFloat(localStorage.getItem(FONT_KEY)); if (Number.isFinite(saved)) return clampZoom(saved); } catch (_) { /* ignore */ } return DEFAULT_EDITOR_FONT; } /** Apply a font size to every CodeMirror scroller + fallback textarea. */ export function applyEditorFontSize(size) { const s = clampZoom(size); try { localStorage.setItem(FONT_KEY, String(s)); } catch (_) { /* ignore */ } const px = s + 'px'; document.querySelectorAll('.cm-editor .cm-scroller').forEach((el) => { if (el.style.fontSize !== px) el.style.fontSize = px; }); if (state.fallbackEditorEl) state.fallbackEditorEl.style.fontSize = px; return s; } function bumpFontSize(delta) { return applyEditorFontSize(currentEditorFontSize() + delta); } // ── Formatting toolbar + clipboard ───────────────────────────────────────── function handleFormat(action) { if (state.editorView) { applyFormatToView(state.editorView, action); } else if (state.fallbackEditorEl) { applyFormatToTextarea(state.fallbackEditorEl, action); } } async function handlePaste() { let text = ''; try { if (navigator.clipboard && navigator.clipboard.readText) { text = await navigator.clipboard.readText(); } } catch (_) { // iOS Safari / permission denied — fall through to the hint. } if (!text) { showToastSafe(t('mobile_editor.paste_hint'), 'info'); return; } if (state.editorView) { const { from, to } = state.editorView.state.selection.main; const change = insertText(from, to, text); state.editorView.dispatch({ changes: change.changes, selection: change.selection }); if (typeof state.editorView.focus === 'function') state.editorView.focus(); } else if (state.fallbackEditorEl) { const ta = state.fallbackEditorEl; const from = ta.selectionStart; const to = ta.selectionEnd; ta.value = ta.value.slice(0, from) + text + ta.value.slice(to); ta.focus(); ta.selectionStart = ta.selectionEnd = from + text.length; } } // ── Formatting ribbon (ROADMAP #83) ──────────────────────────────────────── let _ribbonCommands = null; function loadRibbonCommands() { if (_ribbonCommands) return _ribbonCommands; let stored = null; try { stored = JSON.parse(localStorage.getItem(RIBBON_KEY) || 'null'); } catch (_) { stored = null; } _ribbonCommands = normalizeRibbonCommands(stored); return _ribbonCommands; } function persistRibbonCommands(list) { _ribbonCommands = normalizeRibbonCommands(list); try { localStorage.setItem(RIBBON_KEY, JSON.stringify(_ribbonCommands)); } catch (_) { /* ignore quota / private mode */ } } function commandLabel(id) { return t(COMMAND_LABEL_KEYS[id] || 'mobile_editor.cmd_' + id); } function runRibbonCommand(id) { if (id === 'undo' || id === 'redo') { const fn = window.CodeMirror && window.CodeMirror[id]; if (typeof fn === 'function' && state.editorView) { fn(state.editorView); if (typeof state.editorView.focus === 'function') state.editorView.focus(); } return; } if (id === 'files') { import('./palette.js').then((m) => m.open('')).catch(() => {}); return; } if (id === 'paste') { handlePaste(); return; } if (id === 'font_smaller') { bumpFontSize(-1); return; } if (id === 'font_larger') { bumpFontSize(1); return; } handleFormat(id); } function buildRibbonButton(id) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'me-ribbon-btn'; btn.id = 'me-cmd-' + id; btn.dataset.cmd = id; btn.textContent = COMMAND_ICONS[id] || id; const label = commandLabel(id); btn.title = label; btn.setAttribute('aria-label', label); btn.addEventListener('click', () => runRibbonCommand(id)); return btn; } function renderRibbon() { const scroll = document.querySelector('#me-toolbar .me-ribbon-scroll'); if (!scroll) return; scroll.innerHTML = ''; loadRibbonCommands().forEach((id) => scroll.appendChild(buildRibbonButton(id))); } // ── Ribbon customiser (gear button) ──────────────────────────────────────── function buildCustomizerSheet() { let sheet = document.getElementById('me-ribbon-customizer'); if (sheet) return sheet; // Reuse the shared bottom-sheet overlay created by ensureSheet(). ensureSheet(); sheet = document.createElement('div'); sheet.id = 'me-ribbon-customizer'; sheet.className = 'me-sheet me-ribbon-customizer'; sheet.setAttribute('role', 'dialog'); sheet.setAttribute('aria-modal', 'true'); const handle = document.createElement('div'); handle.className = 'me-sheet-handle'; const header = document.createElement('div'); header.className = 'me-sheet-header'; const title = document.createElement('span'); title.className = 'me-sheet-title'; title.textContent = t('mobile_editor.customize_title'); const close = document.createElement('button'); close.type = 'button'; close.className = 'me-sheet-close'; close.textContent = '✕'; close.setAttribute('aria-label', t('mobile_editor.close')); header.appendChild(title); header.appendChild(close); const list = document.createElement('div'); list.className = 'me-sheet-list me-ribbon-customizer-list'; const footer = document.createElement('div'); footer.className = 'me-ribbon-customizer-actions'; const reset = document.createElement('button'); reset.type = 'button'; reset.className = 'config-btn-secondary'; reset.textContent = t('mobile_editor.customize_reset'); const done = document.createElement('button'); done.type = 'button'; done.className = 'config-btn-primary'; done.textContent = t('mobile_editor.done'); footer.appendChild(reset); footer.appendChild(done); sheet.appendChild(handle); sheet.appendChild(header); sheet.appendChild(list); sheet.appendChild(footer); document.body.appendChild(sheet); close.addEventListener('click', () => closeSheet(sheet)); done.addEventListener('click', () => closeSheet(sheet)); reset.addEventListener('click', () => { persistRibbonCommands(DEFAULT_RIBBON_COMMANDS); renderRibbon(); renderCustomizerList(); }); return sheet; } function _customizerRow(labelText, buttons, extraClass) { const row = document.createElement('div'); row.className = 'me-ribbon-cmd-row' + (extraClass ? ' ' + extraClass : ''); const label = document.createElement('span'); label.className = 'me-ribbon-cmd-label'; label.textContent = labelText; row.appendChild(label); buttons.forEach((b) => row.appendChild(b)); return row; } function _smallButton(text, cls, aria, onClick, disabled) { const b = document.createElement('button'); b.type = 'button'; b.className = cls; b.textContent = text; if (aria) b.setAttribute('aria-label', aria); if (disabled) b.disabled = true; b.addEventListener('click', onClick); return b; } function renderCustomizerList() { const sheet = document.getElementById('me-ribbon-customizer'); if (!sheet) return; const list = sheet.querySelector('.me-ribbon-customizer-list'); if (!list) return; const enabled = loadRibbonCommands(); const disabled = RIBBON_COMMANDS.filter((id) => !enabled.includes(id)); list.innerHTML = ''; enabled.forEach((id, index) => { const up = _smallButton('↑', 'me-ribbon-cmd-move', t('mobile_editor.move_up'), () => { persistRibbonCommands(moveRibbonCommand(enabled, id, -1)); renderRibbon(); renderCustomizerList(); }, index === 0); const down = _smallButton('↓', 'me-ribbon-cmd-move', t('mobile_editor.move_down'), () => { persistRibbonCommands(moveRibbonCommand(enabled, id, 1)); renderRibbon(); renderCustomizerList(); }, index === enabled.length - 1); const remove = _smallButton('✕', 'me-ribbon-cmd-remove', t('mobile_editor.remove_command'), () => { if (enabled.length <= 1) return; persistRibbonCommands(enabled.filter((c) => c !== id)); renderRibbon(); renderCustomizerList(); }, false); list.appendChild( _customizerRow(`${COMMAND_ICONS[id] || id} ${commandLabel(id)}`, [up, down, remove]), ); }); if (disabled.length) { const divider = document.createElement('div'); divider.className = 'me-ribbon-cmd-divider'; divider.textContent = t('mobile_editor.add_command'); list.appendChild(divider); disabled.forEach((id) => { const add = _smallButton('+', 'me-ribbon-cmd-add', t('mobile_editor.add_command'), () => { persistRibbonCommands([...enabled, id]); renderRibbon(); renderCustomizerList(); }, false); list.appendChild(_customizerRow(`${COMMAND_ICONS[id] || id} ${commandLabel(id)}`, [add], 'disabled')); }); } } function openRibbonCustomizer() { const sheet = buildCustomizerSheet(); renderCustomizerList(); openSheet(sheet); } // ── Keyboard anchoring (BUG-019) ─────────────────────────────────────────── function initKeyboardAnchor(bar) { if (!window.visualViewport) return; const vv = window.visualViewport; const update = () => { const offset = Math.max(0, Math.round((window.innerHeight || 0) - vv.height - vv.offsetTop)); bar.style.setProperty('--kb-offset', offset + 'px'); bar.classList.toggle('kb-open', offset > 40); }; vv.addEventListener('resize', update); vv.addEventListener('scroll', update); window.addEventListener('resize', update); document.addEventListener('focusin', () => setTimeout(update, 60)); document.addEventListener('focusout', () => setTimeout(update, 60)); update(); } function buildFormattingToolbar() { const modal = document.getElementById('editor-modal'); if (!modal || document.getElementById('me-toolbar')) return; const bar = document.createElement('div'); bar.id = 'me-toolbar'; bar.className = 'me-toolbar me-ribbon'; bar.setAttribute('role', 'toolbar'); bar.setAttribute('aria-label', t('mobile_editor.toolbar')); const scroll = document.createElement('div'); scroll.className = 'me-ribbon-scroll'; const gear = document.createElement('button'); gear.type = 'button'; gear.id = 'me-ribbon-gear'; gear.className = 'me-ribbon-gear'; gear.textContent = '⚙'; gear.title = t('mobile_editor.customize'); gear.setAttribute('aria-label', t('mobile_editor.customize')); gear.addEventListener('click', openRibbonCustomizer); bar.appendChild(scroll); bar.appendChild(gear); modal.appendChild(bar); renderRibbon(); initKeyboardAnchor(bar); } // ── Editor height resize + pinch zoom ────────────────────────────────────── function applySavedHeight(bodyEl) { if (!isMobileViewport()) return; try { const saved = parseInt(localStorage.getItem(HEIGHT_KEY), 10); if (Number.isFinite(saved) && saved >= MIN_EDITOR_HEIGHT) { bodyEl.style.flex = '0 0 ' + saved + 'px'; bodyEl.style.height = saved + 'px'; } } catch (_) { /* ignore */ } } function initHeightResize(bodyEl) { if (!bodyEl || document.getElementById('me-resize-handle')) return; const handle = document.createElement('div'); handle.id = 'me-resize-handle'; handle.className = 'me-resize-handle'; handle.setAttribute('role', 'separator'); handle.setAttribute('aria-orientation', 'horizontal'); handle.title = t('mobile_editor.resize'); (bodyEl.parentElement || bodyEl).insertBefore(handle, bodyEl.nextSibling); let dragging = false; let startY = 0; let startH = 0; handle.addEventListener('pointerdown', (e) => { dragging = true; startY = e.clientY; startH = bodyEl.getBoundingClientRect().height; if (handle.setPointerCapture) { try { handle.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ } } e.preventDefault(); }); handle.addEventListener('pointermove', (e) => { if (!dragging) return; const maxH = Math.max(MIN_EDITOR_HEIGHT, (window.innerHeight || 800) - 140); const h = Math.min(maxH, Math.max(MIN_EDITOR_HEIGHT, startH + (e.clientY - startY))); bodyEl.style.flex = '0 0 ' + h + 'px'; bodyEl.style.height = h + 'px'; }); const stop = () => { if (!dragging) return; dragging = false; try { localStorage.setItem(HEIGHT_KEY, String(Math.round(bodyEl.getBoundingClientRect().height))); } catch (_) { /* ignore */ } }; handle.addEventListener('pointerup', stop); handle.addEventListener('pointercancel', stop); } function initPinchZoom(bodyEl) { if (!bodyEl) return; let startDist = 0; let startSize = 0; const distance = (touches) => Math.hypot( touches[0].clientX - touches[1].clientX, touches[0].clientY - touches[1].clientY, ); bodyEl.addEventListener( 'touchstart', (e) => { if (e.touches.length === 2) { startDist = distance(e.touches); startSize = currentEditorFontSize(); } }, { passive: true }, ); bodyEl.addEventListener( 'touchmove', (e) => { if (e.touches.length === 2 && startDist > 0) { applyEditorFontSize(startSize * (distance(e.touches) / startDist)); if (e.cancelable) e.preventDefault(); } }, { passive: false }, ); bodyEl.addEventListener('touchend', () => { startDist = 0; }); } // ── Bottom sheet (backlinks / TOC) ───────────────────────────────────────── function ensureSheet() { let sheet = document.getElementById('me-sheet'); if (sheet) return sheet; sheet = document.createElement('div'); sheet.id = 'me-sheet'; sheet.className = 'me-sheet'; sheet.setAttribute('role', 'dialog'); sheet.setAttribute('aria-modal', 'true'); const handle = document.createElement('div'); handle.className = 'me-sheet-handle'; const header = document.createElement('div'); header.className = 'me-sheet-header'; const title = document.createElement('span'); title.className = 'me-sheet-title'; const close = document.createElement('button'); close.type = 'button'; close.className = 'me-sheet-close'; close.textContent = '✕'; close.setAttribute('aria-label', t('mobile_editor.close')); header.appendChild(title); header.appendChild(close); const list = document.createElement('div'); list.className = 'me-sheet-list'; sheet.appendChild(handle); sheet.appendChild(header); sheet.appendChild(list); const overlay = document.createElement('div'); overlay.id = 'me-sheet-overlay'; overlay.className = 'me-sheet-overlay'; document.body.appendChild(overlay); document.body.appendChild(sheet); close.addEventListener('click', () => closeSheet(sheet)); overlay.addEventListener('click', () => closeSheet(sheet)); return sheet; } function openSheet(sheet) { sheet.classList.add('open'); const overlay = document.getElementById('me-sheet-overlay'); if (overlay) overlay.classList.add('visible'); } function closeSheet(sheet) { if (sheet) sheet.classList.remove('open'); const overlay = document.getElementById('me-sheet-overlay'); if (overlay) overlay.classList.remove('visible'); } function renderSheetItems(sheet, items, emptyLabel) { const list = sheet.querySelector('.me-sheet-list'); if (!list) return; list.innerHTML = ''; if (!items.length) { const empty = document.createElement('div'); empty.className = 'me-sheet-empty'; empty.textContent = emptyLabel; list.appendChild(empty); return; } items.forEach((item) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'me-sheet-item'; btn.textContent = item.label; btn.addEventListener('click', item.onClick); list.appendChild(btn); }); } async function openBacklinksSheet() { const vault = state.currentVault; const path = state.currentPath; if (!vault || !path) return; const sheet = ensureSheet(); sheet.querySelector('.me-sheet-title').textContent = t('mobile_editor.backlinks'); openSheet(sheet); renderSheetItems(sheet, [], t('mobile_editor.loading')); try { const { api } = await import('./auth.js'); const data = await api( `/api/file/${encodeURIComponent(vault)}/backlinks?path=${encodeURIComponent(path)}`, ); const backlinks = data.backlinks || []; renderSheetItems( sheet, backlinks.map((bl) => ({ label: bl.title || bl.path.split('/').pop().replace(/\.md$/i, ''), onClick: async () => { closeSheet(sheet); if (window.TabManager && typeof window.TabManager.openPreview === 'function') { window.TabManager.openPreview(bl.vault, bl.path); } else { const viewer = await import('./viewer.js'); viewer.openFile(bl.vault, bl.path); } }, })), t('mobile_editor.no_backlinks'), ); } catch (_) { renderSheetItems(sheet, [], t('mobile_editor.error')); } } function openTocSheet() { const sheet = ensureSheet(); sheet.querySelector('.me-sheet-title').textContent = t('mobile_editor.toc'); const headings = state.headingsCache || []; renderSheetItems( sheet, headings.map((h) => ({ label: h.text, onClick: () => { closeSheet(sheet); const target = document.getElementById(h.id); if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, })), t('mobile_editor.no_headings'), ); openSheet(sheet); } // ── Reading mode + page navigation ───────────────────────────────────────── export function isReadingMode() { return document.body.classList.contains('reading-mode'); } export function toggleReadingMode(force) { const on = typeof force === 'boolean' ? force : !isReadingMode(); document.body.classList.toggle('reading-mode', on); const btn = document.getElementById('me-reading-btn'); if (btn) btn.setAttribute('aria-pressed', String(on)); try { localStorage.setItem(READING_KEY, on ? '1' : '0'); } catch (_) { /* ignore */ } try { if (on) { if (document.documentElement.requestFullscreen) { const p = document.documentElement.requestFullscreen(); if (p && typeof p.catch === 'function') p.catch(() => {}); } } else if (document.fullscreenElement && document.exitFullscreen) { const p = document.exitFullscreen(); if (p && typeof p.catch === 'function') p.catch(() => {}); } } catch (_) { /* Fullscreen unsupported (iOS) — CSS reading mode still applies. */ } return on; } async function navigateAdjacentFile(direction) { const vault = state.currentVault; const path = state.currentPath; if (!vault || !path) return; const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''; try { const { api } = await import('./auth.js'); const data = await api( `/api/vault/${encodeURIComponent(vault)}/files?dir=${encodeURIComponent(dir)}&recursive=false&limit=500`, ); const files = (data.files || []) .filter((f) => /\.md$/i.test(f.path)) .sort((a, b) => a.path.localeCompare(b.path)); const idx = files.findIndex((f) => f.path === path); if (idx === -1) return; const next = files[idx + (direction > 0 ? 1 : -1)]; if (!next) { showToastSafe( direction > 0 ? t('mobile_editor.no_next') : t('mobile_editor.no_prev'), 'info', ); return; } const viewer = await import('./viewer.js'); viewer.openFile(vault, next.path); } catch (_) { /* network / offline — silent */ } } function buildReadingControls() { if (document.getElementById('me-reading-btn')) return; const enter = document.createElement('button'); enter.id = 'me-reading-btn'; enter.type = 'button'; enter.className = 'me-reading-btn'; enter.title = t('mobile_editor.reading_enter'); enter.setAttribute('aria-label', t('mobile_editor.reading_enter')); enter.setAttribute('aria-pressed', String(isReadingMode())); enter.textContent = '📖'; enter.addEventListener('click', () => toggleReadingMode(true)); const exit = document.createElement('button'); exit.id = 'me-reading-exit'; exit.type = 'button'; exit.className = 'me-reading-exit'; exit.title = t('mobile_editor.reading_exit'); exit.setAttribute('aria-label', t('mobile_editor.reading_exit')); exit.textContent = '✕'; exit.addEventListener('click', () => toggleReadingMode(false)); document.body.appendChild(enter); document.body.appendChild(exit); } /** True when the BooksLM assistant panel is expanded on screen. */ function isAssistantOpen() { const panel = document.querySelector('.bookslm-panel'); return !!(panel && panel.classList.contains('open') && !panel.classList.contains('hidden')); } /** * Show the floating reading-mode button only while a file is open and the * assistant panel is closed. Without this guard the button floats above the * full-screen mobile assistant and covers its send button. * * Called on init, whenever the content area changes and when the assistant * opens/closes. */ export function updateReadingButtonVisibility() { const btn = document.getElementById('me-reading-btn'); if (!btn) return; const fileOpen = !!state.currentPath; btn.hidden = !fileOpen || isAssistantOpen(); } // ── Swipe gestures ───────────────────────────────────────────────────────── function initSwipeGestures() { const area = document.getElementById('content-area'); if (!area || area.dataset.meSwipe === '1') return; area.dataset.meSwipe = '1'; let startX = 0; let startY = 0; let tracking = false; area.addEventListener( 'touchstart', (e) => { if (e.touches.length !== 1) { tracking = false; return; } tracking = true; startX = e.touches[0].clientX; startY = e.touches[0].clientY; }, { passive: true }, ); area.addEventListener( 'touchend', (e) => { if (!tracking) return; tracking = false; const touch = e.changedTouches[0]; if (!touch) return; const direction = detectSwipe(touch.clientX - startX, touch.clientY - startY); if (!direction) return; if (isReadingMode()) { navigateAdjacentFile(direction === 'left' ? 1 : -1); } else if (direction === 'left') { openBacklinksSheet(); } else { openTocSheet(); } }, { passive: true }, ); } // ── Init ─────────────────────────────────────────────────────────────────── export function initMobileEditor() { if (_initialized) return; _initialized = true; buildFormattingToolbar(); buildReadingControls(); initSwipeGestures(); // Keep the reading button in sync with the current view: visible only when // a file is open and the assistant is closed. updateReadingButtonVisibility(); document.addEventListener('bookslm:opened', updateReadingButtonVisibility); document.addEventListener('bookslm:closed', updateReadingButtonVisibility); const readingArea = document.getElementById('content-area'); if (readingArea && typeof MutationObserver !== 'undefined') { new MutationObserver(updateReadingButtonVisibility).observe(readingArea, { childList: true, subtree: true, }); } const bodyEl = document.getElementById('editor-body'); if (bodyEl) { initPinchZoom(bodyEl); initHeightResize(bodyEl); const modal = document.getElementById('editor-modal'); if (modal && typeof MutationObserver !== 'undefined') { const observer = new MutationObserver(() => { if (modal.classList.contains('active')) { applyEditorFontSize(currentEditorFontSize()); applySavedHeight(bodyEl); } }); observer.observe(modal, { attributes: true, attributeFilter: ['class'] }); // CodeMirror is recreated on every openEditor()/file switch — re-apply the // persisted zoom whenever a new scroller is inserted. Observe direct // children only so per-keystroke DOM mutations don't retrigger this. const bodyObserver = new MutationObserver(() => { if (bodyEl.querySelector('.cm-editor .cm-scroller')) { applyEditorFontSize(currentEditorFontSize()); } }); bodyObserver.observe(bodyEl, { childList: true }); } } // Keep the reading-mode class in sync when the user exits fullscreen (ESC). document.addEventListener('fullscreenchange', () => { if (!document.fullscreenElement && isReadingMode()) { document.body.classList.remove('reading-mode'); try { localStorage.setItem(READING_KEY, '0'); } catch (_) { /* ignore */ } } }); // Restore a previous reading-mode session on reload (mobile only). try { if (isMobileViewport() && localStorage.getItem(READING_KEY) === '1') { document.body.classList.add('reading-mode'); } } catch (_) { /* ignore */ } } export { isMobileViewport };