feat(ui): add smart dropdown placement to block editor menus
- introduce `useDropdownPlacement` hook to compute above/below positioning based on available viewport space - apply dynamic `maxHeight` and position class to `BlockInsertMenu` and `BlockActionsMenu` panels - attach `triggerRef` to menu trigger buttons for accurate anchor rect calculation - use `useLayoutEffect` to avoid layout flicker on open
This commit is contained in:
@@ -1,10 +1,39 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useImperativeHandle, useMemo, useRef, useState, forwardRef } from 'react';
|
import React, { useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, forwardRef } from 'react';
|
||||||
import { Add01Icon, ArrowRight01Icon, Copy01Icon, Delete02Icon, DragDropVerticalIcon } from '@zen/core/shared/icons';
|
import { Add01Icon, ArrowRight01Icon, Copy01Icon, Delete02Icon, DragDropVerticalIcon } from '@zen/core/shared/icons';
|
||||||
|
|
||||||
// Style « boîte » pour l'icône d'un type de bloc, repris du SlashMenu.
|
// Style « boîte » pour l'icône d'un type de bloc, repris du SlashMenu.
|
||||||
const TYPE_ICON_BOX_CLASS = 'w-8 h-8 flex items-center justify-center rounded-md border border-neutral-200 dark:border-neutral-700 text-xs font-medium text-neutral-700 dark:text-neutral-300 flex-shrink-0';
|
const TYPE_ICON_BOX_CLASS = 'w-8 h-8 flex items-center justify-center rounded-md border border-neutral-200 dark:border-neutral-700 text-xs font-medium text-neutral-700 dark:text-neutral-300 flex-shrink-0';
|
||||||
|
|
||||||
|
const DROPDOWN_MAX_HEIGHT = 360;
|
||||||
|
const DROPDOWN_MIN_HEIGHT = 160;
|
||||||
|
const DROPDOWN_VIEWPORT_MARGIN = 8;
|
||||||
|
const DROPDOWN_GAP = 6;
|
||||||
|
|
||||||
|
// Décide si un dropdown ancré au `triggerRef` doit s'ouvrir au-dessus ou en
|
||||||
|
// dessous, et calcule le maxHeight disponible. Recalculé chaque fois que le
|
||||||
|
// menu s'ouvre (anchor stable tant qu'il est ouvert). Quand il s'ouvre vers
|
||||||
|
// le haut, le contenu est ancré par `bottom: 100%` côté caller — comme ça
|
||||||
|
// le bas reste collé au bouton et le contenu rétrécit vers le haut.
|
||||||
|
function useDropdownPlacement(open, triggerRef) {
|
||||||
|
const [placement, setPlacement] = useState({ side: 'below', maxHeight: DROPDOWN_MAX_HEIGHT });
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const el = triggerRef.current;
|
||||||
|
if (!el || typeof window === 'undefined') return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const spaceBelow = vh - rect.bottom - DROPDOWN_VIEWPORT_MARGIN - DROPDOWN_GAP;
|
||||||
|
const spaceAbove = rect.top - DROPDOWN_VIEWPORT_MARGIN - DROPDOWN_GAP;
|
||||||
|
if (spaceBelow >= Math.min(DROPDOWN_MAX_HEIGHT, DROPDOWN_MIN_HEIGHT) || spaceBelow >= spaceAbove) {
|
||||||
|
setPlacement({ side: 'below', maxHeight: Math.max(DROPDOWN_MIN_HEIGHT, Math.min(DROPDOWN_MAX_HEIGHT, spaceBelow)) });
|
||||||
|
} else {
|
||||||
|
setPlacement({ side: 'above', maxHeight: Math.max(DROPDOWN_MIN_HEIGHT, Math.min(DROPDOWN_MAX_HEIGHT, spaceAbove)) });
|
||||||
|
}
|
||||||
|
}, [open, triggerRef]);
|
||||||
|
return placement;
|
||||||
|
}
|
||||||
import { getBlockDef, listBlocks } from './blockRegistry.js';
|
import { getBlockDef, listBlocks } from './blockRegistry.js';
|
||||||
import { inlineLength } from './inline/types.js';
|
import { inlineLength } from './inline/types.js';
|
||||||
import { inlineToDom, domToInline } from './inline/serialize.js';
|
import { inlineToDom, domToInline } from './inline/serialize.js';
|
||||||
@@ -31,6 +60,8 @@ function BlockInsertMenu({
|
|||||||
onInsert,
|
onInsert,
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
|
const triggerRef = useRef(null);
|
||||||
|
const { side, maxHeight } = useDropdownPlacement(open, triggerRef);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -62,9 +93,12 @@ function BlockInsertMenu({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const panelPositionClass = side === 'above' ? 'bottom-full mb-1' : 'top-full mt-1';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative">
|
<div ref={containerRef} className="relative">
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
title="Insérer un bloc"
|
title="Insérer un bloc"
|
||||||
@@ -76,8 +110,11 @@ function BlockInsertMenu({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute left-0 mt-1 w-64 rounded-xl border border-black/8 dark:border-white/8 bg-white dark:bg-[#0B0B0B] shadow-lg z-50">
|
<div
|
||||||
<div className="p-1.5 flex flex-col gap-0.5 max-h-80 overflow-y-auto">
|
className={`absolute left-0 ${panelPositionClass} w-64 rounded-xl border border-black/8 dark:border-white/8 bg-white dark:bg-[#0B0B0B] shadow-lg z-50 flex flex-col`}
|
||||||
|
style={{ maxHeight }}
|
||||||
|
>
|
||||||
|
<div className="p-1.5 flex flex-col gap-0.5 overflow-y-auto">
|
||||||
<div className="px-2 pt-1 pb-1 text-[10px] font-medium uppercase tracking-wider text-neutral-400 dark:text-neutral-500">
|
<div className="px-2 pt-1 pb-1 text-[10px] font-medium uppercase tracking-wider text-neutral-400 dark:text-neutral-500">
|
||||||
Insérer un bloc
|
Insérer un bloc
|
||||||
</div>
|
</div>
|
||||||
@@ -118,6 +155,8 @@ function BlockActionsMenu({
|
|||||||
onDelete,
|
onDelete,
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
|
const triggerRef = useRef(null);
|
||||||
|
const { side, maxHeight } = useDropdownPlacement(open, triggerRef);
|
||||||
const [submenuOpen, setSubmenuOpen] = useState(false);
|
const [submenuOpen, setSubmenuOpen] = useState(false);
|
||||||
const submenuTimerRef = useRef(null);
|
const submenuTimerRef = useRef(null);
|
||||||
|
|
||||||
@@ -173,9 +212,12 @@ function BlockActionsMenu({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const panelPositionClass = side === 'above' ? 'bottom-full mb-1' : 'top-full mt-1';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative">
|
<div ref={containerRef} className="relative">
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
title="Glisser pour réordonner ou cliquer pour les actions"
|
title="Glisser pour réordonner ou cliquer pour les actions"
|
||||||
@@ -188,8 +230,11 @@ function BlockActionsMenu({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute left-0 mt-1 w-56 rounded-xl border border-black/8 dark:border-white/8 bg-white dark:bg-[#0B0B0B] shadow-lg z-50">
|
<div
|
||||||
<div className="p-1.5 flex flex-col gap-0.5">
|
className={`absolute left-0 ${panelPositionClass} w-56 rounded-xl border border-black/8 dark:border-white/8 bg-white dark:bg-[#0B0B0B] shadow-lg z-50 flex flex-col`}
|
||||||
|
style={{ maxHeight }}
|
||||||
|
>
|
||||||
|
<div className="p-1.5 flex flex-col gap-0.5 overflow-y-auto">
|
||||||
{transformOptions.length > 0 && (
|
{transformOptions.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="relative"
|
className="relative"
|
||||||
|
|||||||
Reference in New Issue
Block a user