/* 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; const FONT_KEY = 'obsigate-editor-font-size'; const HEIGHT_KEY = 'obsigate-editor-height'; const READING_KEY = 'obsigate-reading-mode'; // ── 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. * * @param {string} text Full document text. * @param {number} from Selection start. * @param {number} to Selection end. * @param {string} action One of 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); const wrap = (marker) => { // Toggle off when the selection already carries the markers. if ( selected.length >= marker.length * 2 && selected.startsWith(marker) && selected.endsWith(marker) ) { const inner = selected.slice(marker.length, -marker.length); return { changes: { from, to, insert: inner }, selection: { anchor: from, head: from + inner.length }, }; } const insert = marker + selected + marker; return { changes: { from, to, insert }, selection: { anchor: from + marker.length, head: to + marker.length }, }; }; switch (action) { case 'bold': return wrap('**'); case 'italic': return wrap('*'); case 'code': return wrap('`'); case 'list': { 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); const lines = block.split('\n'); const allList = lines.every((l) => l.startsWith('- ')); const prefixed = lines .map((l) => (allList ? l.replace(/^- /, '') : l.startsWith('- ') ? l : '- ' + l)) .join('\n'); return { changes: { from: lineStart, to: lineEnd, insert: prefixed }, selection: { anchor: lineStart, head: lineStart + prefixed.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; } } /** 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; } } function makeButton(id, label, title, onClick) { const btn = document.createElement('button'); btn.type = 'button'; btn.id = id; btn.className = 'me-btn'; btn.title = title; btn.setAttribute('aria-label', title); btn.textContent = label; btn.addEventListener('click', onClick); return btn; } 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'; bar.setAttribute('role', 'toolbar'); bar.setAttribute('aria-label', t('mobile_editor.toolbar')); const formats = [ { action: 'bold', label: 'B', title: t('mobile_editor.bold') }, { action: 'italic', label: 'I', title: t('mobile_editor.italic') }, { action: 'code', label: '<>', title: t('mobile_editor.code') }, { action: 'list', label: '•', title: t('mobile_editor.list') }, { action: 'link', label: '🔗', title: t('mobile_editor.link') }, ]; formats.forEach((f) => { const btn = makeButton('me-fmt-' + f.action, f.label, f.title, () => handleFormat(f.action)); btn.dataset.action = f.action; btn.classList.add('me-btn-format'); bar.appendChild(btn); }); const sep = document.createElement('span'); sep.className = 'me-sep'; bar.appendChild(sep); bar.appendChild( makeButton('me-font-minus', 'A−', t('mobile_editor.font_smaller'), () => bumpFontSize(-1)), ); bar.appendChild( makeButton('me-font-plus', 'A+', t('mobile_editor.font_larger'), () => bumpFontSize(1)), ); const sep2 = document.createElement('span'); sep2.className = 'me-sep'; bar.appendChild(sep2); const pasteBtn = makeButton('me-paste', '📋', t('mobile_editor.paste'), handlePaste); pasteBtn.classList.add('me-btn-paste'); bar.appendChild(pasteBtn); modal.appendChild(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 };