Compare commits

...
2 Commits
Author SHA1 Message Date
bruno 699165469d feat: menus mobiles façon Notion — barre horizontale au « / » + Insert block plein écran (v7.63.0)
FlowDeck CI / lint (push) Successful in 1m32s
FlowDeck CI / test (push) Failing after 21m41s
FlowDeck CI / docker (push) Skipped
- Barre flottante défilante sous le curseur (mobile ≤ 768 px) au lieu de la
  popup desktop, 15 boutons dans l'ordre de la description : +▾, Turn into▾,
  @, 💬, 🖼, 🗑, ←, →, ↑, ↓, ↩, ↪, Color▾, More▾, ⌨
- +▾ → feuille plein écran « Insert block » (titre centré, Cancel bleu, groupes
  Basic blocks / AI writing / Media / Data / Synced / Actions, raccourcis
  markdown à droite) ; items partagés avec la popup desktop (SM._slashItemHtml)
- Turn into ▾ / Color ▾ → feuilles réutilisant les générateurs extraits
  _blockTurnItems / _blockColorItems (extraits de _blockTurnMenuOpen /
  _blockColorMenuOpen, comportement desktop inchangé)
- More ▾ → menu contextuel de bloc existant ; @ insère + ouvre les mentions ;
  💬 commentaire ; 🖼 bloc image ; 🗑 suppression ; ⌨ masque le clavier ;
  ↩ ↪ annuler/rétablir
- moveBlock(bid, dir) : ↑↓ réordonne (racine + enfants toggle/columns),
  → indent dans le frère précédent, ← outdent, garde-fous avec toast
- Fermeture auto de la barre quand le « / » n'est plus en tête de bloc ; taps
  dans la barre/les feuilles exclus du handler « clic hors-zone » ; Cancel ne
  referme que la feuille (la barre reste tant que le / est tapé)
- Raccourcis slash alignés sur les préfixes markdown (-, [], >, ---), groupe
  SYNCED libellé « Synced »
- Tests : tests/test_v763_mobile_menus.py (ordre, câblage, structure, styles +
  moveBlock exécuté sous node), e2e/v763_mobile_menus.spec.js (390×844, 1
  passed) ; test_v761 adapté aux extractions ; suite complète 1375 passed / 0
  failed, ruff OK, OpenAPI 7.63.0 (525 chemins)
2026-10-08 09:36:37 -04:00
bruno 3a6445ca4e feat: menu contextuel au highlight de texte — toolbar 4 lignes façon Notion (v7.62.0)
FlowDeck CI / lint (push) Successful in 1m32s
FlowDeck CI / test (push) Failing after 20m17s
FlowDeck CI / docker (push) Skipped
- Toolbar de sélection sous le surlignage : ligne de style (T + type de bloc
  dynamique → sous-menu Turn into), A/B/I/U/T barré/Tx, lien/surlignage/
  code/équation/…, Comment + Ask AI, section grise Skills (4 actions) et pied
  « Edit with AI » (Alt+⇧+E) ; positionnée sous la sélection (au-dessus si
  manque de place), fermée au clic hors sélection
- « … » → menu contextuel de bloc existant (recherche « Search actions… »,
  groupe Text, 10 actions + raccourcis, pied auteur/heure/taille)
- Info-bulle claire à droite des éléments du menu : data-tooltip-html +
  classe .tooltip-light dans le tooltip global (app.js)
- Persistance du formatage inline : sérialiseur gtMd() (DOM formaté → source
  markdown) branché sur sync() ; mdEsc() rend __…__ (u), ==…== (mark) et
  $$…$$ (équation inline KaTeX, data-tex conservé)
- Fixes racine : askAI(idx) écrasé par askAI() → askAIBlock(idx) (menu de bloc,
  Ctrl+J, Alt+⇧+E) ; ed.__x (Alpine v2) inexistant sous v3 → _xed() via
  _x_dataStack[0] (showFmt/hideCommentBtn/mentions ne s'exécutaient jamais) ;
  ancre de sélection nœud élément gérée ; checkMd() appelé avec un id au lieu
  de l'index (exception à chaque barre d'espace)
- Tests : tests/test_v762_selection_toolbar.py (roundtrip gtMd/mdEsc sous node)
  + e2e/v762_selection_toolbar.spec.js (structure, sous-menus, géométrie,
  persistance bold/highlight après reload) ; suite complète 1368 passed / 0
  failed, ruff OK, OpenAPI régénérée 7.62.0 (525 chemins)
