Files
ObsiGate/frontend/js/mobile-editor.js
T
bruno d47fcbf2be
CI / lint (push) Successful in 1m10s
CI / security (push) Successful in 46s
CI / test (push) Successful in 2m23s
CI / build (push) Successful in 41s
CI / e2e (push) Successful in 10m28s
fix(mobile): bouton mode lecture visible uniquement fichier ouvert (BUG-016)
2026-09-12 22:44:15 -04:00

746 lines
24 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;
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 };