From db468b56b5dd14d5edb7b1b735a8991f23d048a5 Mon Sep 17 00:00:00 2001 From: Hyko Date: Sun, 26 Apr 2026 15:39:41 -0400 Subject: [PATCH] refactor(block-editor): extract shared menu styles into dedicated module - add `menuStyles.js` with reusable `BOX_CLASS`, `ITEM_CLASS`, `ITEM_DANGER_CLASS`, and `SEPARATOR_CLASS` constants - replace inline tailwind strings in `Block.client.js` with imported style constants - update `BlockEditor.client.js`, `LinkPopover.client.js`, and `Toolbar.client.js` to use shared menu styles - update `README.md` to document the new `menuStyles.js` file --- .../components/BlockEditor/Block.client.js | 15 +- .../BlockEditor/BlockEditor.client.js | 76 ++-- src/shared/components/BlockEditor/README.md | 34 +- .../BlockEditor/inline/LinkPopover.client.js | 31 +- .../BlockEditor/inline/Toolbar.client.js | 327 +++++++++++------- .../BlockEditor/inline/menuStyles.js | 25 ++ 6 files changed, 339 insertions(+), 169 deletions(-) create mode 100644 src/shared/components/BlockEditor/inline/menuStyles.js diff --git a/src/shared/components/BlockEditor/Block.client.js b/src/shared/components/BlockEditor/Block.client.js index 37c20e5..6c66bf7 100644 --- a/src/shared/components/BlockEditor/Block.client.js +++ b/src/shared/components/BlockEditor/Block.client.js @@ -2,6 +2,7 @@ import React, { useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, forwardRef } from 'react'; import { Add01Icon, ArrowRight01Icon, Copy01Icon, Delete02Icon, DragDropVerticalIcon, RepeatIcon } from '@zen/core/shared/icons'; +import { BOX_CLASS, ITEM_CLASS, ITEM_DANGER_CLASS, SEPARATOR_CLASS } from './inline/menuStyles.js'; // 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'; @@ -111,7 +112,7 @@ function BlockInsertMenu({ {open && (
@@ -251,7 +252,7 @@ function BlockActionsMenu({ {open && (
{transformOptions.length > 0 && ( @@ -263,7 +264,7 @@ function BlockActionsMenu({ >
Transformer @@ -272,7 +273,7 @@ function BlockActionsMenu({ {submenuOpen && (
@@ -297,18 +298,18 @@ function BlockActionsMenu({ -
+
{slashState && ( setSlashState(s => ({ ...s, selectedIndex: i }))} /> )} - {toolbar && (() => { - const block = blocks.find(b => b.id === toolbar.blockId); - const marks = block ? marksInRange(block.content ?? [], toolbar.start, toolbar.end) : []; - return ( - { toolbarPinnedRef.current = p; }} - /> - ); - })()} - {linkPopover && ( - setLinkPopover(null)} - /> - )} {error && (

{error}

)} diff --git a/src/shared/components/BlockEditor/README.md b/src/shared/components/BlockEditor/README.md index 53abaec..8d41c16 100644 --- a/src/shared/components/BlockEditor/README.md +++ b/src/shared/components/BlockEditor/README.md @@ -123,16 +123,30 @@ Quand une sélection non-vide existe dans un bloc, un toolbar flottant apparaît au-dessus. Il propose : - **B I U S ``** — marks simples (toggle) -- **A** — couleur du texte (popover : palette par défaut + couleurs déjà - utilisées dans le document + bouton `+` pour une couleur libre via - ``) -- **◐** — surlignage (même structure de popover) -- **🔗** — lien (popover avec input URL ; ✕ pour retirer) +- **A** — couleur du texte (sous-menu drop-down : palette par défaut + + couleurs déjà utilisées dans le document + bouton `+` pour une couleur + libre via ``) +- **◐** — surlignage (même structure de sous-menu) +- **🔗** — lien (sous-menu drop-down avec input URL ; ✕ pour retirer) - **T/** — effacer tout le formatage de la sélection (supprime toutes les marks) L'état actif est calculé à partir des marks **communes à toute la plage** (via `marksInRange`). Toggle off si toute la plage est déjà marquée. +La toolbar est rendue en `position: absolute` à l'intérieur du container +relatif de l'éditeur et **suit la sélection lors du scroll**. Les sous-menus +(couleur, surlignage, lien) s'ouvrent **au survol** avec une fenêtre de +fermeture de 120 ms — même idiome que le sous-menu *Transformer ▸* du menu +d'actions, mais en drop-down (vers le bas) plutôt qu'à droite. Une petite +flèche `▾` après le glyphe principal indique la présence du sous-menu. + +## Popover de lien + +Au simple clic dans un lien existant, un popover s'ouvre sous le `` avec +l'URL et la case « Ouvrir dans un nouvel onglet ». Il est rendu en +`position: absolute` dans le container de l'éditeur et **suit le lien lors +du scroll**. + ## Sélection multi-blocs Deux façons d'entrer en mode sélection multi-blocs : @@ -180,6 +194,16 @@ Sinon (clic sans déplacement), le menu s'ouvre normalement. Les dropdowns sont des composants maison (pas de Headless UI ici) car `MenuButton` ouvrait sur `pointerdown`, ce qui empêchait le clic-maintenu nécessaire au drag. +## Identité visuelle partagée + +Les quatre dropdowns (menu d'insertion, menu d'actions du bloc, toolbar +de formatage, popover de lien) partagent les classes `BOX_CLASS`, +`ITEM_CLASS`, `ITEM_DANGER_CLASS`, `ICON_BTN_CLASS` et `SEPARATOR_CLASS` +exportées par [`inline/menuStyles.js`](./inline/menuStyles.js). Pas de +wrapper de composant — juste des constantes Tailwind, parce que les trois +formes (liste verticale, barre horizontale, formulaire) sont trop +différentes pour qu'une abstraction soit rentable. + ## Étendre — enregistrer un bloc custom ```js diff --git a/src/shared/components/BlockEditor/inline/LinkPopover.client.js b/src/shared/components/BlockEditor/inline/LinkPopover.client.js index b86bc85..8f4111b 100644 --- a/src/shared/components/BlockEditor/inline/LinkPopover.client.js +++ b/src/shared/components/BlockEditor/inline/LinkPopover.client.js @@ -1,6 +1,13 @@ 'use client'; import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { BOX_CLASS } from './menuStyles.js'; + +// Popover ouvert au simple clic sur un lien existant. Rendu en `position: +// absolute` à l'intérieur du container relatif du BlockEditor : il scrolle +// avec le texte. `rect` est en coords locales au container (top/left/bottom). +// Flip-up vs flip-down se base sur la position viewport du container pour +// rester proche du lien visible. const GAP = 8; const VIEWPORT_MARGIN = 8; @@ -13,16 +20,22 @@ export default function LinkPopover({ rect, mark, onSetLink, onRemoveLink, onClo useLayoutEffect(() => { if (!rect || typeof window === 'undefined') return; - const width = ref.current?.offsetWidth ?? 280; - const height = ref.current?.offsetHeight ?? 80; - const vw = window.innerWidth; + const el = ref.current; + if (!el) return; + const w = el.offsetWidth || 280; + const h = el.offsetHeight || 80; + const container = el.offsetParent; + const containerW = container?.clientWidth ?? Infinity; + const containerVPRect = container?.getBoundingClientRect?.(); + const linkVPBottom = (containerVPRect?.top ?? 0) + rect.bottom; const vh = window.innerHeight; - let top = rect.bottom + GAP; + const spaceBelow = vh - linkVPBottom - VIEWPORT_MARGIN; + const flipUp = spaceBelow < h + GAP; + let top = flipUp ? rect.top - h - GAP : rect.bottom + GAP; let left = rect.left; - if (left + width + VIEWPORT_MARGIN > vw) left = vw - width - VIEWPORT_MARGIN; + if (left + w + VIEWPORT_MARGIN > containerW) left = containerW - w - VIEWPORT_MARGIN; if (left < VIEWPORT_MARGIN) left = VIEWPORT_MARGIN; - if (top + height + VIEWPORT_MARGIN > vh) top = rect.top - height - GAP; - if (top < VIEWPORT_MARGIN) top = VIEWPORT_MARGIN; + if (top < 0) top = 0; setPos({ top, left }); }, [rect]); @@ -44,8 +57,8 @@ export default function LinkPopover({ rect, mark, onSetLink, onRemoveLink, onClo
{ - onPinChange?.(popover !== null); - }, [popover, onPinChange]); + onPinChange?.(openSubmenu !== null); + }, [openSubmenu, onPinChange]); useLayoutEffect(() => { if (!rect || typeof window === 'undefined') return; - const width = ref.current?.offsetWidth ?? 280; - const height = ref.current?.offsetHeight ?? TOOLBAR_HEIGHT; - const vw = window.innerWidth; - const vh = window.innerHeight; - const spaceAbove = rect.top - VIEWPORT_MARGIN; - const flipBelow = spaceAbove < height + TOOLBAR_GAP; - let top = flipBelow - ? rect.bottom + TOOLBAR_GAP - : rect.top - height - TOOLBAR_GAP; - let left = rect.left + rect.width / 2 - width / 2; - if (left + width + VIEWPORT_MARGIN > vw) left = vw - width - VIEWPORT_MARGIN; + const el = ref.current; + if (!el) return; + const w = el.offsetWidth || 280; + const h = el.offsetHeight || TOOLBAR_HEIGHT; + const container = el.offsetParent; + const containerW = container?.clientWidth ?? Infinity; + const containerVPTop = container?.getBoundingClientRect?.().top ?? 0; + const selectionVPTop = containerVPTop + rect.top; + const spaceAbove = selectionVPTop - VIEWPORT_MARGIN; + const flipBelow = spaceAbove < h + TOOLBAR_GAP; + let top = flipBelow ? rect.bottom + TOOLBAR_GAP : rect.top - h - TOOLBAR_GAP; + let left = rect.left + rect.width / 2 - w / 2; + if (left + w + VIEWPORT_MARGIN > containerW) left = containerW - w - VIEWPORT_MARGIN; if (left < VIEWPORT_MARGIN) left = VIEWPORT_MARGIN; - if (top < VIEWPORT_MARGIN) top = VIEWPORT_MARGIN; - if (top + height + VIEWPORT_MARGIN > vh) top = vh - height - VIEWPORT_MARGIN; + if (top < 0) top = 0; setPos({ top, left, flipped: flipBelow }); }, [rect]); + function scheduleClose() { + if (submenuTimerRef.current) clearTimeout(submenuTimerRef.current); + submenuTimerRef.current = setTimeout(() => setOpenSubmenu(null), SUBMENU_CLOSE_DELAY); + } + function cancelClose() { + if (submenuTimerRef.current) { + clearTimeout(submenuTimerRef.current); + submenuTimerRef.current = null; + } + } + useEffect(() => () => { + if (submenuTimerRef.current) clearTimeout(submenuTimerRef.current); + }, []); + + function openSubmenuFor(key) { + cancelClose(); + if (openSubmenu !== key && key === 'link') { + const link = activeMarks.find(m => m.type === 'link'); + setLinkUrl(link?.href ?? ''); + setLinkNewTab(link ? !!link.newTab : false); + } + setOpenSubmenu(key); + } + + function closeSubmenu() { + cancelClose(); + setOpenSubmenu(null); + } function isActive(type, payloadKey) { if (!Array.isArray(activeMarks)) return false; @@ -66,41 +110,33 @@ export default function InlineToolbar({ rect, activeMarks, onToggleMark, onSetMa function handleColor(color) { onToggleMark?.({ type: 'color', color }); - setPopover(null); + closeSubmenu(); } function handleHighlight(color) { onToggleMark?.({ type: 'highlight', color }); - setPopover(null); + closeSubmenu(); } function handleLinkSubmit(e) { - e.preventDefault?.(); + e?.preventDefault?.(); if (!linkUrl) return; onSetMark?.({ type: 'link', href: linkUrl, newTab: linkNewTab }); setLinkUrl(''); - setPopover(null); + closeSubmenu(); } function handleLinkRemove() { - // Trouver la mark active pour reproduire la même clé (toggle off). const link = activeMarks.find(m => m.type === 'link'); if (link) onToggleMark?.({ type: 'link', href: link.href, ...(link.newTab ? { newTab: true } : {}) }); - setPopover(null); - } - - function openLinkPopover() { - const link = activeMarks.find(m => m.type === 'link'); - setLinkUrl(link?.href ?? ''); - setLinkNewTab(link ? !!link.newTab : false); - setPopover(p => (p === 'link' ? null : 'link')); + closeSubmenu(); } return (
{SIMPLE_BUTTONS.map(btn => ( @@ -110,117 +146,166 @@ export default function InlineToolbar({ rect, activeMarks, onToggleMark, onSetMa title={btn.title} onMouseDown={(e) => e.preventDefault()} onClick={() => handleSimple(btn.type)} - className={`w-7 h-7 flex items-center justify-center rounded text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 ${btn.className} ${isActive(btn.type) ? 'bg-neutral-100 dark:bg-neutral-800' : ''}`} + className={`${ICON_BTN_CLASS} ${btn.className} ${isActive(btn.type) ? ICON_BTN_ACTIVE_CLASS : ''}`} > {btn.label} ))} - + - - - - - - - - - {popover === 'color' && ( m.type === 'color')?.color} usedColors={usedColors?.color} onPick={handleColor} /> - )} - {popover === 'highlight' && ( + + + openSubmenuFor('highlight')} + onMouseLeave={scheduleClose} + onPanelEnter={cancelClose} + onPanelLeave={scheduleClose} + icon={} + > m.type === 'highlight')?.color} usedColors={usedColors?.highlight} onPick={handleHighlight} /> - )} - {popover === 'link' && ( + + + openSubmenuFor('link')} + onMouseLeave={scheduleClose} + onPanelEnter={cancelClose} + onPanelLeave={scheduleClose} + icon={} + > + + + + + + +
+ ); +} + +// Wrapper hover-to-open pour les boutons de la toolbar avec sous-menu. La +// fenêtre de tolérance (120 ms) entre `mouseleave` du trigger et +// `mouseenter` du panneau permet de traverser le gap visuel. +function SubmenuTrigger({ title, active, open, flipUp, icon, children, onMouseEnter, onMouseLeave, onPanelEnter, onPanelLeave }) { + const panelPosition = flipUp ? 'bottom-full mb-1' : 'top-full mt-1'; + return ( +
+ + {open && (
-
- setLinkUrl(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleLinkSubmit(e); } }} - className="w-56 px-2 py-1 text-sm rounded border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white outline-none focus:border-blue-500" - /> - - {isActive('link') && ( - - )} -
- + {children}
)}
); } +function LinkForm({ url, newTab, showRemove, onUrlChange, onNewTabChange, onSubmit, onRemove }) { + return ( +
+
+ onUrlChange(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); onSubmit(e); } }} + className="w-56 px-2 py-1 text-sm rounded border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white outline-none focus:border-blue-500" + /> + + {showRemove && ( + + )} +
+ +
+ ); +} + const USED_COLORS_LIMIT = 8; function ColorGrid({ mode, activeKey, usedColors, onPick }) { @@ -238,7 +323,7 @@ function ColorGrid({ mode, activeKey, usedColors, onPick }) { } return ( -
+
{INLINE_COLOR_KEYS.map(key => { const palette = INLINE_COLORS[key]; const tw = isText ? palette.text : palette.highlight; diff --git a/src/shared/components/BlockEditor/inline/menuStyles.js b/src/shared/components/BlockEditor/inline/menuStyles.js new file mode 100644 index 0000000..d3fe139 --- /dev/null +++ b/src/shared/components/BlockEditor/inline/menuStyles.js @@ -0,0 +1,25 @@ +// Identité visuelle partagée par les dropdowns du BlockEditor (BlockActionsMenu, +// BlockInsertMenu, InlineToolbar, LinkPopover). Constantes de className +// uniquement — pas de JSX, pas de logique. Le but est d'aligner les +// quatre menus sur la même boîte arrondie / mêmes hover states sans +// imposer un wrapper de composant qui ne conviendrait pas aux trois +// formes très différentes (liste verticale, barre horizontale, formulaire). + +export const BOX_CLASS = + 'rounded-xl border border-black/8 dark:border-white/8 bg-white dark:bg-[#0B0B0B] shadow-lg'; + +export const ITEM_CLASS = + 'cursor-pointer w-full flex items-center gap-2 px-[7px] py-[10px] rounded-lg text-[13px] leading-none text-neutral-500 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-white/5 hover:text-neutral-900 dark:hover:text-white transition-colors duration-[120ms] ease-out text-left'; + +export const ITEM_DANGER_CLASS = + 'cursor-pointer w-full flex items-center gap-2 px-[7px] py-[10px] rounded-lg text-[13px] leading-none text-red-700 dark:text-red-600 hover:bg-red-700/10 dark:hover:bg-red-700/20 transition-colors duration-150 text-left'; + +export const ICON_BTN_CLASS = + 'w-7 h-7 flex items-center justify-center rounded-lg text-[13px] leading-none text-neutral-500 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-white/5 hover:text-neutral-900 dark:hover:text-white transition-colors duration-[120ms] ease-out'; + +export const ICON_BTN_ACTIVE_CLASS = + 'bg-neutral-100 dark:bg-white/5 text-neutral-900 dark:text-white'; + +export const SEPARATOR_CLASS = 'h-px bg-black/6 dark:bg-white/6 my-0.5'; + +export const SEPARATOR_VERTICAL_CLASS = 'w-px h-5 bg-black/6 dark:bg-white/6 mx-1 self-center';