refactor(ui): implement mouse-driven multi-block selection with text and marquee modes

- add `sameSet` helper to compare two Sets for equality
- add `dragRef` to track drag state (mode, startBlockId, origin coords)
- replace `selectionchange`-based detection with `mousemove`/`mouseup` listeners
- support `text` mode: stay native until cursor leaves origin block, then switch to `block` mode
- support `block` mode: extend selection across blocks as cursor moves between them
- support `marquee` mode: rect-based selection when mousedown starts outside a contentEditable
- use `sameSet` to avoid redundant `setSelectedBlockIds` calls on unchanged selections
This commit is contained in:
2026-04-25 18:05:34 -04:00
parent 1a132bb1af
commit 980f9cc5a0
@@ -32,6 +32,13 @@ function makeBlock(type, init) {
return def.create(init || {});
}
function sameSet(a, b) {
if (a === b) return true;
if (a.size !== b.size) return false;
for (const v of a) if (!b.has(v)) return false;
return true;
}
function ensureNonEmpty(blocks) {
if (!Array.isArray(blocks) || blocks.length === 0) {
return [makeBlock(DEFAULT_BLOCK_TYPE)];
@@ -444,43 +451,94 @@ export default function BlockEditor({
}
}
// --- Sélection multi-blocs ---
// Le drag souris natif qui traverse plusieurs blocs déclenche une sélection
// texte à cheval sur plusieurs contentEditable : la suppression fusionne
// alors leur texte. On détecte ce cas via `selectionchange` et on bascule
// en sélection « bloc » (les contenteditables sont défocus, surlignage bleu).
// --- Sélection multi-blocs (souris) ---
// Deux modes selon où le mousedown a démarré :
// - `text` : sur un contentEditable. Tant que la souris reste dans le
// bloc d'origine, sélection de texte native. Dès qu'elle en
// sort, on bascule en mode `block` : caret défocus, sélection
// native effacée, on étend la sélection « bloc » de l'origine
// jusqu'au bloc sous le curseur.
// - `marquee`: mousedown ailleurs (padding du container). Rectangle invisible
// de la position de départ jusqu'au curseur ; tous les blocs
// qui intersectent sont sélectionnés.
const dragRef = useRef(null);
useEffect(() => {
function findBlockEl(node, container) {
let el = node?.nodeType === 1 ? node : node?.parentElement;
while (el && el !== container) {
if (el.dataset && el.dataset.blockId) return el;
el = el.parentElement;
}
return null;
}
function onSelectionChange() {
function onMove(e) {
const drag = dragRef.current;
if (!drag) return;
const container = containerRef.current;
if (!container) return;
const sel = document.getSelection();
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return;
const range = sel.getRangeAt(0);
if (!container.contains(range.startContainer) || !container.contains(range.endContainer)) return;
const startEl = findBlockEl(range.startContainer, container);
const endEl = findBlockEl(range.endContainer, container);
if (!startEl || !endEl || startEl === endEl) return;
const startId = startEl.dataset.blockId;
const endId = endEl.dataset.blockId;
const startIdx = blocks.findIndex(b => b.id === startId);
const endIdx = blocks.findIndex(b => b.id === endId);
if (startIdx < 0 || endIdx < 0) return;
const [a, b] = startIdx <= endIdx ? [startIdx, endIdx] : [endIdx, startIdx];
const ids = new Set(blocks.slice(a, b + 1).map(x => x.id));
setSelectedBlockIds(ids);
sel.removeAllRanges();
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
if (drag.mode === 'text') {
if (!drag.startBlockId) return;
const startEl = container.querySelector(`[data-block-id="${drag.startBlockId}"]`);
if (!startEl) return;
const r = startEl.getBoundingClientRect();
const outside = e.clientY < r.top || e.clientY > r.bottom;
if (outside) {
drag.mode = 'block';
const sel = document.getSelection();
sel?.removeAllRanges();
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
}
}
if (drag.mode === 'block') {
e.preventDefault();
const sel = document.getSelection();
if (sel && sel.rangeCount > 0 && !sel.isCollapsed) sel.removeAllRanges();
const elAt = document.elementFromPoint(e.clientX, e.clientY);
const blockEl = elAt?.closest?.('[data-block-id]');
let endId = blockEl?.getAttribute('data-block-id') ?? null;
if (!endId) {
// curseur entre deux blocs : prendre le bloc dont le rect est le
// plus proche verticalement
const all = container.querySelectorAll('[data-block-id]');
let best = null;
let bestDist = Infinity;
for (const el of all) {
const r = el.getBoundingClientRect();
const cy = (r.top + r.bottom) / 2;
const d = Math.abs(cy - e.clientY);
if (d < bestDist) { bestDist = d; best = el; }
}
endId = best?.getAttribute('data-block-id') ?? drag.startBlockId;
}
const startIdx = blocks.findIndex(b => b.id === drag.startBlockId);
const endIdx = blocks.findIndex(b => b.id === endId);
if (startIdx >= 0 && endIdx >= 0) {
const [a, b] = startIdx <= endIdx ? [startIdx, endIdx] : [endIdx, startIdx];
const ids = new Set(blocks.slice(a, b + 1).map(x => x.id));
setSelectedBlockIds(prev => sameSet(prev, ids) ? prev : ids);
}
} else if (drag.mode === 'marquee') {
e.preventDefault();
const x1 = Math.min(drag.startX, e.clientX);
const y1 = Math.min(drag.startY, e.clientY);
const x2 = Math.max(drag.startX, e.clientX);
const y2 = Math.max(drag.startY, e.clientY);
const ids = new Set();
const all = container.querySelectorAll('[data-block-id]');
for (const el of all) {
const r = el.getBoundingClientRect();
if (r.right >= x1 && r.left <= x2 && r.bottom >= y1 && r.top <= y2) {
const id = el.getAttribute('data-block-id');
if (id) ids.add(id);
}
}
setSelectedBlockIds(prev => sameSet(prev, ids) ? prev : ids);
}
}
document.addEventListener('selectionchange', onSelectionChange);
return () => document.removeEventListener('selectionchange', onSelectionChange);
function onUp() {
dragRef.current = null;
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
return () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
}, [blocks]);
// Touches actives pendant qu'une sélection de blocs existe : Backspace/Delete
@@ -538,21 +596,53 @@ export default function BlockEditor({
}, [selectedBlockIds, blocks]);
function handleContainerMouseDown(e) {
// Clic dans une zone non-bloc → déselectionner.
if (selectedBlockIds.size === 0) return;
if (e.button !== 0) return;
const target = e.target;
if (target instanceof Element && target.closest('[data-block-id]')) {
// si le clic est sur un bloc non sélectionné, on déselectionne aussi
const el = target.closest('[data-block-id]');
const id = el?.getAttribute('data-block-id');
if (id && !selectedBlockIds.has(id)) clearBlockSelection();
else if (id && selectedBlockIds.has(id)) {
// clic sur un bloc sélectionné : déselectionner et laisser le focus se faire
clearBlockSelection();
}
const blockEl = target instanceof Element ? target.closest('[data-block-id]') : null;
const editableEl = target instanceof Element ? target.closest('[contenteditable="true"]') : null;
const onHandle = target instanceof Element ? target.closest('button') : null;
// Boutons (poignée +, drag handle…) : ne pas démarrer de sélection.
if (onHandle) {
if (selectedBlockIds.size > 0) clearBlockSelection();
return;
}
clearBlockSelection();
// Toute nouvelle interaction → reset de la sélection bloc en cours.
if (selectedBlockIds.size > 0) clearBlockSelection();
if (editableEl && blockEl) {
// Démarre potentiellement en mode texte : la bascule en mode bloc se
// fait dans onMove dès que le curseur quitte le bloc d'origine.
dragRef.current = {
mode: 'text',
startBlockId: blockEl.getAttribute('data-block-id'),
startX: e.clientX,
startY: e.clientY,
};
return;
}
if (blockEl) {
// Clic sur un bloc non-texte (image, divider, …) : commencer en bloc.
dragRef.current = {
mode: 'block',
startBlockId: blockEl.getAttribute('data-block-id'),
startX: e.clientX,
startY: e.clientY,
};
setSelectedBlockIds(new Set([blockEl.getAttribute('data-block-id')]));
return;
}
// Mousedown ailleurs (padding du container) : marquee invisible.
e.preventDefault();
dragRef.current = {
mode: 'marquee',
startBlockId: null,
startX: e.clientX,
startY: e.clientY,
};
}
// Escalade Ctrl+A : appelé par un bloc quand son contenu est déjà entièrement