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 || {});
|
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) {
|
function ensureNonEmpty(blocks) {
|
||||||
if (!Array.isArray(blocks) || blocks.length === 0) {
|
if (!Array.isArray(blocks) || blocks.length === 0) {
|
||||||
return [makeBlock(DEFAULT_BLOCK_TYPE)];
|
return [makeBlock(DEFAULT_BLOCK_TYPE)];
|
||||||
@@ -444,43 +451,94 @@ export default function BlockEditor({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Sélection multi-blocs ---
|
// --- Sélection multi-blocs (souris) ---
|
||||||
// Le drag souris natif qui traverse plusieurs blocs déclenche une sélection
|
// Deux modes selon où le mousedown a démarré :
|
||||||
// texte à cheval sur plusieurs contentEditable : la suppression fusionne
|
// - `text` : sur un contentEditable. Tant que la souris reste dans le
|
||||||
// alors leur texte. On détecte ce cas via `selectionchange` et on bascule
|
// bloc d'origine, sélection de texte native. Dès qu'elle en
|
||||||
// en sélection « bloc » (les contenteditables sont défocus, surlignage bleu).
|
// 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(() => {
|
useEffect(() => {
|
||||||
function findBlockEl(node, container) {
|
function onMove(e) {
|
||||||
let el = node?.nodeType === 1 ? node : node?.parentElement;
|
const drag = dragRef.current;
|
||||||
while (el && el !== container) {
|
if (!drag) return;
|
||||||
if (el.dataset && el.dataset.blockId) return el;
|
|
||||||
el = el.parentElement;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
function onSelectionChange() {
|
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
const sel = document.getSelection();
|
|
||||||
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return;
|
if (drag.mode === 'text') {
|
||||||
const range = sel.getRangeAt(0);
|
if (!drag.startBlockId) return;
|
||||||
if (!container.contains(range.startContainer) || !container.contains(range.endContainer)) return;
|
const startEl = container.querySelector(`[data-block-id="${drag.startBlockId}"]`);
|
||||||
const startEl = findBlockEl(range.startContainer, container);
|
if (!startEl) return;
|
||||||
const endEl = findBlockEl(range.endContainer, container);
|
const r = startEl.getBoundingClientRect();
|
||||||
if (!startEl || !endEl || startEl === endEl) return;
|
const outside = e.clientY < r.top || e.clientY > r.bottom;
|
||||||
const startId = startEl.dataset.blockId;
|
if (outside) {
|
||||||
const endId = endEl.dataset.blockId;
|
drag.mode = 'block';
|
||||||
const startIdx = blocks.findIndex(b => b.id === startId);
|
const sel = document.getSelection();
|
||||||
const endIdx = blocks.findIndex(b => b.id === endId);
|
sel?.removeAllRanges();
|
||||||
if (startIdx < 0 || endIdx < 0) return;
|
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
|
||||||
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 (drag.mode === 'block') {
|
||||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
|
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);
|
function onUp() {
|
||||||
return () => document.removeEventListener('selectionchange', onSelectionChange);
|
dragRef.current = null;
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
}, [blocks]);
|
}, [blocks]);
|
||||||
|
|
||||||
// Touches actives pendant qu'une sélection de blocs existe : Backspace/Delete
|
// Touches actives pendant qu'une sélection de blocs existe : Backspace/Delete
|
||||||
@@ -538,21 +596,53 @@ export default function BlockEditor({
|
|||||||
}, [selectedBlockIds, blocks]);
|
}, [selectedBlockIds, blocks]);
|
||||||
|
|
||||||
function handleContainerMouseDown(e) {
|
function handleContainerMouseDown(e) {
|
||||||
// Clic dans une zone non-bloc → déselectionner.
|
if (e.button !== 0) return;
|
||||||
if (selectedBlockIds.size === 0) return;
|
|
||||||
const target = e.target;
|
const target = e.target;
|
||||||
if (target instanceof Element && target.closest('[data-block-id]')) {
|
const blockEl = target instanceof Element ? target.closest('[data-block-id]') : null;
|
||||||
// si le clic est sur un bloc non sélectionné, on déselectionne aussi
|
const editableEl = target instanceof Element ? target.closest('[contenteditable="true"]') : null;
|
||||||
const el = target.closest('[data-block-id]');
|
const onHandle = target instanceof Element ? target.closest('button') : null;
|
||||||
const id = el?.getAttribute('data-block-id');
|
|
||||||
if (id && !selectedBlockIds.has(id)) clearBlockSelection();
|
// Boutons (poignée +, drag handle…) : ne pas démarrer de sélection.
|
||||||
else if (id && selectedBlockIds.has(id)) {
|
if (onHandle) {
|
||||||
// clic sur un bloc sélectionné : déselectionner et laisser le focus se faire
|
if (selectedBlockIds.size > 0) clearBlockSelection();
|
||||||
clearBlockSelection();
|
|
||||||
}
|
|
||||||
return;
|
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
|
// Escalade Ctrl+A : appelé par un bloc quand son contenu est déjà entièrement
|
||||||
|
|||||||
Reference in New Issue
Block a user