Files
ObsiGate/frontend/js/mobile-editor.js
T
bruno 520059d866
CI / lint (push) Successful in 1m13s
CI / security (push) Successful in 47s
CI / test (push) Successful in 2m41s
CI / build (push) Successful in 43s
CI / e2e (push) Successful in 11m14s
feat(mobile): ruban d'edition style Obsidian + correctifs mobile (#83, BUG-017 a BUG-020)
- #83: ruban horizontal defilable ancre au-dessus du clavier, commandes
  etendues (titres, barre, surligne, code bloc, citation, listes, cases,
  wikilinks, tags, pieces jointes, undo/redo, colle, police) et
  personnalisation persistee via l'icone engrenage
- BUG-017: cartes du tableau de bord compressibles (min-width:0)
- BUG-018: en-tete editeur mobile compact, boutons Annuler/Sauvegarder
  accessibles, barre IA plus tactile
- BUG-019: ancrage du ruban via visualViewport (--kb-offset)
- BUG-020: suppression de la double barre de defilement

Tests: mobile-editor 35/35, ai 50/50, 9 suites JSDOM, validate-imports,
pytest 961 passed / 6 skipped, ruff + mypy 0.
2026-09-13 11:49:45 -04:00

1110 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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_<id>).
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 };