feat(BlockEditor): add multi-block selection with ctrl+a and delete support
- add `isSelected` prop and overlay highlight to Block component - implement double ctrl+a: first selects block content, second selects all blocks - add `onSelectAllBlocks` callback prop to Block - add `selectedBlockIds` state and `selectAllBlocks`/`deleteSelectedBlocks` helpers in BlockEditor - detect cross-block native selection via `selectionchange` and convert to block selection - handle backspace/delete key to remove all selected blocks - clear block selection on click outside or focus change - update README to document multi-block selection behaviour - export new icons used by the feature
This commit is contained in:
@@ -26,11 +26,13 @@ const Block = forwardRef(function Block(
|
|||||||
disabled,
|
disabled,
|
||||||
isDragOverTop,
|
isDragOverTop,
|
||||||
isDragOverBottom,
|
isDragOverBottom,
|
||||||
|
isSelected,
|
||||||
onContentChange,
|
onContentChange,
|
||||||
onEnter,
|
onEnter,
|
||||||
onBackspaceAtStart,
|
onBackspaceAtStart,
|
||||||
onSlashOpen,
|
onSlashOpen,
|
||||||
onSlashClose,
|
onSlashClose,
|
||||||
|
onSelectAllBlocks,
|
||||||
onShortcutMatch,
|
onShortcutMatch,
|
||||||
onFocus,
|
onFocus,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
@@ -120,18 +122,27 @@ const Block = forwardRef(function Block(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl/Cmd+A : limite la sélection au bloc courant. La sélection native
|
// Ctrl/Cmd+A : 1er appui → sélectionne le contenu du bloc courant.
|
||||||
// s'étend sur plusieurs contentEditable et leur suppression fusionne le
|
// 2e appui (le contenu est déjà entièrement sélectionné) → bascule en
|
||||||
// texte en un seul bloc — bug. On force la sélection à rester ici.
|
// sélection multi-blocs (tous les blocs).
|
||||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) {
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) {
|
||||||
if (el) {
|
if (el) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
const sel = window.getSelection();
|
||||||
|
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();
|
const range = document.createRange();
|
||||||
range.selectNodeContents(el);
|
range.selectNodeContents(el);
|
||||||
const sel = window.getSelection();
|
|
||||||
sel?.removeAllRanges();
|
sel?.removeAllRanges();
|
||||||
sel?.addRange(range);
|
sel?.addRange(range);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +238,12 @@ const Block = forwardRef(function Block(
|
|||||||
data-block-id={block.id}
|
data-block-id={block.id}
|
||||||
>
|
>
|
||||||
{dropIndicator}
|
{dropIndicator}
|
||||||
|
{isSelected && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 rounded-md bg-blue-500/20 dark:bg-blue-400/20 pointer-events-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-0.5 pt-1 transition-opacity ${hovered ? 'opacity-100' : 'opacity-0'} ${disabled ? 'pointer-events-none' : ''}`}
|
className={`flex items-center gap-0.5 pt-1 transition-opacity ${hovered ? 'opacity-100' : 'opacity-0'} ${disabled ? 'pointer-events-none' : ''}`}
|
||||||
|
|||||||
@@ -54,6 +54,28 @@ export default function BlockEditor({
|
|||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const [focusBlockId, setFocusBlockId] = useState(null);
|
const [focusBlockId, setFocusBlockId] = useState(null);
|
||||||
const [focusOffset, setFocusOffset] = useState(null);
|
const [focusOffset, setFocusOffset] = useState(null);
|
||||||
|
const [selectedBlockIds, setSelectedBlockIds] = useState(() => 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 ---
|
// --- Undo / Redo ---
|
||||||
const [undoStack, setUndoStack] = useState([]);
|
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
|
// 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
|
// 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).
|
// (sinon ↑/↓ déplacent le caret et Entrée insère une nouvelle ligne).
|
||||||
@@ -471,6 +610,7 @@ export default function BlockEditor({
|
|||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
onKeyDown={handleGlobalKeyDown}
|
onKeyDown={handleGlobalKeyDown}
|
||||||
|
onMouseDownCapture={handleContainerMouseDown}
|
||||||
className={`block-editor border rounded-xl bg-white dark:bg-neutral-900/60 px-3 py-6 ${error ? 'border-red-500/50' : 'border-neutral-300 dark:border-neutral-700/50'} ${className}`}
|
className={`block-editor border rounded-xl bg-white dark:bg-neutral-900/60 px-3 py-6 ${error ? 'border-red-500/50' : 'border-neutral-300 dark:border-neutral-700/50'} ${className}`}
|
||||||
>
|
>
|
||||||
<BlockEditorStyles />
|
<BlockEditorStyles />
|
||||||
@@ -488,12 +628,14 @@ export default function BlockEditor({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
isDragOverTop={dragOver?.blockId === block.id && dragOver.position === 'top'}
|
isDragOverTop={dragOver?.blockId === block.id && dragOver.position === 'top'}
|
||||||
isDragOverBottom={dragOver?.blockId === block.id && dragOver.position === 'bottom'}
|
isDragOverBottom={dragOver?.blockId === block.id && dragOver.position === 'bottom'}
|
||||||
|
isSelected={selectedBlockIds.has(block.id)}
|
||||||
onContentChange={handleContentChange}
|
onContentChange={handleContentChange}
|
||||||
onEnter={handleEnter}
|
onEnter={handleEnter}
|
||||||
onBackspaceAtStart={handleBackspaceAtStart}
|
onBackspaceAtStart={handleBackspaceAtStart}
|
||||||
onShortcutMatch={handleShortcutMatch}
|
onShortcutMatch={handleShortcutMatch}
|
||||||
onSlashOpen={handleSlashOpen}
|
onSlashOpen={handleSlashOpen}
|
||||||
onSlashClose={handleSlashClose}
|
onSlashClose={handleSlashClose}
|
||||||
|
onSelectAllBlocks={handleBlockSelectAll}
|
||||||
onDragStart={() => setDragOver(null)}
|
onDragStart={() => setDragOver(null)}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
|
|||||||
@@ -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`)
|
- `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
|
- `Entrée` sur un item de liste vide → sort de la liste
|
||||||
- `Ctrl/Cmd + Z` / `Ctrl/Cmd + Shift + Z` → undo / redo
|
- `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
|
## Drag and drop
|
||||||
|
|
||||||
|
|||||||
@@ -538,3 +538,20 @@ export const Menu01Icon = (props) => (
|
|||||||
<path fillRule="evenodd" clipRule="evenodd" d="M3 19C3 18.4477 3.44772 18 4 18L20 18C20.5523 18 21 18.4477 21 19C21 19.5523 20.5523 20 20 20L4 20C3.44772 20 3 19.5523 3 19Z" fill="currentColor"></path>
|
<path fillRule="evenodd" clipRule="evenodd" d="M3 19C3 18.4477 3.44772 18 4 18L20 18C20.5523 18 21 18.4477 21 19C21 19.5523 20.5523 20 20 20L4 20C3.44772 20 3 19.5523 3 19Z" fill="currentColor"></path>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const PlusSignIcon = (props) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={64} height={64} color={"currentColor"} fill={"none"} {...props}>
|
||||||
|
<path fillRule="evenodd" clipRule="evenodd" d="M12 2.75C12.6904 2.75 13.25 3.30964 13.25 4V10.75H20C20.6904 10.75 21.25 11.3096 21.25 12C21.25 12.6904 20.6904 13.25 20 13.25H13.25V20C13.25 20.6904 12.6904 21.25 12 21.25C11.3096 21.25 10.75 20.6904 10.75 20V13.25H4C3.30964 13.25 2.75 12.6904 2.75 12C2.75 11.3096 3.30964 10.75 4 10.75H10.75V4C10.75 3.30964 11.3096 2.75 12 2.75Z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DragDropVerticalIcon = (props) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={64} height={64} color={"currentColor"} fill={"none"} {...props}>
|
||||||
|
<path d="M15 7.75C15.9665 7.75 16.75 6.9665 16.75 6C16.75 5.0335 15.9665 4.25 15 4.25C14.0335 4.25 13.25 5.0335 13.25 6C13.25 6.9665 14.0335 7.75 15 7.75Z" fill="currentColor" />
|
||||||
|
<path d="M9 7.75C9.9665 7.75 10.75 6.9665 10.75 6C10.75 5.0335 9.9665 4.25 9 4.25C8.0335 4.25 7.25 5.0335 7.25 6C7.25 6.9665 8.0335 7.75 9 7.75Z" fill="currentColor" />
|
||||||
|
<path d="M15 19.75C15.9665 19.75 16.75 18.9665 16.75 18C16.75 17.0335 15.9665 16.25 15 16.25C14.0335 16.25 13.25 17.0335 13.25 18C13.25 18.9665 14.0335 19.75 15 19.75Z" fill="currentColor" />
|
||||||
|
<path d="M15 13.75C15.9665 13.75 16.75 12.9665 16.75 12C16.75 11.0335 15.9665 10.25 15 10.25C14.0335 10.25 13.25 11.0335 13.25 12C13.25 12.9665 14.0335 13.75 15 13.75Z" fill="currentColor" />
|
||||||
|
<path d="M9 19.75C9.9665 19.75 10.75 18.9665 10.75 18C10.75 17.0335 9.9665 16.25 9 16.25C8.0335 16.25 7.25 17.0335 7.25 18C7.25 18.9665 8.0335 19.75 9 19.75Z" fill="currentColor" />
|
||||||
|
<path d="M9 13.75C9.9665 13.75 10.75 12.9665 10.75 12C10.75 11.0335 9.9665 10.25 9 10.25C8.0335 10.25 7.25 11.0335 7.25 12C7.25 12.9665 8.0335 13.75 9 13.75Z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user