feat(mobile): ruban d'edition style Obsidian + correctifs mobile (#83, BUG-017 a BUG-020)
- #83: ruban horizontal defilable ancre au-dessus du clavier, commandes etendues (titres, barre, surligne, code bloc, citation, listes, cases, wikilinks, tags, pieces jointes, undo/redo, colle, police) et personnalisation persistee via l'icone engrenage - BUG-017: cartes du tableau de bord compressibles (min-width:0) - BUG-018: en-tete editeur mobile compact, boutons Annuler/Sauvegarder accessibles, barre IA plus tactile - BUG-019: ancrage du ruban via visualViewport (--kb-offset) - BUG-020: suppression de la double barre de defilement Tests: mobile-editor 35/35, ai 50/50, 9 suites JSDOM, validate-imports, pytest 961 passed / 6 skipped, ruff + mypy 0.
This commit is contained in:
@@ -54,6 +54,16 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#83 Barre d'outils d'édition mobile — ruban style Obsidian Android** — la barre de mise en
|
||||
forme flottante est remplacée par un **ruban horizontal défilable** ancré juste au-dessus du
|
||||
clavier virtuel : fond anthracite aux coins arrondis, insertion/enrobage au curseur ou sur la
|
||||
sélection. Commandes : annuler/refaire, `[[ ]]` (lien interne), fichiers/modèles, tag `#`,
|
||||
pièce jointe, H1–H6, gras, italique, barré (`~~`), surligné (`==`), code en ligne/bloc, citation,
|
||||
lien externe, listes à puces/numérotées, case à cocher, indenter/désindenter, coller, A−/A+.
|
||||
Une icône **⚙** ouvre un panneau de personnalisation (ajouter / retirer / réordonner, persistance
|
||||
`localStorage`). i18n FR/EN. Helpers purs exportés (`formatChange`, `normalizeRibbonCommands`,
|
||||
`moveRibbonCommand`). Tests JSDOM `tests/frontend/mobile-editor.test.mjs` (+13) et E2E mobile.
|
||||
|
||||
- **#82 Assistant IA — menu `@` instantané** — nouvel endpoint `GET /api/vault/{vault}/paths`
|
||||
(liste plate et plafonnée de l'index des chemins). Le frontend la récupère **une fois par vault**
|
||||
(préchargée à l'ouverture du panneau) puis filtre **côté client** pendant la frappe : l'affichage
|
||||
@@ -291,6 +301,26 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-017 — Accueil mobile : tuiles « Favoris » / « Récents » mal dimensionnées** — les cartes
|
||||
de tableau de bord débordaient horizontalement à cause d'un titre/chemin `nowrap` : ajout de
|
||||
`min-width: 0` et `overflow: hidden` sur `.dashboard-card` (et ses en-têtes/pieds) pour que la
|
||||
grille mobile (1 colonne) respecte la largeur du viewport. Les tuiles « Partagés » étaient déjà
|
||||
conformes.
|
||||
|
||||
- **BUG-018 — Éditeur mobile : boutons « Annuler » / « Sauvegarder » inaccessibles** — l'en-tête
|
||||
cumulait marque, titre, nom de fichier et actions, poussant les boutons hors écran. Sur mobile,
|
||||
les éléments secondaires (marque, séparateur, espaceur, présence) sont masqués, les champs
|
||||
titre/nom de fichier deviennent compressibles (`flex`, `min-width: 0`) et le libellé « Saved »
|
||||
est masqué (le point d'état reste). La barre d'outils IA reçoit des cibles tactiles plus grandes.
|
||||
|
||||
- **BUG-019 — Éditeur mobile : barre d'outils masquée par le clavier** — le ruban est désormais
|
||||
ancré via `window.visualViewport` (`--kb-offset`) pour rester juste au-dessus du clavier, quel
|
||||
que soit le défilement, et le scroller CodeMirror reçoit un `padding-bottom` pour ne pas cacher
|
||||
le curseur.
|
||||
|
||||
- **BUG-020 — Éditeur mobile : double barre de défilement** — `.editor-body` passe en
|
||||
`overflow: hidden` + colonne flex ; seul le scroller CodeMirror défile.
|
||||
|
||||
- **BUG-016 — Mobile : le bouton « mode lecture » recouvre le bouton d'envoi de l'assistant** —
|
||||
le bouton flottant `📖` (`#me-reading-btn`, `z-index: 890`) passait au-dessus du panneau
|
||||
assistant plein écran sur mobile (`z-index: 100`) et masquait le bouton d'envoi `✈️`. Il
|
||||
|
||||
@@ -126,10 +126,10 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| *BUG-014* | Assistant IA : les flèches ↑/↓ ne naviguent pas dans les menus `/` et `@` | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Ouvrir `/`, puis ↑/↓ (y compris après un clic hors zone de texte) | Navigation gérée au niveau du **panneau en phase de capture** (`panel.addEventListener('keydown', ..., true)`), donc indépendante du focus | Le `keydown` n'était écouté que sur la zone de texte ; dès que le focus changeait, les flèches étaient ignorées. Tests : `tests/frontend/ai.test.mjs` (+1) |
|
||||
| *BUG-015* | Assistant IA : la ligne sélectionnée des menus `/` et `@` est invisible au clavier | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/style.css` | Ouvrir `/` ou `@`, naviguer avec ↑/↓ | État `.active` en `--bg-hover` + barre d'accent à gauche (`inset 3px 0 0 var(--accent)`) au lieu de `--surface2` | `--surface2` est identique à `--bg-primary` (fond du menu) en thème sombre : la sélection ne se voyait pas. Idem pour la liste de modèles |
|
||||
| *BUG-016* | Mobile : le bouton « mode lecture » flottant recouvre le bouton d'envoi de l'assistant IA | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/mobile-editor.js`, `frontend/style.css` | Mobile, sans fichier ouvert : ouvrir l'assistant puis constater que `📖` masque `✈️` | `updateReadingButtonVisibility()` n'affiche `#me-reading-btn` que si `state.currentPath` est défini **et** que le panneau assistant est fermé ; resynchronisé à chaque mutation de `#content-area` et sur `bookslm:opened`/`bookslm:closed` ; règle `.me-reading-btn[hidden]{display:none}` | Le bouton flottant (z-index 890) passait au-dessus du panneau plein écran mobile (z-index 100). Tests : `tests/frontend/mobile-editor.test.mjs` (+2) |
|
||||
| *BUG-017* | Accueil mobile : les tuiles de « Favoris » et « Récents » ne s'affichent pas correctement en largeur (l'onglet « Partagés » s'affiche correctement) | 🔴 ouvert | P2 | 📱 frontend | IA | `frontend/js/dashboard.js`, `frontend/js/viewer.js`, `frontend/style.css` | Mobile : page d'accueil → onglets Favoris / Récents | — | Grille de tuiles non adaptée au viewport (largeur / nombre de colonnes) ; l'onglet « Partagés » (`.shared-card`) est conforme |
|
||||
| *BUG-018* | Éditeur mobile : mise en page inadaptée — boutons « Annuler » / « Sauvegarder » inaccessibles et barre d'outils IA non optimisée | 🔴 ouvert | P1 | 📱 frontend | IA | `frontend/index.html`, `frontend/style.css`, `frontend/js/ai.js` | Mobile : ouvrir un fichier → barre d'outils de l'éditeur puis panneau assistant | — | Boutons de l'éditeur hors écran / masqués sur petit écran ; barre d'outils IA trop dense ou mal positionnée |
|
||||
| *BUG-019* | Éditeur mobile : la barre d'outils du bas n'est pas toujours visible au-dessus du clavier quand le curseur n'est pas en bas de l'éditeur | 🔴 ouvert | P1 | 📱 frontend | IA | `frontend/js/mobile-editor.js`, `frontend/style.css` | Mobile : ouvrir un fichier, placer le curseur au milieu du document, afficher le clavier | — | La barre ne suit pas la position du curseur / n'est pas ancrée au-dessus du clavier virtuel |
|
||||
| *BUG-020* | Éditeur mobile : deux barres de défilement verticales à droite de la page en mode édition | 🔴 ouvert | P3 | 📱 frontend | IA | `frontend/style.css` | Mobile : ouvrir l'éditeur et observer le bord droit | — | Double scrollbar (conteneur de l'éditeur + `.cm-scroller`) peu naturelle pour un éditeur |
|
||||
| *BUG-017* | Accueil mobile : les tuiles de « Favoris » et « Récents » ne s'affichent pas correctement en largeur (l'onglet « Partagés » s'affiche correctement) | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/style.css` | Mobile : page d'accueil → onglets Favoris / Récents | `frontend/style.css` : `min-width:0` + `overflow:hidden` sur `.dashboard-card` (et header/footer) → la grille 1 colonne ne déborde plus | Cause : titre/chemin `nowrap` forçaient la largeur mini de la carte |
|
||||
| *BUG-018* | Éditeur mobile : mise en page inadaptée — boutons « Annuler » / « Sauvegarder » inaccessibles et barre d'outils IA non optimisée | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/style.css` | Mobile : ouvrir un fichier → barre d'outils de l'éditeur puis panneau assistant | `frontend/style.css` : en-tête mobile compact (marque/séparateur/espaceur/présence masqués, champs compressibles, libellé « Saved » masqué) + cibles tactiles `.ai-toolbar-btn` agrandies | Les 3 boutons d'action restent accessibles ; voir aussi #83 pour le ruban |
|
||||
| *BUG-019* | Éditeur mobile : la barre d'outils du bas n'est pas toujours visible au-dessus du clavier quand le curseur n'est pas en bas de l'éditeur | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/mobile-editor.js`, `frontend/style.css` | Mobile : ouvrir un fichier, placer le curseur au milieu du document, afficher le clavier | `initKeyboardAnchor()` utilise `window.visualViewport` (`--kb-offset`) pour ancrer le ruban au-dessus du clavier ; `padding-bottom` sur `.cm-scroller` | Fonctionne quel que soit le défilement / la position du curseur |
|
||||
| *BUG-020* | Éditeur mobile : deux barres de défilement verticales à droite de la page en mode édition | 🟢 corrigé | P3 | 📱 frontend | IA | `frontend/style.css` | Mobile : ouvrir l'éditeur et observer le bord droit | `frontend/style.css` : `.editor-body` en `overflow:hidden` + colonne flex ; seul `.cm-scroller` défile | Une seule barre de défilement |
|
||||
| *BUG-021* | [🔴 CRITIQUE] XSS stocké via le rendu markdown (`escape=False`) | 🟢 corrigé | P0 | 🔐 sécurité | IA | `backend/services/sanitizer.py`, `backend/main.py` | Ouvrir une note contenant `<img src=x onerror=alert(1)>` | `backend/services/sanitizer.py` (whitelist stdlib) appliqué après `_add_heading_ids` dans `_render_markdown` ; tests `tests/test_security_hardening.py` | HTML brut d'un vault injecté dans le DOM → JS dans le navigateur de chaque utilisateur. DOMPurify client non ajouté (défense serveur suffisante) |
|
||||
| *BUG-022* | [🔴 CRITIQUE] XSS stocké sur la page publique `/s/{token}` (title + frontmatter non échappés) | 🟢 corrigé | P0 | 🔐 sécurité | IA | `backend/main.py` | Partager une note dont le frontmatter contient un `title` avec `<script>` | `backend/main.py` : `html.escape()` sur `title`/frontmatter + JSON échappé (`\u003c`) pour le bloc `<script>` et `a.download` | `a.download="{title}.md"` se trouvait dans un bloc script → breakout JS. Test d'intégration dans `tests/test_security_hardening.py` |
|
||||
| *BUG-023* | [🔴 CRITIQUE] Brute-force MFA sans rate-limit ni verrouillage | 🟢 corrigé | P0 | 🔐 sécurité | IA | `backend/auth/router.py` | `POST /api/auth/mfa/totp/verify` en boucle avec des codes aléatoires | `_enforce_mfa_rate_limit` + `_record_mfa_failure` sur `totp/verify`, `recovery`, `webauthn/verify` (IP + compte + lockout) | TOTP 6 chiffres brute-forceable ; le login était protégé, pas le second facteur |
|
||||
@@ -185,6 +185,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-09-13 | BUG-017, BUG-018, BUG-019, BUG-020, #83 | Enregistrement | `docs/ISSUES_TODOLIST.md`, `docs/ROADMAP.md` | Signalements mobile consignés : accueil (tuiles Favoris/Récents), éditeur (boutons Annuler/Sauvegarder inaccessibles + barre IA), visibilité de la barre d'outils au-dessus du clavier, double barre de défilement. Feature #83 ajoutée : barre d'outils d'édition mobile style Obsidian Android. | 🔴 ouvert (à traiter) |
|
||||
| 2026-09-13 | Revue sécurité statique (BUG-021 → BUG-040, roadmap #84 → #87) | Enregistrement | `docs/ISSUES_TODOLIST.md`, `docs/ROADMAP.md` | Revue statique 2026-09-13 consignée : XSS markdown (`escape=False`) + page de partage publique, brute-force MFA, traversal par préfixe `paths.py`, ReDoS, SSRF webhooks, cycle de vie des sessions, politique de mot de passe, races `users.json`, audits IP, rate-limit, indexation symlinks, recherche O(N), CSP. BUG-021→BUG-040 ouverts au registre ; roadmap #84→#87 (consolidation/sécurité, refonte architecturale, performance, CI/CD). | 🔴 ouvert (à traiter) |
|
||||
| 2026-09-13 | BUG-021 → BUG-034 (#84 phase 1) | Correction | `backend/services/sanitizer.py`, `backend/services/paths.py`, `backend/services/net.py`, `backend/services/regex_safety.py`, `backend/main.py`, `backend/search.py`, `backend/webhooks.py`, `backend/indexer.py`, `backend/ratelimit.py`, `backend/auth/{router,user_store,password,jwt_handler,middleware}.py`, `frontend/js/{auth,admin,sync}.js`, `tests/test_security_hardening.py`, `tests/test_auth_api.py` | Sanitizer XSS serveur (markdown + page de partage), rate-limit/lockout MFA, isolation vaults par segments, caps regex (ReDoS), SSRF webhooks + secrets externalisés, rotation/révocation des jetons, politique de mot de passe + invalidation des sessions, verrous `users.json`, IP réelle dans les audits, rate-limit par compte, symlinks d'index ignorés, recherche simple via inverted index, token en mémoire + cookie HttpOnly + CSP durcie. Vérifié : pytest 961 passed / 6 skipped, ruff 0, mypy 0, tests frontend 36 modules + 9 JSDOM suites verts. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-13 | BUG-017 → BUG-020, #83 | Correction + feature | `frontend/js/mobile-editor.js`, `frontend/style.css`, `frontend/index.html`, `frontend/locales/{fr,en}.json`, `tests/frontend/mobile-editor.test.mjs`, `tests/e2e/mobile-editor.spec.js` | BUG-017 : cartes de tableau de bord compressibles (`min-width:0`) ; BUG-018 : en-tête éditeur mobile compact + barre IA tactile ; BUG-019 : ancrage du ruban via `visualViewport` ; BUG-020 : suppression de la double scrollbar. #83 : ruban d'édition horizontal défilable style Obsidian (commandes étendues + personnalisation ⚙ persistée). Vérifié : tests frontend 35/35 (mobile) + 50/50 (IA) + 9 JSDOM suites. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+9
-8
@@ -65,17 +65,18 @@
|
||||
### 83. Barre d'outils d'édition mobile — style Obsidian Android
|
||||
|
||||
- **Effort :** 3-5 jours | **Impact :** 🟡 | **Zone :** frontend (mobile)
|
||||
- **Statut :** ✅ livré — ruban horizontal défilable ancré au-dessus du clavier, commandes étendues et personnalisation persistée. Détail : [archive/COMPLETED_v1-v2.md](./archive/COMPLETED_v1-v2.md) (section #83).
|
||||
- **Description :** remplacer la barre de mise en forme Markdown actuelle par un **ruban horizontal
|
||||
défilable** ancré juste au-dessus du clavier virtuel, reprenant l'ergonomie de l'app Android
|
||||
Obsidian : fond anthracite aux coins arrondis, insertion/enrobage de la syntaxe au curseur ou sur
|
||||
la sélection, et personnalisation des commandes via une icône clé à molette.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Ruban horizontal défilable (glissement tactile gauche/droite) ancré au-dessus du clavier
|
||||
- [ ] Actions rapides : annuler, refaire, `[[ ]]` (lien interne), modèle/fichiers, tag `#`, pièce jointe
|
||||
- [ ] Formatage : H1–H6, gras, italique, barré (`~~`), surligné (`==`), code en ligne/bloc, citation (`>`)
|
||||
- [ ] Liens externes, listes à puces/numérotées, case à cocher (`- [ ]`), indenter / désindenter
|
||||
- [ ] Personnalisation (clé à molette) : ajouter / supprimer / réordonner les commandes
|
||||
- [ ] i18n FR/EN + tests frontend (helpers purs) + E2E mobile
|
||||
- [x] Ruban horizontal défilable (glissement tactile gauche/droite) ancré au-dessus du clavier
|
||||
- [x] Actions rapides : annuler, refaire, `[[ ]]` (lien interne), modèle/fichiers, tag `#`, pièce jointe
|
||||
- [x] Formatage : H1–H6, gras, italique, barré (`~~`), surligné (`==`), code en ligne/bloc, citation (`>`)
|
||||
- [x] Liens externes, listes à puces/numérotées, case à cocher (`- [ ]`), indenter / désindenter
|
||||
- [x] Personnalisation (clé à molette) : ajouter / supprimer / réordonner les commandes
|
||||
- [x] i18n FR/EN + tests frontend (helpers purs) + E2E mobile
|
||||
|
||||
---
|
||||
|
||||
@@ -166,6 +167,7 @@
|
||||
| 70 | Recherche sémantique — Embeddings vectoriels (hybride TF-IDF + RRF) | 2.3.0 | [features/semantic-search.md](./features/semantic-search.md) |
|
||||
| 77 | Application Desktop native — Tauri | 🔵 en cours | [features/desktop-tauri.md](./features/desktop-tauri.md) |
|
||||
| 84 | Consolidation & sécurité — revue statique 2026-09-13 (phase 1, BUG-021→034) | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) |
|
||||
| 83 | Barre d'outils d'édition mobile — ruban style Obsidian Android | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) |
|
||||
|
||||
---
|
||||
|
||||
@@ -175,10 +177,9 @@
|
||||
|---|---|---|
|
||||
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–82 | ~107 jours réalisés |
|
||||
| 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour |
|
||||
| ⚪ P2 restant | #83 Barre d'outils d'édition mobile style Obsidian (3-5j) | 3-5 jours |
|
||||
| ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours |
|
||||
| ⚪ P0/P1 restant | #85-87 Refonte architecturale, performance, CI/CD (issues BUG-035 → BUG-040) | ~15-23 jours |
|
||||
| **Total restant** | **7 items + finitions** | **~31-47 jours** |
|
||||
| **Total restant** | **6 items + finitions** | **~28-42 jours** |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -342,6 +342,26 @@ Traitement des vulnérabilités de la revue statique du 2026-09-13 (BUG-021 →
|
||||
|
||||
---
|
||||
|
||||
## #83 — Barre d'outils d'édition mobile (ruban style Obsidian) ✅ TERMINÉ
|
||||
|
||||
La barre de mise en forme flottante du mode édition mobile est remplacée par un
|
||||
**ruban horizontal défilable** ancré juste au-dessus du clavier virtuel.
|
||||
|
||||
- **Ruban** (`frontend/js/mobile-editor.js`, `frontend/style.css`) : fond anthracite
|
||||
aux coins arrondis, insertion/enrobage au curseur ou sur la sélection.
|
||||
- **Commandes** : annuler/refaire, `[[ ]]` (lien interne), fichiers/modèles, tag `#`,
|
||||
pièce jointe, H1–H6, gras, italique, barré (`~~`), surligné (`==`), code en ligne/bloc,
|
||||
citation, lien externe, listes à puces/numérotées, case à cocher, indenter/désindenter,
|
||||
coller, A−/A+.
|
||||
- **Personnalisation** : icône **⚙** → ajouter / retirer / réordonner les commandes,
|
||||
persisté dans `localStorage` (`obsigate-mobile-ribbon`).
|
||||
- **Ancrage clavier** (`BUG-019`) : `window.visualViewport` → variable CSS `--kb-offset`.
|
||||
- **Correctifs associés** : BUG-017 (tuiles du tableau de bord), BUG-018 (en-tête de
|
||||
l'éditeur mobile), BUG-020 (double barre de défilement).
|
||||
- **Tests** : `tests/frontend/mobile-editor.test.mjs` (35) + E2E `mobile-editor.spec.js`.
|
||||
|
||||
---
|
||||
|
||||
## Grosses fonctionnalités — fiches dédiées
|
||||
|
||||
| # | Feature | Version | Fiche |
|
||||
|
||||
@@ -129,6 +129,8 @@
|
||||
defaultKeymap,
|
||||
history,
|
||||
historyKeymap,
|
||||
undo,
|
||||
redo,
|
||||
} from "https://esm.sh/@codemirror/[email protected]?external=@codemirror/state";
|
||||
import {
|
||||
searchKeymap,
|
||||
@@ -200,6 +202,8 @@
|
||||
rust,
|
||||
oneDark,
|
||||
keymap,
|
||||
undo,
|
||||
redo,
|
||||
Decoration,
|
||||
ViewPlugin,
|
||||
WidgetType,
|
||||
|
||||
+420
-56
@@ -22,9 +22,43 @@ export const MAX_EDITOR_FONT = 28;
|
||||
export const DEFAULT_EDITOR_FONT = 13.5;
|
||||
export const MIN_EDITOR_HEIGHT = 140;
|
||||
|
||||
// ROADMAP #83 — every command the mobile ribbon can expose.
|
||||
export const RIBBON_COMMANDS = [
|
||||
'undo', 'redo',
|
||||
'wikilink', 'files', 'tag', 'attachment',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'bold', 'italic', 'strike', 'highlight', 'code', 'codeblock', 'quote',
|
||||
'link', 'bullet', 'ordered', 'checkbox', 'indent', 'dedent',
|
||||
'paste', 'font_smaller', 'font_larger',
|
||||
];
|
||||
// Default ribbon layout (Obsidian Android-like).
|
||||
export const DEFAULT_RIBBON_COMMANDS = [
|
||||
'undo', 'redo', 'wikilink', 'files', 'tag', 'attachment',
|
||||
'h1', 'h2', 'h3', 'bold', 'italic', 'strike', 'highlight',
|
||||
'code', 'codeblock', 'quote', 'link', 'bullet', 'ordered',
|
||||
'checkbox', 'indent', 'dedent', 'paste', 'font_smaller', 'font_larger',
|
||||
];
|
||||
|
||||
const FONT_KEY = 'obsigate-editor-font-size';
|
||||
const HEIGHT_KEY = 'obsigate-editor-height';
|
||||
const READING_KEY = 'obsigate-reading-mode';
|
||||
const RIBBON_KEY = 'obsigate-mobile-ribbon';
|
||||
|
||||
// Command glyphs (kept as plain text so they render with the mono font).
|
||||
const COMMAND_ICONS = {
|
||||
undo: '↶', redo: '↷',
|
||||
wikilink: '[[]]', files: '📁', tag: '#', attachment: '![[ ]]',
|
||||
h1: 'H1', h2: 'H2', h3: 'H3', h4: 'H4', h5: 'H5', h6: 'H6',
|
||||
bold: 'B', italic: 'I', strike: 'S̶', highlight: '==', code: '<>', codeblock: '{}', quote: '❝',
|
||||
link: '🔗', bullet: '•', ordered: '1.', checkbox: '☑', indent: '→', dedent: '←',
|
||||
paste: '📋', font_smaller: 'A−', font_larger: 'A+',
|
||||
};
|
||||
// Commands reusing an existing i18n key (the rest use mobile_editor.cmd_<id>).
|
||||
const COMMAND_LABEL_KEYS = {
|
||||
paste: 'mobile_editor.paste',
|
||||
font_smaller: 'mobile_editor.font_smaller',
|
||||
font_larger: 'mobile_editor.font_larger',
|
||||
};
|
||||
|
||||
// ── Pure helpers (unit tested) ─────────────────────────────────────────────
|
||||
|
||||
@@ -45,35 +79,62 @@ export function detectSwipe(dx, dy, threshold = SWIPE_THRESHOLD) {
|
||||
/**
|
||||
* Compute the text change produced by a Markdown formatting action.
|
||||
*
|
||||
* Supports the legacy actions (bold, italic, code, list, link) plus the
|
||||
* ROADMAP #83 ribbon commands (headings, strike, highlight, code block,
|
||||
* quote, ordered list, checkbox, indent/dedent, wikilink, tag, attachment).
|
||||
*
|
||||
* @param {string} text Full document text.
|
||||
* @param {number} from Selection start.
|
||||
* @param {number} to Selection end.
|
||||
* @param {string} action One of FORMAT_ACTIONS.
|
||||
* @param {string} action One of RIBBON_COMMANDS / 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.
|
||||
// Wrap/unwrap the selection with a marker pair.
|
||||
const wrap = (open, close = open) => {
|
||||
if (
|
||||
selected.length >= marker.length * 2 &&
|
||||
selected.startsWith(marker) &&
|
||||
selected.endsWith(marker)
|
||||
selected.length >= open.length + close.length &&
|
||||
selected.startsWith(open) &&
|
||||
selected.endsWith(close)
|
||||
) {
|
||||
const inner = selected.slice(marker.length, -marker.length);
|
||||
const inner = selected.slice(open.length, selected.length - close.length);
|
||||
return {
|
||||
changes: { from, to, insert: inner },
|
||||
selection: { anchor: from, head: from + inner.length },
|
||||
};
|
||||
}
|
||||
const insert = marker + selected + marker;
|
||||
const insert = open + selected + close;
|
||||
return {
|
||||
changes: { from, to, insert },
|
||||
selection: { anchor: from + marker.length, head: to + marker.length },
|
||||
selection: { anchor: from + open.length, head: from + open.length + selected.length },
|
||||
};
|
||||
};
|
||||
|
||||
// Apply a per-line prefix, toggling it off when every line already has it.
|
||||
// ``pattern`` strips any competing prefix before adding the new one.
|
||||
const prefixLines = (prefix, pattern) => {
|
||||
const lineStart = text.lastIndexOf('\n', from - 1) + 1;
|
||||
let lineEnd = text.indexOf('\n', to);
|
||||
if (lineEnd === -1) lineEnd = text.length;
|
||||
const lines = text.slice(lineStart, lineEnd).split('\n');
|
||||
const allHave = lines.every((l) => l.startsWith(prefix));
|
||||
const out = lines
|
||||
.map((l) => {
|
||||
if (allHave) return l.slice(prefix.length);
|
||||
const base = pattern ? l.replace(pattern, '') : l;
|
||||
return prefix + base;
|
||||
})
|
||||
.join('\n');
|
||||
return {
|
||||
changes: { from: lineStart, to: lineEnd, insert: out },
|
||||
selection: { anchor: lineStart, head: lineStart + out.length },
|
||||
};
|
||||
};
|
||||
|
||||
const listPattern = /^([-*+]\s+|\d+\.\s+|- \[[ xX]\]\s+)/;
|
||||
|
||||
switch (action) {
|
||||
case 'bold':
|
||||
return wrap('**');
|
||||
@@ -81,19 +142,90 @@ export function formatChange(text, from, to, action) {
|
||||
return wrap('*');
|
||||
case 'code':
|
||||
return wrap('`');
|
||||
case 'list': {
|
||||
case 'strike':
|
||||
return wrap('~~');
|
||||
case 'highlight':
|
||||
return wrap('==');
|
||||
case 'wikilink':
|
||||
return wrap('[[', ']]');
|
||||
case 'attachment':
|
||||
return wrap('![[', ']]');
|
||||
case 'tag': {
|
||||
const insert = '#' + selected;
|
||||
const anchor = from + insert.length;
|
||||
return { changes: { from, to, insert }, selection: { anchor, head: anchor } };
|
||||
}
|
||||
case 'h1':
|
||||
case 'h2':
|
||||
case 'h3':
|
||||
case 'h4':
|
||||
case 'h5':
|
||||
case 'h6': {
|
||||
const level = Number(action.slice(1));
|
||||
return prefixLines('#'.repeat(level) + ' ', /^#{1,6}\s+/);
|
||||
}
|
||||
case 'quote':
|
||||
return prefixLines('> ', /^>\s?/);
|
||||
case 'bullet':
|
||||
case 'list':
|
||||
return prefixLines('- ', listPattern);
|
||||
case 'checkbox':
|
||||
return prefixLines('- [ ] ', listPattern);
|
||||
case 'ordered': {
|
||||
const lineStart = text.lastIndexOf('\n', from - 1) + 1;
|
||||
let lineEnd = text.indexOf('\n', to);
|
||||
if (lineEnd === -1) lineEnd = text.length;
|
||||
const lines = text.slice(lineStart, lineEnd).split('\n');
|
||||
const allOrdered = lines.every((l) => /^\d+\.\s+/.test(l));
|
||||
const out = lines
|
||||
.map((l, i) => {
|
||||
if (allOrdered) return l.replace(/^\d+\.\s+/, '');
|
||||
return `${i + 1}. ` + l.replace(listPattern, '');
|
||||
})
|
||||
.join('\n');
|
||||
return {
|
||||
changes: { from: lineStart, to: lineEnd, insert: out },
|
||||
selection: { anchor: lineStart, head: lineStart + out.length },
|
||||
};
|
||||
}
|
||||
case 'codeblock': {
|
||||
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');
|
||||
if (block.startsWith('```') && block.endsWith('```') && block.length >= 6) {
|
||||
const inner = block.slice(3, -3).replace(/^\n/, '').replace(/\n$/, '');
|
||||
return {
|
||||
changes: { from: lineStart, to: lineEnd, insert: inner },
|
||||
selection: { anchor: lineStart, head: lineStart + inner.length },
|
||||
};
|
||||
}
|
||||
const insert = '```\n' + block + '\n```';
|
||||
return {
|
||||
changes: { from: lineStart, to: lineEnd, insert: prefixed },
|
||||
selection: { anchor: lineStart, head: lineStart + prefixed.length },
|
||||
changes: { from: lineStart, to: lineEnd, insert },
|
||||
selection: { anchor: lineStart + 4, head: lineStart + 4 + block.length },
|
||||
};
|
||||
}
|
||||
case 'indent': {
|
||||
const lineStart = text.lastIndexOf('\n', from - 1) + 1;
|
||||
let lineEnd = text.indexOf('\n', to);
|
||||
if (lineEnd === -1) lineEnd = text.length;
|
||||
const lines = text.slice(lineStart, lineEnd).split('\n');
|
||||
const out = lines.map((l) => ' ' + l).join('\n');
|
||||
return {
|
||||
changes: { from: lineStart, to: lineEnd, insert: out },
|
||||
selection: { anchor: lineStart, head: lineStart + out.length },
|
||||
};
|
||||
}
|
||||
case 'dedent': {
|
||||
const lineStart = text.lastIndexOf('\n', from - 1) + 1;
|
||||
let lineEnd = text.indexOf('\n', to);
|
||||
if (lineEnd === -1) lineEnd = text.length;
|
||||
const lines = text.slice(lineStart, lineEnd).split('\n');
|
||||
const out = lines.map((l) => l.replace(/^(\t| {1,2})/, '')).join('\n');
|
||||
return {
|
||||
changes: { from: lineStart, to: lineEnd, insert: out },
|
||||
selection: { anchor: lineStart, head: lineStart + out.length },
|
||||
};
|
||||
}
|
||||
case 'link': {
|
||||
@@ -109,6 +241,32 @@ export function formatChange(text, from, to, action) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep only known ribbon command ids, de-duplicated, in order. */
|
||||
export function normalizeRibbonCommands(list) {
|
||||
const valid = new Set(RIBBON_COMMANDS);
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
(Array.isArray(list) ? list : []).forEach((id) => {
|
||||
if (valid.has(id) && !seen.has(id)) {
|
||||
seen.add(id);
|
||||
out.push(id);
|
||||
}
|
||||
});
|
||||
return out.length ? out : [...DEFAULT_RIBBON_COMMANDS];
|
||||
}
|
||||
|
||||
/** Move a command by ``delta`` positions within the list (clamped). */
|
||||
export function moveRibbonCommand(list, id, delta) {
|
||||
const arr = [...list];
|
||||
const i = arr.indexOf(id);
|
||||
if (i === -1) return arr;
|
||||
const j = Math.max(0, Math.min(arr.length - 1, i + delta));
|
||||
if (i === j) return arr;
|
||||
arr.splice(i, 1);
|
||||
arr.splice(j, 0, id);
|
||||
return arr;
|
||||
}
|
||||
|
||||
/** Build a plain insertion change (used by the paste helper). */
|
||||
export function insertText(from, to, insert) {
|
||||
return {
|
||||
@@ -228,62 +386,268 @@ async function handlePaste() {
|
||||
}
|
||||
}
|
||||
|
||||
function makeButton(id, label, title, onClick) {
|
||||
// ── Formatting ribbon (ROADMAP #83) ────────────────────────────────────────
|
||||
|
||||
let _ribbonCommands = null;
|
||||
|
||||
function loadRibbonCommands() {
|
||||
if (_ribbonCommands) return _ribbonCommands;
|
||||
let stored = null;
|
||||
try {
|
||||
stored = JSON.parse(localStorage.getItem(RIBBON_KEY) || 'null');
|
||||
} catch (_) {
|
||||
stored = null;
|
||||
}
|
||||
_ribbonCommands = normalizeRibbonCommands(stored);
|
||||
return _ribbonCommands;
|
||||
}
|
||||
|
||||
function persistRibbonCommands(list) {
|
||||
_ribbonCommands = normalizeRibbonCommands(list);
|
||||
try {
|
||||
localStorage.setItem(RIBBON_KEY, JSON.stringify(_ribbonCommands));
|
||||
} catch (_) {
|
||||
/* ignore quota / private mode */
|
||||
}
|
||||
}
|
||||
|
||||
function commandLabel(id) {
|
||||
return t(COMMAND_LABEL_KEYS[id] || 'mobile_editor.cmd_' + id);
|
||||
}
|
||||
|
||||
function runRibbonCommand(id) {
|
||||
if (id === 'undo' || id === 'redo') {
|
||||
const fn = window.CodeMirror && window.CodeMirror[id];
|
||||
if (typeof fn === 'function' && state.editorView) {
|
||||
fn(state.editorView);
|
||||
if (typeof state.editorView.focus === 'function') state.editorView.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (id === 'files') {
|
||||
import('./palette.js').then((m) => m.open('')).catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (id === 'paste') {
|
||||
handlePaste();
|
||||
return;
|
||||
}
|
||||
if (id === 'font_smaller') {
|
||||
bumpFontSize(-1);
|
||||
return;
|
||||
}
|
||||
if (id === 'font_larger') {
|
||||
bumpFontSize(1);
|
||||
return;
|
||||
}
|
||||
handleFormat(id);
|
||||
}
|
||||
|
||||
function buildRibbonButton(id) {
|
||||
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);
|
||||
btn.className = 'me-ribbon-btn';
|
||||
btn.id = 'me-cmd-' + id;
|
||||
btn.dataset.cmd = id;
|
||||
btn.textContent = COMMAND_ICONS[id] || id;
|
||||
const label = commandLabel(id);
|
||||
btn.title = label;
|
||||
btn.setAttribute('aria-label', label);
|
||||
btn.addEventListener('click', () => runRibbonCommand(id));
|
||||
return btn;
|
||||
}
|
||||
|
||||
function renderRibbon() {
|
||||
const scroll = document.querySelector('#me-toolbar .me-ribbon-scroll');
|
||||
if (!scroll) return;
|
||||
scroll.innerHTML = '';
|
||||
loadRibbonCommands().forEach((id) => scroll.appendChild(buildRibbonButton(id)));
|
||||
}
|
||||
|
||||
// ── Ribbon customiser (gear button) ────────────────────────────────────────
|
||||
|
||||
function buildCustomizerSheet() {
|
||||
let sheet = document.getElementById('me-ribbon-customizer');
|
||||
if (sheet) return sheet;
|
||||
|
||||
// Reuse the shared bottom-sheet overlay created by ensureSheet().
|
||||
ensureSheet();
|
||||
|
||||
sheet = document.createElement('div');
|
||||
sheet.id = 'me-ribbon-customizer';
|
||||
sheet.className = 'me-sheet me-ribbon-customizer';
|
||||
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';
|
||||
title.textContent = t('mobile_editor.customize_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 me-ribbon-customizer-list';
|
||||
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'me-ribbon-customizer-actions';
|
||||
const reset = document.createElement('button');
|
||||
reset.type = 'button';
|
||||
reset.className = 'config-btn-secondary';
|
||||
reset.textContent = t('mobile_editor.customize_reset');
|
||||
const done = document.createElement('button');
|
||||
done.type = 'button';
|
||||
done.className = 'config-btn-primary';
|
||||
done.textContent = t('mobile_editor.done');
|
||||
footer.appendChild(reset);
|
||||
footer.appendChild(done);
|
||||
|
||||
sheet.appendChild(handle);
|
||||
sheet.appendChild(header);
|
||||
sheet.appendChild(list);
|
||||
sheet.appendChild(footer);
|
||||
document.body.appendChild(sheet);
|
||||
|
||||
close.addEventListener('click', () => closeSheet(sheet));
|
||||
done.addEventListener('click', () => closeSheet(sheet));
|
||||
reset.addEventListener('click', () => {
|
||||
persistRibbonCommands(DEFAULT_RIBBON_COMMANDS);
|
||||
renderRibbon();
|
||||
renderCustomizerList();
|
||||
});
|
||||
return sheet;
|
||||
}
|
||||
|
||||
function _customizerRow(labelText, buttons, extraClass) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'me-ribbon-cmd-row' + (extraClass ? ' ' + extraClass : '');
|
||||
const label = document.createElement('span');
|
||||
label.className = 'me-ribbon-cmd-label';
|
||||
label.textContent = labelText;
|
||||
row.appendChild(label);
|
||||
buttons.forEach((b) => row.appendChild(b));
|
||||
return row;
|
||||
}
|
||||
|
||||
function _smallButton(text, cls, aria, onClick, disabled) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = cls;
|
||||
b.textContent = text;
|
||||
if (aria) b.setAttribute('aria-label', aria);
|
||||
if (disabled) b.disabled = true;
|
||||
b.addEventListener('click', onClick);
|
||||
return b;
|
||||
}
|
||||
|
||||
function renderCustomizerList() {
|
||||
const sheet = document.getElementById('me-ribbon-customizer');
|
||||
if (!sheet) return;
|
||||
const list = sheet.querySelector('.me-ribbon-customizer-list');
|
||||
if (!list) return;
|
||||
|
||||
const enabled = loadRibbonCommands();
|
||||
const disabled = RIBBON_COMMANDS.filter((id) => !enabled.includes(id));
|
||||
list.innerHTML = '';
|
||||
|
||||
enabled.forEach((id, index) => {
|
||||
const up = _smallButton('↑', 'me-ribbon-cmd-move', t('mobile_editor.move_up'), () => {
|
||||
persistRibbonCommands(moveRibbonCommand(enabled, id, -1));
|
||||
renderRibbon();
|
||||
renderCustomizerList();
|
||||
}, index === 0);
|
||||
const down = _smallButton('↓', 'me-ribbon-cmd-move', t('mobile_editor.move_down'), () => {
|
||||
persistRibbonCommands(moveRibbonCommand(enabled, id, 1));
|
||||
renderRibbon();
|
||||
renderCustomizerList();
|
||||
}, index === enabled.length - 1);
|
||||
const remove = _smallButton('✕', 'me-ribbon-cmd-remove', t('mobile_editor.remove_command'), () => {
|
||||
if (enabled.length <= 1) return;
|
||||
persistRibbonCommands(enabled.filter((c) => c !== id));
|
||||
renderRibbon();
|
||||
renderCustomizerList();
|
||||
}, false);
|
||||
list.appendChild(
|
||||
_customizerRow(`${COMMAND_ICONS[id] || id} ${commandLabel(id)}`, [up, down, remove]),
|
||||
);
|
||||
});
|
||||
|
||||
if (disabled.length) {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'me-ribbon-cmd-divider';
|
||||
divider.textContent = t('mobile_editor.add_command');
|
||||
list.appendChild(divider);
|
||||
disabled.forEach((id) => {
|
||||
const add = _smallButton('+', 'me-ribbon-cmd-add', t('mobile_editor.add_command'), () => {
|
||||
persistRibbonCommands([...enabled, id]);
|
||||
renderRibbon();
|
||||
renderCustomizerList();
|
||||
}, false);
|
||||
list.appendChild(_customizerRow(`${COMMAND_ICONS[id] || id} ${commandLabel(id)}`, [add], 'disabled'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function openRibbonCustomizer() {
|
||||
const sheet = buildCustomizerSheet();
|
||||
renderCustomizerList();
|
||||
openSheet(sheet);
|
||||
}
|
||||
|
||||
// ── Keyboard anchoring (BUG-019) ───────────────────────────────────────────
|
||||
|
||||
function initKeyboardAnchor(bar) {
|
||||
if (!window.visualViewport) return;
|
||||
const vv = window.visualViewport;
|
||||
const update = () => {
|
||||
const offset = Math.max(0, Math.round((window.innerHeight || 0) - vv.height - vv.offsetTop));
|
||||
bar.style.setProperty('--kb-offset', offset + 'px');
|
||||
bar.classList.toggle('kb-open', offset > 40);
|
||||
};
|
||||
vv.addEventListener('resize', update);
|
||||
vv.addEventListener('scroll', update);
|
||||
window.addEventListener('resize', update);
|
||||
document.addEventListener('focusin', () => setTimeout(update, 60));
|
||||
document.addEventListener('focusout', () => setTimeout(update, 60));
|
||||
update();
|
||||
}
|
||||
|
||||
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.className = 'me-toolbar me-ribbon';
|
||||
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 scroll = document.createElement('div');
|
||||
scroll.className = 'me-ribbon-scroll';
|
||||
|
||||
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);
|
||||
const gear = document.createElement('button');
|
||||
gear.type = 'button';
|
||||
gear.id = 'me-ribbon-gear';
|
||||
gear.className = 'me-ribbon-gear';
|
||||
gear.textContent = '⚙';
|
||||
gear.title = t('mobile_editor.customize');
|
||||
gear.setAttribute('aria-label', t('mobile_editor.customize'));
|
||||
gear.addEventListener('click', openRibbonCustomizer);
|
||||
|
||||
bar.appendChild(scroll);
|
||||
bar.appendChild(gear);
|
||||
modal.appendChild(bar);
|
||||
|
||||
renderRibbon();
|
||||
initKeyboardAnchor(bar);
|
||||
}
|
||||
|
||||
// ── Editor height resize + pinch zoom ──────────────────────────────────────
|
||||
|
||||
@@ -1143,8 +1143,8 @@
|
||||
"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_toolbar": ": horizontally scrollable ribbon (headings, bold, italic, strikethrough, highlight, code, quote, lists, checkboxes, links, tags, attachments). The ⚙ icon lets you add, remove or reorder commands; A−/A+ adjust the font size.",
|
||||
"help.mobile_editor_toolbar_label": "Editing ribbon",
|
||||
"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",
|
||||
@@ -1307,10 +1307,40 @@
|
||||
"mermaid.zoom_in": "Zoom in",
|
||||
"mermaid.zoom_out": "Zoom out",
|
||||
"mobile.all_vaults": "📂 All vaults",
|
||||
"mobile_editor.add_command": "Add a command",
|
||||
"mobile_editor.backlinks": "Backlinks",
|
||||
"mobile_editor.bold": "Bold",
|
||||
"mobile_editor.close": "Close",
|
||||
"mobile_editor.cmd_attachment": "Attachment",
|
||||
"mobile_editor.cmd_bold": "Bold",
|
||||
"mobile_editor.cmd_bullet": "Bullet list",
|
||||
"mobile_editor.cmd_checkbox": "Checkbox",
|
||||
"mobile_editor.cmd_code": "Inline code",
|
||||
"mobile_editor.cmd_codeblock": "Code block",
|
||||
"mobile_editor.cmd_dedent": "Outdent",
|
||||
"mobile_editor.cmd_files": "Template / files",
|
||||
"mobile_editor.cmd_h1": "Heading 1",
|
||||
"mobile_editor.cmd_h2": "Heading 2",
|
||||
"mobile_editor.cmd_h3": "Heading 3",
|
||||
"mobile_editor.cmd_h4": "Heading 4",
|
||||
"mobile_editor.cmd_h5": "Heading 5",
|
||||
"mobile_editor.cmd_h6": "Heading 6",
|
||||
"mobile_editor.cmd_highlight": "Highlight",
|
||||
"mobile_editor.cmd_indent": "Indent",
|
||||
"mobile_editor.cmd_italic": "Italic",
|
||||
"mobile_editor.cmd_link": "External link",
|
||||
"mobile_editor.cmd_ordered": "Numbered list",
|
||||
"mobile_editor.cmd_quote": "Quote",
|
||||
"mobile_editor.cmd_redo": "Redo",
|
||||
"mobile_editor.cmd_strike": "Strikethrough",
|
||||
"mobile_editor.cmd_tag": "Tag",
|
||||
"mobile_editor.cmd_undo": "Undo",
|
||||
"mobile_editor.cmd_wikilink": "Internal link",
|
||||
"mobile_editor.code": "Code",
|
||||
"mobile_editor.customize": "Customize toolbar",
|
||||
"mobile_editor.customize_reset": "Reset",
|
||||
"mobile_editor.customize_title": "Customize toolbar",
|
||||
"mobile_editor.done": "Done",
|
||||
"mobile_editor.error": "Loading error",
|
||||
"mobile_editor.font_larger": "Increase font size",
|
||||
"mobile_editor.font_smaller": "Decrease font size",
|
||||
@@ -1318,6 +1348,8 @@
|
||||
"mobile_editor.link": "Link",
|
||||
"mobile_editor.list": "Bullet list",
|
||||
"mobile_editor.loading": "Loading…",
|
||||
"mobile_editor.move_down": "Move down",
|
||||
"mobile_editor.move_up": "Move up",
|
||||
"mobile_editor.no_backlinks": "No backlinks",
|
||||
"mobile_editor.no_headings": "No headings in this document",
|
||||
"mobile_editor.no_next": "Last file in folder",
|
||||
@@ -1326,6 +1358,7 @@
|
||||
"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.remove_command": "Remove",
|
||||
"mobile_editor.resize": "Adjust editor height",
|
||||
"mobile_editor.toc": "Table of contents",
|
||||
"mobile_editor.toolbar": "Mobile editing toolbar",
|
||||
|
||||
@@ -1143,8 +1143,8 @@
|
||||
"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_toolbar": " : ruban horizontal défilable (titres, gras, italique, barré, surligné, code, citation, listes, cases à cocher, liens, tags, pièces jointes). L'icône ⚙ permet d'ajouter, retirer ou réordonner les commandes ; A−/A+ ajustent la taille de la police.",
|
||||
"help.mobile_editor_toolbar_label": "Ruban d'édition",
|
||||
"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",
|
||||
@@ -1307,10 +1307,40 @@
|
||||
"mermaid.zoom_in": "Zoom avant",
|
||||
"mermaid.zoom_out": "Zoom arrière",
|
||||
"mobile.all_vaults": "📂 Toutes les vaults",
|
||||
"mobile_editor.add_command": "Ajouter une commande",
|
||||
"mobile_editor.backlinks": "Liens entrants",
|
||||
"mobile_editor.bold": "Gras",
|
||||
"mobile_editor.close": "Fermer",
|
||||
"mobile_editor.cmd_attachment": "Pièce jointe",
|
||||
"mobile_editor.cmd_bold": "Gras",
|
||||
"mobile_editor.cmd_bullet": "Liste à puces",
|
||||
"mobile_editor.cmd_checkbox": "Case à cocher",
|
||||
"mobile_editor.cmd_code": "Code en ligne",
|
||||
"mobile_editor.cmd_codeblock": "Bloc de code",
|
||||
"mobile_editor.cmd_dedent": "Désindenter",
|
||||
"mobile_editor.cmd_files": "Modèle / fichiers",
|
||||
"mobile_editor.cmd_h1": "Titre 1",
|
||||
"mobile_editor.cmd_h2": "Titre 2",
|
||||
"mobile_editor.cmd_h3": "Titre 3",
|
||||
"mobile_editor.cmd_h4": "Titre 4",
|
||||
"mobile_editor.cmd_h5": "Titre 5",
|
||||
"mobile_editor.cmd_h6": "Titre 6",
|
||||
"mobile_editor.cmd_highlight": "Surligné",
|
||||
"mobile_editor.cmd_indent": "Indenter",
|
||||
"mobile_editor.cmd_italic": "Italique",
|
||||
"mobile_editor.cmd_link": "Lien externe",
|
||||
"mobile_editor.cmd_ordered": "Liste numérotée",
|
||||
"mobile_editor.cmd_quote": "Citation",
|
||||
"mobile_editor.cmd_redo": "Refaire",
|
||||
"mobile_editor.cmd_strike": "Barré",
|
||||
"mobile_editor.cmd_tag": "Tag",
|
||||
"mobile_editor.cmd_undo": "Annuler",
|
||||
"mobile_editor.cmd_wikilink": "Lien interne",
|
||||
"mobile_editor.code": "Code",
|
||||
"mobile_editor.customize": "Personnaliser la barre",
|
||||
"mobile_editor.customize_reset": "Réinitialiser",
|
||||
"mobile_editor.customize_title": "Personnaliser la barre d'outils",
|
||||
"mobile_editor.done": "Terminé",
|
||||
"mobile_editor.error": "Erreur de chargement",
|
||||
"mobile_editor.font_larger": "Agrandir la police",
|
||||
"mobile_editor.font_smaller": "Réduire la police",
|
||||
@@ -1318,6 +1348,8 @@
|
||||
"mobile_editor.link": "Lien",
|
||||
"mobile_editor.list": "Liste à puces",
|
||||
"mobile_editor.loading": "Chargement…",
|
||||
"mobile_editor.move_down": "Descendre",
|
||||
"mobile_editor.move_up": "Monter",
|
||||
"mobile_editor.no_backlinks": "Aucun lien entrant",
|
||||
"mobile_editor.no_headings": "Aucun titre dans ce document",
|
||||
"mobile_editor.no_next": "Dernier fichier du dossier",
|
||||
@@ -1326,6 +1358,7 @@
|
||||
"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.remove_command": "Supprimer",
|
||||
"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",
|
||||
|
||||
+199
-42
@@ -3440,23 +3440,62 @@ select {
|
||||
height: 36px;
|
||||
min-width: 36px;
|
||||
}
|
||||
/* BUG-018: keep the Save / Cancel / Delete buttons reachable on small
|
||||
screens by shrinking the inputs and hiding secondary header items. */
|
||||
.editor-brand,
|
||||
.editor-sep,
|
||||
.editor-spacer,
|
||||
.collab-presence {
|
||||
display: none !important;
|
||||
}
|
||||
.editor-title-input {
|
||||
flex: 1 1 60px;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.editor-filename-input {
|
||||
width: auto;
|
||||
flex: 0 1 84px;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
.editor-save-dot {
|
||||
margin-right: 2px;
|
||||
padding: 0;
|
||||
}
|
||||
#editor-save-label {
|
||||
display: none;
|
||||
}
|
||||
.editor-header .editor-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.editor-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
/* BUG-020: only the CodeMirror scroller scrolls — no double scrollbar. */
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
#ai-toolbar-container {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.cm-editor {
|
||||
flex: 1 1 auto;
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.cm-scroller {
|
||||
overflow: auto !important;
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
/* BUG-019: keep the caret clear of the fixed ribbon. */
|
||||
padding-bottom: 72px;
|
||||
}
|
||||
.fallback-editor {
|
||||
min-height: 100%;
|
||||
height: auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 72px;
|
||||
}
|
||||
.search-results-header {
|
||||
align-items: flex-start;
|
||||
@@ -6102,6 +6141,10 @@ body.popup-mode .content-area {
|
||||
gap: 4px;
|
||||
animation: fadeSlideIn 0.3s ease forwards;
|
||||
opacity: 0;
|
||||
/* BUG-017: allow the card to shrink inside the grid instead of overflowing
|
||||
the viewport because of long nowrap titles/paths. */
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-card:hover {
|
||||
@@ -6160,6 +6203,7 @@ body.popup-mode .content-area {
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-card-icon {
|
||||
@@ -6253,6 +6297,7 @@ body.popup-mode .content-area {
|
||||
margin-top: 4px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-card-time {
|
||||
@@ -9482,64 +9527,176 @@ body.bookslm-resizing { cursor: ew-resize; user-select: none; }
|
||||
shortcuts and distraction-free reading mode.
|
||||
══════════════════════════════════════════ */
|
||||
|
||||
/* ── Floating formatting toolbar (inside editor modal) ── */
|
||||
/* ── Obsidian-style editing ribbon (ROADMAP #83) ── */
|
||||
.me-toolbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.me-btn {
|
||||
.me-ribbon {
|
||||
--kb-offset: 0px;
|
||||
}
|
||||
|
||||
.me-ribbon-scroll {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
padding: 2px 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.me-ribbon-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.me-ribbon-btn {
|
||||
flex: 0 0 auto;
|
||||
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);
|
||||
min-width: 42px;
|
||||
height: 42px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
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-ribbon-btn:active {
|
||||
transform: scale(0.92);
|
||||
background: rgba(128, 128, 128, 0.22);
|
||||
}
|
||||
.me-btn-format {
|
||||
font-family: var(--font-mono);
|
||||
|
||||
.me-ribbon-gear {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: none;
|
||||
border-left: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.me-btn-paste {
|
||||
margin-left: auto;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
.me-ribbon-gear:active {
|
||||
color: var(--accent);
|
||||
}
|
||||
.me-sep {
|
||||
width: 1px;
|
||||
height: 26px;
|
||||
background: var(--border);
|
||||
flex-shrink: 0;
|
||||
|
||||
/* Ribbon customiser rows */
|
||||
.me-ribbon-customizer-list {
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.me-ribbon-cmd-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.me-ribbon-cmd-row.disabled {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.me-ribbon-cmd-label {
|
||||
flex: 1;
|
||||
font-size: 0.85rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.me-ribbon-cmd-move,
|
||||
.me-ribbon-cmd-remove,
|
||||
.me-ribbon-cmd-add {
|
||||
flex: 0 0 auto;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.me-ribbon-cmd-move:disabled {
|
||||
opacity: 0.35;
|
||||
}
|
||||
.me-ribbon-cmd-remove {
|
||||
color: var(--danger);
|
||||
}
|
||||
.me-ribbon-cmd-divider {
|
||||
padding: 10px;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.me-ribbon-customizer-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* BUG-018: the in-editor AI toolbar was too dense on small screens. */
|
||||
@media (max-width: 768px) {
|
||||
.ai-toolbar {
|
||||
gap: 4px !important;
|
||||
padding: 6px 8px !important;
|
||||
}
|
||||
.ai-toolbar-btn {
|
||||
min-height: 34px;
|
||||
padding: 6px 10px !important;
|
||||
font-size: 0.78rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.editor-modal.active .me-toolbar {
|
||||
.editor-modal.active .me-ribbon {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
/* BUG-019: sit just above the on-screen keyboard. */
|
||||
bottom: calc(var(--kb-offset, 0px) + 8px + env(safe-area-inset-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;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background: #1c1c1e;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.me-ribbon-btn {
|
||||
color: #e8e8ea;
|
||||
}
|
||||
.me-ribbon-gear {
|
||||
color: #b8b8bd;
|
||||
}
|
||||
[data-theme="light"] .me-ribbon {
|
||||
background: #f1f1f4;
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
[data-theme="light"] .me-ribbon-btn {
|
||||
color: #1c1c1e;
|
||||
}
|
||||
[data-theme="light"] .me-ribbon-gear {
|
||||
color: #5a5a60;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,19 +45,19 @@ test.describe('Mobile editor (#69)', () => {
|
||||
await expect(page.locator('body')).not.toHaveClass(/reading-mode/);
|
||||
});
|
||||
|
||||
test('editor modal exposes the floating formatting toolbar', async ({ page, viewport }) => {
|
||||
test('editor modal exposes the Obsidian-style editing ribbon', 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.
|
||||
// Force the editor modal open — the ribbon 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();
|
||||
for (const cmd of ['bold', 'italic', 'strike', 'highlight', 'wikilink', 'checkbox', 'undo', 'redo']) {
|
||||
await expect(page.locator('#me-cmd-' + cmd)).toBeVisible();
|
||||
}
|
||||
await expect(page.locator('#me-paste')).toBeVisible();
|
||||
await expect(page.locator('#me-ribbon-gear')).toBeVisible();
|
||||
});
|
||||
|
||||
test('font zoom buttons persist the editor font size', async ({ page, viewport }) => {
|
||||
@@ -65,8 +65,8 @@ test.describe('Mobile editor (#69)', () => {
|
||||
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();
|
||||
await page.locator('#me-cmd-font_larger').click();
|
||||
await page.locator('#me-cmd-font_larger').click();
|
||||
const stored = await page.evaluate(() => localStorage.getItem('obsigate-editor-font-size'));
|
||||
expect(Number(stored)).toBeGreaterThan(13.5);
|
||||
});
|
||||
|
||||
@@ -52,11 +52,15 @@ const mod = await import(
|
||||
|
||||
const {
|
||||
FORMAT_ACTIONS,
|
||||
RIBBON_COMMANDS,
|
||||
DEFAULT_RIBBON_COMMANDS,
|
||||
MIN_EDITOR_FONT,
|
||||
MAX_EDITOR_FONT,
|
||||
clampZoom,
|
||||
detectSwipe,
|
||||
formatChange,
|
||||
normalizeRibbonCommands,
|
||||
moveRibbonCommand,
|
||||
insertText,
|
||||
applyFormatToTextarea,
|
||||
applyEditorFontSize,
|
||||
@@ -161,7 +165,7 @@ test("formatChange link builds [selection](url) and selects the url", () => {
|
||||
});
|
||||
|
||||
test("formatChange returns null for an unknown action", () => {
|
||||
assert.equal(formatChange("abc", 0, 0, "strike"), null);
|
||||
assert.equal(formatChange("abc", 0, 0, "nope"), null);
|
||||
});
|
||||
|
||||
test("insertText builds an insertion change at the cursor", () => {
|
||||
@@ -170,6 +174,62 @@ test("insertText builds an insertion change at the cursor", () => {
|
||||
assert.deepEqual(c.selection, { anchor: 8, head: 8 });
|
||||
});
|
||||
|
||||
// ── ROADMAP #83 — ribbon command transforms ────────────────────────────────
|
||||
test("formatChange strike and highlight wrap with the right markers", () => {
|
||||
assert.equal(formatChange("x", 0, 1, "strike").changes.insert, "~~x~~");
|
||||
assert.equal(formatChange("x", 0, 1, "highlight").changes.insert, "==x==");
|
||||
});
|
||||
|
||||
test("formatChange wikilink and attachment wrap with brackets", () => {
|
||||
assert.equal(formatChange("Note", 0, 4, "wikilink").changes.insert, "[[Note]]");
|
||||
assert.equal(formatChange("img.png", 0, 7, "attachment").changes.insert, "![[img.png]]");
|
||||
});
|
||||
|
||||
test("formatChange tag prefixes a hash", () => {
|
||||
const c = formatChange("devops", 0, 6, "tag");
|
||||
assert.equal(c.changes.insert, "#devops");
|
||||
assert.deepEqual(c.selection, { anchor: 7, head: 7 });
|
||||
});
|
||||
|
||||
test("formatChange heading replaces an existing heading level", () => {
|
||||
const c = formatChange("# Title", 0, 7, "h2");
|
||||
assert.equal(c.changes.insert, "## Title");
|
||||
});
|
||||
|
||||
test("formatChange heading toggles off the same level", () => {
|
||||
const c = formatChange("## Title", 0, 8, "h2");
|
||||
assert.equal(c.changes.insert, "Title");
|
||||
});
|
||||
|
||||
test("formatChange quote, bullet, checkbox and ordered list prefix lines", () => {
|
||||
assert.equal(formatChange("a\nb", 0, 3, "quote").changes.insert, "> a\n> b");
|
||||
assert.equal(formatChange("a\nb", 0, 3, "bullet").changes.insert, "- a\n- b");
|
||||
assert.equal(formatChange("a\nb", 0, 3, "checkbox").changes.insert, "- [ ] a\n- [ ] b");
|
||||
assert.equal(formatChange("a\nb", 0, 3, "ordered").changes.insert, "1. a\n2. b");
|
||||
});
|
||||
|
||||
test("formatChange codeblock wraps the selected block in fences", () => {
|
||||
const c = formatChange("code", 0, 4, "codeblock");
|
||||
assert.equal(c.changes.insert, "```\ncode\n```");
|
||||
});
|
||||
|
||||
test("formatChange indent and dedent shift lines", () => {
|
||||
assert.equal(formatChange("a\nb", 0, 3, "indent").changes.insert, " a\n b");
|
||||
assert.equal(formatChange(" a\n\tb", 0, 6, "dedent").changes.insert, "a\nb");
|
||||
});
|
||||
|
||||
test("normalizeRibbonCommands filters unknown ids and falls back to default", () => {
|
||||
assert.deepEqual(normalizeRibbonCommands(["bold", "nope", "bold"]), ["bold"]);
|
||||
assert.deepEqual(normalizeRibbonCommands([]), [...DEFAULT_RIBBON_COMMANDS]);
|
||||
assert.deepEqual(normalizeRibbonCommands(null), [...DEFAULT_RIBBON_COMMANDS]);
|
||||
});
|
||||
|
||||
test("moveRibbonCommand reorders within bounds", () => {
|
||||
assert.deepEqual(moveRibbonCommand(["a", "b", "c"], "b", -1), ["b", "a", "c"]);
|
||||
assert.deepEqual(moveRibbonCommand(["a", "b", "c"], "c", 1), ["a", "b", "c"]);
|
||||
assert.deepEqual(moveRibbonCommand(["a", "b"], "a", -1), ["a", "b"]);
|
||||
});
|
||||
|
||||
// ── Textarea application ───────────────────────────────────────────────────
|
||||
test("applyFormatToTextarea formats the current textarea selection", () => {
|
||||
const ta = w.document.createElement("textarea");
|
||||
@@ -187,17 +247,29 @@ test("applyFormatToTextarea returns false without an element", () => {
|
||||
});
|
||||
|
||||
// ── DOM wiring ─────────────────────────────────────────────────────────────
|
||||
test("initMobileEditor builds the floating toolbar with the five actions", () => {
|
||||
test("initMobileEditor builds the ribbon toolbar with the gear button", () => {
|
||||
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(bar.classList.contains("me-ribbon"), "uses the ribbon style");
|
||||
assert.ok(w.document.querySelector("#me-toolbar .me-ribbon-scroll"), "scroll container exists");
|
||||
for (const id of DEFAULT_RIBBON_COMMANDS) {
|
||||
assert.ok(w.document.getElementById("me-cmd-" + id), id + " command exists");
|
||||
}
|
||||
assert.ok(w.document.getElementById("me-paste"), "paste button exists");
|
||||
assert.ok(w.document.getElementById("me-ribbon-gear"), "gear button exists");
|
||||
assert.ok(w.document.getElementById("me-resize-handle"), "resize handle exists");
|
||||
});
|
||||
|
||||
test("ribbon customiser opens and lists commands", () => {
|
||||
const gear = w.document.getElementById("me-ribbon-gear");
|
||||
gear.click();
|
||||
const sheet = w.document.getElementById("me-ribbon-customizer");
|
||||
assert.ok(sheet, "customiser sheet exists");
|
||||
assert.ok(sheet.classList.contains("open"), "customiser is open");
|
||||
assert.ok(sheet.querySelectorAll(".me-ribbon-cmd-row").length >= RIBBON_COMMANDS.length,
|
||||
"every command is listed");
|
||||
});
|
||||
|
||||
test("initMobileEditor is idempotent", () => {
|
||||
initMobileEditor();
|
||||
const bars = w.document.querySelectorAll("#me-toolbar");
|
||||
|
||||
Reference in New Issue
Block a user