Files
flowdeck/e2e/v769_selection_persistence.spec.js
bruno 3d3b46b58b
FlowDeck CI / lint (push) Successful in 1m34s
FlowDeck CI / test (push) Failing after 17m31s
FlowDeck CI / docker (push) Skipped
fix: sélection multi-blocs fonctionnelle (Ctrl+C/X, drag souris, clic droit) + identité visuelle (v7.69.0)
Trois bugs d'éditeur, chacun avec sa cause racine prouvée par trace
d'événements — le mécanisme Clipboard livré en v7.68 était inutilisable.

1) Ctrl+C / Ctrl+X multi-blocs ne faisaient RIEN.
   Cause : le navigateur ne sait pas sérialiser une sélection qui traverse
   deux `contenteditable` (chaque bloc en a un). `getSelection().toString()`
   renvoie `""` et Chromium n'émet AUCUN événement `copy` — les handlers
   `ct.addEventListener('copy')` n'étaient donc jamais atteints.
   → le raccourci est intercepté au keydown (capture sur document, garde
   `closest('#_blocksCt')` pour ne pas voler Ctrl+C ailleurs) et le markdown
   est écrit dans le presse-papier par nos soins. Sélection mono-bloc =
   comportement natif inchangé.

2) Glisser la souris d'un bloc à l'autre ne sélectionnait qu'un bloc.
   Cause : `mouseup` (on reconstruit la plage) → `click` → `focusin` → le
   navigateur efface la sélection. La plage était reconstruite puis détruite
   dans la foulée.
   → la plage reconstruite est mise en attente (`_mdSelRange`) et restaurée
   au tour de boucle suivant si le focus l'a effectivement effacée. Chaque
   nouveau geste (mousedown) remet l'attente à zéro, donc un clic pour
   placer le curseur reste normal.

3) Le clic droit effaçait le surlignage.
   Cause : `.bm-search` prenait le focus 10 ms après l'ouverture du menu.
   → le menu ne vole plus le focus quand un texte est sélectionné. Les
   actions Clipboard utilisaient déjà la sélection figée à l'ouverture
   (`d._clipSt`) ; le `preventDefault` du clic droit est remonté sur
   `mousedown` (sur `contextmenu` il arrive trop tard).

Identité visuelle (bannière + logo du projet ajoutés à la racine) :
- assets optimisés servis : `static/img/banner.webp` 22,5 Ko (l'original
  fait 1,6 Mo) + JPEG de secours, `logo.webp` 5,3 Ko + `logo-512.jpg` ;
  originaux conservés dans `assets/` pour régénération.
- icônes PWA régénérées à partir du vrai logo (72 → 512 px, marge
  maskable sur 192/512) + apple-touch-icon.
- landing : logo dans la barre de navigation, bannière en <picture>
  (WebP + JPEG) avec dimensions déclarées (pas de décalage de mise en page).
- `og:image`, `twitter:card` et `meta description` ajoutés sur la landing
  ET dans base.html — la landing a son propre <head>.

Tests : `tests/test_v769_selection_persistence.py` (19 statiques) +
`e2e/v769_selection_persistence.spec.js` (5, vrais gestes : drag souris,
Ctrl+C/Ctrl+X au clavier, clic droit). `e2e/v768_columns_clipboard.spec.js`
corrigé : les gestes simulés (`execCommand`, plage fabriquée en evaluate)
sont remplacés par de vrais gestes, et le token statique obsolète
`si>=ei)return null;` aligné sur la garde `isCollapsed` réelle.

pytest 1413 passed / 0 failed · ruff OK · OpenAPI 526 chemins / 7.69.0
2026-10-08 17:27:14 -04:00

165 lines
6.6 KiB
JavaScript

