fix: colonnes rendues, tables IA sans pipe de tête, copier/couper/coller multi-blocs (v7.68.0)
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:
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user