feat(mobile): editeur mobile natif - interface tactile optimisee (#69)
CI / lint (push) Successful in 1m3s
CI / security (push) Successful in 46s
CI / test (push) Successful in 2m43s
CI / build (push) Successful in 38s
CI / e2e (push) Successful in 11m12s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 1m3s
CI / security (push) Successful in 46s
CI / test (push) Successful in 2m43s
CI / build (push) Successful in 38s
CI / e2e (push) Successful in 11m12s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
Barre d'outils Markdown flottante (gras/italique/code/liste/lien) sur la selection CodeMirror, bouton Coller persistant (contournement iOS), zoom par pincement + hauteur ajustable persistes, raccourcis swipe (gauche -> liens entrants, droite -> table des matieres), mode lecture plein ecran avec navigation entre fichiers du dossier. 22 tests JSDOM + spec E2E mobile. Docs: fiche feature, CHANGELOG, ROADMAP, guide i18n FR/EN, README.
This commit is contained in:
@@ -17,6 +17,7 @@ import { initGraphView } from './graph.js';
|
||||
import * as Legacy from './legacy.js';
|
||||
import { initCommandPalette } from './palette.js';
|
||||
import { initMobileToolbar } from './mobile-toolbar.js';
|
||||
import { initMobileEditor } from './mobile-editor.js';
|
||||
import { initDragDrop } from './dragdrop.js';
|
||||
import { initMermaid, setupFocusMode } from './mermaid-viewer.js';
|
||||
import PaneManager from './pane-manager.js';
|
||||
@@ -71,6 +72,7 @@ async function init() {
|
||||
PaneManager.init();
|
||||
initCommandPalette();
|
||||
initMobileToolbar();
|
||||
initMobileEditor();
|
||||
initPush();
|
||||
initPlugins();
|
||||
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
/* 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);
|
||||
}
|
||||
|
||||
// ── 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();
|
||||
|
||||
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 };
|
||||
Reference in New Issue
Block a user