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:
@@ -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;
|
||||
|
||||
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();
|
||||
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();
|
||||
sel?.removeAllRanges();
|
||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
|
||||
}
|
||||
document.addEventListener('selectionchange', onSelectionChange);
|
||||
return () => document.removeEventListener('selectionchange', onSelectionChange);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user