diff --git a/src/shared/components/BlockEditor/BlockEditor.client.js b/src/shared/components/BlockEditor/BlockEditor.client.js index 2077c34..f9d878a 100644 --- a/src/shared/components/BlockEditor/BlockEditor.client.js +++ b/src/shared/components/BlockEditor/BlockEditor.client.js @@ -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