fix: colonnes rendues, tables IA sans pipe de tête, copier/couper/coller multi-blocs (v7.68.0)
FlowDeck CI / lint (push) Successful in 1m33s
FlowDeck CI / test (push) Failing after 18m53s
FlowDeck CI / docker (push) Skipped

Colonnes / équation / TOC
- renderBlock() n'appelait JAMAIS renderColumnsBlock()/renderMathBlock()/renderTOC()
  (3 renderers morts) → un bloc colonnes (slash « Column list », Turn into
  2/3/4/5 colonnes) tombait dans le rendu générique = paragraphe vide.
  Dispatch ajouté ; la × de suppression de colonne (opacity:0 sans survol)
  devient visible au survol.

Tables rendues par l'IA
- _tblIsData/_tblIsSep exigeaient un | de tête → le GFM sans pipe (A | B /
  --- | ---) devenait des paragraphes. Pipe de tête optionnel, séparateur
  gardé à ≥2 cellules (« texte » + « --- » reste un séparateur). md2b et
  paste2b partagent les helpers.

Copier / couper / coller
- Sélection de texte MULTI-BLOCS : _clipState()/_clipMarkdown() (début partiel
  → blocs entiers via blocksToMarkdown → fin partielle), clipCopy()/clipCut()
  (coupure = tête du 1er bloc + queue du dernier), listeners copy/cut (Ctrl+C/X
  via les événements natifs), groupe Clipboard (Cut/Copy/Paste) dans le menu du
  clic droit avec la sélection figée à l'ouverture (la recherche prend le focus),
  clic droit sur un bloc sélectionné = sélection multi-blocs conservée.
- Coller un seul bloc structuré (table/code/titre) = vraie insertion (avant :
  texte brut) ; pasteBlocks colle après le dernier bloc sans focus.
- « Copy link to block » réparé (data-bm-act link → copy-link).

