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) {
// MVP : on colle uniquement du texte brut pour éviter le HTML externe.
e.preventDefault();
@@ -324,7 +335,7 @@ const Block = forwardRef(function Block(
onInput={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onFocus={() => onFocus?.(block.id)}
onFocus={handleFocus}
/>
</div>
) : (
@@ -336,7 +347,7 @@ const Block = forwardRef(function Block(
onInput={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onFocus={() => onFocus?.(block.id)}
onFocus={handleFocus}
/>
)
) : def.Component ? (
@@ -71,6 +71,7 @@ export default function BlockEditor({
const blocks = useMemo(() => ensureNonEmpty(value), [value]);
const blockRefs = useRef(new Map());
const containerRef = useRef(null);
const outerRef = useRef(null);
const [focusBlockId, setFocusBlockId] = useState(null);
const [focusOffset, setFocusOffset] = useState(null);
const [selectedBlockIds, setSelectedBlockIds] = useState(() => new Set());
@@ -764,6 +765,26 @@ export default function BlockEditor({
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
// 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).
@@ -804,7 +825,7 @@ export default function BlockEditor({
}, [slashState, enabledBlocks]);
return (
<div className="space-y-2">
<div ref={outerRef} className="space-y-2">
{label && (
<label className="block text-xs font-medium text-neutral-700 dark:text-neutral-400">
{label}
@@ -120,6 +120,7 @@ export default function SlashMenu({
return (
<div
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"
style={{ top, left, width: MENU_WIDTH, maxHeight }}
onMouseDown={(e) => e.preventDefault()} // ne pas voler le focus