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
186 lines
8.4 KiB
JavaScript
186 lines
8.4 KiB
JavaScript
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;
|
|
}
|
|
|
|
// VRAIE sélection multi-blocs : glisser la souris du 1er bloc jusqu'au 3e
|
|
// (le navigateur seul ne franchit jamais deux contenteditable)
|
|
async function dragAcross(page, last = 2) {
|
|
const b1 = await page.locator('#_blocksCt [data-bid]').first().boundingBox();
|
|
const b3 = 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 + ((b3.y - b1.y) * i) / 8);
|
|
}
|
|
await page.mouse.move(b3.x + b3.width - 2, b3.y + b3.height / 2);
|
|
await page.mouse.up();
|
|
}
|
|
|
|
// sélection multi-blocs au CLAVIER : caret en fin de bloc 1, Maj+↓ Maj+↓
|
|
async function shiftExtend(page) {
|
|
// VRAI geste : un clic met le caret dans le bloc, End va à sa fin,
|
|
// Maj+↓ l'étend ensuite dans les blocs suivants.
|
|
const el = page.locator('#_blocksCt [data-bid]').first();
|
|
const b = await el.boundingBox();
|
|
await page.mouse.click(b.x + 2, b.y + b.height / 2);
|
|
await page.keyboard.press('End');
|
|
await page.keyboard.press('Shift+ArrowDown');
|
|
await page.keyboard.press('Shift+ArrowDown');
|
|
await page.waitForTimeout(80);
|
|
}
|
|
|
|
// état de la sélection côté éditeur
|
|
const clipState = (page) => page.evaluate(() => window.E._clipState());
|
|
|
|
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' },
|
|
]);
|
|
// sélection multi-blocs au CLAVIER (Maj+↓) — geste réel
|
|
await shiftExtend(page);
|
|
const st = await clipState(page);
|
|
expect(st.si).toBe(0);
|
|
expect(st.ei).toBe(2);
|
|
// VRAI raccourci clavier (pas execCommand) : c'est lui qui était cassé
|
|
await page.keyboard.press('Control+c');
|
|
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');
|
|
|
|
// coupe (raccourci Ctrl+X) : on recouvre les 3 blocs du début à la fin
|
|
await dragAcross(page);
|
|
await page.keyboard.press('Control+x');
|
|
await page.waitForTimeout(300);
|
|
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.keyboard.press('Control+v');
|
|
await page.waitForTimeout(300);
|
|
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 dragAcross(page, 1);
|
|
// la sélection multi-blocs doit SURVIVRE au clic droit (le navigateur
|
|
// déplacerait sinon le curseur dans le bloc cliqué)
|
|
expect((await clipState(page)).ei).toBe(1);
|
|
await page.locator('#_blocksCt [data-bid]').nth(1).click({ button: 'right', position: { x: 30, y: 6 } });
|
|
expect((await clipState(page)).ei).toBe(1);
|
|
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');
|
|
});
|