fix(BlockEditor): close slash menu when clicking outside or on different block

- add `handleFocus` in Block to reopen slash menu on focus if content starts with `/`
- add `outerRef` on editor root div to detect outside clicks
- add `useEffect` with document `mousedown` listener to close slash menu when clicking outside the editor or on a different block
- add `data-slash-menu` attribute on SlashMenu to exclude it from the close trigger
This commit is contained in:
2026-04-25 20:05:32 -04:00
parent 20f31269e4
commit c87f74a18e
3 changed files with 36 additions and 3 deletions
@@ -205,6 +205,17 @@ const Block = forwardRef(function Block(
} }
} }
function handleFocus() {
if (def?.isText && !disabled) {
const el = editableRef.current;
const text = el?.textContent ?? '';
if (text.startsWith('/') && !text.slice(1).includes(' ')) {
onSlashOpen?.({ blockId: block.id, query: text.slice(1) });
}
}
onFocus?.(block.id);
}
function handlePaste(e) { function handlePaste(e) {
// MVP : on colle uniquement du texte brut pour éviter le HTML externe. // MVP : on colle uniquement du texte brut pour éviter le HTML externe.
e.preventDefault(); e.preventDefault();
@@ -324,7 +335,7 @@ const Block = forwardRef(function Block(
onInput={handleInput} onInput={handleInput}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={handlePaste} onPaste={handlePaste}
onFocus={() => onFocus?.(block.id)} onFocus={handleFocus}
/> />
</div> </div>
) : ( ) : (
@@ -336,7 +347,7 @@ const Block = forwardRef(function Block(
onInput={handleInput} onInput={handleInput}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={handlePaste} onPaste={handlePaste}
onFocus={() => onFocus?.(block.id)} onFocus={handleFocus}
/> />
) )
) : def.Component ? ( ) : def.Component ? (
@@ -71,6 +71,7 @@ export default function BlockEditor({
const blocks = useMemo(() => ensureNonEmpty(value), [value]); const blocks = useMemo(() => ensureNonEmpty(value), [value]);
const blockRefs = useRef(new Map()); const blockRefs = useRef(new Map());
const containerRef = useRef(null); const containerRef = useRef(null);
const outerRef = 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()); const [selectedBlockIds, setSelectedBlockIds] = useState(() => new Set());
@@ -764,6 +765,26 @@ export default function BlockEditor({
selectAllBlocks(); selectAllBlocks();
} }
// Ferme le slash menu quand on clique en dehors de l'éditeur ou sur un
// bloc différent de celui qui a ouvert le menu.
useEffect(() => {
if (!slashState) return;
function onDocMouseDown(e) {
if (e.target instanceof Element && e.target.closest('[data-slash-menu]')) return;
const outer = outerRef.current;
if (!outer || !outer.contains(e.target)) {
setSlashState(null);
return;
}
const blockEl = e.target instanceof Element ? e.target.closest('[data-block-id]') : null;
if (!blockEl || blockEl.getAttribute('data-block-id') !== slashState.blockId) {
setSlashState(null);
}
}
document.addEventListener('mousedown', onDocMouseDown, true);
return () => document.removeEventListener('mousedown', onDocMouseDown, true);
}, [slashState]);
// 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).
@@ -804,7 +825,7 @@ export default function BlockEditor({
}, [slashState, enabledBlocks]); }, [slashState, enabledBlocks]);
return ( return (
<div className="space-y-2"> <div ref={outerRef} className="space-y-2">
{label && ( {label && (
<label className="block text-xs font-medium text-neutral-700 dark:text-neutral-400"> <label className="block text-xs font-medium text-neutral-700 dark:text-neutral-400">
{label} {label}
@@ -120,6 +120,7 @@ export default function SlashMenu({
return ( return (
<div <div
ref={listRef} ref={listRef}
data-slash-menu
className="fixed z-50 overflow-y-auto rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-md py-1" className="fixed z-50 overflow-y-auto rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-md py-1"
style={{ top, left, width: MENU_WIDTH, maxHeight }} style={{ top, left, width: MENU_WIDTH, maxHeight }}
onMouseDown={(e) => e.preventDefault()} // ne pas voler le focus onMouseDown={(e) => e.preventDefault()} // ne pas voler le focus