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
165 lines
6.6 KiB
JavaScript
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();
|
|
});
|