2026-10-08 08:39:39 -04:00
15 changed files with 1219 additions and 58 deletions
+107
View File
@@ -1,5 +1,112 @@
# Changelog - FlowDeck
## v7.63.0 (2026-10-08) — Menus mobiles façon Notion (barre au « / » + Insert block plein écran)
### Added
- **Barre horizontale mobile** — sur un viewport ≤ 768 px, taper `/` sur une ligne
vide affiche une **barre flottante défilante sous le curseur** (au lieu de la popup
desktop), 15 boutons dans l'ordre Notion : `+▾`, `Turn into▾`, `@`, 💬, 🖼, 🗑,
`←`, `→`, `↑`, `↓`, `↩`, `↪`, `Color▾`, `More▾`, `⌨` :
- `+▾` → **feuille plein écran « Insert block »** : titre centré, bouton bleu
« Cancel », liste verticale par groupes (Basic blocks / AI writing / Media /
Data / Synced / Actions) avec icône, nom et **raccourci markdown** à droite
(`#`, `##`, `-`, `1.`, `[]`, `>`, `"`, `---`…) — items partagés avec la popup
desktop (`SM._slashItemHtml`), choix → `applySlash` → tout se referme.
- `Turn into▾` / `Color▾` → feuilles réutilisant **les mêmes générateurs d'items**
que les sous-menus du menu de bloc (`_blockTurnItems` / `_blockColorItems`
extraits de `_blockTurnMenuOpen` / `_blockColorMenuOpen`).
- `More▾` → menu contextuel de bloc existant (recherche + 10 actions + pied).
- `@` insère `@` et ouvre le sélecteur de mention ; 💬 commentaire ; 🖼 insère un
bloc image ; 🗑 supprime ; `⌨` masque le clavier ; `↩ ↪` annuler/rétablir.
- **Déplacement de bloc `moveBlock(bid, dir)`** — `↑↓` réordonne (y compris dans un
parent toggle/columns), `→` indente dans le frère précédent (toggle/columns),
`←` ressort à la racine ; garde-fous avec toast (déjà en haut/bas, déjà à la
racine, pas de cible).
- **Fermeture automatique** de la barre quand le `/` n'est plus au début du bloc ;
taps dans la barre/la feuille n'essaient plus de fermer le menu (exclusion
`MTB.contains` sur le handler mousedown hors-zone).
- **Raccourcis slash alignés sur les préfixes markdown** (description Notion) :
`-` (puces), `[]` (à faire), `>` (toggle), `---` (séparateur) — partagés avec la
popup desktop ; groupe `SYNCED` libellé « Synced ».
### Tests
- `tests/test_v763_mobile_menus.py` : ordre des 15 boutons, câblage de chaque
action, structure de la feuille (titre/Cancel/groupes), branchement mobile,
styles — et **`moveBlock` exécuté pour de vrai sous node** (ordre, indent,
outdent, garde-fous).
- **e2e Playwright mobile** `e2e/v763_mobile_menus.spec.js` (viewport 390×844,
`hasTouch`) : barre visible au `/` avec les 15 boutons dans l'ordre + `overflow-x:auto`
et popup desktop masquée, « Insert block » (groupes, raccourci `#` de Heading 1)
→ le bloc devient `heading_1`, « Turn into » (coche du type courant), « Color »
(3 groupes), « More » (menu de bloc + pied), `↑` qui réordonne réellement les blocs.
- `tests/test_v761_block_menu.py` adapté aux extractions `_blockTurnItems` /
`_blockColorItems`.
## v7.62.0 (2026-10-08) — Menu contextuel au highlight de texte (toolbar façon Notion)
### Added
- **Toolbar de sélection** — dès qu'un texte est surligné dans l'éditeur, un menu
flottant apparaît **sous la sélection** (au-dessus si manque de place), centré et
clampé au viewport, en 4 lignes comme dans Notion :
1. **Style de texte** — `T` + libellé **dynamique** du type de bloc (Normal Text,
Heading 1… ; le type courant) + `›` → ouvre le sous-menu **Turn into** existant
(coche sur le type actuel, fermeture au survol sortant).
2. **A** (sous-menu Color : texte/fond + dernière couleur), **B** gras, *I* italique,
**U** souligné, **T** barré, **Tx** code inline.
3. 🔗 lien, **S** surlignage (`<mark>`), `</>` bloc code, **√x** équation inline
(prompt LaTeX → KaTeX), **…** → menu contextuel de bloc (recherche + 10 actions +
pied « Last edited by … / N words, M characters »).
4. 💬 Comment (`Ctrl+⇧+M`), ✨ Ask AI (`Ctrl+J`).
puis la section grise **Skills** (Improve writing, Proofread, Explain, Reformat →
`runSkill` sur le bloc) et le pied **Edit with AI** `Alt+⇧+E`.
- **Info-bulle claire à droite** des éléments du menu — extension du système de
tooltip existant (`app.js`) : `data-tooltip-html` (ex. en italique + description
pour la ligne 1) + classe `.tooltip-light`, position à droite de l'élément (débord
→ à gauche), sous l'élément hors toolbar.
- **Persistance du formatage inline** : nouveau sérialiseur **`gtMd()`** (DOM formaté
→ source markdown) appelé par `sync()` (blocs + enfants toggle/columns) — le
formatage posé ressort en `**gras**`, `*ital*`, `__souligné__`, `~~barré~~`,
`` `code` ``, `==surlignage==`, `[texte](url)` ; les chips wiki gardent leur token.
`mdEsc()` rend en plus `__…__` (`<u>`), `==…==` (`<mark>`) et `$$…$$`
(équation inline KaTeX, `data-tex` conservé pour l'aller-retour).
- **Fermeture propre** : clic hors sélection → le menu se referme ; clic **dans** la
toolbar → sélection et menu intacts (`@mousedown.prevent` + garde `mouseup`).
### Fixed
- **`askAI(idx)` était écrasé par `askAI()`** (même clé dans l'objet éditeur, définie
plus bas) : « Ask AI » du menu de bloc et `Ctrl+J` ouvraient l'IA de **page** au
lieu du composeur du bloc → renommé **`askAIBlock(idx)`** (appels : menu de bloc,
`Ctrl+J`, `Alt+⇧+E`).
- **`ed.__x` introuvable sous Alpine v3** — le data de `.page-editor-wrapper` était
lu via `ed.__x` (API Alpine v2, plus exposée depuis le bundle CSP) : `showFmt`,
`hideCommentBtn` et les handlers de mentions ne s'exécutaient **jamais** → la
toolbar de sélection n'apparaissait pas. Accès unifié via `_xed()`
(`_x_dataStack[0]`, repli `__x`), 4 sites éditeur.
- **Ancre de sélection** — `closest('[data-bid]')` partait du parent de l'ancre :
quand l'ancre est un nœud **élément** (sélection programmée, double-clic), le bloc
n'était jamais trouvé → `showFmt` pas appelé. Gestion des 2 types de nœud.
- **Barre d'espace = exception** — `checkMd()` recevait l'**id** du bloc au lieu de
l'index : chaque espace levait `Cannot read properties of undefined (reading 'id')`
et les raccourcis markdown (`# `, `- `) ne se déclenchaient jamais.
### Tests
- `tests/test_v762_selection_toolbar.py` : ordre des 4 lignes + Skills + pied,
câblage réel des actions, persistance `gtMd`/`mdEsc` (**roundtrip réel sous node**),
placement/fermeture, styles ; `tests/test_v761_block_menu.py` adapté
(`askAIBlock`).
- **e2e Playwright** `e2e/v762_selection_toolbar.spec.js` (instance 8081) : structure
des 4 lignes, sous-menu Turn into (coche du type courant), menu « … » (recherche +
pied), géométrie (sous la sélection, dans le viewport), **persistance du gras et du
surlignage après reload**, fermeture hors sélection → 1 passed (captures dans
`e2e/shots/` — répertoire gitignoré).
- **Suite complète** : `pytest tests/ -q -n auto` → **1368 passed / 0 failed**,
`ruff check app tests` → All checks passed.
## v7.61.0 (2026-10-08) — Menu contextuel de bloc refondu façon Notion
### Added
+30
View File
@@ -1408,6 +1408,36 @@ Détails livrés :
- [x] **Tests follow-ups** — `tests/test_v73_wiki_polish.py` (58 → **72 tests**) : sidebar cross-workspace, page teamspace (owner 200 / outsider 404 API), auto-follow comment, notif throttlée, unfurl gitea/github + endpoint, KPI + dashboards multi-DB
- [x] **Suite complète** — `python -m pytest -n auto` → **1016 passed** (était 981 passed / 21 failed en SSO)
### v7.62.0 — Menu contextuel au highlight de texte ✅ (2026-10-08)
> **Objectif** : menu façon Notion (mode sombre) au surlignage de texte dans l'éditeur. **COMPLETED**.
- [x] **Toolbar de sélection 4 lignes** — `T` + type de bloc dynamique + `›` (sous-menu Turn into existant) / `A` `B` `I` `U` `T` barré `Tx` / 🔗 `S` `</>` `√x` `…` / 💬 `✨`, section grise **Skills** (Improve writing, Proofread, Explain, Reformat → `runSkill`) et pied **Edit with AI** `Alt+⇧+E` — affichée **sous** la sélection (au-dessus si pas de place), clampée au viewport, fermeture au clic hors sélection
- [x] **`…` → menu contextuel de bloc** déjà existant (recherche « Search actions… », groupe Text, 10 actions + raccourcis, pied métadonnées auteur/heure/taille) — pas de doublon de menu
- [x] **Persistance du formatage inline** — sérialiseur `gtMd()` (DOM → source markdown) branché sur `sync()` ; `mdEsc()` rend `__…__`, `==…==`, `$$…$$` (KaTeX inline) — avant, `sync()` relisait du texte brut et perdait tout le formatage au prochain rendu
- [x] **Info-bulle claire à droite** — `data-tooltip-html` + `.tooltip-light` dans le tooltip global (`app.js`)
- [x] **Fix `askAI(idx)` écrasé par `askAI()`** — « Ask AI » / `Ctrl+J` ouvraient l'IA de page → `askAIBlock(idx)`
- [x] **Fix « toolbar muette sous Alpine v3 »** — `ed.__x` (API v2) n'existe plus : `showFmt`/`hideCommentBtn`/mentions ne s'exécutaient jamais → accès via `_xed()` (`_x_dataStack[0]`) ; ancre de sélection élément gérée ; `checkMd()` recevait un id au lieu d'un index (exception à chaque espace)
- [x] **Tests** — `tests/test_v762_selection_toolbar.py` (roundtrip `gtMd`/`mdEsc` réel sous node) + **e2e Playwright** `e2e/v762_selection_toolbar.spec.js` (structure, sous-menus, géométrie, persistance bold/highlight après reload) ; `VERSION` + `app/main.py` = 7.62.0
### v7.63.0 — Menus mobiles façon Notion ✅ (2026-10-08)
> **Objectif** : parité des menus mobiles (Android sombre) — barre contextuelle au
> « / » + menu « Insert block » plein écran. **COMPLETED**.
- [x] **Barre horizontale défilante** sous le curseur (mobile ≤ 768 px) — 15 boutons
dans l'ordre Notion : `+▾`, `Turn into▾`, `@`, 💬, 🖼, 🗑, `←→↑↓`, `↩↪`, `Color▾`,
`More▾`, `⌨` ; `overflow-x:auto`, fermeture auto quand le `/` disparaît, taps
exclus du handler « clic hors-zone »
- [x] **Feuille plein écran « Insert block »** (`+`) — titre centré + « Cancel » bleu,
groupes CMDS avec raccourcis markdown (items partagés avec la popup desktop) ;
`Turn into` / `Color` réutilisent les générateurs extraits (`_blockTurnItems`,
`_blockColorItems`) ; `More` ouvre le menu de bloc existant
- [x] **`moveBlock(bid, dir)`** — ↑↓ ordre (racine + enfants toggle/columns),
→ indent dans le frère précédent, ← outdent, garde-fous toast
- [x] **Raccourcis slash** alignés sur les préfixes markdown (`-`, `[]`, `>`, `---`)
- [x] **Tests** — `tests/test_v763_mobile_menus.py` (+ `moveBlock` exécuté sous node),
e2e mobile `e2e/v763_mobile_menus.spec.js` (390×844) ; `VERSION` + `app/main.py`
= 7.63.0
---
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
+1 -1
View File
@@ -1 +1 @@
7.61.0
7.63.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.50.2 (peek de Library nu — iframe pleine, comme local-workspace) | **Statut**: EN COURS 🔄
> **Début**: 2026-07-08 | **Version**: v7.63.0 (menus mobiles — barre horizontale au « / » + Insert block plein écran) | **Statut**: EN COURS 🔄
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
## Avancement Global
+1 -1
View File
@@ -186,7 +186,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.61.0",
version="7.63.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+47 -5
View File
@@ -635,17 +635,59 @@
</div>
</div>
<!-- ═══════════ Menu contextuel au highlight de texte (v7.62.0, façon Notion) ═══════════ -->
<div
class="format-toolbar"
x-show="fmt.open"
x-cloak
:style="{top:fmt.top+'px',left:fmt.left+'px'}"
@mousedown.prevent
>
<button @click="fmtApply('bold')"><strong>B</strong></button>
<button @click="fmtApply('italic')"><em>I</em></button>
<button @click="fmtApply('underline')"><u>U</u></button>
<button @click="fmtApply('strikeThrough')"><s>S</s></button>
<button @click="fmtLink()">{{ fd_icon("link",14) }}</button>
<!-- Ligne 1 : style de texte -->
<div class="ft-row">
<button type="button" class="ft-item ft-style"
@click="fmtStyle($event)"
data-tooltip-html="<em>To be the foremost driver…</em><br>Just start writing with plain text">
<span class="ft-style-ico">T</span>
<span class="ft-style-name" x-text="fmtStyleName()"></span>
<span class="ft-caret">›</span>
</button>
</div>
<!-- Ligne 2 : formatage de base -->
<div class="ft-row">
<button type="button" class="ft-item ft-a" @click="fmtColor($event)" data-tooltip="Color — text and background colors"><span class="ft-a-letter">A</span></button>
<button type="button" class="ft-item" @click="fmtApply('bold')" data-tooltip="Bold — Ctrl+B"><strong>B</strong></button>
<button type="button" class="ft-item" @click="fmtApply('italic')" data-tooltip="Italic — Ctrl+I"><em>I</em></button>
<button type="button" class="ft-item" @click="fmtApply('underline')" data-tooltip="Underline"><u>U</u></button>
<button type="button" class="ft-item" @click="fmtApply('strikeThrough')" data-tooltip="Strikethrough"><s>T</s></button>
<button type="button" class="ft-item ft-tx" @click="fmtCode()" data-tooltip="Inline code">Tx</button>
</div>
<!-- Ligne 3 : outils avancés -->
<div class="ft-row">
<button type="button" class="ft-item" @click="fmtLink()" data-tooltip="Link">{{ fd_icon("link",15) }}</button>
<button type="button" class="ft-item" @click="fmtHighlight()" data-tooltip="Highlight"><span class="ft-hl">S</span></button>
<button type="button" class="ft-item ft-code-ico" @click="fmtCodeBlock()" data-tooltip="Code block">&lt;/&gt;</button>
<button type="button" class="ft-item" @click="fmtEquation()" data-tooltip="Equation">√x</button>
<button type="button" class="ft-item ft-more" @click="fmtMore($event)" data-tooltip="More actions">…</button>
</div>
<!-- Ligne 4 : actions -->
<div class="ft-row">
<button type="button" class="ft-item" @click="fmtComment()" data-tooltip="Comment — Ctrl+⇧+M">{{ fd_icon("message-square",15) }}</button>
<button type="button" class="ft-item" @click="fmtAskAI()" data-tooltip="Ask AI — Ctrl+J">{{ fd_icon("sparkles",15) }}</button>
</div>
<!-- Section « Skills » -->
<div class="ft-sep"></div>
<div class="ft-group">Skills</div>
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Improve the writing of this text (wording, clarity, style). Keep the meaning, language and format. Reply ONLY with the improved text.')" data-tooltip="Run on this block">Improve writing</button>
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Proofread this text: fix spelling and grammar mistakes only. Reply ONLY with the corrected text.')" data-tooltip="Run on this block">Proofread</button>
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Explain this text in simpler terms, in the same language. Reply ONLY with the explanation.')" data-tooltip="Run on this block">Explain</button>
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Reformat this text for readability (structure, spacing, lists where useful) without changing its meaning. Reply ONLY with the reformatted text.')" data-tooltip="Run on this block">Reformat</button>
<!-- Pied de page -->
<div class="ft-sep"></div>
<button type="button" class="ft-item ft-foot" @click="fmtEditAI()" data-tooltip="Edit with AI">
<span>Edit with AI</span>
<kbd>Alt+⇧+E</kbd>
</button>
</div>
<!-- ═══════════ Copy Link Toast ═══════════ -->
+2 -2
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.61.0"
"version": "7.63.0"
},
"paths": {
"/auth/register": {
@@ -30444,4 +30444,4 @@
}
}
}
}
}
+127
View File
@@ -0,0 +1,127 @@
const { test, expect } = require('@playwright/test');
/**
* v7.62 — menu contextuel au highlight de texte (toolbar façon Notion).
* Vérifie : les 4 lignes + Skills + pied, le menu « … », le sous-menu
* « Turn into », et surtout la PERSISTANCE du formatage après reload
* (sync() relit le DOM formaté via gtMd → source markdown).
* Instance de test attendue sur FD_BASE_URL (défaut 8081).
*/
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
test.use({ serviceWorkers: 'block' });
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page
.waitForURL('**/workspaces', { timeout: 8000 })
.then(() => true)
.catch(() => false);
if (!ok) {
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
data: { email: USER, password: PASS, name: 'E2E' },
});
if (resp.status() !== 409) expect(resp.ok()).toBeTruthy();
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
}
}
// Sélectionne tout le bloc et simule la fin du glisser-souris
async function selectBlock(page) {
await page.evaluate(() => {
const el = document.querySelector('#_blocksCt [data-bid]');
const r = document.createRange();
r.selectNodeContents(el);
const s = getSelection();
s.removeAllRanges();
s.addRange(r);
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
});
await expect(page.locator('.format-toolbar')).toBeVisible({ timeout: 5000 });
}
test('toolbar de sélection : structure, sous-menus, persistance', async ({ page }) => {
await login(page);
const cookies = await page.context().cookies();
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
const created = await page.request.post(
`${FD_BASE}/board/api/pages?title=Menu highlight ${Date.now()}`,
{ headers: { 'X-CSRF-Token': csrf } }
);
expect(created.status(), await created.text()).toBeLessThan(400);
const { id } = await created.json();
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
await page.click('#_blocksCt [data-bid]');
await page.keyboard.type('Menu lors d un highlight de texte');
// ── 1. structure : 4 lignes + Skills + pied ──
await selectBlock(page);
const bar = page.locator('.format-toolbar');
await expect(bar.locator('.ft-row')).toHaveCount(4);
await expect(bar.locator('.ft-style')).toContainText('Normal Text');
await expect(bar.locator('.ft-group')).toHaveText('Skills');
await expect(bar.locator('.ft-skill')).toHaveCount(4);
await expect(bar.locator('.ft-skill').first()).toHaveText('Improve writing');
await expect(bar.locator('.ft-foot')).toContainText('Edit with AI');
// géométrie : sous la sélection, entièrement dans le viewport
const barBox = await bar.boundingBox();
const selBox = await page.evaluate(() => {
const r = getSelection().getRangeAt(0).getBoundingClientRect();
return { bottom: r.bottom };
});
expect(barBox.y).toBeGreaterThanOrEqual(selBox.bottom);
expect(barBox.y + barBox.height).toBeLessThanOrEqual(900);
expect(barBox.x).toBeGreaterThanOrEqual(0);
expect(barBox.x + barBox.width).toBeLessThanOrEqual(1440);
await page.screenshot({ path: 'shots/v762-toolbar.png' });
// ── 2. ligne 1 → sous-menu « Turn into » ──
await bar.locator('.ft-style').click();
const sub = page.locator('#blockMenuSub');
await expect(sub).toBeVisible();
await expect(sub).toContainText('Heading 4');
await expect(sub.locator('.bm-check')).toHaveCount(1); // type courant coché
await page.screenshot({ path: 'shots/v762-turn-submenu.png' });
await page.keyboard.press('Escape');
// ── 3. « … » → menu contextuel de bloc (recherche + pied) ──
await selectBlock(page);
await bar.locator('.ft-more').click();
const menu = page.locator('#blockMenu');
await expect(menu).toBeVisible();
await expect(menu.locator('.bm-search')).toHaveAttribute('placeholder', 'Search actions...');
await expect(menu).toContainText('Copy link to block');
await expect(menu.locator('.bm-foot')).toContainText('Last edited by');
await page.screenshot({ path: 'shots/v762-more-menu.png' });
await page.keyboard.press('Escape');
await expect(menu).toBeHidden();
// ── 4. persistance : B (gras) survit au reload ──
await selectBlock(page);
await page.getByRole('button', { name: 'B', exact: true }).click();
await expect(page.locator('#_blocksCt [data-bid] b, #_blocksCt [data-bid] strong')).toHaveCount(1);
await page.waitForTimeout(2500); // autosave 1,5 s
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
await expect(page.locator('#_blocksCt [data-bid] strong')).toHaveCount(1); // rendu mdEsc
// ── 5. persistance : S (surlignage) survit au reload ──
await selectBlock(page);
await bar.locator('.ft-hl').click();
await expect(page.locator('#_blocksCt [data-bid] mark')).toHaveCount(1);
await page.waitForTimeout(2500);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
await expect(page.locator('#_blocksCt [data-bid] mark')).toHaveCount(1);
// ── 6. fermeture hors sélection ──
await page.mouse.click(10, 400);
await expect(page.locator('.format-toolbar')).toBeHidden();
});
+125
View File
@@ -0,0 +1,125 @@
const { test, expect } = require('@playwright/test');
/**
* v7.63 — menus mobiles façon Notion (viewport 390×844) :
* - « / » → barre horizontale défilante sous le curseur (15 boutons, ordre du doc)
* - « + » → feuille plein écran « Insert block » (titre centré + Cancel, groupes,
* raccourcis) → choix d'un type = applySlash
* - « Turn into » / « Color » → feuilles réutilisant les sous-menus de bloc
* - « More » → menu contextuel de bloc (desktop)
* Instance de test attendue sur FD_BASE_URL (défaut 8081).
*/
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
test.use({ serviceWorkers: 'block', viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page
.waitForURL('**/workspaces', { timeout: 8000 })
.then(() => true)
.catch(() => false);
if (!ok) {
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
data: { email: USER, password: PASS, name: 'E2E' },
});
if (resp.status() !== 409) expect(resp.ok()).toBeTruthy();
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
}
}
async function newPage(page) {
const cookies = await page.context().cookies();
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
const created = await page.request.post(
`${FD_BASE}/board/api/pages?title=Mobile menus ${Date.now()}`,
{ headers: { 'X-CSRF-Token': csrf } }
);
expect(created.status(), await created.text()).toBeLessThan(400);
const { id } = await created.json();
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
return id;
}
test('barre mobile au / + feuilles Insert block / Turn into / More', async ({ page }) => {
await login(page);
await newPage(page);
await page.click('#_blocksCt [data-bid]');
await page.keyboard.type('/');
// ── 1. la barre apparaît, 15 boutons dans l'ordre de la description ──
const bar = page.locator('#fdMobileBar');
await expect(bar).toBeVisible({ timeout: 5000 });
const keys = await page.evaluate(() =>
Array.from(document.querySelectorAll('#fdMobileBar [data-mtb]')).map((b) => b.dataset.mtb));
expect(keys).toEqual(['plus', 'turn', 'at', 'comment', 'image', 'trash',
'left', 'right', 'up', 'down', 'undo', 'redo', 'color', 'more', 'kbd']);
// défilement latéral
const overflowX = await bar.evaluate((el) => getComputedStyle(el).overflowX);
expect(overflowX).toBe('auto');
// pas de popup desktop en parallèle
await expect(page.locator('#_slashMenu')).toBeHidden();
await page.screenshot({ path: 'shots/v763-bar.png' });
// ── 2. « + » → feuille plein écran « Insert block » ──
await bar.locator('[data-mtb="plus"]').click();
const sheet = page.locator('.fd-msheet');
await expect(sheet).toBeVisible();
await expect(sheet.locator('.fd-msheet-title')).toHaveText('Insert block');
await expect(sheet.locator('.fd-msheet-cancel')).toHaveText('Cancel');
await expect(sheet.locator('.sm-group').first()).toHaveText('Basic blocks');
await expect(sheet.locator('.slash-item', { hasText: 'Heading 1' })).toHaveCount(1);
await page.screenshot({ path: 'shots/v763-insert.png' });
// ── 3. choix d'un type → applySlash (le bloc devient un titre) ──
await sheet.locator('.slash-item', { hasText: 'Heading 1' }).click();
await expect(sheet).toHaveCount(0);
await expect(bar).toBeHidden();
expect(await page.evaluate(() => window.E.blocks[0].type)).toBe('heading_1');
// ── 4. « Turn into » → feuille réutilisant les items du sous-menu de bloc ──
await page.keyboard.type('/');
await expect(bar).toBeVisible();
await bar.locator('[data-mtb="turn"]').click();
await expect(sheet.locator('.fd-msheet-title')).toHaveText('Turn into');
await expect(sheet.locator('.bm-item', { hasText: 'Bulleted list' })).toHaveCount(1);
await expect(sheet.locator('.bm-check')).toHaveCount(1); // type courant coché
await page.screenshot({ path: 'shots/v763-turn.png' });
await sheet.locator('.fd-msheet-cancel').click();
await expect(sheet).toHaveCount(0);
expect(await page.evaluate(() => window.E.blocks[0].type)).toBe('heading_1');
// ── 5. « Color » → feuille avec les 3 groupes ──
await page.evaluate(() => localStorage.setItem('fdLastBlockColor', 'color|#E5484D'));
await bar.locator('[data-mtb="color"]').click();
for (const g of ['Last used', 'Text color', 'Background color']) {
await expect(sheet.locator('.bm-group', { hasText: g })).toHaveCount(1);
}
await sheet.locator('.fd-msheet-cancel').click();
// ── 6. « More » → menu contextuel de bloc (recherche + pied) ──
await bar.locator('[data-mtb="more"]').click();
const menu = page.locator('#blockMenu');
await expect(menu).toBeVisible();
await expect(menu.locator('.bm-search')).toHaveAttribute('placeholder', 'Search actions...');
await expect(menu.locator('.bm-foot')).toContainText('Last edited by');
await page.keyboard.press('Escape');
await expect(menu).toBeHidden();
// ── 7. ↑ déplace réellement le bloc ── (bloc 0 = titre, bloc 1 = paragraphe)
await page.click('#_blocksCt [data-bid]'); // bloc 0 (contient '/')
await page.keyboard.press('Enter');
await page.waitForTimeout(150); // le focus du nouveau bloc est différé (60 ms)
await page.keyboard.type('/'); // bloc neuf et vide → slash exact = ouverture
await expect(bar).toBeVisible(); // SM._i = 1 (bloc du bas)
await bar.locator('[data-mtb="up"]').click();
expect(await page.evaluate(() => window.E.blocks.map((b) => b.type).join(',')))
.toBe('paragraph,heading_1');
await page.screenshot({ path: 'shots/v763-moved.png' });
});
+218 -9
View File
@@ -2915,35 +2915,244 @@ button.breadcrumb-current { max-width: 260px; }
font-size: 14px;
}
/* FORMAT TOOLBAR (FLOATING) */
/* FORMAT TOOLBAR (FLOATING) — menu contextuel au highlight (v7.62, façon Notion) */
.format-toolbar {
position: fixed;
z-index: 998;
display: flex;
align-items: center;
gap: 2px;
flex-direction: column;
align-items: stretch;
gap: 1px;
min-width: 216px;
max-height: calc(100vh - 16px);
overflow-y: auto;
background: var(--bg-modal);
border: 1px solid var(--border-light);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
padding: 4px 6px;
padding: 6px;
}
.format-toolbar button {
width: 30px;
.format-toolbar .ft-row {
display: flex;
align-items: center;
gap: 2px;
}
.format-toolbar .ft-item {
height: 28px;
min-width: 28px;
padding: 0 7px;
border: none;
background: transparent;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
color: var(--text-primary);
font-size: 14px;
font-size: 13px;
cursor: pointer;
transition: background 0.1s;
}
.format-toolbar button:hover {
.format-toolbar .ft-item:hover {
background: var(--bg-hover);
}
.format-toolbar .ft-item svg {
display: block;
}
/* Ligne 1 : style de texte (libellé du type de bloc + sous-menu) */
.format-toolbar .ft-style {
width: 100%;
justify-content: flex-start;
}
.format-toolbar .ft-style-ico {
font-weight: 600;
color: var(--text-secondary);
}
.format-toolbar .ft-style-name {
flex: 1;
text-align: left;
}
.format-toolbar .ft-caret {
color: var(--text-dim);
font-size: 15px;
line-height: 1;
}
/* « A » : couleur du texte (barre colorée sous la lettre) */
.format-toolbar .ft-a-letter {
position: relative;
font-weight: 600;
}
.format-toolbar .ft-a-letter::after {
content: '';
position: absolute;
left: 1px;
right: 1px;
bottom: -4px;
height: 3px;
border-radius: 2px;
background: var(--accent);
}
.format-toolbar .ft-tx,
.format-toolbar .ft-code-ico {
font-size: 12px;
font-family: var(--font-mono, monospace);
}
.format-toolbar .ft-hl {
border-radius: 2px;
padding: 0 3px;
background: rgba(255,197,61,.28);
}
.format-toolbar .ft-more {
font-size: 16px;
letter-spacing: 1px;
}
/* Séparateurs, groupe « Skills », pied de page */
.format-toolbar .ft-sep {
height: 1px;
background: var(--border);
margin: 5px 2px;
}
.format-toolbar .ft-group {
font-size: 11px;
color: var(--text-dim);
padding: 3px 7px 2px;
cursor: default;
}
.format-toolbar .ft-skill {
height: 26px;
justify-content: flex-start;
font-size: 13px;
color: var(--text-secondary);
}
.format-toolbar .ft-foot {
height: 26px;
width: 100%;
justify-content: space-between;
font-size: 12px;
color: var(--text-secondary);
}
.format-toolbar .ft-foot kbd {
font-family: inherit;
font-size: 11px;
color: var(--text-dim);
}
/* Info-bulle des éléments du menu de sélection : bulle claire, à droite */
.tooltip.tooltip-light {
background: #ffffff;
border-color: #e3e3e3;
color: #191919;
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
white-space: normal;
max-width: 240px;
line-height: 1.35;
}
.tooltip.tooltip-light em {
color: #6b6b6b;
}
/* ══ v7.63 — Menus mobiles façon Notion : barre horizontale au « / » ══
(défilement latéral) + feuille plein écran Insert block / Turn into / Color ══ */
.fd-mtb {
position: fixed;
z-index: 1300;
display: none;
gap: 2px;
padding: 5px 6px;
max-width: calc(100vw - 16px);
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
background: var(--bg-modal);
border: 1px solid var(--border-light);
border-radius: 10px;
box-shadow: 0 6px 20px rgba(0,0,0,.45);
}
.fd-mtb::-webkit-scrollbar { display: none; }
.fd-mtb .fd-mtb-item {
flex: 0 0 auto;
min-width: 38px;
height: 38px;
padding: 0 8px;
border: none;
background: transparent;
border-radius: 6px;
color: var(--text-primary);
font-size: 16px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
cursor: pointer;
}
.fd-mtb .fd-mtb-item:active { background: var(--bg-hover); }
.fd-mtb .fd-mtb-wide { font-size: 13px; font-weight: 600; padding: 0 10px; }
.fd-mtb .fd-mtb-caret { font-size: 10px; color: var(--text-dim); }
/* Feuille plein écran (titre centré + Cancel bleu à droite, liste qui défile) */
.fd-msheet {
position: fixed;
inset: 0;
z-index: 1500;
display: flex;
flex-direction: column;
background: var(--bg-secondary, #191919);
}
.fd-msheet-head {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
flex: 0 0 auto;
}
.fd-msheet-title { font-size: 15px; font-weight: 600; color: var(--text-primary); }
.fd-msheet-cancel {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--accent, #2383E2);
font-size: 14px;
font-weight: 600;
padding: 6px;
cursor: pointer;
}
/* la liste porte la classe block-menu (hérite des styles .bm-item/.bm-group)
mais doit couvrir tout l'écran, sans la chrome du menu flottant */
.fd-msheet-list.block-menu {
max-width: none;
min-width: 0;
background: none;
border: none;
border-radius: 0;
box-shadow: none;
padding: 6px 8px 40px;
flex: 1 1 auto;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.fd-msheet .bm-item { padding: 12px 10px; font-size: 15px; }
.fd-msheet .bm-group { padding: 14px 10px 4px; }
.fd-msheet .sm-group {
padding: 14px 10px 4px;
font-size: 11px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: .4px;
}
.fd-msheet .slash-item { padding: 12px 10px; font-size: 15px; }
.fd-msheet .slash-item:active { background: var(--bg-hover); }
/* EDITOR STATUSBAR */
.editor-statusbar {
@@ -3643,7 +3852,7 @@ button.breadcrumb-current { max-width: 260px; }
.slash-menu { width: 90vw; max-width: 90vw; left: 5vw !important; }
/* ── Format Toolbar ── */
.format-toolbar { left: 8px !important; right: 8px; flex-wrap: wrap; justify-content: center; }
.format-toolbar { left: 8px !important; right: 8px; }
}
/* ═══════════════════════════════════════════════════════════
+18 -5
View File
@@ -84,19 +84,32 @@
// Tooltip system
document.addEventListener('mouseover', function(e) {
const target = e.target.closest('[data-tooltip]');
const target = e.target.closest('[data-tooltip], [data-tooltip-html]');
if (!target) return;
const tip = document.getElementById('tooltip');
if (!tip) return;
tip.textContent = target.getAttribute('data-tooltip');
const html = target.getAttribute('data-tooltip-html');
if (html) tip.innerHTML = html;
else tip.textContent = target.getAttribute('data-tooltip') || '';
// v7.62 : éléments du menu de sélection → info-bulle claire à droite (façon Notion)
const inToolbar = !!target.closest('.format-toolbar');
tip.classList.toggle('tooltip-light', inToolbar);
tip.style.display = 'block';
const rect = target.getBoundingClientRect();
tip.style.left = rect.left + 'px';
tip.style.top = (rect.bottom + 4) + 'px';
if (inToolbar) {
tip.style.left = (rect.right + 8) + 'px';
tip.style.top = Math.max(8, rect.top + rect.height / 2 - tip.offsetHeight / 2) + 'px';
if (rect.right + 8 + tip.offsetWidth > window.innerWidth) {
tip.style.left = Math.max(8, rect.left - tip.offsetWidth - 8) + 'px';
}
} else {
tip.style.left = rect.left + 'px';
tip.style.top = (rect.bottom + 4) + 'px';
}
});
document.addEventListener('mouseout', function(e) {
const target = e.target.closest('[data-tooltip]');
const target = e.target.closest('[data-tooltip], [data-tooltip-html]');
if (!target) return;
const tip = document.getElementById('tooltip');
if (tip) tip.style.display = 'none';
+245 -31
View File
@@ -52,6 +52,57 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
return out;
}
window.gtTok=gtTok;
// v7.62.0 : lecture d'un bloc vers sa source markdown, en passant par le
// DOM formaté (bold/italic/underline/strike/code/mark/liens inline posés
// par le menu de sélection). Sans ça, sync() relisait du texte brut et
// perdait tout le formatage au prochain render(). Sans balise de mise en
// forme, on retombe sur gtTok (comportement identique à l'ancien).
function gtMd(e){
if(!e) return '';
if(!e.querySelector||!e.querySelector('strong,b,em,i,u,s,del,strike,code,mark,a[href]')) return gtTok(e);
function w(node){
var out='';
for(var i=0;i<node.childNodes.length;i++){
var n=node.childNodes[i];
if(n.nodeType===3){out+=n.nodeValue;continue;}
if(n.nodeType!==1)continue;
var cl=n.classList;
if(cl&&(cl.contains('fd-wiki-chip')||cl.contains('fd-wiki-date'))){out+=n.getAttribute('data-token')||'';continue;}
if(n.tagName==='BR'){out+='\n';continue;}
if(cl&&cl.contains('fd-math-inline')){out+='$$'+(n.getAttribute('data-tex')||'')+'$$';continue;}
var t=n.tagName, inner=w(n);
if((t==='STRONG'||t==='B')&&inner){out+='**'+inner+'**';continue;}
if((t==='EM'||t==='I')&&inner){out+='*'+inner+'*';continue;}
if(t==='U'&&inner){out+='__'+inner+'__';continue;}
if((t==='S'||t==='DEL'||t==='STRIKE')&&inner){out+='~~'+inner+'~~';continue;}
if(t==='CODE'&&inner&&!(n.hasAttribute&&n.hasAttribute('data-bid'))){out+='`'+inner+'`';continue;}
if(t==='MARK'&&inner){out+='=='+inner+'==';continue;}
if(t==='A'){var h=n.getAttribute('href')||'';out+=/^https?:/i.test(h)?'['+(inner||h)+']('+h+')':inner;continue;}
out+=inner;
}
return out;
}
return w(e);
}
window.gtMd=gtMd;
// Enveloppe/désenveloppe une sélection dans une balise inline (code, mark)
function _selWrapHost(r,tag){
var a=r.startContainer.nodeType===1?r.startContainer:r.startContainer.parentElement;
var b=r.endContainer.nodeType===1?r.endContainer:r.endContainer.parentElement;
a=a&&a.closest(tag);b=b&&b.closest(tag);
if(!a||a!==b)return null;
return (a.hasAttribute&&a.hasAttribute('data-bid'))?null:a; // <code> racine d'un bloc code : ne pas dépiler
}
function _selWrapToggle(tag){
var s=window.getSelection();if(!s.rangeCount)return;
var r=s.getRangeAt(0);
var host=_selWrapHost(r,tag);
if(host){var p=host.parentNode;while(host.firstChild)p.insertBefore(host.firstChild,host);p.removeChild(host);p.normalize();return;}
var el=document.createElement(tag);
try{r.surroundContents(el);}catch(e){el.appendChild(r.extractContents());r.insertNode(el);}
var s2=window.getSelection(),nr=document.createRange();
nr.selectNodeContents(el);s2.removeAllRanges();s2.addRange(nr);
}
function splitCaret(e){
const s=window.getSelection(); if(!s.rangeCount||!s.rangeCount)return null; const sel=s.getRangeAt(0);
const MK='\uF000FD'; const mark=document.createElement('span'); mark.textContent=MK;
@@ -73,6 +124,9 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
.replace(/\*\*([^*]+)\*\*/g,'<strong>$1</strong>')
.replace(/\*([^*]+)\*/g,'<em>$1</em>')
.replace(/~~([^~]+)~~/g,'<s>$1</s>')
.replace(/__([^_]+)__/g,'<u>$1</u>') // souligné (toolbar de sélection)
.replace(/==([^=]+)==/g,'<mark>$1</mark>') // surlignage
.replace(/\$\$([^$]+)\$\$/g,function(m,t){return '<span class="fd-math-inline" data-tex="'+t.replace(/"/g,'&quot;')+'"></span>';}) // équation inline
.replace(/\[\[fdpage:(\d+)\]\]/g,function(m,pid){
return '<a class="fd-wiki-chip" contenteditable="false" href="/pages/'+pid+'" data-token="'+m+'" data-pid="'+pid+'" title="Linked page"><span class="fd-wiki-label">…</span></a>';})
.replace(/\[\[fddate:([0-9]{4}-[0-9]{2}-[0-9]{2})(?:T[0-9:]{5,8})?\]\]/g,function(m,iso){
@@ -266,8 +320,9 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
}
// ── Block type shortcuts (shown on the right of each slash item) ──
const _SHORTCUT={text:'T',heading_1:'#',heading_2:'##',heading_3:'###',heading_4:'####',bulleted_list:'•',numbered_list:'1.',to_do:'[ ]',toggle:'▸',quote:'"',callout:'!',table_of_contents:'TOC',math:'∑',columns:'‖',divider:'———',code:'<>',table:'|',image:'[img]',embed:'[🔗]',bookmark:'[🔖]',video:'[🎬]',audio:'[🎵]',database:'DB',ai_write:'/ai',ai_summarize:'/ai',ai_translate:'/ai',ai_continue:'/ai',duplicate:'Ctrl+D',turn_into:'⋯',copy_link:'#',delete:'⌫'};
const _GROUP_LABEL={BASIC:'Basic blocks',AI:'AI writing',MEDIA:'Media',DATA:'Data',ACTIONS:'Actions'};
// Raccourcis = préfixes markdown (alignés sur la description mobile Notion : -, [], >, ---)
const _SHORTCUT={text:'T',heading_1:'#',heading_2:'##',heading_3:'###',heading_4:'####',bulleted_list:'-',numbered_list:'1.',to_do:'[]',toggle:'>',quote:'"',callout:'!',table_of_contents:'TOC',math:'∑',columns:'‖',divider:'---',code:'<>',table:'|',image:'[img]',embed:'[🔗]',bookmark:'[🔖]',video:'[🎬]',audio:'[🎵]',database:'DB',ai_write:'/ai',ai_summarize:'/ai',ai_translate:'/ai',ai_continue:'/ai',duplicate:'Ctrl+D',turn_into:'⋯',copy_link:'#',delete:'⌫'};
const _GROUP_LABEL={BASIC:'Basic blocks',AI:'AI writing',MEDIA:'Media',DATA:'Data',ACTIONS:'Actions',SYNCED:'Synced'};
// ── Hover preview card (matches Notion's visual hint) ──
function _previewHtml(blockId){
const p=function(inner,cls){return '<div class="slash-prev" style="padding:0">'+inner+'</div>';};
@@ -311,7 +366,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
inp.oninput=()=>SM.flt();
e.appendChild(list);e.appendChild(footer);e.appendChild(inp);
SM._e=e;SM._in=inp;SM._li=list;SM._f=footer;
document.addEventListener('mousedown',ev=>{if(!e.contains(ev.target))SM.close();});
document.addEventListener('mousedown',ev=>{if(!e.contains(ev.target)&&!MTB.contains(ev.target))SM.close();});
if(!SM._doc){
SM._doc=true;
document.addEventListener('keydown',function(ev){
@@ -336,19 +391,22 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
SM._pv.style.left=left+'px'; SM._pv.style.top=top+'px'; SM._pv.style.display='block';
},
open(i,el){if(!SM._e)SM.init();SM.close();SM._i=i;SM._s=0;SM._in.value='';SM.flt();
// v7.63 : mobile → barre horizontale façon Notion sous le curseur
if(_fdMobile()){SM._o=true;MTB.open(i,el);return;}
const r=el.getBoundingClientRect();SM._e.style.display='flex';
SM._e.style.top=Math.max(4,(r.bottom+4>window.innerHeight-360)?r.top-364:r.bottom+4)+'px';
SM._e.style.left=Math.min(r.left,window.innerWidth-340)+'px';
SM._rs();setTimeout(()=>SM._in.focus(),10);SM._o=true;},
close(){if(SM._e)SM._e.style.display='none';SM._o=false;SM._in&&(SM._in.value='');if(SM._pv)SM._pv.style.display='none';},
close(){if(SM._e)SM._e.style.display='none';SM._o=false;SM._in&&(SM._in.value='');if(SM._pv)SM._pv.style.display='none';MTB.close();},
// Ligne d'item du slash (partagée par la popup desktop et la feuille mobile)
_slashItemHtml(c){return `<div class="slash-item" data-sid="${c.id}" style="display:flex;align-items:center;gap:10px;padding:7px 10px;font-size:14px;color:var(--text-primary);cursor:pointer;border-radius:5px">
<span class="slash-item-icon" style="width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-size:15px;background:var(--bg-secondary);border-radius:4px;flex-shrink:0">${c.icon}</span>
<span class="slash-item-name" style="flex:1;font-weight:500">${c.name}</span>
<span class="slash-item-hint" style="font-size:12px;color:var(--text-dim);font-family:var(--font-mono);opacity:.7">${_SHORTCUT[c.id]||''}</span></div>`;},
flt(){if(!SM._li)return;SM._it=[];const q=(SM._in.value||'').toLowerCase();SM._q=q;let h='';
for(const g of CMDS){let items=g.items;if(q)items=items.filter(c=>c.name.toLowerCase().includes(q)||c.id.includes(q));if(!items.length)continue;
h+=`<div class="sm-group" style="padding:6px 10px 3px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.4px">${_GROUP_LABEL[g.name]||g.name}</div>`;
items.forEach(c=>{SM._it.push(c);
h+=`<div class="slash-item" data-sid="${c.id}" style="display:flex;align-items:center;gap:10px;padding:7px 10px;font-size:14px;color:var(--text-primary);cursor:pointer;border-radius:5px">
<span class="slash-item-icon" style="width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-size:15px;background:var(--bg-secondary);border-radius:4px;flex-shrink:0">${c.icon}</span>
<span class="slash-item-name" style="flex:1;font-weight:500">${c.name}</span>
<span class="slash-item-hint" style="font-size:12px;color:var(--text-dim);font-family:var(--font-mono);opacity:.7">${_SHORTCUT[c.id]||''}</span></div>`;});}
items.forEach(c=>{SM._it.push(c);h+=SM._slashItemHtml(c);});}
SM._li.innerHTML=h;SM._rs();},
_kd(e){if(e.key==='ArrowDown'){e.preventDefault();SM._s=Math.min(SM._s+1,SM._it.length-1);SM._rs();}
else if(e.key==='ArrowUp'){e.preventDefault();SM._s=Math.max(SM._s-1,0);SM._rs();}
@@ -358,6 +416,64 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
sel(id){if(window.E)window.E.applySlash(id);SM.close();},
get isOpen(){return SM._o;}};
// ═══════════ v7.63.0 — Menus mobiles façon Notion : barre horizontale sous le curseur
// + feuille plein écran « Insert block » / « Turn into » / « Color ». Tout est
// branché sur les actions existantes (applySlash, turnInto, blockMenu, moveBlock…).
function _fdMobile(){try{return window.matchMedia('(max-width: 768px)').matches;}catch(e){return false;}}
const MTB={
_b:null,_sheet:null,
contains(t){return !!t&&((this._b&&this._b.contains(t))||(this._sheet&&this._sheet.contains(t)));},
el(){ if(this._b)return this._b;
const wide=['plus','turn','color','more'];
const items=[['plus','+<span class="fd-mtb-caret">▾</span>'],['turn','Turn into<span class="fd-mtb-caret">▾</span>'],
['at','@'],['comment','💬'],['image','🖼️'],['trash','🗑️'],
['left','←'],['right','→'],['up','↑'],['down','↓'],['undo','↩'],['redo','↪'],
['color','Color<span class="fd-mtb-caret">▾</span>'],['more','More<span class="fd-mtb-caret">▾</span>'],['kbd','⌨️']];
const d=document.createElement('div');d.id='fdMobileBar';d.className='fd-mtb';
d.innerHTML=items.map(it=>'<button type="button" class="fd-mtb-item'+(wide.indexOf(it[0])>=0?' fd-mtb-wide':'')+'" data-mtb="'+it[0]+'">'+it[1]+'</button>').join('');
d.addEventListener('click',ev=>{const bt=ev.target.closest('[data-mtb]');if(bt)MTB.act(bt.getAttribute('data-mtb'),bt);});
document.body.appendChild(d);this._b=d;return d;},
open(i,el){const d=this.el();d.style.display='flex';
const r=el.getBoundingClientRect();
const w=Math.min(d.scrollWidth||280,window.innerWidth-16);
d.style.left=Math.max(8,Math.min(r.left,window.innerWidth-w-8))+'px';
let top=r.bottom+6;if(top+54>window.innerHeight-8)top=Math.max(8,r.top-54);
d.style.top=top+'px';d.scrollLeft=0;},
close(){if(this._b)this._b.style.display='none';this.closeSheet();},
closeSheet(){if(this._sheet){this._sheet.remove();this._sheet=null;}},
sheet(title,html,handler){
this.closeSheet();
const s=document.createElement('div');s.className='fd-msheet';
s.innerHTML='<div class="fd-msheet-head"><span class="fd-msheet-title">'+title+'</span><button type="button" class="fd-msheet-cancel">Cancel</button></div><div class="fd-msheet-list block-menu">'+html+'</div>';
s.querySelector('.fd-msheet-cancel').addEventListener('click',()=>{const E=window.E;this.closeSheet();if(E&&SM._i>=0&&E.blocks[SM._i])E._focusBid(E.blocks[SM._i].id);});
s.addEventListener('click',handler);
document.body.appendChild(s);this._sheet=s;},
_insertHtml(){let h='';for(const g of CMDS){if(!g.items||!g.items.length)continue;
h+='<div class="sm-group">'+(_GROUP_LABEL[g.name]||g.name)+'</div>';
g.items.forEach(c=>{h+=SM._slashItemHtml(c);});}return h;},
_bmAct(ev){if(!ev.target||!ev.target.closest||!ev.target.closest('[data-bm-act]'))return;_blockMenuClick(ev);this.closeSheet();},
act(k,btn){
const E=window.E;if(!E)return;
const idx=SM._i;const b=(idx>=0&&idx<E.blocks.length)?E.blocks[idx]:null;
const bid=b?b.id:'';
if(k==='plus'){this.sheet('Insert block',this._insertHtml(),ev=>{const it=ev.target.closest('.slash-item');if(it)SM.sel(it.dataset.sid);});return;}
if(k==='turn'){this.sheet('Turn into',_blockTurnItems(bid),ev=>this._bmAct(ev));return;}
if(k==='color'){this.sheet('Color',_blockColorItems(bid),ev=>this._bmAct(ev));return;}
if(k==='more'){const el=E.getEl(bid);const r=el?el.getBoundingClientRect():btn.getBoundingClientRect();
this.close();blockMenuOpen(Math.max(8,Math.min(r.left,window.innerWidth-250)),Math.min(r.bottom+6,window.innerHeight-120),bid);return;}
if(k==='kbd'){const a=document.activeElement;if(a&&a.blur)a.blur();return;}
if(!b)return;
if(k==='at'){const el=E.getEl(bid);if(!el)return;el.focus();document.execCommand('insertText',false,'@');
E.dirty=true;E.autoSave();E.openMention(el);return;}
if(k==='comment'){E.commentBlock(idx);return;}
if(k==='image'){E.applySlash('image');return;}
if(k==='trash'){E.deleteSelected(idx);return;}
if(k==='up'||k==='down'||k==='left'||k==='right'){E.moveBlock(bid,k);return;}
if(k==='undo'){E.undo();return;}
if(k==='redo'){E.redo();return;}
}
};
// ═══════════ v5.11.0 Wiki-link picker ([[ → page search) ═══════════
const WM={_o:false,_idx:-1,_s:0,_it:[],_root:null,_in:null,_li:null,_pv:null,
mk(){ if(this._root)return this._root;
@@ -962,6 +1078,8 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
s.addEventListener('mouseleave',function(){_blockSubCloseLater();});}
}
function _blockSubCloseLater(){clearTimeout(_bmSubTimer);_bmSubTimer=setTimeout(function(){var s=document.getElementById('blockMenuSub');if(s&&!s.matches(':hover')){s.remove();_bmSubKind='';}},260);}
// Sous-menu ouvert depuis la toolbar de sélection (pas de #blockMenu parent)
function _subHoverClose(){var s=document.getElementById('blockMenuSub');if(s)s.addEventListener('mouseleave',function(){_blockSubCloseLater();});}
function _blockMenuClick(ev){
var t=ev.target.closest&&ev.target.closest('[data-bm-act]');if(!t)return;
var act=t.getAttribute('data-bm-act');var bid=t.getAttribute('data-bid')||_blockMenuBid();
@@ -976,7 +1094,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
else if(act==='delete'){E.deleteSelected(idx);}
else if(act==='comment'){E.commentBlock(idx);}
else if(act==='suggest'){E.suggestEdit(idx);}
else if(act==='ai'){E.askAI(idx);}
else if(act==='ai'){E.askAIBlock(idx);}
else if(act==='skill'){E.runSkill(idx,t.getAttribute('data-prompt')||'');}
else if(act==='color'){var key=t.getAttribute('data-color-key'),val=t.getAttribute('data-color-val');E.setBlockColor(idx,key,val);}
else if(act==='turn-type'){E.turnInto(idx,t.getAttribute('data-type'),parseInt(t.getAttribute('data-cols')||'0',10)||0);_blockMenuClose();return;}
@@ -1069,8 +1187,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
d.style.left=x+'px';d.style.top=y+'px';
return d;
}
function _blockTurnMenuOpen(x,y,bid){
var d=_blockSubShell(x,y);
function _blockTurnItems(bid){
var E=window.E;var cur='';
if(E){var i=E.getIdx(bid);if(i>=0&&E.blocks[i])cur=E.blocks[i].type||'';}
var html='';
@@ -1080,10 +1197,13 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
title:t.cols?('Create '+t.cols+' columns of blocks'):'',
check:on,current:on});
});
d.innerHTML=html;
return html;
}
function _blockColorMenuOpen(x,y,bid){
function _blockTurnMenuOpen(x,y,bid){
var d=_blockSubShell(x,y);
d.innerHTML=_blockTurnItems(bid);
}
function _blockColorItems(bid){
var E=window.E;var idx=E?E.getIdx(bid):-1;var b=idx>=0?E.blocks[idx]:null;
var curT=(b&&b.style&&b.style.color)||'default';
var curB=(b&&b.style&&b.style.bgColor)||'default';
@@ -1111,7 +1231,11 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
_BLOCK_COLOR_NAMES[n]+' background',{bid:bid,colorKey:'bgColor',colorVal:v,
check:v===curB,current:v===curB});
});
d.innerHTML=html;
return html;
}
function _blockColorMenuOpen(x,y,bid){
var d=_blockSubShell(x,y);
d.innerHTML=_blockColorItems(bid);
}
function _blockSkillsMenuOpen(x,y,bid){
var d=_blockSubShell(x,y);
@@ -1397,14 +1521,15 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
sync(){const ct=document.getElementById('_blocksCt');if(!ct)return;for(const b of this.blocks){if(b.type==='table'){const wrap=ct.querySelector(`[data-bid="${b.id}"].ftable-editor`);if(wrap){const rows=[];wrap.querySelectorAll('tr.ftable-row').forEach(function(tr){const cells=tr.querySelectorAll('th.ftable-cell,td.ftable-cell');if(!cells.length)return;const rowarr=[];cells.forEach(function(cell){rowarr.push(cell.textContent||'');});rows.push(rowarr);});if(rows.length)b.rows=rows;}continue;}
if(b.type==='divider'||b.type==='image'||b.type==='embed'||b.type==='table_of_contents'||b.type==='button')continue;
if(b.type==='math'){const te=ct.querySelector(`[data-math-bid="${b.id}"]`);if(te)b.content=te.value||'';continue;}
const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el&&!el.classList.contains('toggle-title'))b.content=gtTok(el)||'';else if(el)b.content=gtTok(el)||'';
if(b.type==='toggle'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gtTok(che)||'';});
if(b.type==='columns'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gtTok(che)||'';});
const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el&&!el.classList.contains('toggle-title'))b.content=gtMd(el)||'';else if(el)b.content=gtMd(el)||'';
if(b.type==='toggle'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gtMd(che)||'';});
if(b.type==='columns'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gtMd(che)||'';});
}},
render(){const ct=document.getElementById('_blocksCt');if(!ct)return;let html='';for(let i=0;i<this.blocks.length;i++)html+=renderBlock(this.blocks[i],i);ct.innerHTML=html;
// Render math blocks with KaTeX
var self=this;
ct.querySelectorAll('.math-display').forEach(function(el){var bid=el.dataset.bid;var b=self.blocks.find(function(x){return x.id===bid;});self._renderKatex(el,b?b.content:'');});
ct.querySelectorAll('.fd-math-inline').forEach(function(el){var tex=el.getAttribute('data-tex')||'';if(window.katex&&tex){try{el.innerHTML=window.katex.renderToString(tex,{displayMode:false,throwOnError:false});}catch(e){el.textContent=tex;}}else if(tex&&!el.textContent){el.textContent=tex;}});
ct.querySelectorAll('.math-editor').forEach(function(ta){var disp=ct.querySelector(`.math-display[data-bid="${ta.dataset.mathBid}"]`);ta.addEventListener('input',function(){self._renderKatex(disp,ta.value);self.dirty=true;self.autoSave();});});
// Initialize inline database blocks (scripts in innerHTML don't execute)
setTimeout(()=>{ct.querySelectorAll('.block-embed-collection').forEach(el=>{const cid=parseInt(el.id.replace('inline-db-',''));if(cid&&window.FlowDeckDB)window.FlowDeckDB.renderInto(el,cid,null);});},50);
@@ -1680,7 +1805,9 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
if(!this._commentSel||!this._commentSel.range){this.showToast('Sélectionnez du texte pour commenter','error');return;}
this.commentOnSelection();
},
askAI(idx){const b=this.blocks[idx];if(!b)return;if(window.AIC)window.AIC.open(idx);},
// v7.62 : renommé — « askAI() » (sans argument, plus bas) l'écrasait,
// le menu de bloc et Ctrl+J ouvraient l'IA de page au lieu du bloc.
askAIBlock(idx){const b=this.blocks[idx];if(!b)return;if(window.AIC)window.AIC.open(idx);},
suggestEdit(idx){
const b=this.blocks[idx];if(!b||!(b.content||'').trim()){this.showToast('Bloc vide','error');return;}
this.showToast('FlowDeck AI : suggestion en cours…');
@@ -1691,6 +1818,35 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
this.showToast('FlowDeck AI : compétence en cours…');
this._aiWritingRun('write',idx,{prompt:String(tpl||'')+'\n\nContenu du bloc :\n'+b.content},'insert');
},
// v7.63 : déplacement de bloc depuis la barre mobile
// (↑↓ = ordre, → = indent dans le frère précédent, ← = sortir du parent)
moveBlock(bid,dir){
const top=this.blocks.findIndex(b=>b.id===bid);
const pi=this.blocks.findIndex(b=>Array.isArray(b.children)&&b.children.some(c=>c.id===bid));
const parent=pi>=0?this.blocks[pi]:null;
const list=parent?parent.children:this.blocks;
const k=list.findIndex(b=>b.id===bid);
if(k<0){this.showToast('Bloc introuvable','error');return;}
if(dir==='up'||dir==='down'){
const j=dir==='up'?k-1:k+1;
if(j<0||j>=list.length){this.showToast(dir==='up'?'Déjà en haut':'Déjà en bas','error');return;}
this.sync();this.pushHistory();
const mv=list.splice(k,1)[0];list.splice(j,0,mv);
}else if(dir==='right'){
if(parent){this.showToast('Déjà indenté','error');return;}
const prev=this.blocks[top-1];
if(!prev||!Array.isArray(prev.children)||(prev.type!=='toggle'&&prev.type!=='columns')){this.showToast('Pas de bloc cible à droite','error');return;}
this.sync();this.pushHistory();
if(prev.type==='toggle')prev.expanded=true;
const mv=this.blocks.splice(top,1)[0];prev.children.push(mv);
}else{
if(!parent){this.showToast('Déjà à la racine','error');return;}
this.sync();this.pushHistory();
const mv=parent.children.splice(k,1)[0];
this.blocks.splice(pi+1,0,mv);
}
this.dirty=true;this.autoSave();this.render();_rtSync();this._focusBid(bid);
},
applyLastColor(idx){
let v='';try{v=localStorage.getItem('fdLastBlockColor')||'';}catch(e){}
const i=v.indexOf('|');
@@ -2166,7 +2322,9 @@ applyAIBlocks(text){
if(e.key==='Escape'){e.preventDefault();window.AIAC.clear();return;}
if(e.key==='Enter'||e.key==='Backspace'){window.AIAC.clear();}
}
if(e.key.length===1||e.key==='Backspace'||e.key==='Delete'){const i2=idx;setTimeout(()=>{const e2=this.getEl(this.blocks[i2]?.id);if(e2&&e2.textContent?.trim()==='/'&&!SM.isOpen&&!WM.isOpen)SM.open(i2,e2);
if(e.key.length===1||e.key==='Backspace'||e.key==='Delete'){const i2=idx;setTimeout(()=>{const e2=this.getEl(this.blocks[i2]?.id);
if(e2&&e2.textContent?.trim()==='/'&&!SM.isOpen&&!WM.isOpen){SM.open(i2,e2);}
else if(SM.isOpen){const t=(e2&&e2.textContent)||'';if(t.charAt(0)!=='/')SM.close();}
if(e2&&!SM.isOpen&&!WM.isOpen){const t=gtTok(e2)||'';const mm=t.match(/\[\[$/);if(mm){WM.open(i2,e2);}}},15);}
if(e.key===' '){
if(block.type==='paragraph'&&!(block.content||'').trim()&&!(el.textContent||'').trim()&&!SM.isOpen&&window.AIC){
@@ -2193,7 +2351,7 @@ applyAIBlocks(text){
if(e.key==='ArrowUp'){const _sp=splitCaret(el);if(_sp&&!_sp.before.trim()&&idx>0){e.preventDefault();const prev=this.getEl(this.blocks[idx-1]?.id);if(prev){prev.focus();ce(prev);}}return;}
if(e.key==='ArrowDown'){const _sp=splitCaret(el);if(_sp&&!_sp.after.trim()&&idx<this.blocks.length-1){e.preventDefault();const next=this.getEl(this.blocks[idx+1]?.id);if(next){next.focus();cs(next);}}return;}
if(e.key==='Backspace'){if(!(el.textContent||'').trim()){this.sync();if(block.type!=='paragraph'){e.preventDefault();this.pushHistory();block.type='paragraph';block.content='';this.render();_rtSync();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}else if(this.blocks.length>1){e.preventDefault();this.removeBlock(idx);}}return;}
if(e.key===' '){const b2=bid;setTimeout(()=>this.checkMd(b2),15);}
if(e.key===' '){const i3=idx;setTimeout(()=>this.checkMd(i3),15);}
if((e.ctrlKey||e.metaKey)&&!e.altKey){
if(e.key.toLowerCase()==='z'){e.preventDefault();if(e.shiftKey)this.redo();else this.undo();return;}
if(e.key.toLowerCase()==='y'){e.preventDefault();this.redo();return;}
@@ -2315,9 +2473,48 @@ applyAIBlocks(text){
this.render();this.dirty=true;this.autoSave();_rtSync();
}catch(e){this.showToast('Failed to load synced blocks','error');}
},
showFmt(idx){const s=window.getSelection();if(!s.rangeCount||s.isCollapsed){this.fmt.open=false;return;}const r=s.getRangeAt(0).getBoundingClientRect();this.fmt.open=true;this.fmt.idx=idx;this.fmt.top=Math.max(r.top-44,0);this.fmt.left=Math.min(r.left+r.width/2-120,window.innerWidth-260);this._sel={range:s.getRangeAt(0).cloneRange(),idx};},
fmtApply(f){this.fmt.open=false;if(!this._sel)return;const s=window.getSelection();s.removeAllRanges();s.addRange(this._sel.range);document.execCommand(f==='strikeThrough'?'strikeThrough':f);this._sel=null;this.dirty=true;this.autoSave();},
fmtLink(){this.fmt.open=false;const u=prompt('URL:');if(u){document.execCommand('createLink',false,u);this.dirty=true;this.autoSave();}},
// ── v7.62.0 : menu contextuel au highlight de texte (façon Notion) ──
showFmt(idx){const s=window.getSelection();if(!s.rangeCount||s.isCollapsed){this.fmt.open=false;this._sel=null;return;}const r=s.getRangeAt(0).getBoundingClientRect();
const H=300,W=230;let top=r.bottom+8;if(top+H>window.innerHeight-8)top=Math.max(8,r.top-H);
this.fmt.open=true;this.fmt.idx=idx;this.fmt.top=top;
this.fmt.left=Math.max(8,Math.min(r.left+r.width/2-W/2,window.innerWidth-W-8));
this._sel={range:s.getRangeAt(0).cloneRange(),idx};},
_fmtRestore(){const s=window.getSelection();if(this._sel&&this._sel.range){s.removeAllRanges();s.addRange(this._sel.range);}},
_fmtBid(){const b=this.blocks[this.fmt.idx];return b?b.id:'';},
_fmtSaved(){this.sync();this.dirty=true;this.autoSave();},
// style de la 1ʳᵉ ligne : libellé du type de bloc courant
fmtStyleName(){const b=this.blocks[this.fmt.idx];const t=b?b.type:'paragraph';
const m={paragraph:'Normal Text',heading_1:'Heading 1',heading_2:'Heading 2',heading_3:'Heading 3',heading_4:'Heading 4',
bulleted_list:'Bulleted list',numbered_list:'Numbered list',to_do:'To-do list',toggle:'Toggle list',quote:'Quote',
callout:'Callout',code:'Code',math:'Block equation',divider:'Divider',table:'Table',image:'Image',embed:'Embed',button:'Button'};
return m[t]||'Normal Text';},
_fmtSubPos(ev){const r=ev.currentTarget.getBoundingClientRect();
return {x:Math.min(r.right+4,window.innerWidth-250),y:Math.min(Math.max(8,r.top),window.innerHeight-140)};},
// « Normal Text › » → sous-menu Turn into (menu de bloc existant)
fmtStyle(ev){const bid=this._fmtBid();if(!bid)return;const p=this._fmtSubPos(ev);_blockTurnMenuOpen(p.x,p.y,bid);_subHoverClose();},
// « A » → sous-menu Color
fmtColor(ev){const bid=this._fmtBid();if(!bid)return;const p=this._fmtSubPos(ev);_blockColorMenuOpen(p.x,p.y,bid);_subHoverClose();},
// mise en forme de base : le navigateur fait le toggle, sync() persiste (gtMd)
fmtApply(f){if(!this._sel)return;const idx=this.fmt.idx;this._fmtRestore();document.execCommand(f);this._fmtSaved();this.showFmt(idx);},
fmtCode(){if(!this._sel)return;this._fmtRestore();_selWrapToggle('code');this._fmtSaved();this.fmt.open=false;},
fmtHighlight(){if(!this._sel)return;this._fmtRestore();_selWrapToggle('mark');this._fmtSaved();this.fmt.open=false;},
fmtCodeBlock(){this.fmt.open=false;const i=this.fmt.idx;if(i>=0)this.turnInto(i,'code');},
fmtLink(){if(!this._sel)return;this._fmtRestore();const u=prompt('URL:');if(!u)return;
document.execCommand('createLink',false,u);this._fmtSaved();this.fmt.open=false;},
fmtEquation(){if(!this._sel)return;this._fmtRestore();const s=window.getSelection();if(!s.rangeCount)return;
const tex=prompt('Equation (LaTeX):',s.toString()||'x^2');if(tex===null||!tex.trim())return;
const span=document.createElement('span');span.className='fd-math-inline';span.setAttribute('data-tex',tex.trim());
if(window.katex){try{span.innerHTML=window.katex.renderToString(tex.trim(),{displayMode:false,throwOnError:false});}catch(e){span.textContent=tex;}}
else span.textContent=tex;
const r=s.getRangeAt(0);r.deleteContents();r.insertNode(span);
this._fmtSaved();this.fmt.open=false;},
// « … » → le menu contextuel de bloc (recherche + Text + pied métadonnées)
fmtMore(ev){const bid=this._fmtBid();if(!bid)return;const r=ev.currentTarget.getBoundingClientRect();
this.fmt.open=false;blockMenuOpen(Math.max(8,Math.min(r.left-190,window.innerWidth-250)),r.bottom+6,bid);},
fmtComment(){this.fmt.open=false;const i=this.fmt.idx;if(i>=0)this.commentBlock(i);},
fmtAskAI(){this.fmt.open=false;const i=this.fmt.idx;if(i>=0)this.askAIBlock(i);},
fmtEditAI(){this.fmtAskAI();},
fmtSkill(tpl){this.fmt.open=false;const i=this.fmt.idx;if(i>=0)this.runSkill(i,tpl);},
// ── v4.9.0: Collaboration — comments & mentions ──
toggleComments(){this.commentsOpen=!this.commentsOpen;if(this.commentsOpen)this.loadComments();},
async loadComments(){try{const r=await fetch('/api/pages/'+this.pid+'/comments',{credentials:'same-origin'});const d=await r.json();this.comments=d.comments||[];this.commentCount=this.comments.length;}catch(e){this.comments=[];}},
@@ -2686,11 +2883,27 @@ applyAIBlocks(text){
}return bl;},
};
}
document.addEventListener('mouseup',()=>{setTimeout(()=>{const s=window.getSelection();if(!s||s.isCollapsed)return;const ed=document.querySelector('.page-editor-wrapper');if(!ed?.__x)return;const d=ed.__x.$data;const ct=document.getElementById('_blocksCt');if(!ct?.contains(s.anchorNode))return;const bel=s.anchorNode.parentElement?.closest('[data-bid]');if(bel){const idx=d.getIdx(bel.dataset.bid);if(idx>=0)d.showFmt(idx);d.showCommentBtn();}else{d.hideCommentBtn();}},80);});
document.addEventListener('mousedown',()=>{setTimeout(()=>{const ed=document.querySelector('.page-editor-wrapper');if(ed?.__x)ed.__x.$data.hideCommentBtn();},10);});
// v7.62 : data Alpine du wrapper éditeur — Alpine v3 n'expose plus __x
// (l'ancien accès rendait muets showFmt/hideCommentBtn/mentions).
function _xed(el){ if(!el) return null; if(el.__x&&el.__x.$data) return el.__x.$data;
return (el._x_dataStack&&el._x_dataStack[0])||null; }
document.addEventListener('mouseup',(ev)=>{setTimeout(()=>{
// v7.62 : clic dans la toolbar → on laisse la sélection (et le menu) intacts ;
// sélection relâchée/cliquée ailleurs → le menu de sélection se ferme.
if(ev.target&&ev.target.closest&&ev.target.closest('.format-toolbar'))return;
const d=_xed(document.querySelector('.page-editor-wrapper'));if(!d)return;
const s=window.getSelection();
if(!s||s.isCollapsed){d.fmt.open=false;d._sel=null;return;}
const ct=document.getElementById('_blocksCt');
if(!ct?.contains(s.anchorNode)){d.fmt.open=false;d._sel=null;return;}
const bel0=s.anchorNode&&(s.anchorNode.nodeType===1?s.anchorNode:s.anchorNode.parentElement);
const bel=bel0?bel0.closest('[data-bid]'):null;
if(bel){const idx=d.getIdx(bel.dataset.bid);if(idx>=0)d.showFmt(idx);d.showCommentBtn();}else{d.hideCommentBtn();}
},80);});
document.addEventListener('mousedown',()=>{setTimeout(()=>{const d=_xed(document.querySelector('.page-editor-wrapper'));if(d)d.hideCommentBtn();},10);});
document.addEventListener('keyup',(e)=>{
const ed=document.querySelector('.page-editor-wrapper');if(!ed?.__x)return;
const d=ed.__x.$data;if(!d.mentionOpen){if(e.key!=='@')return;}
const d=_xed(document.querySelector('.page-editor-wrapper'));if(!d)return;
if(!d.mentionOpen){if(e.key!=='@')return;}
const el=document.activeElement;if(!el||!el.hasAttribute('data-bid'))return;
const ct=document.getElementById('_blocksCt');if(!ct||!ct.contains(el))return;
if(d.mentionOpen){
@@ -2701,8 +2914,8 @@ applyAIBlocks(text){
d.openMention(el);
},true);
document.addEventListener('click',(e)=>{
const ed=document.querySelector('.page-editor-wrapper');if(!ed?.__x)return;
const d=ed.__x.$data;if(!d.mentionOpen)return;
const d=_xed(document.querySelector('.page-editor-wrapper'));if(!d)return;
if(!d.mentionOpen)return;
const menu=document.getElementById('_mentionMenu');
if(menu&&!menu.contains(e.target))d.closeMention();
});
@@ -2763,11 +2976,12 @@ applyAIBlocks(text){
var ab = E.getActiveBlock();
return ab && ab.idx >= 0 ? ab.idx : -1;
}
if (ctrl && !sh && !alt && k === 'j') { e.preventDefault(); var i1 = curIdx(); if (i1 >= 0) E.askAI(i1); return; }
if (ctrl && !sh && !alt && k === 'j') { e.preventDefault(); var i1 = curIdx(); if (i1 >= 0) E.askAIBlock(i1); return; }
if (ctrl && sh && !alt && k === 'h') { e.preventDefault(); var i2 = curIdx(); if (i2 >= 0) E.applyLastColor(i2); return; }
if (ctrl && sh && !alt && k === 'm') { e.preventDefault(); var i3 = curIdx(); if (i3 >= 0) E.commentBlock(i3); return; }
if (ctrl && sh && alt && k === 'x') { e.preventDefault(); var i4 = curIdx(); if (i4 >= 0) E.suggestEdit(i4); return; }
if (alt && sh && !ctrl && k === 'l') { e.preventDefault(); var i5 = curIdx(); if (i5 >= 0) E.copyBlockLink(E.blocks[i5].id); return; }
if (alt && sh && !ctrl && k === 'e') { e.preventDefault(); var i7 = curIdx(); if (i7 >= 0) E.askAIBlock(i7); return; }
if (ctrl && sh && !alt && k === 'p') { e.preventDefault(); var i6 = curIdx(); if (i6 >= 0) _blockMoveToOpen(i6); return; }
// Ctrl+D : déjà géré par la saisie quand le bloc a le focus (pas de doublon)
if (ctrl && !sh && !alt && k === 'd') {
+6 -3
View File
@@ -48,7 +48,8 @@ def test_actions_are_all_dispatched():
"comment", "suggest", "ai", "skill", "color", "turn-type"):
assert f"act==='{act}'" in dispatch, act
# méthodes réelles côté éditeur
for m in ("commentBlock(idx)", "askAI(idx)", "suggestEdit(idx)",
# v7.62 : « askAI(idx) » renommé askAIBlock(idx) — « askAI() » l'écrasait
for m in ("commentBlock(idx)", "askAIBlock(idx)", "suggestEdit(idx)",
"runSkill(idx,tpl)", "applyLastColor(idx)",
"copyBlockLink(bid)", "deleteSelected(idx)", "duplicateBlock("):
assert m in SRC, m
@@ -59,7 +60,9 @@ def test_actions_are_all_dispatched():
# ── Sous-menus ──────────────────────────────────────────────────────────────
def test_turn_into_submenu():
turn = _slice("function _blockTurnMenuOpen(", "\n function _blockColorMenuOpen(")
# v7.63 : le générateur d'items a été extrait (_blockTurnItems) pour être
# réutilisé par la feuille mobile — le test vise le générateur.
turn = _slice("function _blockTurnItems(", "\n function _blockColorItems(")
assert "_BLOCK_TURN_MENU" in turn and "check:on" in turn # ✓ du type actuel
assert "'<span class=\"bm-check\">✓</span>'" in SRC
for name in ("Text", "Heading 1", "Heading 4", "Bulleted list",
@@ -74,7 +77,7 @@ def test_turn_into_submenu():
def test_color_submenu():
color = _slice("function _blockColorMenuOpen(", "\n function _blockSkillsMenuOpen(")
color = _slice("function _blockColorItems(", "\n function _blockSkillsMenuOpen(")
for group in ("Last used", "Text color", "Background color"):
assert f">{group}</div>" in color, group
names = ["'Default'", "'Gray'", "'Brown'", "'Orange'", "'Yellow'",
+155
View File
@@ -0,0 +1,155 @@
"""7.62.0 — menu contextuel au highlight de texte (toolbar façon Notion)."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
SRC = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
TPL = (ROOT / "app" / "templates" / "_page_editor_content.html").read_text(encoding="utf-8")
CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
APPJS = (ROOT / "static" / "js" / "app.js").read_text(encoding="utf-8")
def _slice(src: str, start: str, end: str) -> str:
i = src.index(start)
return src[i:src.index(end, i)]
# ── Markup : les 4 lignes + Skills + pied, dans l'ordre de la description ───
def test_toolbar_markup_matches_spec():
chunk = _slice(TPL, 'class="format-toolbar"', "Copy Link Toast")
order = [
"fmtStyle($event)", # ligne 1 : Normal Text ›
"fmtApply('bold')", "fmtApply('italic')", "fmtApply('underline')",
"fmtApply('strikeThrough')", "fmtCode()", # ligne 2 : A B I U T Tx
"fmtLink()", "fmtHighlight()", "fmtCodeBlock()", "fmtEquation()",
"fmtMore($event)", # ligne 3 : 🔗 S </> √x …
"fmtComment()", "fmtAskAI()", # ligne 4 : 💬 ✨
"ft-group", "Improve writing", "Proofread", "Explain", "Reformat",
"Edit with AI", "Alt+⇧+E", # pied de page
]
positions = [chunk.index(o) for o in order]
assert positions == sorted(positions), positions
# ligne 1 dynamique (type du bloc) + chevron de sous-menu
assert "fmtStyleName()" in chunk and "ft-caret" in chunk
# info-bulle HTML (exemple en italique + description)
assert "data-tooltip-html=" in chunk
# icônes réellement définies dans le jeu d'icônes
assert 'fd_icon("message-square",15)' in chunk
assert 'fd_icon("sparkles",15)' in chunk and 'fd_icon("link",15)' in chunk
# ── Actions : câblées vers de vraies méthodes, sous-menus réutilisés ────────
def test_actions_are_wired_to_real_methods():
for m in ("fmtStyle(ev)", "fmtColor(ev)", "fmtApply(f)", "fmtCode()",
"fmtHighlight()", "fmtCodeBlock()", "fmtLink()", "fmtEquation()",
"fmtMore(ev)", "fmtComment()", "fmtAskAI()", "fmtSkill(tpl)",
"fmtEditAI()", "askAIBlock(idx)"):
assert m in SRC, m
# pas de doublon de menu : on rouvre les sous-menus/menus déjà existants
assert "_blockTurnMenuOpen(p.x,p.y,bid)" in SRC
assert "_blockColorMenuOpen(p.x,p.y,bid)" in SRC
assert "blockMenuOpen(" in SRC
# sous-menu ouvert depuis la toolbar : fermeture au survol sortant
assert "function _subHoverClose()" in SRC
# Ctrl+J et Alt+⇧+E ouvrent l'IA du BLOC (l'ancien askAI(idx) était écrasé
# par askAI() défini plus bas dans le même objet)
assert "E.askAIBlock(i1)" in SRC
assert "k === 'e'" in SRC
assert "askAI(idx)" not in SRC
# ── Persistance du formatage : sync() relit le DOM formaté en markdown ─────
def test_formatting_survives_sync():
assert "function gtMd(" in SRC
assert "gtMd(el)" in SRC and "gtMd(che)" in SRC # sync() + enfants toggle/columns
for tok in ("'**'+inner+'**'", "'*'+inner+'*'", "'__'+inner+'__'",
"'~~'+inner+'~~'", "'`'+inner+'`'", "'=='+inner+'=='"):
assert tok in SRC, tok
# mdEsc rend les nouveaux marqueurs (souligné, surlignage, équation inline)
for re_ in ("/__([^_]+)__/g", "/==([^=]+)==/g", "/\\$\\$([^$]+)\\$\\$/g"):
assert re_ in SRC, re_
# les chips wiki gardent leur token brut
assert "fd-wiki-chip" in SRC
# ── Placement & fermeture ──────────────────────────────────────────────────
def test_menu_position_and_close():
assert "r.bottom+8" in SRC # sous la sélection (bas de l'écran : au-dessus)
assert "d.fmt.open=false;d._sel=null" in SRC # clic ailleurs → fermeture
assert "closest('.format-toolbar')" in SRC # clic dans la toolbar ≠ clic ailleurs
assert "@mousedown.prevent" in TPL # la sélection survit au clic
# ── Styles ────────────────────────────────────────────────────────────────
def test_menu_styles():
for sel in (".format-toolbar .ft-row {", ".format-toolbar .ft-item {",
".format-toolbar .ft-style {", ".format-toolbar .ft-group {",
".format-toolbar .ft-sep {", ".format-toolbar .ft-foot {",
".tooltip.tooltip-light {"):
assert sel in CSS, sel
# l'info-bulle claire s'applique aux éléments du menu de sélection
assert "data-tooltip-html" in APPJS and "tooltip-light" in APPJS
# ── Comportement réel : gtMd (DOM → source markdown) + mdEsc, sous node ────
def test_gt_md_and_md_esc_roundtrip_for_real():
node = shutil.which("node")
if not node:
pytest.skip("node absent : test comportemental ignoré")
gtmd = _slice(SRC, "function gtMd(", " // Enveloppe/désenveloppe")
mdesc = _slice(SRC, "function mdEsc(", " // v5.11.0: labels of [[fdpage:ID]]")
script = f"""
var window = {{}}; // gtMd fait window.gtMd=…
function gtTok(e){{ return e.__raw || ''; }}
function fdDateLabel(iso){{ return iso; }}
{gtmd}
{mdesc}
// DOM minimal (gtMd ne lit que childNodes / tagName / classList / attributs)
function T(s){{ return {{nodeType:3, nodeValue:s}}; }}
function E(tag, attrs, kids){{
attrs = attrs || {{}};
const cls = String(attrs['class'] || '').split(/\\s+/);
return {{nodeType:1, tagName:tag, childNodes:kids || [],
classList:{{contains:c=>cls.indexOf(c) >= 0}},
getAttribute:k=>(k in attrs ? attrs[k] : null),
hasAttribute:k=>(k in attrs)}};
}}
// querySelector truthy → chemin « formaté » de gtMd (gtTok jamais appelé)
const root = E('DIV', {{'class':''}}, [
E('B', {{}}, [T('gras')]),
E('EM', {{}}, [T('italique')]),
E('U', {{}}, [T('souligne')]),
E('S', {{}}, [T('barre')]),
E('CODE', {{}}, [T('code')]),
E('MARK', {{}}, [T('surligne')]),
E('A', {{'href':'https://example.com/x'}}, [T('lien')]),
E('SPAN', {{'class':'fd-wiki-chip', 'data-token':'[[fdpage:42]]'}}),
E('SPAN', {{'class':'fd-math-inline', 'data-tex':'x^2'}}),
T(' fin')
]);
root.querySelector = () => ({{}}); // force le chemin formaté
const md = gtMd(root);
const html = mdEsc(md);
console.log(JSON.stringify({{md:md, html:html}}));
"""
out = subprocess.run([node, "-e", script], capture_output=True, text=True,
timeout=30, encoding="utf-8")
assert out.returncode == 0, out.stderr
data = json.loads(out.stdout)
assert data["md"] == ("**gras***italique*__souligne__~~barre~~`code`==surligne=="
"[lien](https://example.com/x)[[fdpage:42]]$$x^2$$ fin"), data["md"]
for token in ("<strong>gras</strong>", "<u>souligne</u>", "<mark>surligne</mark>",
"<code>code</code>", 'href="https://example.com/x"',
'class="fd-wiki-chip"', 'data-tex="x^2"'):
assert token in data["html"], token
+136
View File
@@ -0,0 +1,136 @@
"""7.63.0 — menus mobiles façon Notion : barre horizontale au « / » + feuille plein écran."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
SRC = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
def _slice(src: str, start: str, end: str) -> str:
i = src.index(start)
return src[i:src.index(end, i)]
# ── Barre horizontale : les 15 boutons, dans l'ordre de la description ─────
def test_bar_items_in_order():
chunk = _slice(SRC, "const MTB={", "open(i,el){const d=this.el()")
order = ["['plus'", "['turn'", "['at'", "['comment'", "['image'", "['trash'",
"['left'", "['right'", "['up'", "['down'", "['undo'", "['redo'",
"['color'", "['more'", "['kbd'"]
positions = [chunk.index(o) for o in order]
assert positions == sorted(positions), positions
# les 4 entrées « texte + chevron » de la description
for w in ("'plus'", "'turn'", "'color'", "'more'"):
assert w in chunk, w
assert "fd-mtb-wide" in chunk and "fd-mtb-caret" in chunk
# ── Chaque bouton → une action réelle (pas de bouton mort) ─────────────────
def test_bar_actions_are_real():
act = _slice(SRC, "act(k,btn){", "\n };")
for token in ("sheet('Insert block'", "sheet('Turn into'", "sheet('Color'",
"blockMenuOpen(", "a.blur()", "E.openMention(el)",
"E.commentBlock(idx)", "E.applySlash('image')", "E.deleteSelected(idx)",
"E.moveBlock(bid,k)", "E.undo()", "E.redo()"):
assert token in act, token
# « More » ouvre bien le menu contextuel de bloc existant (desktop)
assert "this.close();blockMenuOpen(" in act
# ── Feuille plein écran : titre centré + Cancel + listes réutilisées ───────
def test_fullscreen_sheet():
sheet = _slice(SRC, "sheet(title,html,handler){", "_insertHtml(){")
for token in ("fd-msheet-head", "fd-msheet-title", "fd-msheet-cancel", "Cancel",
"fd-msheet-list block-menu"):
assert token in sheet, token
# contenu : items slash partagés + sous-menus de bloc partagés
ins = _slice(SRC, "_insertHtml(){", "_bmAct(")
assert "_GROUP_LABEL[g.name]" in ins and "_slashItemHtml" in ins
assert "function _blockTurnItems(bid)" in SRC and "function _blockColorItems(bid)" in SRC
# choix d'un item → applySlash via SM.sel (ferme tout)
assert "SM.sel(it.dataset.sid)" in SRC
# ── Branchement sur le slash : mobile = barre, desktop = popup intacte ─────
def test_mobile_hook():
for token in ("function _fdMobile()", "max-width: 768px",
"if(_fdMobile()){SM._o=true;MTB.open(i,el);return;}",
"MTB.close();", "!MTB.contains(ev.target)",
"else if(SM.isOpen)"):
assert token in SRC, token
# le popup desktop (positionnement + focus de la recherche) est toujours là
assert "SM._e.style.display='flex'" in SRC and "SM._in.focus()" in SRC
# la barre se referme quand le / disparaît
assert "if(t.charAt(0)!=='/')SM.close();" in SRC
# ── Déplacement de bloc (↑↓ ordre, → indent, ← outdent) ────────────────────
def test_move_block_logic():
m = _slice(SRC, "moveBlock(bid,dir){", "applyLastColor(idx){")
for token in ("dir==='up'", "dir==='down'", "dir==='right'", "parent.children",
"pushHistory()", "this.sync()", "_focusBid(bid)"):
assert token in m, token
# ── Styles ────────────────────────────────────────────────────────────────
def test_styles():
for sel in (".fd-mtb {", ".fd-mtb .fd-mtb-item {", ".fd-mtb .fd-mtb-wide {",
".fd-msheet {", ".fd-msheet-head {", ".fd-msheet-cancel {",
".fd-msheet-list.block-menu {", ".fd-msheet .bm-item {",
".fd-msheet .slash-item {"):
assert sel in CSS, sel
# ── Comportement réel : moveBlock sous node ───────────────────────────────
def test_move_block_for_real():
node = shutil.which("node")
if not node:
pytest.skip("node absent : test comportemental ignoré")
method = _slice(SRC, "moveBlock(bid,dir){", "applyLastColor(idx){")
script = f"""
function _rtSync(){{}}
const E = {{
blocks:[{{id:'a',type:'paragraph',content:'A'}},
{{id:'tg',type:'toggle',content:'T',expanded:true,children:[]}},
{{id:'c',type:'paragraph',content:'C'}}],
sync(){{}}, pushHistory(){{}}, dirty:false, autoSave(){{}}, render(){{}},
_focusBid(){{}}, showToast(){{}}, msgs:[],
{method}
}};
E.moveBlock('c','right'); // C s'indente dans le toggle
const indent = {{kids:E.blocks[1].children.map(b=>b.id).join(','), top:E.blocks.map(b=>b.id).join(',')}};
E.moveBlock('c','left'); // C ressort à la racine : a,tg,c
const top2 = E.blocks.map(b=>b.id).join(',');
E.moveBlock('a','down'); // A descend : tg,a,c
const ordre1 = E.blocks.map(b=>b.id).join(',');
E.moveBlock('c','up'); // C remonte : tg,c,a
const ordre2 = E.blocks.map(b=>b.id).join(',');
E.moveBlock('c','up'); // encore : c,tg,a
const ordre3 = E.blocks.map(b=>b.id).join(',');
E.moveBlock('c','up'); // déjà en haut → toast, inchangé
const ordre4 = E.blocks.map(b=>b.id).join(',');
console.log(JSON.stringify({{indent:indent, top2:top2, ordre1:ordre1, ordre2:ordre2, ordre3:ordre3, ordre4:ordre4}}));
"""
out = subprocess.run([node, "-e", script], capture_output=True, text=True,
timeout=30, encoding="utf-8")
assert out.returncode == 0, out.stderr
d = json.loads(out.stdout)
assert d["indent"] == {"kids": "c", "top": "a,tg"}, d
assert d["top2"] == "a,tg,c", d
assert d["ordre1"] == "tg,a,c", d
assert d["ordre2"] == "tg,c,a", d
assert d["ordre3"] == "c,tg,a", d
assert d["ordre4"] == "c,tg,a", d # garde « déjà en haut »