fix(ui): improve slash menu keyboard handling and adaptive positioning
- move slash menu keyboard listener to document capture phase to prevent contentEditable default behaviors - use circular navigation for arrow keys in slash menu - separate undo/redo shortcuts into dedicated global keydown handler - add adaptive positioning for slash menu with flip-up, horizontal clamp, and max-height constraints
This commit is contained in:
@@ -407,38 +407,8 @@ export default function BlockEditor({
|
|||||||
commitChange(next, { immediate: true });
|
commitChange(next, { immediate: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Raccourcis globaux ---
|
// --- Raccourcis globaux (Undo/Redo seulement) ---
|
||||||
function handleGlobalKeyDown(e) {
|
function handleGlobalKeyDown(e) {
|
||||||
// Slash menu : navigation et sélection
|
|
||||||
if (slashState) {
|
|
||||||
const items = getSlashItems(slashState.query, enabledBlocks);
|
|
||||||
if (e.key === 'ArrowDown') {
|
|
||||||
e.preventDefault();
|
|
||||||
setSlashState(s => ({ ...s, selectedIndex: Math.min((s.selectedIndex ?? 0) + 1, Math.max(items.length - 1, 0)) }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === 'ArrowUp') {
|
|
||||||
e.preventDefault();
|
|
||||||
setSlashState(s => ({ ...s, selectedIndex: Math.max((s.selectedIndex ?? 0) - 1, 0) }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
if (items.length > 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
const def = items[slashState.selectedIndex ?? 0];
|
|
||||||
if (def) handleSlashSelect(def.type);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
setSlashState(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Undo / Redo
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (e.shiftKey) handleRedo();
|
if (e.shiftKey) handleRedo();
|
||||||
@@ -452,6 +422,45 @@ export default function BlockEditor({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slashState) return;
|
||||||
|
function onKey(e) {
|
||||||
|
if (!slashState) return;
|
||||||
|
const items = getSlashItems(slashState.query, enabledBlocks);
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setSlashState(s => s && ({
|
||||||
|
...s,
|
||||||
|
selectedIndex: items.length === 0 ? 0 : ((s.selectedIndex ?? 0) + 1) % items.length,
|
||||||
|
}));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setSlashState(s => s && ({
|
||||||
|
...s,
|
||||||
|
selectedIndex: items.length === 0 ? 0 : ((s.selectedIndex ?? 0) - 1 + items.length) % items.length,
|
||||||
|
}));
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
if (items.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const def = items[slashState.selectedIndex ?? 0];
|
||||||
|
if (def) handleSlashSelect(def.type);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setSlashState(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey, true);
|
||||||
|
return () => document.removeEventListener('keydown', onKey, true);
|
||||||
|
}, [slashState, enabledBlocks]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{label && (
|
{label && (
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const MENU_WIDTH = 256; // w-64
|
||||||
|
const MENU_MAX_HEIGHT = 288; // max-h-72
|
||||||
|
const VIEWPORT_MARGIN = 8;
|
||||||
import { listBlocks } from './blockRegistry.js';
|
import { listBlocks } from './blockRegistry.js';
|
||||||
|
|
||||||
// Menu flottant des commandes. Affiché ancré à un élément (anchorRect).
|
// Menu flottant des commandes. Affiché ancré à un élément (anchorRect).
|
||||||
@@ -45,23 +49,51 @@ export default function SlashMenu({
|
|||||||
}, [allowed, query]);
|
}, [allowed, query]);
|
||||||
|
|
||||||
const listRef = useRef(null);
|
const listRef = useRef(null);
|
||||||
|
const [position, setPosition] = useState({ top: 0, left: 0, maxHeight: MENU_MAX_HEIGHT });
|
||||||
|
|
||||||
// Scroll l'élément sélectionné dans la vue
|
// Scroll l'élément sélectionné dans la vue (interne au menu uniquement)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = listRef.current?.querySelector(`[data-slash-index="${selectedIndex}"]`);
|
const el = listRef.current?.querySelector(`[data-slash-index="${selectedIndex}"]`);
|
||||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||||
}, [selectedIndex]);
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
// Positionnement adaptatif : flip au-dessus si pas assez de place en bas,
|
||||||
|
// clamp horizontalement, et limite la hauteur à l'espace disponible.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!anchorRect || typeof window === 'undefined') return;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const spaceBelow = vh - anchorRect.bottom - VIEWPORT_MARGIN;
|
||||||
|
const spaceAbove = anchorRect.top - VIEWPORT_MARGIN;
|
||||||
|
|
||||||
|
let top;
|
||||||
|
let maxHeight;
|
||||||
|
if (spaceBelow >= Math.min(MENU_MAX_HEIGHT, 200) || spaceBelow >= spaceAbove) {
|
||||||
|
top = anchorRect.bottom + 6;
|
||||||
|
maxHeight = Math.max(120, Math.min(MENU_MAX_HEIGHT, spaceBelow - 6));
|
||||||
|
} else {
|
||||||
|
maxHeight = Math.max(120, Math.min(MENU_MAX_HEIGHT, spaceAbove - 6));
|
||||||
|
top = anchorRect.top - 6 - maxHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
let left = anchorRect.left;
|
||||||
|
if (left + MENU_WIDTH + VIEWPORT_MARGIN > vw) {
|
||||||
|
left = Math.max(VIEWPORT_MARGIN, vw - MENU_WIDTH - VIEWPORT_MARGIN);
|
||||||
|
}
|
||||||
|
if (left < VIEWPORT_MARGIN) left = VIEWPORT_MARGIN;
|
||||||
|
|
||||||
|
setPosition({ top, left, maxHeight });
|
||||||
|
}, [anchorRect, items.length]);
|
||||||
|
|
||||||
if (!anchorRect) return null;
|
if (!anchorRect) return null;
|
||||||
|
|
||||||
const top = anchorRect.bottom + 6;
|
const { top, left, maxHeight } = position;
|
||||||
const left = anchorRect.left;
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed z-50 w-64 rounded-xl border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-lg p-3 text-sm text-neutral-500"
|
className="fixed z-50 w-64 rounded-xl border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-lg p-3 text-sm text-neutral-500"
|
||||||
style={{ top, left }}
|
style={{ top, left, maxHeight }}
|
||||||
>
|
>
|
||||||
Aucune commande pour « {query} »
|
Aucune commande pour « {query} »
|
||||||
</div>
|
</div>
|
||||||
@@ -71,8 +103,8 @@ export default function SlashMenu({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
className="fixed z-50 w-64 max-h-72 overflow-y-auto rounded-xl border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-lg py-1"
|
className="fixed z-50 w-64 overflow-y-auto rounded-xl border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-lg py-1"
|
||||||
style={{ top, left }}
|
style={{ top, left, maxHeight }}
|
||||||
onMouseDown={(e) => e.preventDefault()} // ne pas voler le focus
|
onMouseDown={(e) => e.preventDefault()} // ne pas voler le focus
|
||||||
>
|
>
|
||||||
{items.map((def, i) => {
|
{items.map((def, i) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user