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)
This commit is contained in:
2026-10-08 08:39:39 -04:00
parent 033490c464
commit 3a6445ca4e
13 changed files with 679 additions and 41 deletions
+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();
});