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

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:
2026-09-11 23:51:16 -04:00
parent 063b02e996
commit 45be3125de
14 changed files with 1523 additions and 15 deletions
+41
View File
@@ -2878,6 +2878,11 @@
data-i18n="help.nav_editing">✏️ Édition</a
>
</li>
<li>
<a href="#help-mobile-editor" class="help-nav-link"
data-i18n="help.nav_mobile_editor">📱 Mobile</a
>
</li>
<li>
<a href="#help-graphe" class="help-nav-link"
data-i18n="help.nav_graph">🗺️ Graphe</a
@@ -3633,6 +3638,42 @@
section</span><a href="#help-ia" data-i18n="settings.ai">🤖 IA</a>)
</li>
</ul>
<h3 data-i18n="help.mobile_editor_title">📱 Édition mobile</h3>
<p data-i18n="help.mobile_editor_intro">
Sur téléphone et tablette, l'édition s'adapte au
tactile : barre d'outils flottante, gestes et mode
lecture plein écran.
</p>
<ul>
<li>
<strong data-i18n="help.mobile_editor_toolbar_label">Barre d'outils flottante</strong><span data-i18n="help.mobile_editor_toolbar"> :
gras, italique, code, liste à puces et lien.
Les boutons A−/A+ ajustent la taille de la
police.</span>
</li>
<li>
<strong data-i18n="help.mobile_editor_paste_label">Presse-papiers</strong><span data-i18n="help.mobile_editor_paste"> :
bouton « Coller » persistant (contourne la
restriction iOS).</span>
</li>
<li>
<strong data-i18n="help.mobile_editor_gestures_label">Gestes tactiles</strong><span data-i18n="help.mobile_editor_gestures"> :
balayage vers la gauche → liens entrants,
vers la droite → table des matières.</span>
</li>
<li>
<strong data-i18n="help.mobile_editor_reading_label">Mode lecture</strong><span data-i18n="help.mobile_editor_reading"> :
plein écran sans en-tête ni barres latérales ;
balayage horizontal pour passer au fichier
suivant ou précédent.</span>
</li>
<li>
<strong data-i18n="help.mobile_editor_zoom_label">Zoom &amp; hauteur</strong><span data-i18n="help.mobile_editor_zoom"> :
pincement à deux doigts pour zoomer, poignée
pour redimensionner la zone d'édition.</span>
</li>
</ul>
</section>
<!-- 🗺️ Graphe -->
+2
View File
@@ -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();
+711
View File
@@ -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 };
+35
View File
@@ -1095,6 +1095,18 @@
"help.mermaid_section": "Mermaid diagrams",
"help.mermaid_templates": "Templates",
"help.mobile": "Floating results bar",
"help.mobile_editor_gestures": ": swipe left → backlinks, swipe right → table of contents.",
"help.mobile_editor_gestures_label": "Touch gestures",
"help.mobile_editor_intro": "On phones and tablets, editing adapts to touch: floating toolbar, gestures and a full-screen reading mode.",
"help.mobile_editor_paste": ": persistent “Paste” button (works around the iOS restriction).",
"help.mobile_editor_paste_label": "Clipboard",
"help.mobile_editor_reading": ": full screen without header or sidebars; swipe horizontally to go to the next or previous file.",
"help.mobile_editor_reading_label": "Reading mode",
"help.mobile_editor_title": "📱 Mobile editing",
"help.mobile_editor_toolbar": ": bold, italic, code, bullet list and link. The A−/A+ buttons adjust the font size.",
"help.mobile_editor_toolbar_label": "Floating toolbar",
"help.mobile_editor_zoom": ": pinch with two fingers to zoom, drag the handle to resize the editing area.",
"help.mobile_editor_zoom_label": "Zoom & height",
"help.modal_title": "ObsiGate User Guide",
"help.multi_vault": "Multi-vault",
"help.nav_ai": "🤖 AI Editor",
@@ -1105,6 +1117,7 @@
"help.nav_graph": "🗺️ Graph",
"help.nav_interface": "🧭 Interface",
"help.nav_intro": "📘 Introduction",
"help.nav_mobile_editor": "📱 Mobile",
"help.nav_navigation": "🗺️ Navigation",
"help.nav_palette": "⌨️ Palette",
"help.nav_search": "🔍 Search",
@@ -1250,6 +1263,28 @@
"mermaid.zoom_in": "Zoom in",
"mermaid.zoom_out": "Zoom out",
"mobile.all_vaults": "📂 All vaults",
"mobile_editor.backlinks": "Backlinks",
"mobile_editor.bold": "Bold",
"mobile_editor.close": "Close",
"mobile_editor.code": "Code",
"mobile_editor.error": "Loading error",
"mobile_editor.font_larger": "Increase font size",
"mobile_editor.font_smaller": "Decrease font size",
"mobile_editor.italic": "Italic",
"mobile_editor.link": "Link",
"mobile_editor.list": "Bullet list",
"mobile_editor.loading": "Loading…",
"mobile_editor.no_backlinks": "No backlinks",
"mobile_editor.no_headings": "No headings in this document",
"mobile_editor.no_next": "Last file in folder",
"mobile_editor.no_prev": "First file in folder",
"mobile_editor.paste": "Paste",
"mobile_editor.paste_hint": "Long-press the field then choose “Paste” (clipboard access denied).",
"mobile_editor.reading_enter": "Reading mode",
"mobile_editor.reading_exit": "Exit reading mode",
"mobile_editor.resize": "Adjust editor height",
"mobile_editor.toc": "Table of contents",
"mobile_editor.toolbar": "Mobile editing toolbar",
"modal.create_btn": "Create",
"modal.create_file": "Create a file",
"modal.creating": "Creating...",
+35
View File
@@ -1095,6 +1095,18 @@
"help.mermaid_section": "Diagrammes Mermaid",
"help.mermaid_templates": "Templates",
"help.mobile": "Barre flottante résultats",
"help.mobile_editor_gestures": " : balayage vers la gauche → liens entrants, vers la droite → table des matières.",
"help.mobile_editor_gestures_label": "Gestes tactiles",
"help.mobile_editor_intro": "Sur téléphone et tablette, l'édition s'adapte au tactile : barre d'outils flottante, gestes et mode lecture plein écran.",
"help.mobile_editor_paste": " : bouton « Coller » persistant (contourne la restriction iOS).",
"help.mobile_editor_paste_label": "Presse-papiers",
"help.mobile_editor_reading": " : plein écran sans en-tête ni barres latérales ; balayage horizontal pour passer au fichier suivant ou précédent.",
"help.mobile_editor_reading_label": "Mode lecture",
"help.mobile_editor_title": "📱 Édition mobile",
"help.mobile_editor_toolbar": " : gras, italique, code, liste à puces et lien. Les boutons A−/A+ ajustent la taille de la police.",
"help.mobile_editor_toolbar_label": "Barre d'outils flottante",
"help.mobile_editor_zoom": " : pincement à deux doigts pour zoomer, poignée pour redimensionner la zone d'édition.",
"help.mobile_editor_zoom_label": "Zoom & hauteur",
"help.modal_title": "Guide d'utilisation ObsiGate",
"help.multi_vault": "Multi-vault",
"help.nav_ai": "🤖 IA Éditeur",
@@ -1105,6 +1117,7 @@
"help.nav_graph": "🗺️ Graphe",
"help.nav_interface": "🧭 Interface",
"help.nav_intro": "📘 Introduction",
"help.nav_mobile_editor": "📱 Mobile",
"help.nav_navigation": "🗺️ Navigation",
"help.nav_palette": "⌨️ Palette",
"help.nav_search": "🔍 Recherche",
@@ -1250,6 +1263,28 @@
"mermaid.zoom_in": "Zoom avant",
"mermaid.zoom_out": "Zoom arrière",
"mobile.all_vaults": "📂 Toutes les vaults",
"mobile_editor.backlinks": "Liens entrants",
"mobile_editor.bold": "Gras",
"mobile_editor.close": "Fermer",
"mobile_editor.code": "Code",
"mobile_editor.error": "Erreur de chargement",
"mobile_editor.font_larger": "Agrandir la police",
"mobile_editor.font_smaller": "Réduire la police",
"mobile_editor.italic": "Italique",
"mobile_editor.link": "Lien",
"mobile_editor.list": "Liste à puces",
"mobile_editor.loading": "Chargement…",
"mobile_editor.no_backlinks": "Aucun lien entrant",
"mobile_editor.no_headings": "Aucun titre dans ce document",
"mobile_editor.no_next": "Dernier fichier du dossier",
"mobile_editor.no_prev": "Premier fichier du dossier",
"mobile_editor.paste": "Coller",
"mobile_editor.paste_hint": "Appuyez longuement dans le champ puis choisissez « Coller » (accès au presse-papiers refusé).",
"mobile_editor.reading_enter": "Mode lecture",
"mobile_editor.reading_exit": "Quitter le mode lecture",
"mobile_editor.resize": "Ajuster la hauteur de l'éditeur",
"mobile_editor.toc": "Table des matières",
"mobile_editor.toolbar": "Barre d'outils d'édition mobile",
"modal.create_btn": "Créer",
"modal.create_file": "Créer un fichier",
"modal.creating": "Création...",
+258
View File
@@ -9346,3 +9346,261 @@ body.popup-mode .content-area {
.ai-fab[hidden] {
display: none;
}
/* ══════════════════════════════════════════
Mobile editor UX (ROADMAP #69)
Floating Markdown toolbar, paste helper,
pinch-zoom, adjustable height, swipe
shortcuts and distraction-free reading mode.
══════════════════════════════════════════ */
/* ── Floating formatting toolbar (inside editor modal) ── */
.me-toolbar {
display: none;
}
.me-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 40px;
padding: 0 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 15px;
font-weight: 600;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background 120ms ease, transform 120ms ease;
}
.me-btn:active {
transform: scale(0.94);
background: var(--bg-hover, rgba(128, 128, 128, 0.18));
}
.me-btn-format {
font-family: var(--font-mono);
}
.me-btn-paste {
margin-left: auto;
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.me-sep {
width: 1px;
height: 26px;
background: var(--border);
flex-shrink: 0;
}
@media (max-width: 768px) {
.editor-modal.active .me-toolbar {
display: flex;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 30;
gap: 6px;
align-items: center;
padding: 8px 10px;
padding-bottom: calc(8px + env(safe-area-inset-bottom, 0));
background: var(--bg-primary);
border-top: 1px solid var(--border);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
}
/* ── Adjustable editor height handle ── */
.me-resize-handle {
display: none;
}
@media (max-width: 768px) {
.me-resize-handle {
display: block;
height: 14px;
margin: 0;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
cursor: ns-resize;
touch-action: none;
position: relative;
flex-shrink: 0;
}
.me-resize-handle::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 40px;
height: 4px;
border-radius: 2px;
background: var(--border);
transform: translate(-50%, -50%);
}
}
/* ── Reading mode enter / exit buttons ── */
.me-reading-btn,
.me-reading-exit {
display: none;
}
@media (max-width: 768px) {
.me-reading-btn {
display: flex;
position: fixed;
right: 16px;
bottom: calc(80px + env(safe-area-inset-bottom, 0));
z-index: 890;
width: 48px;
height: 48px;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
background: var(--accent);
color: #fff;
font-size: 20px;
cursor: pointer;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
}
body.reading-mode .me-reading-btn {
display: none;
}
body.reading-mode .me-reading-exit {
display: flex;
position: fixed;
top: calc(12px + env(safe-area-inset-top, 0));
right: 12px;
z-index: 1100;
width: 40px;
height: 40px;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: 50%;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 16px;
cursor: pointer;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
}
/* ── Distraction-free reading mode ── */
body.reading-mode .header,
body.reading-mode .sidebar,
body.reading-mode .sidebar-overlay,
body.reading-mode .right-sidebar,
body.reading-mode .right-sidebar-resize-handle,
body.reading-mode .mobile-toolbar,
body.reading-mode .vault-context-bar,
body.reading-mode .tab-bar {
display: none !important;
}
body.reading-mode .content-area {
max-width: 820px;
margin: 0 auto;
padding: clamp(20px, 6vw, 48px) clamp(18px, 6vw, 40px) 96px;
}
body.reading-mode .breadcrumb,
body.reading-mode .file-actions {
display: none;
}
/* ── Bottom sheet (backlinks / table of contents) ── */
.me-sheet {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 1200;
max-height: 70vh;
display: flex;
flex-direction: column;
background: var(--bg-secondary);
border-top-left-radius: 16px;
border-top-right-radius: 16px;
box-shadow: 0 -6px 28px rgba(0, 0, 0, 0.4);
transform: translateY(110%);
transition: transform 220ms ease;
}
.me-sheet.open {
transform: translateY(0);
}
.me-sheet-overlay {
position: fixed;
inset: 0;
z-index: 1190;
background: var(--overlay-bg, rgba(0, 0, 0, 0.5));
opacity: 0;
pointer-events: none;
transition: opacity 220ms ease;
}
.me-sheet-overlay.visible {
opacity: 1;
pointer-events: auto;
}
.me-sheet-handle {
width: 40px;
height: 4px;
border-radius: 2px;
background: var(--border);
margin: 8px auto 4px;
flex-shrink: 0;
}
.me-sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 16px 10px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.me-sheet-title {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary);
}
.me-sheet-close {
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-muted);
font-size: 16px;
cursor: pointer;
}
.me-sheet-list {
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 6px 8px calc(12px + env(safe-area-inset-bottom, 0));
}
.me-sheet-item {
display: block;
width: 100%;
padding: 12px 12px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-primary);
font-size: 0.9rem;
text-align: left;
cursor: pointer;
}
.me-sheet-item:active {
background: var(--bg-hover, rgba(128, 128, 128, 0.18));
}
.me-sheet-empty {
padding: 24px 16px;
text-align: center;
color: var(--text-muted);
font-size: 0.85rem;
}