From 3a6445ca4e2485b577afda7de059488974ccdfa9 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 8 Oct 2026 08:39:39 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20menu=20contextuel=20au=20highlight=20de?= =?UTF-8?q?=20texte=20=E2=80=94=20toolbar=204=20lignes=20fa=C3=A7on=20Noti?= =?UTF-8?q?on=20(v7.62.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- CHANGELOG.md | 63 ++++++++++ ROADMAP.md | 11 ++ VERSION | 2 +- WORKLOAD.md | 2 +- app/main.py | 2 +- app/templates/_page_editor_content.html | 52 +++++++- docs/openapi-v2.json | 4 +- e2e/v762_selection_toolbar.spec.js | 127 +++++++++++++++++++ static/css/app.css | 129 ++++++++++++++++++-- static/js/app.js | 23 +++- static/js/page_editor_scripts.js | 147 +++++++++++++++++++--- tests/test_v761_block_menu.py | 3 +- tests/test_v762_selection_toolbar.py | 155 ++++++++++++++++++++++++ 13 files changed, 679 insertions(+), 41 deletions(-) create mode 100644 e2e/v762_selection_toolbar.spec.js create mode 100644 tests/test_v762_selection_toolbar.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d7d063..f8b519d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,68 @@ # Changelog - FlowDeck +## 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 (``), `` 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 `__…__` (``), `==…==` (``) 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 diff --git a/ROADMAP.md b/ROADMAP.md index c2e5cde..c67ec3c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1408,6 +1408,17 @@ 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 + --- ## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code) diff --git a/VERSION b/VERSION index 4a41042..3fa542c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.61.0 +7.62.0 diff --git a/WORKLOAD.md b/WORKLOAD.md index e011119..40d5fe2 100644 --- a/WORKLOAD.md +++ b/WORKLOAD.md @@ -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.62.0 (menu contextuel au highlight de texte — toolbar 4 lignes façon Notion) | **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 diff --git a/app/main.py b/app/main.py index 94c75c6..2e0d1f9 100644 --- a/app/main.py +++ b/app/main.py @@ -186,7 +186,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.61.0", + version="7.62.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/app/templates/_page_editor_content.html b/app/templates/_page_editor_content.html index ccca7e1..a36d9a1 100644 --- a/app/templates/_page_editor_content.html +++ b/app/templates/_page_editor_content.html @@ -635,17 +635,59 @@ +
- - - - - + +
+ +
+ +
+ + + + + + +
+ +
+ + + + + +
+ +
+ + +
+ +
+
Skills
+ + + + + +
+
diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json index fc5cdcb..277bd21 100644 --- a/docs/openapi-v2.json +++ b/docs/openapi-v2.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "FlowDeck", - "version": "7.61.0" + "version": "7.62.0" }, "paths": { "/auth/register": { @@ -30444,4 +30444,4 @@ } } } -} \ No newline at end of file +} diff --git a/e2e/v762_selection_toolbar.spec.js b/e2e/v762_selection_toolbar.spec.js new file mode 100644 index 0000000..3f14d75 --- /dev/null +++ b/e2e/v762_selection_toolbar.spec.js @@ -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 || 'e2e@flowdeck.local'; +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(); +}); diff --git a/static/css/app.css b/static/css/app.css index e27fe7d..c9cf824 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -2915,35 +2915,146 @@ 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; +} /* EDITOR STATUSBAR */ .editor-statusbar { @@ -3643,7 +3754,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; } } /* ═══════════════════════════════════════════════════════════ diff --git a/static/js/app.js b/static/js/app.js index 12a31c8..aaf34ff 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -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'; diff --git a/static/js/page_editor_scripts.js b/static/js/page_editor_scripts.js index a3aaf54..ea970bd 100644 --- a/static/js/page_editor_scripts.js +++ b/static/js/page_editor_scripts.js @@ -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 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,'$1') .replace(/\*([^*]+)\*/g,'$1') .replace(/~~([^~]+)~~/g,'$1') + .replace(/__([^_]+)__/g,'$1') // souligné (toolbar de sélection) + .replace(/==([^=]+)==/g,'$1') // surlignage + .replace(/\$\$([^$]+)\$\$/g,function(m,t){return '';}) // équation inline .replace(/\[\[fdpage:(\d+)\]\]/g,function(m,pid){ return '…';}) .replace(/\[\[fddate:([0-9]{4}-[0-9]{2}-[0-9]{2})(?:T[0-9:]{5,8})?\]\]/g,function(m,iso){ @@ -962,6 +1016,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 +1032,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;} @@ -1397,14 +1453,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{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 +1737,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…'); @@ -2193,7 +2252,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{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 +2374,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 +2784,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 +2815,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 +2877,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') { diff --git a/tests/test_v761_block_menu.py b/tests/test_v761_block_menu.py index 70519aa..ee67886 100644 --- a/tests/test_v761_block_menu.py +++ b/tests/test_v761_block_menu.py @@ -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 diff --git a/tests/test_v762_selection_toolbar.py b/tests/test_v762_selection_toolbar.py new file mode 100644 index 0000000..e694caa --- /dev/null +++ b/tests/test_v762_selection_toolbar.py @@ -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 ("gras", "souligne", "surligne", + "code", 'href="https://example.com/x"', + 'class="fd-wiki-chip"', 'data-tex="x^2"'): + assert token in data["html"], token