From c32ab0909c5f5f0908cb396af96a2f4275bc932e Mon Sep 17 00:00:00 2001 From: Hyko Date: Sat, 25 Apr 2026 20:51:52 -0400 Subject: [PATCH] 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 --- .../components/BlockEditor/Block.client.js | 55 +++++++++++++++++-- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/src/shared/components/BlockEditor/Block.client.js b/src/shared/components/BlockEditor/Block.client.js index 3481c88..0ca794a 100644 --- a/src/shared/components/BlockEditor/Block.client.js +++ b/src/shared/components/BlockEditor/Block.client.js @@ -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 (