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
This commit is contained in:
2026-04-26 15:39:41 -04:00
parent 3e90ef8c5d
commit db468b56b5
6 changed files with 339 additions and 169 deletions
@@ -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
<div
ref={ref}
data-link-popover
style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 50 }}
className="flex flex-col gap-1.5 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-md px-2 py-1.5"
style={{ top: pos.top, left: pos.left }}
className={`absolute z-50 flex flex-col gap-1.5 p-2 ${BOX_CLASS}`}
>
<div className="flex items-center gap-1">
<input
@@ -1,19 +1,33 @@
'use client';
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { TextColorIcon, HighlighterIcon, Link02Icon, CodeSimpleIcon, TextClearIcon } from '@zen/core/shared/icons';
import {
TextColorIcon,
HighlighterIcon,
Link02Icon,
CodeSimpleIcon,
TextClearIcon,
ArrowDown01Icon,
} from '@zen/core/shared/icons';
import { INLINE_COLORS, INLINE_COLOR_KEYS, markKey } from './types.js';
import {
BOX_CLASS,
ICON_BTN_CLASS,
ICON_BTN_ACTIVE_CLASS,
SEPARATOR_VERTICAL_CLASS,
} from './menuStyles.js';
// Toolbar flottant de formatage. Affiché tant qu'une sélection non-vide
// existe dans un bloc. Ancré au-dessus du rect de sélection ; flip en
// dessous si pas assez de place.
//
// Ne contient pas d'état métier — tous les changements remontent via
// `onToggleMark(mark)`. Le parent recalcule `activeMarks` à chaque rendu.
// Toolbar flottant de formatage. Rendu en `position: absolute` à l'intérieur
// du container relatif du BlockEditor : il scrolle naturellement avec le
// texte. `rect` est exprimé en coords locales au container (top/left/bottom
// relatifs au content origin du container). La décision flip-up vs flip-down
// utilise la position viewport du container pour rester proche de la
// sélection visible.
const TOOLBAR_HEIGHT = 36;
const TOOLBAR_GAP = 8;
const VIEWPORT_MARGIN = 8;
const SUBMENU_CLOSE_DELAY = 120;
const SIMPLE_BUTTONS = [
{ type: 'bold', label: 'B', title: 'Gras (Ctrl+B)', className: 'font-bold' },
@@ -26,33 +40,63 @@ const SIMPLE_BUTTONS = [
export default function InlineToolbar({ rect, activeMarks, onToggleMark, onSetMark, onClearMarks, onPinChange, usedColors }) {
const ref = useRef(null);
const [pos, setPos] = useState({ top: 0, left: 0, flipped: false });
const [popover, setPopover] = useState(null);
const [openSubmenu, setOpenSubmenu] = useState(null);
const submenuTimerRef = useRef(null);
const [linkUrl, setLinkUrl] = useState('');
const [linkNewTab, setLinkNewTab] = useState(false);
useEffect(() => {
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 (
<div
ref={ref}
data-inline-toolbar
className="fixed z-50 flex items-center gap-0.5 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-md px-1 py-1"
className={`absolute z-50 flex items-center gap-0.5 p-1 ${BOX_CLASS}`}
style={{ top: pos.top, left: pos.left }}
>
{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}
</button>
))}
<span className="mx-1 w-px h-5 bg-neutral-200 dark:bg-neutral-700" aria-hidden />
<span className={SEPARATOR_VERTICAL_CLASS} aria-hidden />
<button
type="button"
<SubmenuTrigger
title="Couleur du texte"
onMouseDown={(e) => e.preventDefault()}
onClick={() => setPopover(p => (p === 'color' ? null : 'color'))}
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 ${isActive('color') ? 'bg-neutral-100 dark:bg-neutral-800' : ''}`}
active={isActive('color')}
open={openSubmenu === 'color'}
flipUp={pos.flipped}
onMouseEnter={() => openSubmenuFor('color')}
onMouseLeave={scheduleClose}
onPanelEnter={cancelClose}
onPanelLeave={scheduleClose}
icon={<TextColorIcon width={16} height={16} />}
>
<TextColorIcon width={16} height={16} />
</button>
<button
type="button"
title="Surlignage"
onMouseDown={(e) => e.preventDefault()}
onClick={() => setPopover(p => (p === 'highlight' ? null : 'highlight'))}
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 ${isActive('highlight') ? 'bg-neutral-100 dark:bg-neutral-800' : ''}`}
>
<HighlighterIcon width={16} height={16} />
</button>
<button
type="button"
title="Lien (Ctrl+K)"
onMouseDown={(e) => e.preventDefault()}
onClick={openLinkPopover}
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 ${isActive('link') ? 'bg-neutral-100 dark:bg-neutral-800' : ''}`}
>
<Link02Icon width={16} height={16} />
</button>
<span className="mx-1 w-px h-5 bg-neutral-200 dark:bg-neutral-700" aria-hidden />
<button
type="button"
title="Effacer le formatage"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onClearMarks?.()}
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"
>
<TextClearIcon width={16} height={16} />
</button>
{popover === 'color' && (
<ColorGrid
mode="text"
activeKey={activeMarks.find(m => m.type === 'color')?.color}
usedColors={usedColors?.color}
onPick={handleColor}
/>
)}
{popover === 'highlight' && (
</SubmenuTrigger>
<SubmenuTrigger
title="Surlignage"
active={isActive('highlight')}
open={openSubmenu === 'highlight'}
flipUp={pos.flipped}
onMouseEnter={() => openSubmenuFor('highlight')}
onMouseLeave={scheduleClose}
onPanelEnter={cancelClose}
onPanelLeave={scheduleClose}
icon={<HighlighterIcon width={16} height={16} />}
>
<ColorGrid
mode="highlight"
activeKey={activeMarks.find(m => m.type === 'highlight')?.color}
usedColors={usedColors?.highlight}
onPick={handleHighlight}
/>
)}
{popover === 'link' && (
</SubmenuTrigger>
<SubmenuTrigger
title="Lien (Ctrl+K)"
active={isActive('link')}
open={openSubmenu === 'link'}
flipUp={pos.flipped}
onMouseEnter={() => openSubmenuFor('link')}
onMouseLeave={scheduleClose}
onPanelEnter={cancelClose}
onPanelLeave={scheduleClose}
icon={<Link02Icon width={16} height={16} />}
>
<LinkForm
url={linkUrl}
newTab={linkNewTab}
showRemove={isActive('link')}
onUrlChange={setLinkUrl}
onNewTabChange={setLinkNewTab}
onSubmit={handleLinkSubmit}
onRemove={handleLinkRemove}
/>
</SubmenuTrigger>
<span className={SEPARATOR_VERTICAL_CLASS} aria-hidden />
<button
type="button"
title="Effacer le formatage"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onClearMarks?.()}
className={ICON_BTN_CLASS}
>
<TextClearIcon width={16} height={16} />
</button>
</div>
);
}
// 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 (
<div
className="relative"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<button
type="button"
title={title}
onMouseDown={(e) => e.preventDefault()}
className={`h-7 px-1.5 flex items-center gap-0.5 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 ${active || open ? ICON_BTN_ACTIVE_CLASS : ''}`}
>
{icon}
<ArrowDown01Icon width={10} height={10} className="opacity-60" />
</button>
{open && (
<div
className="absolute top-full left-0 mt-1 flex flex-col gap-1.5 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-md px-2 py-1.5"
className={`absolute ${panelPosition} left-0 ${BOX_CLASS} p-1.5 z-50`}
onMouseEnter={onPanelEnter}
onMouseLeave={onPanelLeave}
>
<div className="flex items-center gap-1">
<input
autoFocus
type="url"
placeholder="https://..."
value={linkUrl}
onChange={(e) => 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"
/>
<button
type="button"
onClick={handleLinkSubmit}
className="px-2 py-1 text-xs rounded bg-blue-600 hover:bg-blue-700 text-white"
>
OK
</button>
{isActive('link') && (
<button
type="button"
onClick={handleLinkRemove}
title="Retirer le lien"
className="px-2 py-1 text-xs rounded text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30"
>
</button>
)}
</div>
<label className="flex items-center gap-2 text-xs text-neutral-700 dark:text-neutral-300 select-none cursor-pointer">
<input
type="checkbox"
checked={linkNewTab}
onChange={(e) => setLinkNewTab(e.target.checked)}
className="cursor-pointer"
/>
Ouvrir dans un nouvel onglet
</label>
{children}
</div>
)}
</div>
);
}
function LinkForm({ url, newTab, showRemove, onUrlChange, onNewTabChange, onSubmit, onRemove }) {
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-1">
<input
autoFocus
type="url"
placeholder="https://..."
value={url}
onChange={(e) => 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"
/>
<button
type="button"
onClick={onSubmit}
className="px-2 py-1 text-xs rounded bg-blue-600 hover:bg-blue-700 text-white"
>
OK
</button>
{showRemove && (
<button
type="button"
onClick={onRemove}
title="Retirer le lien"
className="px-2 py-1 text-xs rounded text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30"
>
</button>
)}
</div>
<label className="flex items-center gap-2 text-xs text-neutral-700 dark:text-neutral-300 select-none cursor-pointer">
<input
type="checkbox"
checked={newTab}
onChange={(e) => onNewTabChange(e.target.checked)}
className="cursor-pointer"
/>
Ouvrir dans un nouvel onglet
</label>
</div>
);
}
const USED_COLORS_LIMIT = 8;
function ColorGrid({ mode, activeKey, usedColors, onPick }) {
@@ -238,7 +323,7 @@ function ColorGrid({ mode, activeKey, usedColors, onPick }) {
}
return (
<div className="absolute top-full left-0 mt-1 flex items-center gap-1 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-md px-2 py-1.5">
<div className="flex items-center gap-1">
{INLINE_COLOR_KEYS.map(key => {
const palette = INLINE_COLORS[key];
const tw = isText ? palette.text : palette.highlight;
@@ -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';