Tests: pytest tests/ -q -n auto → 1394 passed / 0 failed ; ruff OK ;
e2e v768_columns_clipboard (4 : slash+Turn into colonnes, table IA sans pipe,
copier/couper/coller, clic droit Clipboard) + v762/v764/v766/v7641/v763/
regression_editor_mount → verts sur :8081 (image flowdeck:v7.68.0).
This commit is contained in:
2026-10-08 15:23:46 -04:00
parent d58c321c07
commit 7cf922106f
10 changed files with 409 additions and 11 deletions
+162
View File
@@ -0,0 +1,162 @@
const { test, expect } = require('@playwright/test');
/**
* v7.68 — colonnes rendues (slash + Turn into), tables IA sans pipe de tête,
* copier/couper/coller d'une sélection multi-blocs (Ctrl+C/X/V + clic droit).
* 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', 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) {
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, 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=v768 ${Date.now()}`,
{ headers: { 'X-CSRF-Token': csrf } }
);
expect(created.status(), await created.text()).toBeLessThan(400);
const { id } = await created.json();
if (body) {
const bs = await page.request.post(`${FD_BASE}/board/api/pages/${id}/blocks`, {
headers: { 'X-CSRF-Token': csrf },
data: { title: 'v768', blocks: body },
});
expect(bs.status(), await bs.text()).toBeLessThan(400);
}
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
return id;
}
// sélection multi-blocs (du milieu du 1er au milieu du 3e) + fin de mouseup
async function selectAcross(page) {
await page.evaluate(() => {
const el1 = document.querySelector('#_blocksCt [data-bid]');
const blocks = document.querySelectorAll('#_blocksCt [data-bid]');
const el3 = blocks[blocks.length - 1];
const r = document.createRange();
r.setStart(el1.firstChild, 0);
r.setEnd(el3.firstChild, el3.firstChild.nodeValue.length);
const s = getSelection();
s.removeAllRanges();
s.addRange(r);
});
}
test('colonnes : slash + Turn into 2 colonnes', async ({ page }) => {
await login(page);
await newPage(page, [{ id: 'b1', type: 'paragraph', content: '' }]);
await page.click('#_blocksCt [data-bid]');
await page.keyboard.type('/');
await expect(page.locator('#_slashMenu')).toBeVisible({ timeout: 5000 });
await page.keyboard.type('columns');
await page.locator('#_slashMenu .slash-item[data-sid="columns"]').click();
// le bloc colonnes est RENDU (il tombait dans le rendu générique = vide)
await expect(page.locator('#_blocksCt .block-columns')).toHaveCount(1);
await expect(page.locator('#_blocksCt .block-column')).toHaveCount(1);
await page.locator('#_blocksCt .columns-add-btn').click();
await expect(page.locator('#_blocksCt .block-column')).toHaveCount(2);
await page.screenshot({ path: 'shots/v768-columns-slash.png' });
// Turn into → 2 colonnes
await newPage(page, [{ id: 'b1', type: 'paragraph', content: 'Pour le menu' }]);
await page.locator('#_blocksCt [data-bid]').first().click({ button: 'right' });
const menu = page.locator('#blockMenu');
await expect(menu).toBeVisible();
await menu.locator('[data-sub="turn"]').hover();
const sub = page.locator('#blockMenuSub');
await expect(sub).toBeVisible();
await sub.locator('[data-bm-act="turn-type"][data-cols="2"]').click();
await expect(page.locator('#_blocksCt .block-column')).toHaveCount(2);
});
test('table IA sans pipe de tête devient un vrai tableau', async ({ page }) => {
await login(page);
await newPage(page, [{ id: 'b1', type: 'paragraph', content: '' }]);
await page.evaluate(() => {
window.E.applyAIBlocks('Nom | Rôle\n--- | ---\nAlice | Dev\nBob | Test');
});
await expect(page.locator('#_blocksCt .ftable-editor')).toHaveCount(1);
await expect(page.locator('#_blocksCt .ftable-cell').first()).toHaveText('Nom');
await expect(page.locator('#_blocksCt .ftable-cell').last()).toHaveText('Test');
// la ligne « texte » suivie d'« --- » ne doit PAS devenir un tableau
await page.evaluate(() => {
window.E.applyAIBlocks('juste du texte\n---\nune phrase');
});
await expect(page.locator('#_blocksCt .ftable-editor')).toHaveCount(1);
await page.screenshot({ path: 'shots/v768-ai-table.png' });
});
test('Ctrl+C / Ctrl+X / Ctrl+V sur une sélection multi-blocs', async ({ page }) => {
await login(page);
await newPage(page, [
{ id: 'b1', type: 'paragraph', content: 'Premier bloc' },
{ id: 'b2', type: 'heading_2', content: 'Titre deux' },
{ id: 'b3', type: 'paragraph', content: 'Troisieme bloc' },
]);
// le raccourci clavier nécessite un éditeur focalisé (sinon Chromium
// n'émet pas l'événement copy)
await page.locator('#_blocksCt [data-bid]').first().click();
await selectAcross(page);
// headless Chromium n'émet PAS le raccourci clavier (pas de presse-papier
// OS) : execCommand('copy') déclenche le MÊME événement copy → notre handler
await page.evaluate(() => document.execCommand('copy'));
const clip = await page.evaluate(() => navigator.clipboard.readText());
expect(clip).toContain('Premier bloc');
expect(clip).toContain('## Titre deux'); // le titre sort en markdown
// coupe : les 3 blocs deviennent 2 blocs vides (tête + queue)
await selectAcross(page);
await page.evaluate(() => document.execCommand('cut'));
await expect(page.locator('#_blocksCt [data-bid]')).toHaveCount(2);
await expect(page.locator('#_blocksCt [data-bid]').first()).toHaveText('');
// colle : les 3 blocs reviennent APRÈS le bloc du curseur (comme l'entrée
// Paste du menu) → 2 blocs restants + 3 collés = 5
await page.evaluate(() => window.E.pasteBlocks());
await expect(page.locator('#_blocksCt [data-bid]')).toHaveCount(5, { timeout: 5000 });
await expect(page.locator('#_blocksCt [data-bid]').nth(1)).toContainText('Premier bloc');
await expect(page.locator('#_blocksCt [data-bid]').nth(2)).toContainText('Titre deux');
});
test('clic droit avec une sélection → groupe Clipboard', async ({ page }) => {
await login(page);
await newPage(page, [
{ id: 'b1', type: 'paragraph', content: 'Alpha bloc' },
{ id: 'b2', type: 'paragraph', content: 'Beta bloc' },
]);
await selectAcross(page);
await page.locator('#_blocksCt [data-bid]').first().click({ button: 'right', position: { x: 4, y: 4 } });
const menu = page.locator('#blockMenu');
await expect(menu).toBeVisible();
await expect(menu.locator('[data-bm-act="clip-copy"]')).toBeVisible();
await expect(menu.locator('[data-bm-act="clip-cut"]')).toBeVisible();
await expect(menu.locator('[data-bm-act="clip-paste"]')).toBeVisible();
await page.screenshot({ path: 'shots/v768-ctx-clipboard.png' });
await menu.locator('[data-bm-act="clip-copy"]').click();
const clip = await page.evaluate(() => navigator.clipboard.readText());
expect(clip).toContain('Alpha bloc');
expect(clip).toContain('Beta bloc');
});