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
This commit is contained in:
@@ -50,21 +50,37 @@ async function newPage(page, body) {
|
||||
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);
|
||||
});
|
||||
// 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: '' }]);
|
||||
@@ -116,26 +132,29 @@ test('Ctrl+C / Ctrl+X / Ctrl+V sur une sélection multi-blocs', async ({ page })
|
||||
{ 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'));
|
||||
// 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('Premier bloc');
|
||||
expect(clip).toContain('## Titre deux'); // le titre sort en markdown
|
||||
expect(clip).toContain('Troisieme bloc');
|
||||
|
||||
// coupe : les 3 blocs deviennent 2 blocs vides (tête + queue)
|
||||
await selectAcross(page);
|
||||
await page.evaluate(() => document.execCommand('cut'));
|
||||
// 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.evaluate(() => window.E.pasteBlocks());
|
||||
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');
|
||||
@@ -147,8 +166,12 @@ test('clic droit avec une sélection → groupe Clipboard', async ({ 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 } });
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user