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';
|
||||
|
||||
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';
|
||||
|
||||
// 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 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 { inlineLength } from './inline/types.js';
|
||||
import { inlineToDom, domToInline } from './inline/serialize.js';
|
||||
@@ -31,6 +60,8 @@ function BlockInsertMenu({
|
||||
onInsert,
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const triggerRef = useRef(null);
|
||||
const { side, maxHeight } = useDropdownPlacement(open, triggerRef);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -62,9 +93,12 @@ function BlockInsertMenu({
|
||||
};
|
||||
}
|
||||
|
||||
const panelPositionClass = side === 'above' ? 'bottom-full mb-1' : 'top-full mt-1';
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
title="Insérer un bloc"
|
||||
@@ -76,8 +110,11 @@ function BlockInsertMenu({
|
||||
</button>
|
||||
|
||||
{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 className="p-1.5 flex flex-col gap-0.5 max-h-80 overflow-y-auto">
|
||||
<div
|
||||
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">
|
||||
Insérer un bloc
|
||||
</div>
|
||||
@@ -118,6 +155,8 @@ function BlockActionsMenu({
|
||||
onDelete,
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const triggerRef = useRef(null);
|
||||
const { side, maxHeight } = useDropdownPlacement(open, triggerRef);
|
||||
const [submenuOpen, setSubmenuOpen] = useState(false);
|
||||
const submenuTimerRef = useRef(null);
|
||||
|
||||
@@ -173,9 +212,12 @@ function BlockActionsMenu({
|
||||
};
|
||||
}
|
||||
|
||||
const panelPositionClass = side === 'above' ? 'bottom-full mb-1' : 'top-full mt-1';
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
title="Glisser pour réordonner ou cliquer pour les actions"
|
||||
@@ -188,8 +230,11 @@ function BlockActionsMenu({
|
||||
</button>
|
||||
|
||||
{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 className="p-1.5 flex flex-col gap-0.5">
|
||||
<div
|
||||
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 && (
|
||||
<div
|
||||
className="relative"
|
||||
|
||||
Reference in New Issue
Block a user