diff --git a/src/shared/components/BlockEditor/Block.client.js b/src/shared/components/BlockEditor/Block.client.js
index 8a7f822..3ba5ed2 100644
--- a/src/shared/components/BlockEditor/Block.client.js
+++ b/src/shared/components/BlockEditor/Block.client.js
@@ -26,11 +26,13 @@ const Block = forwardRef(function Block(
disabled,
isDragOverTop,
isDragOverBottom,
+ isSelected,
onContentChange,
onEnter,
onBackspaceAtStart,
onSlashOpen,
onSlashClose,
+ onSelectAllBlocks,
onShortcutMatch,
onFocus,
onDragStart,
@@ -120,17 +122,26 @@ const Block = forwardRef(function Block(
return;
}
- // Ctrl/Cmd+A : limite la sélection au bloc courant. La sélection native
- // s'étend sur plusieurs contentEditable et leur suppression fusionne le
- // texte en un seul bloc — bug. On force la sélection à rester ici.
+ // Ctrl/Cmd+A : 1er appui → sélectionne le contenu du bloc courant.
+ // 2e appui (le contenu est déjà entièrement sélectionné) → bascule en
+ // sélection multi-blocs (tous les blocs).
if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) {
if (el) {
e.preventDefault();
- const range = document.createRange();
- range.selectNodeContents(el);
const sel = window.getSelection();
- sel?.removeAllRanges();
- sel?.addRange(range);
+ const txt = el.textContent ?? '';
+ const fullySelected =
+ !!sel && sel.rangeCount > 0 && !sel.isCollapsed &&
+ el.contains(sel.anchorNode) && el.contains(sel.focusNode) &&
+ sel.toString() === txt && txt.length > 0;
+ if (fullySelected) {
+ onSelectAllBlocks?.();
+ } else {
+ const range = document.createRange();
+ range.selectNodeContents(el);
+ sel?.removeAllRanges();
+ sel?.addRange(range);
+ }
}
return;
}
@@ -227,6 +238,12 @@ const Block = forwardRef(function Block(
data-block-id={block.id}
>
{dropIndicator}
+ {isSelected && (
+
new Set());
+
+ function clearBlockSelection() {
+ setSelectedBlockIds(prev => (prev.size === 0 ? prev : new Set()));
+ }
+
+ function selectAllBlocks() {
+ setSelectedBlockIds(new Set(blocks.map(b => b.id)));
+ const sel = typeof window !== 'undefined' ? window.getSelection() : null;
+ sel?.removeAllRanges();
+ if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
+ }
+
+ function deleteSelectedBlocks() {
+ if (selectedBlockIds.size === 0) return;
+ const next = blocks.filter(b => !selectedBlockIds.has(b.id));
+ const finalNext = next.length === 0 ? [makeBlock(DEFAULT_BLOCK_TYPE)] : next;
+ commitChange(finalNext, { immediate: true });
+ setSelectedBlockIds(new Set());
+ setFocusBlockId(finalNext[0].id);
+ setFocusOffset(0);
+ }
// --- Undo / Redo ---
const [undoStack, setUndoStack] = useState([]);
@@ -422,6 +444,123 @@ 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).
+ 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() {
+ 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();
+ }
+ document.addEventListener('selectionchange', onSelectionChange);
+ return () => document.removeEventListener('selectionchange', onSelectionChange);
+ }, [blocks]);
+
+ // Touches actives pendant qu'une sélection de blocs existe : Backspace/Delete
+ // suppriment, Escape efface, frappe alphanumérique remplace.
+ useEffect(() => {
+ if (selectedBlockIds.size === 0) return;
+ function onKey(e) {
+ if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) {
+ // Déjà tout sélectionné — laisser passer (no-op).
+ if (selectedBlockIds.size === blocks.length) return;
+ e.preventDefault();
+ selectAllBlocks();
+ return;
+ }
+ if (e.key === 'Backspace' || e.key === 'Delete') {
+ e.preventDefault();
+ deleteSelectedBlocks();
+ return;
+ }
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ clearBlockSelection();
+ return;
+ }
+ if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'C' || e.key === 'x' || e.key === 'X')) {
+ // Copy/cut : fallback simple — concatène le contenu texte.
+ const text = blocks
+ .filter(b => selectedBlockIds.has(b.id))
+ .map(b => b.content ?? '')
+ .join('\n');
+ try { e.clipboardData?.setData?.('text/plain', text); } catch {}
+ if (navigator.clipboard) navigator.clipboard.writeText(text).catch(() => {});
+ if (e.key === 'x' || e.key === 'X') {
+ e.preventDefault();
+ deleteSelectedBlocks();
+ }
+ return;
+ }
+ // Frappe utile (caractère imprimable) → supprimer puis insérer un nouveau
+ // paragraphe avec ce caractère.
+ if (!e.ctrlKey && !e.metaKey && !e.altKey && e.key.length === 1) {
+ e.preventDefault();
+ const ch = e.key;
+ const next = blocks.filter(b => !selectedBlockIds.has(b.id));
+ const replaced = makeBlock(DEFAULT_BLOCK_TYPE, { content: ch });
+ const finalNext = next.length === 0 ? [replaced] : [replaced, ...next];
+ commitChange(finalNext, { immediate: true });
+ setSelectedBlockIds(new Set());
+ setFocusBlockId(replaced.id);
+ setFocusOffset(ch.length);
+ }
+ }
+ document.addEventListener('keydown', onKey, true);
+ return () => document.removeEventListener('keydown', onKey, true);
+ }, [selectedBlockIds, blocks]);
+
+ function handleContainerMouseDown(e) {
+ // Clic dans une zone non-bloc → déselectionner.
+ if (selectedBlockIds.size === 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();
+ }
+ return;
+ }
+ clearBlockSelection();
+ }
+
+ // Escalade Ctrl+A : appelé par un bloc quand son contenu est déjà entièrement
+ // sélectionné — on bascule en sélection « tous les blocs ».
+ function handleBlockSelectAll() {
+ selectAllBlocks();
+ }
+
// Le slash menu utilise un listener au niveau document en phase capture pour
// intercepter les touches avant que le contentEditable ne gère ses défauts
// (sinon ↑/↓ déplacent le caret et Entrée insère une nouvelle ligne).
@@ -471,6 +610,7 @@ export default function BlockEditor({
@@ -488,12 +628,14 @@ export default function BlockEditor({
disabled={disabled}
isDragOverTop={dragOver?.blockId === block.id && dragOver.position === 'top'}
isDragOverBottom={dragOver?.blockId === block.id && dragOver.position === 'bottom'}
+ isSelected={selectedBlockIds.has(block.id)}
onContentChange={handleContentChange}
onEnter={handleEnter}
onBackspaceAtStart={handleBackspaceAtStart}
onShortcutMatch={handleShortcutMatch}
onSlashOpen={handleSlashOpen}
onSlashClose={handleSlashClose}
+ onSelectAllBlocks={handleBlockSelectAll}
onDragStart={() => setDragOver(null)}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
diff --git a/src/shared/components/BlockEditor/README.md b/src/shared/components/BlockEditor/README.md
index 9afd2d7..5c60947 100644
--- a/src/shared/components/BlockEditor/README.md
+++ b/src/shared/components/BlockEditor/README.md
@@ -57,6 +57,22 @@ couleur, lien). Phase 3 : `table`.
- `Backspace` au début d'un bloc typé → repasse en paragraphe ; au début d'un paragraphe, fusionne avec le bloc précédent (uniquement si la sélection est repliée — sinon le navigateur supprime le texte sélectionné, ex. après `Ctrl+A`)
- `Entrée` sur un item de liste vide → sort de la liste
- `Ctrl/Cmd + Z` / `Ctrl/Cmd + Shift + Z` → undo / redo
+- `Ctrl/Cmd + A` → 1er appui : sélectionne le contenu du bloc courant ; 2e appui : sélectionne **tous les blocs** (mode sélection multi-blocs)
+
+## Sélection multi-blocs
+
+Deux façons d'entrer en mode sélection multi-blocs :
+
+- **Souris** : un drag qui traverse plusieurs blocs bascule automatiquement en sélection bloc (les contenteditables sont défocus, surlignage bleu transparent sur les blocs sélectionnés). Évite la fusion accidentelle de texte entre blocs lors d'un Backspace/Delete.
+- **Clavier** : double `Ctrl/Cmd + A` (cf. ci-dessus).
+
+En mode sélection multi-blocs :
+- `Backspace` / `Delete` → supprime tous les blocs sélectionnés
+- `Escape` → quitte la sélection
+- `Ctrl/Cmd + A` → étend à tous les blocs (no-op si déjà tous sélectionnés)
+- `Ctrl/Cmd + C` / `Ctrl/Cmd + X` → copie/coupe le texte concaténé
+- frappe d'un caractère imprimable → remplace les blocs sélectionnés par un nouveau paragraphe contenant ce caractère
+- clic dans l'éditeur → quitte la sélection
## Drag and drop
diff --git a/src/shared/icons/index.js b/src/shared/icons/index.js
index 8e3c0ef..9d54218 100644
--- a/src/shared/icons/index.js
+++ b/src/shared/icons/index.js
@@ -537,4 +537,21 @@ export const Menu01Icon = (props) => (
+);
+
+export const PlusSignIcon = (props) => (
+
+);
+
+export const DragDropVerticalIcon = (props) => (
+
);
\ No newline at end of file