diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 86bd5b3..d757ddc 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: - name: Frontend unit tests run: node tests/frontend/unit.test.mjs - - name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab) + - name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile) run: | cd tests/frontend if [ -d node_modules ]; then @@ -48,6 +48,7 @@ jobs: node ai.test.mjs node sw.test.mjs node collab.test.mjs + node mobile-editor.test.mjs else echo "tests/frontend/node_modules missing — installing jsdom" npm install --no-audit --no-fund --silent @@ -57,6 +58,7 @@ jobs: node ai.test.mjs node sw.test.mjs node collab.test.mjs + node mobile-editor.test.mjs fi # ── Tests ───────────────────────────────────────────────────────── diff --git a/CHANGELOG.md b/CHANGELOG.md index c152ac8..337175a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,20 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Ajouté +- **#69 Éditeur mobile natif — Interface tactile optimisée** — refonte de l'expérience + d'édition sur téléphone/tablette. **Barre d'outils flottante** dans l'éditeur (gras, italique, + code, liste à puces, lien) opérant directement sur la sélection CodeMirror (ou le textarea de + secours), avec **toggle** (re-appui pour dé-formater) ; **bouton « Coller » persistant** qui + contourne la restriction presse-papiers d'iOS (repli sur un message d'aide si l'accès est + refusé). **Adaptation CodeMirror** : zoom par **pincement à deux doigts**, boutons A−/A+ et + **hauteur ajustable** via une poignée (valeurs persistées en `localStorage`). Côté lecture : + **raccourcis swipe** (gauche → liens entrants, droite → table des matières) et **mode lecture** + plein écran (masquage de l'en-tête, des barres latérales et de la barre mobile, API Fullscreen, + **swipe horizontal** pour passer au fichier suivant/précédent du même dossier). Nouveau module + `frontend/js/mobile-editor.js`, styles `frontend/style.css`, clés i18n FR/EN. Tests : + `tests/frontend/mobile-editor.test.mjs` (22 tests). Détail : + [docs/features/mobile-editor.md](./docs/features/mobile-editor.md). + - **#62 Collaboration temps réel — Édition simultanée** — plusieurs utilisateurs peuvent éditer le même document markdown en même temps, façon Google Docs. **WebSocket** : nouvel endpoint `/ws/collab/{vault}/{path}` (une *room* par fichier) qui relaie les changements instantanément ; diff --git a/README.fr.md b/README.fr.md index d83fbe9..b28c5b2 100644 --- a/README.fr.md +++ b/README.fr.md @@ -57,6 +57,7 @@ - **🤖 AI Editor intégré** — Éditeur CodeMirror 6 avec toolbar IA : amélioration, correction, traduction, génération, réécriture personnalisée, toolbox (liste, tableau, frontmatter, canvas) — multi-provider DeepSeek/OpenRouter/Gemini - **🧩 Serveur MCP & agent IA** — Serveur Model Context Protocol intégré (`/mcp`) et assistant avec function calling : lisez, cherchez et modifiez vos vaults depuis Claude Desktop, Cursor… avec confirmations two-step, permissions par vault, rate limiting et redaction des secrets ([guide](docs/MCP_GUIDE.md)) - **👥 Collaboration temps réel** — Édition simultanée d'un même document (Yjs/CRDT) : curseurs distants colorés, indicateur de présence, fusion sans conflit, reconnexion automatique et persistance serveur ([détail](docs/features/collaboration.md)) +- **📱 Éditeur mobile natif** — Édition optimisée pour le tactile : barre d'outils Markdown flottante (gras/italique/code/liste/lien), bouton « Coller » persistant (contournement iOS), zoom par pincement et hauteur ajustable, raccourcis swipe (liens entrants / table des matières) et mode lecture plein écran avec navigation entre fichiers ([détail](docs/features/mobile-editor.md)) - **🗺️ Vue graphe interactive** — Canvas force-directed avec Barnes-Hut O(n log n), filtres (tag, type), profondeur, mode focus, historique de navigation ←→↑, export PNG, aperçu au survol (Ctrl+click) - **🗂️ Multi-vault** : Visualisez plusieurs vaults Obsidian simultanément - **🌳 Navigation arborescente** : Parcourez vos dossiers et fichiers dans la sidebar diff --git a/README.md b/README.md index dc15e5b..746e572 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ - **🤖 Integrated AI Editor** — CodeMirror 6 editor with AI toolbar: improve, correct, translate, generate, custom rewrite, toolbox (list, table, frontmatter, canvas) — multi-provider DeepSeek/OpenRouter/Gemini - **🧩 MCP Server & AI Agent** — Built-in Model Context Protocol server (`/mcp`) and tool-calling assistant: read, search and edit your vaults from Claude Desktop, Cursor… with two-step confirmations, per-vault permissions, rate limiting and secret redaction ([guide](docs/MCP_GUIDE.md)) - **👥 Real-time Collaboration** — Simultaneous editing of the same document (Yjs/CRDT): colored remote cursors, presence indicator, conflict-free merge, automatic reconnection and server-side persistence ([details](docs/features/collaboration.md)) +- **📱 Native Mobile Editor** — Touch-optimised editing: floating Markdown toolbar (bold/italic/code/list/link), persistent Paste button (iOS workaround), pinch-zoom font & adjustable height, swipe shortcuts (backlinks / table of contents) and a full-screen reading mode with page navigation ([details](docs/features/mobile-editor.md)) - **🗺️ Interactive Graph View** — Canvas force-directed with Barnes-Hut O(n log n), filters (tag, type), depth, focus mode, navigation history ←→↑, export PNG, preview on hover (Ctrl+click) - **🗂️ Multi-vault** : View multiple Obsidian vaults simultaneously - **🌳 Tree Navigation** : Browse your folders and files in the sidebar diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 86455bd..f1cf22a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -44,17 +44,6 @@ ## ⚪ Backlog — Priorité 4 (P4) -### 69. Éditeur mobile natif — Interface tactile optimisée - -- **Effort :** 2-3 jours | **Impact :** 🟢 -- **Description :** Refonte de l'expérience mobile pour l'édition : barre d'outils contextuelle, presse-papiers optimisé, gestes tactiles (swipe pour actions rapides), mode lecture plein écran. -- **Sous-tâches :** - - [ ] Barre d'outils mobile flottante : **bold**, *italic*, `code`, liste, lien (inspirée de l'éditeur Obsidian mobile) - - [ ] Raccourcis swipe : gauche → backlinks, droite → table des matières - - [ ] Mode lecture : cache sidebar + header, plein écran, swipe horizontal pour page suivante - - [ ] Presse-papiers : bouton « Coller » persistant (iOS contourne restriction clipboard) - - [ ] Adaptation CodeMirror : hauteur ajustable, police agrandissable (pinch zoom) - ### 70. Recherche sémantique — Embeddings vectoriels - **Effort :** 4-5 jours | **Impact :** 🟢 @@ -126,6 +115,7 @@ | 62 | Collaboration temps réel — Édition simultanée (Yjs/CRDT, WebSocket, awareness) | 2.3.0 | [features/collaboration.md](./features/collaboration.md) | | 79 | Assistant IA — Outils (function calling) & serveur MCP | 2.3.0 | [features/ai-tools-mcp.md](./features/ai-tools-mcp.md) | | 80 | Assistant IA — Rendu Markdown, liens fichiers/paths & sessions | 2.3.0 | [features/ai-assistant-ux.md](./features/ai-assistant-ux.md) | +| 69 | Éditeur mobile natif — Interface tactile optimisée | 2.3.0 | [features/mobile-editor.md](./features/mobile-editor.md) | | 77 | Application Desktop native — Tauri | 🔵 en cours | [features/desktop-tauri.md](./features/desktop-tauri.md) | --- @@ -134,10 +124,10 @@ | Priorité | Items | Effort total estimé | |---|---|---| -| ✅ Complété | #1 → #59, #61–68, #71, #72, #74–76, #78–80 | ~97 jours réalisés | +| ✅ Complété | #1 → #59, #61–72, #74–76, #78–80 | ~99 jours réalisés | | 🔵 P2 restant | #77 Desktop : signature code (optionnel), wizard 1er lancement (optionnel), 6 tests E2E **manuels** | ~1-2 jours | -| ⚪ P4 restant | #69 Mobile éditeur (2-3j) · #70 Sémantique (4-5j) · #73 Sync (6-8j) | 11-16 jours | -| **Total restant** | **4 items + finitions** | **~12-18 jours** | +| ⚪ P4 restant | #70 Sémantique (4-5j) · #73 Sync (6-8j) | 10-13 jours | +| **Total restant** | **3 items + finitions** | **~11-15 jours** | --- diff --git a/docs/features/mobile-editor.md b/docs/features/mobile-editor.md new file mode 100644 index 0000000..e6806e3 --- /dev/null +++ b/docs/features/mobile-editor.md @@ -0,0 +1,123 @@ +# #69 - Éditeur mobile natif — Interface tactile optimisée + +> **Statut :** ✅ Terminé — 100 % implémenté + 22 tests frontend (2026-09-11) +> **Effort :** 2-3 jours (réalisé) | **Impact :** 🟢 +> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) + +- **Fichiers clés :** + - `frontend/js/mobile-editor.js` — module complet (helpers purs + câblage UI) + - `frontend/style.css` — barre flottante, poignée de redimensionnement, mode lecture, bottom sheet + - `frontend/index.html` — section « Édition mobile » du guide intégré + lien de navigation + - `frontend/locales/{fr,en}.json` — clés `mobile_editor.*` et `help.mobile_editor_*` + - `frontend/js/app.js` — appel `initMobileEditor()` au démarrage + - `tests/frontend/mobile-editor.test.mjs` — 22 tests JSDOM (helpers + câblage DOM) + - `.gitea/workflows/ci.yml` — test ajouté au job `lint` + +- **Description :** sur téléphone et tablette, l'édition et la lecture s'adaptent au tactile : + barre d'outils Markdown flottante, bouton « Coller » persistant, zoom par pincement, hauteur + ajustable, gestes de balayage (liens entrants / table des matières) et mode lecture plein écran + avec navigation d'un fichier à l'autre. + +--- + +## Architecture + +``` +mobile-editor.js +├── Helpers purs (testables) clampZoom · detectSwipe · formatChange · insertText +│ applyFormatToView · applyFormatToTextarea +└── Câblage UI (initMobileEditor) + ├── Barre d'outils flottante #me-toolbar (gras/italique/code/liste/lien + A−/A+) + ├── Presse-papiers #me-paste (Clipboard API + repli iOS) + ├── Zoom pincement touchstart/move (2 doigts → taille de police) + ├── Hauteur ajustable #me-resize-handle (poignée, persistée) + ├── Bottom sheet #me-sheet (liens entrants / table des matières) + ├── Raccourcis swipe #content-area (gauche → backlinks, droite → TOC) + └── Mode lecture #me-reading-btn (plein écran + navigation fichiers) +``` + +## 1. Barre d'outils mobile flottante + +Injectée dans `#editor-modal` et affichée uniquement sous `768px` lorsque l'éditeur est actif +(`.editor-modal.active .me-toolbar`). Chaque bouton applique une transformation Markdown à la +sélection courante de CodeMirror (ou du `textarea` de secours) : + +| Bouton | Action | Transformation | +|---|---|---| +| **B** | `bold` | `**texte**` | +| *I* | `italic` | `*texte*` | +| `<>` | `code` | `` `texte` `` | +| • | `list` | préfixe `- ` sur chaque ligne sélectionnée | +| 🔗 | `link` | `[texte](url)` avec le curseur placé sur `url` | + +- **Toggle** : ré-appuyer sur un bouton alors que la sélection porte déjà les marqueurs + retire ces marqueurs (ex. `**hi**` sélectionné → `hi`). +- **Sans sélection** : les marqueurs sont insérés et le curseur est placé à l'intérieur. +- Les helpers `formatChange()` (calcul de la transaction) et `applyFormatToView()` / + `applyFormatToTextarea()` (application) sont purs côté calcul → testables sans CodeMirror. + +## 2. Presse-papiers — bouton « Coller » persistant + +iOS Safari n'autorise `navigator.clipboard.readText()` que sur geste utilisateur explicite et peut +le refuser. Le bouton `📋` : + +1. tente `navigator.clipboard.readText()` ; +2. en cas de refus ou d'absence, affiche un message d'aide (`mobile_editor.paste_hint`) invitant à + appuyer longuement dans le champ puis à choisir « Coller » ; +3. en cas de succès, insère le texte au niveau du curseur (CodeMirror ou textarea). + +## 3. Adaptation CodeMirror + +- **Zoom par pincement** : deux doigts sur `#editor-body` calculent le ratio de distance et + ajustent la taille de police du `.cm-scroller` (bornée `10–28px`). Boutons `A−`/`A+` pour les + utilisateurs sans geste tactile. +- **Hauteur ajustable** : une poignée (`#me-resize-handle`) entre le corps de l'éditeur et le + pied permet d'étirer/réduire la zone d'édition (`flex: 0 0 `), bornée à `140px`. +- Les deux valeurs sont **persistées** dans `localStorage` (`obsigate-editor-font-size`, + `obsigate-editor-height`) et réappliquées à chaque ouverture / changement de fichier via un + `MutationObserver`. + +## 4. Raccourcis swipe (lecture) + +Sur `#content-area`, un `touchstart`/`touchend` unique détecte un geste horizontal +(`detectSwipe`, seuil `60px`, dominance horizontale requise) : + +- **Balayage gauche** → bottom sheet des **liens entrants** (fetch `/api/file/{vault}/backlinks`, + clic → ouverture dans un onglet) ; +- **Balayage droite** → bottom sheet de la **table des matières** (`state.headingsCache`, + clic → `scrollIntoView`). + +## 5. Mode lecture plein écran + +Le bouton flottant `📖` (`#me-reading-btn`) bascule `body.reading-mode` : + +- masque l'en-tête, la sidebar, la barre latérale droite, la barre d'outils mobile et les actions + de fichier ; centre le contenu en colonne de lecture ; +- demande l'API **Fullscreen** (repli silencieux si non supportée, ex. iOS) ; la sortie du + plein écran (Échap) resynchronise la classe ; +- en mode lecture, le **balayage horizontal** navigue vers le fichier **suivant/précédent** du + même dossier (`/api/vault/{vault}/files?dir=…&recursive=false`, tri alphabétique, bouclage + borné avec message si extrémité atteinte) ; +- l'état est mémorisé (`obsigate-reading-mode`) et restauré au rechargement sur mobile. + +--- + +## Tests + +`tests/frontend/mobile-editor.test.mjs` (JSDOM, 22 tests) couvre : + +- `FORMAT_ACTIONS`, `clampZoom`, `detectSwipe` (seuil, dominance verticale, gauche/droite) ; +- `formatChange` pour bold/italic/code/liste/lien, avec et sans sélection, toggle et ligne unique ; +- `insertText`, `applyFormatToTextarea`, `applyEditorFontSize` (clamp + persistance) ; +- le câblage DOM : construction de `#me-toolbar`, idempotence, contrôles du mode lecture, + bascule `toggleReadingMode`/`isReadingMode`. + +Les chemins réseau (backlinks, fichiers adjacents) et les gestes réels restent à valider en E2E +navigateur / test manuel mobile. + +## Notes + +- Le module n'importe `ui.js`, `auth.js` et `viewer.js` que **dynamiquement** dans les fonctions + concernées, afin de rester testable en JSDOM sans charger la pile applicative complète. +- Aucune modification backend : les endpoints réutilisés (`/backlinks`, `/vault/{vault}/files`) + existaient déjà. diff --git a/frontend/index.html b/frontend/index.html index dadd924..c63a8ba 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2878,6 +2878,11 @@ data-i18n="help.nav_editing">✏️ Édition +
  • + 📱 Mobile +
  • 🗺️ Graphe🤖 IA)
  • + +

    📱 Édition mobile

    +

    + Sur téléphone et tablette, l'édition s'adapte au + tactile : barre d'outils flottante, gestes et mode + lecture plein écran. +

    + diff --git a/frontend/js/app.js b/frontend/js/app.js index 7cbf336..ead4cf1 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -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(); diff --git a/frontend/js/mobile-editor.js b/frontend/js/mobile-editor.js new file mode 100644 index 0000000..0251344 --- /dev/null +++ b/frontend/js/mobile-editor.js @@ -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 }; diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 9936b3b..87b1799 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -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...", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index d42f6bb..8c78fc1 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -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...", diff --git a/frontend/style.css b/frontend/style.css index 56c6edd..a5d6ccb 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -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; +} diff --git a/tests/e2e/mobile-editor.spec.js b/tests/e2e/mobile-editor.spec.js new file mode 100644 index 0000000..619bd42 --- /dev/null +++ b/tests/e2e/mobile-editor.spec.js @@ -0,0 +1,68 @@ +/** + * E2E tests for the native mobile editor UX (ROADMAP #69). + * + * Runs only under the `chromium-mobile` Playwright project (viewport ≤ 768px); + * skipped on the desktop project that the CI job executes. + * + * Prerequisites: + * cd tests/e2e && npm install && npx playwright install chromium + * + * Run: + * npx playwright test tests/e2e/mobile-editor.spec.js --project=chromium-mobile + */ + +import { test, expect } from '@playwright/test'; + +const MOBILE_MAX_WIDTH = 768; + +async function boot(page) { + await page.goto('/'); + // Auth may be disabled (test env) or a session may already exist. + await page + .waitForSelector('#app:not(.hidden)', { timeout: 15000 }) + .catch(() => {}); + await page.waitForTimeout(500); +} + +test.describe('Mobile editor (#69)', () => { + test('reading mode button toggles the distraction-free class', async ({ page, viewport }) => { + test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required'); + await boot(page); + + const enter = page.locator('#me-reading-btn'); + await expect(enter).toBeVisible(); + await enter.click(); + + await expect(page.locator('body')).toHaveClass(/reading-mode/); + await expect(page.locator('.header')).toBeHidden(); + + await page.locator('#me-reading-exit').click(); + await expect(page.locator('body')).not.toHaveClass(/reading-mode/); + }); + + test('editor modal exposes the floating formatting toolbar', async ({ page, viewport }) => { + test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required'); + await boot(page); + + // Force the editor modal open — the toolbar visibility is pure CSS. + await page.evaluate(() => document.getElementById('editor-modal').classList.add('active')); + + const toolbar = page.locator('#me-toolbar'); + await expect(toolbar).toBeVisible(); + for (const action of ['bold', 'italic', 'code', 'list', 'link']) { + await expect(page.locator('#me-fmt-' + action)).toBeVisible(); + } + await expect(page.locator('#me-paste')).toBeVisible(); + }); + + test('font zoom buttons persist the editor font size', async ({ page, viewport }) => { + test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required'); + await boot(page); + await page.evaluate(() => document.getElementById('editor-modal').classList.add('active')); + + await page.locator('#me-font-plus').click(); + await page.locator('#me-font-plus').click(); + const stored = await page.evaluate(() => localStorage.getItem('obsigate-editor-font-size')); + expect(Number(stored)).toBeGreaterThan(13.5); + }); +}); diff --git a/tests/frontend/mobile-editor.test.mjs b/tests/frontend/mobile-editor.test.mjs new file mode 100644 index 0000000..2a99591 --- /dev/null +++ b/tests/frontend/mobile-editor.test.mjs @@ -0,0 +1,227 @@ +#!/usr/bin/env node +/** + * ObsiGate — JSDOM tests for the native mobile editor UX (ROADMAP #69). + * + * Covers the pure helpers of frontend/js/mobile-editor.js (Markdown formatting, + * swipe detection, zoom clamping) and the DOM wiring (floating toolbar, reading + * mode toggling, bottom sheet construction). The swipe/navigation network paths + * are exercised manually / in E2E. + * + * Usage: node tests/frontend/mobile-editor.test.mjs + */ + +import { strict as assert } from "node:assert"; +import { JSDOM } from "jsdom"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import path from "node:path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const REPO_ROOT = path.resolve(__dirname, "..", ".."); + +// ── JSDOM bootstrap ──────────────────────────────────────────────────────── +const dom = new JSDOM( + ` +
    +
    +
    +
    +
    +
    +
    +
    + `, + { url: "https://example.com/", pretendToBeVisual: true }, +); + +const w = dom.window; +globalThis.window = w; +globalThis.document = w.document; +Object.defineProperty(globalThis, "navigator", { + value: w.navigator, + configurable: true, + writable: true, +}); +globalThis.localStorage = w.localStorage; +globalThis.MutationObserver = w.MutationObserver; + +const mod = await import( + pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "mobile-editor.js")).href +); + +const { + FORMAT_ACTIONS, + MIN_EDITOR_FONT, + MAX_EDITOR_FONT, + clampZoom, + detectSwipe, + formatChange, + insertText, + applyFormatToTextarea, + applyEditorFontSize, + initMobileEditor, + toggleReadingMode, + isReadingMode, +} = mod; + +let testCount = 0; +let passCount = 0; + +function test(name, fn) { + testCount++; + try { + fn(); + console.log(` ✓ ${name}`); + passCount++; + } catch (e) { + console.log(` ✗ ${name}`); + console.log(` ${e.message}`); + } +} + +console.log("\n── mobile-editor.js unit tests (#69) ──\n"); + +// ── Pure helpers ─────────────────────────────────────────────────────────── +test("FORMAT_ACTIONS exposes the five expected actions", () => { + assert.deepEqual(FORMAT_ACTIONS, ["bold", "italic", "code", "list", "link"]); +}); + +test("clampZoom clamps to [min, max] and falls back to 13.5", () => { + assert.equal(clampZoom(5), MIN_EDITOR_FONT); + assert.equal(clampZoom(100), MAX_EDITOR_FONT); + assert.equal(clampZoom(16), 16); + assert.equal(clampZoom("abc"), 13.5); + assert.equal(clampZoom(NaN), 13.5); +}); + +test("detectSwipe returns null below the threshold", () => { + assert.equal(detectSwipe(20, 0), null); + assert.equal(detectSwipe(-20, 0), null); +}); + +test("detectSwipe ignores vertical-dominant gestures", () => { + assert.equal(detectSwipe(10, 120), null); +}); + +test("detectSwipe detects left and right horizontal swipes", () => { + assert.equal(detectSwipe(-90, 5), "left"); + assert.equal(detectSwipe(90, -5), "right"); +}); + +test("formatChange bold wraps the selection", () => { + const c = formatChange("hello world", 0, 5, "bold"); + assert.equal(c.changes.insert, "**hello**"); + assert.equal(c.changes.from, 0); + assert.equal(c.changes.to, 5); + assert.deepEqual(c.selection, { anchor: 2, head: 7 }); +}); + +test("formatChange bold with no selection inserts markers and selects inside", () => { + const c = formatChange("abc", 1, 1, "bold"); + assert.equal(c.changes.insert, "****"); + assert.deepEqual(c.selection, { anchor: 3, head: 3 }); +}); + +test("formatChange bold toggles off when markers are selected", () => { + const c = formatChange("**hi**", 0, 6, "bold"); + assert.equal(c.changes.insert, "hi"); + assert.deepEqual(c.selection, { anchor: 0, head: 2 }); +}); + +test("formatChange italic and code use the right markers", () => { + assert.equal(formatChange("x", 0, 1, "italic").changes.insert, "*x*"); + assert.equal(formatChange("x", 0, 1, "code").changes.insert, "`x`"); +}); + +test("formatChange list prefixes each selected line", () => { + const c = formatChange("a\nb\nc", 0, 5, "list"); + assert.equal(c.changes.insert, "- a\n- b\n- c"); + assert.equal(c.changes.from, 0); + assert.equal(c.changes.to, 5); +}); + +test("formatChange list toggles an existing list off", () => { + const c = formatChange("- a\n- b", 0, 7, "list"); + assert.equal(c.changes.insert, "a\nb"); +}); + +test("formatChange list only affects the lines spanned by the cursor", () => { + const c = formatChange("head\nbody\ntail", 5, 5, "list"); + assert.equal(c.changes.insert, "- body"); + assert.equal(c.changes.from, 5); + assert.equal(c.changes.to, 9); +}); + +test("formatChange link builds [selection](url) and selects the url", () => { + const c = formatChange("click", 0, 5, "link"); + assert.equal(c.changes.insert, "[click](url)"); + assert.deepEqual(c.selection, { anchor: 8, head: 11 }); +}); + +test("formatChange returns null for an unknown action", () => { + assert.equal(formatChange("abc", 0, 0, "strike"), null); +}); + +test("insertText builds an insertion change at the cursor", () => { + const c = insertText(2, 2, "pasted"); + assert.deepEqual(c.changes, { from: 2, to: 2, insert: "pasted" }); + assert.deepEqual(c.selection, { anchor: 8, head: 8 }); +}); + +// ── Textarea application ─────────────────────────────────────────────────── +test("applyFormatToTextarea formats the current textarea selection", () => { + const ta = w.document.createElement("textarea"); + ta.value = "hello world"; + ta.selectionStart = 0; + ta.selectionEnd = 5; + assert.equal(applyFormatToTextarea(ta, "bold"), true); + assert.equal(ta.value, "**hello** world"); + assert.equal(ta.selectionStart, 2); + assert.equal(ta.selectionEnd, 7); +}); + +test("applyFormatToTextarea returns false without an element", () => { + assert.equal(applyFormatToTextarea(null, "bold"), false); +}); + +// ── DOM wiring ───────────────────────────────────────────────────────────── +test("initMobileEditor builds the floating toolbar with the five actions", () => { + initMobileEditor(); + const bar = w.document.getElementById("me-toolbar"); + assert.ok(bar, "toolbar exists"); + for (const action of FORMAT_ACTIONS) { + assert.ok(w.document.getElementById("me-fmt-" + action), action + " button exists"); + } + assert.ok(w.document.getElementById("me-paste"), "paste button exists"); + assert.ok(w.document.getElementById("me-resize-handle"), "resize handle exists"); +}); + +test("initMobileEditor is idempotent", () => { + initMobileEditor(); + const bars = w.document.querySelectorAll("#me-toolbar"); + assert.equal(bars.length, 1); +}); + +test("applyEditorFontSize persists and clamps the size", () => { + const applied = applyEditorFontSize(999); + assert.equal(applied, MAX_EDITOR_FONT); + assert.equal(localStorage.getItem("obsigate-editor-font-size"), String(MAX_EDITOR_FONT)); +}); + +test("toggleReadingMode toggles the body class and localStorage", () => { + assert.equal(isReadingMode(), false); + assert.equal(toggleReadingMode(true), true); + assert.equal(isReadingMode(), true); + assert.equal(localStorage.getItem("obsigate-reading-mode"), "1"); + assert.equal(toggleReadingMode(false), false); + assert.equal(isReadingMode(), false); + assert.equal(localStorage.getItem("obsigate-reading-mode"), "0"); +}); + +test("reading mode exposes enter and exit controls", () => { + assert.ok(w.document.getElementById("me-reading-btn"), "enter button exists"); + assert.ok(w.document.getElementById("me-reading-exit"), "exit button exists"); +}); + +console.log(`\n${passCount}/${testCount} tests passed\n`); +process.exit(passCount === testCount ? 0 : 1);