// v7.69.0 — la sélection multi-blocs SURVIT aux gestes qui l'effaçaient :
// 1) glisser la souris d'un bloc à l'autre
// 2) Ctrl+C sur cette sélection
// 3) clic droit → le menu n'efface plus le surlignage
const { test, expect } = require('@playwright/test');
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', permissions: ['clipboard-read', 'clipboard-write'] });
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) {
await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } });
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
}
}
async function newPage(page, body) {
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=sel ${Date.now()}`, {
headers: { 'X-CSRF-Token': csrf },
});
const { id } = await created.json();
if (body) {
await page.request.post(`${FD_BASE}/board/api/pages/${id}/blocks`, {
headers: { 'X-CSRF-Token': csrf },
data: { title: 'sel', blocks: body },
});
}
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
}
const BLOCKS = [
{ id: 'b1', type: 'paragraph', content: 'Premier bloc' },
{ id: 'b2', type: 'heading_2', content: 'Titre deux' },
{ id: 'b3', type: 'paragraph', content: 'Troisieme bloc' },
];
// état natif de la sélection
const nativeSel = (page) =>
page.evaluate(() => {
const s = window.getSelection();
return { rc: s.rangeCount, coll: s.isCollapsed, txt: s.toString().slice(0, 40) };
});
const clipState = (page) => page.evaluate(() => window.E._clipState());
// VRAI glisser de souris : le navigateur seul ne franchit jamais deux
// contenteditable — c'est _buildSelAt qui reconstruit la plage.
async function dragAcross(page, last = 2) {
const b1 = await page.locator('#_blocksCt [data-bid]').first().boundingBox();
const bEnd = await page.locator('#_blocksCt [data-bid]').nth(last).boundingBox();
// on part du tout début du 1er bloc et on va jusqu'à la fin du dernier
await page.mouse.move(b1.x + 2, b1.y + b1.height / 2);
await page.mouse.down();
for (let i = 1; i <= 8; i++) {
await page.mouse.move(b1.x + 2, b1.y + b1.height / 2 + ((bEnd.y - b1.y) * i) / 8);
}
await page.mouse.move(bEnd.x + bEnd.width - 2, bEnd.y + bEnd.height / 2);
await page.mouse.up();
await page.waitForTimeout(80);
}
test('glisser la souris sur 3 blocs construit une sélection multi-blocs qui survit', async ({ page }) => {
await login(page);
await newPage(page, BLOCKS);
await dragAcross(page);
// la plage reconstruite ne doit PAS être effacée par le « click » suivant
const s = await nativeSel(page);
expect(s.coll).toBe(false);
expect(s.rc).toBe(1);
const st = await clipState(page);
expect(st.si).toBe(0);
expect(st.ei).toBe(2);
await page.screenshot({ path: 'shots/v769-drag.png' });
});
// VRAI raccourci clavier (pas execCommand) : c'est ce qui était cassé.
// Maj+↓ depuis la fin du bloc 1 → la sélection traverse les 3 blocs.
async function keyboardExtend(page) {
const b1 = await page.locator('#_blocksCt [data-bid]').first().boundingBox();
await page.mouse.click(b1.x + 2, b1.y + b1.height / 2);
await page.keyboard.press('End');
await page.keyboard.press('Shift+ArrowDown');
await page.keyboard.press('Shift+ArrowDown');
await page.waitForTimeout(80);
}
test('Ctrl+C copie la sélection multi-blocs en markdown', async ({ page }) => {
await login(page);
await newPage(page, BLOCKS);
await keyboardExtend(page);
expect((await clipState(page)).ei).toBe(2);
await page.keyboard.press('Control+c'); // le vrai raccourci
await page.waitForTimeout(250);
const clip = await page.evaluate(() => navigator.clipboard.readText());
expect(clip).toContain('## Titre deux'); // le titre sort en markdown
expect(clip).toContain('Troisieme bloc');
// Ctrl+X retire les blocs traversés (tête + queue restent)
await page.keyboard.press('Control+x');
await page.waitForTimeout(300);
await expect(page.locator('#_blocksCt [data-bid]')).toHaveCount(2);
});
test("clic droit : le menu s'ouvre SANS effacer le surlignage", async ({ page }) => {
await login(page);
await newPage(page, BLOCKS);
await dragAcross(page);
expect((await nativeSel(page)).coll).toBe(false);
await page.locator('#_blocksCt [data-bid]').nth(1).click({ button: 'right', position: { x: 30, y: 6 } });
await page.waitForTimeout(150);
// le surlignage reste visible
const s = await nativeSel(page);
expect(s.coll).toBe(false);
expect(s.txt.length).toBeGreaterThan(0);
// …et le groupe Clipboard est bien proposé
const menu = page.locator('#blockMenu');
await expect(menu).toBeVisible();
await expect(menu).toContainText('Clipboard');
await expect(menu.locator('[data-bm-act="clip-copy"]')).toBeVisible();
await page.screenshot({ path: 'shots/v769-rightclick.png' });
});
test('clic droit → Copy via le menu colle bien le markdown figé', async ({ page }) => {
await login(page);
await newPage(page, BLOCKS);
await dragAcross(page);
await page.locator('#_blocksCt [data-bid]').nth(1).click({ button: 'right', position: { x: 30, y: 6 } });
const menu = page.locator('#blockMenu');
await expect(menu.locator('[data-bm-act="clip-copy"]')).toBeVisible();
await menu.locator('[data-bm-act="clip-copy"]').click();
await page.waitForTimeout(200);
const clip = await page.evaluate(() => navigator.clipboard.readText());
expect(clip).toContain('Premier bloc');
expect(clip).toContain('## Titre deux');
});
test('un clic simple dans un bloc place quand même le curseur (pas de sélection fantôme)', async ({ page }) => {
await login(page);
await newPage(page, BLOCKS);
await dragAcross(page);
// le clic suivant est un NOUVEL geste : il doit effacer la sélection
const b1 = await page.locator('#_blocksCt [data-bid]').first().boundingBox();
await page.mouse.click(b1.x + 15, b1.y + b1.height / 2);
await page.waitForTimeout(120);
const st = await clipState(page);
expect(st).toBeNull();
});