feat(mobile): editeur mobile natif - interface tactile optimisee (#69)
CI / lint (push) Successful in 1m3s
CI / security (push) Successful in 46s
CI / test (push) Successful in 2m43s
CI / build (push) Successful in 38s
CI / e2e (push) Successful in 11m12s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 1m3s
CI / security (push) Successful in 46s
CI / test (push) Successful in 2m43s
CI / build (push) Successful in 38s
CI / e2e (push) Successful in 11m12s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
Barre d'outils Markdown flottante (gras/italique/code/liste/lien) sur la selection CodeMirror, bouton Coller persistant (contournement iOS), zoom par pincement + hauteur ajustable persistes, raccourcis swipe (gauche -> liens entrants, droite -> table des matieres), mode lecture plein ecran avec navigation entre fichiers du dossier. 22 tests JSDOM + spec E2E mobile. Docs: fiche feature, CHANGELOG, ROADMAP, guide i18n FR/EN, README.
This commit is contained in:
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 ;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-14
@@ -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** |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 <px>`), 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à.
|
||||
@@ -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 & 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 -->
|
||||
|
||||
@@ -17,6 +17,7 @@ import { initGraphView } from './graph.js';
|
||||
import * as Legacy from './legacy.js';
|
||||
import { initCommandPalette } from './palette.js';
|
||||
import { initMobileToolbar } from './mobile-toolbar.js';
|
||||
import { initMobileEditor } from './mobile-editor.js';
|
||||
import { initDragDrop } from './dragdrop.js';
|
||||
import { initMermaid, setupFocusMode } from './mermaid-viewer.js';
|
||||
import PaneManager from './pane-manager.js';
|
||||
@@ -71,6 +72,7 @@ async function init() {
|
||||
PaneManager.init();
|
||||
initCommandPalette();
|
||||
initMobileToolbar();
|
||||
initMobileEditor();
|
||||
initPush();
|
||||
initPlugins();
|
||||
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
/* ObsiGate — Native mobile editor UX (ROADMAP #69)
|
||||
*
|
||||
* Tactile-optimised editing experience, split in three areas:
|
||||
* 1. Editor — floating Markdown toolbar (bold/italic/code/list/link),
|
||||
* persistent "Paste" button (iOS clipboard workaround),
|
||||
* pinch-zoom font size and adjustable editor height.
|
||||
* 2. Viewer — swipe shortcuts (left → backlinks, right → table of contents)
|
||||
* and a distraction-free reading mode with horizontal page
|
||||
* navigation.
|
||||
*
|
||||
* The pure helpers at the top are exported for unit testing (JSDOM).
|
||||
* All UI wiring happens in initMobileEditor(), called once from app.js.
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
export const FORMAT_ACTIONS = ['bold', 'italic', 'code', 'list', 'link'];
|
||||
export const SWIPE_THRESHOLD = 60;
|
||||
export const MIN_EDITOR_FONT = 10;
|
||||
export const MAX_EDITOR_FONT = 28;
|
||||
export const DEFAULT_EDITOR_FONT = 13.5;
|
||||
export const MIN_EDITOR_HEIGHT = 140;
|
||||
|
||||
const FONT_KEY = 'obsigate-editor-font-size';
|
||||
const HEIGHT_KEY = 'obsigate-editor-height';
|
||||
const READING_KEY = 'obsigate-reading-mode';
|
||||
|
||||
// ── Pure helpers (unit tested) ─────────────────────────────────────────────
|
||||
|
||||
/** Clamp a font size to the supported range; non-numeric falls back to 13.5. */
|
||||
export function clampZoom(value, min = MIN_EDITOR_FONT, max = MAX_EDITOR_FONT) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return DEFAULT_EDITOR_FONT;
|
||||
return Math.min(max, Math.max(min, n));
|
||||
}
|
||||
|
||||
/** Detect a horizontal swipe from a delta. Returns 'left' | 'right' | null. */
|
||||
export function detectSwipe(dx, dy, threshold = SWIPE_THRESHOLD) {
|
||||
if (Math.abs(dx) < threshold) return null;
|
||||
if (Math.abs(dy) > Math.abs(dx)) return null;
|
||||
return dx < 0 ? 'left' : 'right';
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the text change produced by a Markdown formatting action.
|
||||
*
|
||||
* @param {string} text Full document text.
|
||||
* @param {number} from Selection start.
|
||||
* @param {number} to Selection end.
|
||||
* @param {string} action One of FORMAT_ACTIONS.
|
||||
* @returns {{changes:{from:number,to:number,insert:string},selection:{anchor:number,head:number}}|null}
|
||||
*/
|
||||
export function formatChange(text, from, to, action) {
|
||||
const selected = text.slice(from, to);
|
||||
|
||||
const wrap = (marker) => {
|
||||
// Toggle off when the selection already carries the markers.
|
||||
if (
|
||||
selected.length >= marker.length * 2 &&
|
||||
selected.startsWith(marker) &&
|
||||
selected.endsWith(marker)
|
||||
) {
|
||||
const inner = selected.slice(marker.length, -marker.length);
|
||||
return {
|
||||
changes: { from, to, insert: inner },
|
||||
selection: { anchor: from, head: from + inner.length },
|
||||
};
|
||||
}
|
||||
const insert = marker + selected + marker;
|
||||
return {
|
||||
changes: { from, to, insert },
|
||||
selection: { anchor: from + marker.length, head: to + marker.length },
|
||||
};
|
||||
};
|
||||
|
||||
switch (action) {
|
||||
case 'bold':
|
||||
return wrap('**');
|
||||
case 'italic':
|
||||
return wrap('*');
|
||||
case 'code':
|
||||
return wrap('`');
|
||||
case 'list': {
|
||||
const lineStart = text.lastIndexOf('\n', from - 1) + 1;
|
||||
let lineEnd = text.indexOf('\n', to);
|
||||
if (lineEnd === -1) lineEnd = text.length;
|
||||
const block = text.slice(lineStart, lineEnd);
|
||||
const lines = block.split('\n');
|
||||
const allList = lines.every((l) => l.startsWith('- '));
|
||||
const prefixed = lines
|
||||
.map((l) => (allList ? l.replace(/^- /, '') : l.startsWith('- ') ? l : '- ' + l))
|
||||
.join('\n');
|
||||
return {
|
||||
changes: { from: lineStart, to: lineEnd, insert: prefixed },
|
||||
selection: { anchor: lineStart, head: lineStart + prefixed.length },
|
||||
};
|
||||
}
|
||||
case 'link': {
|
||||
const insert = `[${selected}](url)`;
|
||||
const urlStart = from + 1 + selected.length + 2;
|
||||
return {
|
||||
changes: { from, to, insert },
|
||||
selection: { anchor: urlStart, head: urlStart + 3 },
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a plain insertion change (used by the paste helper). */
|
||||
export function insertText(from, to, insert) {
|
||||
return {
|
||||
changes: { from, to, insert },
|
||||
selection: { anchor: from + insert.length, head: from + insert.length },
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply a formatting action to a CodeMirror EditorView. */
|
||||
export function applyFormatToView(view, action) {
|
||||
if (!view || !view.state) return false;
|
||||
const { from, to } = view.state.selection.main;
|
||||
const change = formatChange(view.state.doc.toString(), from, to, action);
|
||||
if (!change) return false;
|
||||
view.dispatch({ changes: change.changes, selection: change.selection });
|
||||
if (typeof view.focus === 'function') view.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Apply a formatting action to a plain textarea fallback editor. */
|
||||
export function applyFormatToTextarea(ta, action) {
|
||||
if (!ta) return false;
|
||||
const change = formatChange(ta.value, ta.selectionStart, ta.selectionEnd, action);
|
||||
if (!change) return false;
|
||||
const { from, to, insert } = change.changes;
|
||||
ta.value = ta.value.slice(0, from) + insert + ta.value.slice(to);
|
||||
if (typeof ta.focus === 'function') ta.focus();
|
||||
ta.selectionStart = change.selection.anchor;
|
||||
ta.selectionEnd = change.selection.head;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Runtime helpers ────────────────────────────────────────────────────────
|
||||
|
||||
let _initialized = false;
|
||||
|
||||
function isMobileViewport() {
|
||||
if (typeof window === 'undefined') return false;
|
||||
if (window.matchMedia) return window.matchMedia('(max-width: 768px)').matches;
|
||||
return window.innerWidth <= 768;
|
||||
}
|
||||
|
||||
async function showToastSafe(message, type) {
|
||||
try {
|
||||
const ui = await import('./ui.js');
|
||||
if (ui && typeof ui.showToast === 'function') ui.showToast(message, type);
|
||||
} catch (_) {
|
||||
/* UI module unavailable (tests) — silent */
|
||||
}
|
||||
}
|
||||
|
||||
function currentEditorFontSize() {
|
||||
try {
|
||||
const saved = parseFloat(localStorage.getItem(FONT_KEY));
|
||||
if (Number.isFinite(saved)) return clampZoom(saved);
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
return DEFAULT_EDITOR_FONT;
|
||||
}
|
||||
|
||||
/** Apply a font size to every CodeMirror scroller + fallback textarea. */
|
||||
export function applyEditorFontSize(size) {
|
||||
const s = clampZoom(size);
|
||||
try {
|
||||
localStorage.setItem(FONT_KEY, String(s));
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
const px = s + 'px';
|
||||
document.querySelectorAll('.cm-editor .cm-scroller').forEach((el) => {
|
||||
if (el.style.fontSize !== px) el.style.fontSize = px;
|
||||
});
|
||||
if (state.fallbackEditorEl) state.fallbackEditorEl.style.fontSize = px;
|
||||
return s;
|
||||
}
|
||||
|
||||
function bumpFontSize(delta) {
|
||||
return applyEditorFontSize(currentEditorFontSize() + delta);
|
||||
}
|
||||
|
||||
// ── Formatting toolbar + clipboard ─────────────────────────────────────────
|
||||
|
||||
function handleFormat(action) {
|
||||
if (state.editorView) {
|
||||
applyFormatToView(state.editorView, action);
|
||||
} else if (state.fallbackEditorEl) {
|
||||
applyFormatToTextarea(state.fallbackEditorEl, action);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePaste() {
|
||||
let text = '';
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.readText) {
|
||||
text = await navigator.clipboard.readText();
|
||||
}
|
||||
} catch (_) {
|
||||
// iOS Safari / permission denied — fall through to the hint.
|
||||
}
|
||||
if (!text) {
|
||||
showToastSafe(t('mobile_editor.paste_hint'), 'info');
|
||||
return;
|
||||
}
|
||||
if (state.editorView) {
|
||||
const { from, to } = state.editorView.state.selection.main;
|
||||
const change = insertText(from, to, text);
|
||||
state.editorView.dispatch({ changes: change.changes, selection: change.selection });
|
||||
if (typeof state.editorView.focus === 'function') state.editorView.focus();
|
||||
} else if (state.fallbackEditorEl) {
|
||||
const ta = state.fallbackEditorEl;
|
||||
const from = ta.selectionStart;
|
||||
const to = ta.selectionEnd;
|
||||
ta.value = ta.value.slice(0, from) + text + ta.value.slice(to);
|
||||
ta.focus();
|
||||
ta.selectionStart = ta.selectionEnd = from + text.length;
|
||||
}
|
||||
}
|
||||
|
||||
function makeButton(id, label, title, onClick) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.id = id;
|
||||
btn.className = 'me-btn';
|
||||
btn.title = title;
|
||||
btn.setAttribute('aria-label', title);
|
||||
btn.textContent = label;
|
||||
btn.addEventListener('click', onClick);
|
||||
return btn;
|
||||
}
|
||||
|
||||
function buildFormattingToolbar() {
|
||||
const modal = document.getElementById('editor-modal');
|
||||
if (!modal || document.getElementById('me-toolbar')) return;
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.id = 'me-toolbar';
|
||||
bar.className = 'me-toolbar';
|
||||
bar.setAttribute('role', 'toolbar');
|
||||
bar.setAttribute('aria-label', t('mobile_editor.toolbar'));
|
||||
|
||||
const formats = [
|
||||
{ action: 'bold', label: 'B', title: t('mobile_editor.bold') },
|
||||
{ action: 'italic', label: 'I', title: t('mobile_editor.italic') },
|
||||
{ action: 'code', label: '<>', title: t('mobile_editor.code') },
|
||||
{ action: 'list', label: '•', title: t('mobile_editor.list') },
|
||||
{ action: 'link', label: '🔗', title: t('mobile_editor.link') },
|
||||
];
|
||||
formats.forEach((f) => {
|
||||
const btn = makeButton('me-fmt-' + f.action, f.label, f.title, () => handleFormat(f.action));
|
||||
btn.dataset.action = f.action;
|
||||
btn.classList.add('me-btn-format');
|
||||
bar.appendChild(btn);
|
||||
});
|
||||
|
||||
const sep = document.createElement('span');
|
||||
sep.className = 'me-sep';
|
||||
bar.appendChild(sep);
|
||||
|
||||
bar.appendChild(
|
||||
makeButton('me-font-minus', 'A−', t('mobile_editor.font_smaller'), () => bumpFontSize(-1)),
|
||||
);
|
||||
bar.appendChild(
|
||||
makeButton('me-font-plus', 'A+', t('mobile_editor.font_larger'), () => bumpFontSize(1)),
|
||||
);
|
||||
|
||||
const sep2 = document.createElement('span');
|
||||
sep2.className = 'me-sep';
|
||||
bar.appendChild(sep2);
|
||||
|
||||
const pasteBtn = makeButton('me-paste', '📋', t('mobile_editor.paste'), handlePaste);
|
||||
pasteBtn.classList.add('me-btn-paste');
|
||||
bar.appendChild(pasteBtn);
|
||||
|
||||
modal.appendChild(bar);
|
||||
}
|
||||
|
||||
// ── Editor height resize + pinch zoom ──────────────────────────────────────
|
||||
|
||||
function applySavedHeight(bodyEl) {
|
||||
if (!isMobileViewport()) return;
|
||||
try {
|
||||
const saved = parseInt(localStorage.getItem(HEIGHT_KEY), 10);
|
||||
if (Number.isFinite(saved) && saved >= MIN_EDITOR_HEIGHT) {
|
||||
bodyEl.style.flex = '0 0 ' + saved + 'px';
|
||||
bodyEl.style.height = saved + 'px';
|
||||
}
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function initHeightResize(bodyEl) {
|
||||
if (!bodyEl || document.getElementById('me-resize-handle')) return;
|
||||
const handle = document.createElement('div');
|
||||
handle.id = 'me-resize-handle';
|
||||
handle.className = 'me-resize-handle';
|
||||
handle.setAttribute('role', 'separator');
|
||||
handle.setAttribute('aria-orientation', 'horizontal');
|
||||
handle.title = t('mobile_editor.resize');
|
||||
(bodyEl.parentElement || bodyEl).insertBefore(handle, bodyEl.nextSibling);
|
||||
|
||||
let dragging = false;
|
||||
let startY = 0;
|
||||
let startH = 0;
|
||||
|
||||
handle.addEventListener('pointerdown', (e) => {
|
||||
dragging = true;
|
||||
startY = e.clientY;
|
||||
startH = bodyEl.getBoundingClientRect().height;
|
||||
if (handle.setPointerCapture) {
|
||||
try {
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
e.preventDefault();
|
||||
});
|
||||
handle.addEventListener('pointermove', (e) => {
|
||||
if (!dragging) return;
|
||||
const maxH = Math.max(MIN_EDITOR_HEIGHT, (window.innerHeight || 800) - 140);
|
||||
const h = Math.min(maxH, Math.max(MIN_EDITOR_HEIGHT, startH + (e.clientY - startY)));
|
||||
bodyEl.style.flex = '0 0 ' + h + 'px';
|
||||
bodyEl.style.height = h + 'px';
|
||||
});
|
||||
const stop = () => {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
try {
|
||||
localStorage.setItem(HEIGHT_KEY, String(Math.round(bodyEl.getBoundingClientRect().height)));
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
handle.addEventListener('pointerup', stop);
|
||||
handle.addEventListener('pointercancel', stop);
|
||||
}
|
||||
|
||||
function initPinchZoom(bodyEl) {
|
||||
if (!bodyEl) return;
|
||||
let startDist = 0;
|
||||
let startSize = 0;
|
||||
const distance = (touches) =>
|
||||
Math.hypot(
|
||||
touches[0].clientX - touches[1].clientX,
|
||||
touches[0].clientY - touches[1].clientY,
|
||||
);
|
||||
|
||||
bodyEl.addEventListener(
|
||||
'touchstart',
|
||||
(e) => {
|
||||
if (e.touches.length === 2) {
|
||||
startDist = distance(e.touches);
|
||||
startSize = currentEditorFontSize();
|
||||
}
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
bodyEl.addEventListener(
|
||||
'touchmove',
|
||||
(e) => {
|
||||
if (e.touches.length === 2 && startDist > 0) {
|
||||
applyEditorFontSize(startSize * (distance(e.touches) / startDist));
|
||||
if (e.cancelable) e.preventDefault();
|
||||
}
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
bodyEl.addEventListener('touchend', () => {
|
||||
startDist = 0;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Bottom sheet (backlinks / TOC) ─────────────────────────────────────────
|
||||
|
||||
function ensureSheet() {
|
||||
let sheet = document.getElementById('me-sheet');
|
||||
if (sheet) return sheet;
|
||||
|
||||
sheet = document.createElement('div');
|
||||
sheet.id = 'me-sheet';
|
||||
sheet.className = 'me-sheet';
|
||||
sheet.setAttribute('role', 'dialog');
|
||||
sheet.setAttribute('aria-modal', 'true');
|
||||
|
||||
const handle = document.createElement('div');
|
||||
handle.className = 'me-sheet-handle';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'me-sheet-header';
|
||||
const title = document.createElement('span');
|
||||
title.className = 'me-sheet-title';
|
||||
const close = document.createElement('button');
|
||||
close.type = 'button';
|
||||
close.className = 'me-sheet-close';
|
||||
close.textContent = '✕';
|
||||
close.setAttribute('aria-label', t('mobile_editor.close'));
|
||||
header.appendChild(title);
|
||||
header.appendChild(close);
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'me-sheet-list';
|
||||
|
||||
sheet.appendChild(handle);
|
||||
sheet.appendChild(header);
|
||||
sheet.appendChild(list);
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'me-sheet-overlay';
|
||||
overlay.className = 'me-sheet-overlay';
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
document.body.appendChild(sheet);
|
||||
|
||||
close.addEventListener('click', () => closeSheet(sheet));
|
||||
overlay.addEventListener('click', () => closeSheet(sheet));
|
||||
return sheet;
|
||||
}
|
||||
|
||||
function openSheet(sheet) {
|
||||
sheet.classList.add('open');
|
||||
const overlay = document.getElementById('me-sheet-overlay');
|
||||
if (overlay) overlay.classList.add('visible');
|
||||
}
|
||||
|
||||
function closeSheet(sheet) {
|
||||
if (sheet) sheet.classList.remove('open');
|
||||
const overlay = document.getElementById('me-sheet-overlay');
|
||||
if (overlay) overlay.classList.remove('visible');
|
||||
}
|
||||
|
||||
function renderSheetItems(sheet, items, emptyLabel) {
|
||||
const list = sheet.querySelector('.me-sheet-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = '';
|
||||
if (!items.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'me-sheet-empty';
|
||||
empty.textContent = emptyLabel;
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
items.forEach((item) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'me-sheet-item';
|
||||
btn.textContent = item.label;
|
||||
btn.addEventListener('click', item.onClick);
|
||||
list.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
async function openBacklinksSheet() {
|
||||
const vault = state.currentVault;
|
||||
const path = state.currentPath;
|
||||
if (!vault || !path) return;
|
||||
const sheet = ensureSheet();
|
||||
sheet.querySelector('.me-sheet-title').textContent = t('mobile_editor.backlinks');
|
||||
openSheet(sheet);
|
||||
renderSheetItems(sheet, [], t('mobile_editor.loading'));
|
||||
try {
|
||||
const { api } = await import('./auth.js');
|
||||
const data = await api(
|
||||
`/api/file/${encodeURIComponent(vault)}/backlinks?path=${encodeURIComponent(path)}`,
|
||||
);
|
||||
const backlinks = data.backlinks || [];
|
||||
renderSheetItems(
|
||||
sheet,
|
||||
backlinks.map((bl) => ({
|
||||
label: bl.title || bl.path.split('/').pop().replace(/\.md$/i, ''),
|
||||
onClick: async () => {
|
||||
closeSheet(sheet);
|
||||
if (window.TabManager && typeof window.TabManager.openPreview === 'function') {
|
||||
window.TabManager.openPreview(bl.vault, bl.path);
|
||||
} else {
|
||||
const viewer = await import('./viewer.js');
|
||||
viewer.openFile(bl.vault, bl.path);
|
||||
}
|
||||
},
|
||||
})),
|
||||
t('mobile_editor.no_backlinks'),
|
||||
);
|
||||
} catch (_) {
|
||||
renderSheetItems(sheet, [], t('mobile_editor.error'));
|
||||
}
|
||||
}
|
||||
|
||||
function openTocSheet() {
|
||||
const sheet = ensureSheet();
|
||||
sheet.querySelector('.me-sheet-title').textContent = t('mobile_editor.toc');
|
||||
const headings = state.headingsCache || [];
|
||||
renderSheetItems(
|
||||
sheet,
|
||||
headings.map((h) => ({
|
||||
label: h.text,
|
||||
onClick: () => {
|
||||
closeSheet(sheet);
|
||||
const target = document.getElementById(h.id);
|
||||
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
},
|
||||
})),
|
||||
t('mobile_editor.no_headings'),
|
||||
);
|
||||
openSheet(sheet);
|
||||
}
|
||||
|
||||
// ── Reading mode + page navigation ─────────────────────────────────────────
|
||||
|
||||
export function isReadingMode() {
|
||||
return document.body.classList.contains('reading-mode');
|
||||
}
|
||||
|
||||
export function toggleReadingMode(force) {
|
||||
const on = typeof force === 'boolean' ? force : !isReadingMode();
|
||||
document.body.classList.toggle('reading-mode', on);
|
||||
const btn = document.getElementById('me-reading-btn');
|
||||
if (btn) btn.setAttribute('aria-pressed', String(on));
|
||||
try {
|
||||
localStorage.setItem(READING_KEY, on ? '1' : '0');
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
if (on) {
|
||||
if (document.documentElement.requestFullscreen) {
|
||||
const p = document.documentElement.requestFullscreen();
|
||||
if (p && typeof p.catch === 'function') p.catch(() => {});
|
||||
}
|
||||
} else if (document.fullscreenElement && document.exitFullscreen) {
|
||||
const p = document.exitFullscreen();
|
||||
if (p && typeof p.catch === 'function') p.catch(() => {});
|
||||
}
|
||||
} catch (_) {
|
||||
/* Fullscreen unsupported (iOS) — CSS reading mode still applies. */
|
||||
}
|
||||
return on;
|
||||
}
|
||||
|
||||
async function navigateAdjacentFile(direction) {
|
||||
const vault = state.currentVault;
|
||||
const path = state.currentPath;
|
||||
if (!vault || !path) return;
|
||||
const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '';
|
||||
try {
|
||||
const { api } = await import('./auth.js');
|
||||
const data = await api(
|
||||
`/api/vault/${encodeURIComponent(vault)}/files?dir=${encodeURIComponent(dir)}&recursive=false&limit=500`,
|
||||
);
|
||||
const files = (data.files || [])
|
||||
.filter((f) => /\.md$/i.test(f.path))
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
const idx = files.findIndex((f) => f.path === path);
|
||||
if (idx === -1) return;
|
||||
const next = files[idx + (direction > 0 ? 1 : -1)];
|
||||
if (!next) {
|
||||
showToastSafe(
|
||||
direction > 0 ? t('mobile_editor.no_next') : t('mobile_editor.no_prev'),
|
||||
'info',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const viewer = await import('./viewer.js');
|
||||
viewer.openFile(vault, next.path);
|
||||
} catch (_) {
|
||||
/* network / offline — silent */
|
||||
}
|
||||
}
|
||||
|
||||
function buildReadingControls() {
|
||||
if (document.getElementById('me-reading-btn')) return;
|
||||
const enter = document.createElement('button');
|
||||
enter.id = 'me-reading-btn';
|
||||
enter.type = 'button';
|
||||
enter.className = 'me-reading-btn';
|
||||
enter.title = t('mobile_editor.reading_enter');
|
||||
enter.setAttribute('aria-label', t('mobile_editor.reading_enter'));
|
||||
enter.setAttribute('aria-pressed', String(isReadingMode()));
|
||||
enter.textContent = '📖';
|
||||
enter.addEventListener('click', () => toggleReadingMode(true));
|
||||
|
||||
const exit = document.createElement('button');
|
||||
exit.id = 'me-reading-exit';
|
||||
exit.type = 'button';
|
||||
exit.className = 'me-reading-exit';
|
||||
exit.title = t('mobile_editor.reading_exit');
|
||||
exit.setAttribute('aria-label', t('mobile_editor.reading_exit'));
|
||||
exit.textContent = '✕';
|
||||
exit.addEventListener('click', () => toggleReadingMode(false));
|
||||
|
||||
document.body.appendChild(enter);
|
||||
document.body.appendChild(exit);
|
||||
}
|
||||
|
||||
// ── Swipe gestures ─────────────────────────────────────────────────────────
|
||||
|
||||
function initSwipeGestures() {
|
||||
const area = document.getElementById('content-area');
|
||||
if (!area || area.dataset.meSwipe === '1') return;
|
||||
area.dataset.meSwipe = '1';
|
||||
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let tracking = false;
|
||||
|
||||
area.addEventListener(
|
||||
'touchstart',
|
||||
(e) => {
|
||||
if (e.touches.length !== 1) {
|
||||
tracking = false;
|
||||
return;
|
||||
}
|
||||
tracking = true;
|
||||
startX = e.touches[0].clientX;
|
||||
startY = e.touches[0].clientY;
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
|
||||
area.addEventListener(
|
||||
'touchend',
|
||||
(e) => {
|
||||
if (!tracking) return;
|
||||
tracking = false;
|
||||
const touch = e.changedTouches[0];
|
||||
if (!touch) return;
|
||||
const direction = detectSwipe(touch.clientX - startX, touch.clientY - startY);
|
||||
if (!direction) return;
|
||||
if (isReadingMode()) {
|
||||
navigateAdjacentFile(direction === 'left' ? 1 : -1);
|
||||
} else if (direction === 'left') {
|
||||
openBacklinksSheet();
|
||||
} else {
|
||||
openTocSheet();
|
||||
}
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function initMobileEditor() {
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
|
||||
buildFormattingToolbar();
|
||||
buildReadingControls();
|
||||
initSwipeGestures();
|
||||
|
||||
const bodyEl = document.getElementById('editor-body');
|
||||
if (bodyEl) {
|
||||
initPinchZoom(bodyEl);
|
||||
initHeightResize(bodyEl);
|
||||
|
||||
const modal = document.getElementById('editor-modal');
|
||||
if (modal && typeof MutationObserver !== 'undefined') {
|
||||
const observer = new MutationObserver(() => {
|
||||
if (modal.classList.contains('active')) {
|
||||
applyEditorFontSize(currentEditorFontSize());
|
||||
applySavedHeight(bodyEl);
|
||||
}
|
||||
});
|
||||
observer.observe(modal, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
// CodeMirror is recreated on every openEditor()/file switch — re-apply the
|
||||
// persisted zoom whenever a new scroller is inserted. Observe direct
|
||||
// children only so per-keystroke DOM mutations don't retrigger this.
|
||||
const bodyObserver = new MutationObserver(() => {
|
||||
if (bodyEl.querySelector('.cm-editor .cm-scroller')) {
|
||||
applyEditorFontSize(currentEditorFontSize());
|
||||
}
|
||||
});
|
||||
bodyObserver.observe(bodyEl, { childList: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the reading-mode class in sync when the user exits fullscreen (ESC).
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
if (!document.fullscreenElement && isReadingMode()) {
|
||||
document.body.classList.remove('reading-mode');
|
||||
try {
|
||||
localStorage.setItem(READING_KEY, '0');
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Restore a previous reading-mode session on reload (mobile only).
|
||||
try {
|
||||
if (isMobileViewport() && localStorage.getItem(READING_KEY) === '1') {
|
||||
document.body.classList.add('reading-mode');
|
||||
}
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export { isMobileViewport };
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
`<!DOCTYPE html><html><body>
|
||||
<div id="content-area"><div class="md-content"></div></div>
|
||||
<div class="editor-modal active" id="editor-modal">
|
||||
<div class="editor-container">
|
||||
<div class="editor-body editor-body-cm" id="editor-body">
|
||||
<div class="cm-editor"><div class="cm-scroller"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>`,
|
||||
{ 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);
|
||||
Reference in New Issue
Block a user