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
@@ -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 && (
<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`}
className={`absolute left-0 ${panelPositionClass} w-64 ${BOX_CLASS} z-50 flex flex-col`}
style={{ maxHeight }}
>
<div className="p-1.5 flex flex-col gap-0.5 overflow-y-auto">
@@ -251,7 +252,7 @@ function BlockActionsMenu({
{open && (
<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`}
className={`absolute left-0 ${panelPositionClass} w-56 ${BOX_CLASS} z-50 flex flex-col`}
>
<div className="p-1.5 flex flex-col gap-0.5">
{transformOptions.length > 0 && (
@@ -263,7 +264,7 @@ function BlockActionsMenu({
>
<div
role="button"
className={`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 transition-colors duration-[120ms] ease-out ${submenuOpen ? 'bg-neutral-100 dark:bg-white/5 text-neutral-900 dark:text-white' : ''}`}
className={`${ITEM_CLASS} ${submenuOpen ? 'bg-neutral-100 dark:bg-white/5 text-neutral-900 dark:text-white' : ''}`}
>
<RepeatIcon className="w-4 h-4 shrink-0" />
<span className="flex-1">Transformer</span>
@@ -272,7 +273,7 @@ function BlockActionsMenu({
{submenuOpen && (
<div
ref={submenuPanelRef}
className={`absolute left-full ${submenuSide === 'above' ? 'bottom-0' : 'top-0'} ml-1 w-56 rounded-xl border border-black/8 dark:border-white/8 bg-white dark:bg-[#0B0B0B] shadow-lg p-1.5 flex flex-col gap-0.5 z-50`}
className={`absolute left-full ${submenuSide === 'above' ? 'bottom-0' : 'top-0'} ml-1 w-56 ${BOX_CLASS} p-1.5 flex flex-col gap-0.5 z-50`}
onMouseEnter={cancelSubmenuClose}
onMouseLeave={scheduleSubmenuClose}
>
@@ -297,18 +298,18 @@ function BlockActionsMenu({
<button
type="button"
onClick={selectAndClose(onDuplicate)}
className="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"
className={ITEM_CLASS}
>
<Copy01Icon className="w-4 h-4 shrink-0" />
Dupliquer
</button>
<div className="h-px bg-black/6 dark:bg-white/6 my-0.5" />
<div className={SEPARATOR_CLASS} />
<button
type="button"
onClick={selectAndClose(onDelete)}
className="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"
className={ITEM_DANGER_CLASS}
>
<Delete02Icon className="w-4 h-4 shrink-0" />
Supprimer
@@ -607,7 +607,15 @@ export default function BlockEditor({
const ref = blockRefs.current.get(blockId);
const r = ref?.getCaretRange?.();
if (!r || r.start === r.end) { setToolbar(null); return; }
const rect = range.getBoundingClientRect();
const vpRect = range.getBoundingClientRect();
const cRect = container.getBoundingClientRect();
const rect = {
top: vpRect.top - cRect.top,
left: vpRect.left - cRect.left,
bottom: vpRect.bottom - cRect.top,
width: vpRect.width,
height: vpRect.height,
};
setLinkPopover(null);
setToolbar({ blockId, start: r.start, end: r.end, rect });
}, [disabled]);
@@ -1001,7 +1009,21 @@ export default function BlockEditor({
if (!r) return;
const linkRange = linkRangeAt(block.content ?? [], r.start);
if (!linkRange) return;
const rect = sel.getRangeAt(0).getBoundingClientRect();
const targetEl = e.target instanceof Element ? e.target : null;
const aEl = targetEl?.closest?.('a') ?? null;
const container = containerRef.current;
if (!container) return;
const cRect = container.getBoundingClientRect();
const vpRect = aEl
? aEl.getBoundingClientRect()
: sel.getRangeAt(0).getBoundingClientRect();
const rect = {
top: vpRect.top - cRect.top,
left: vpRect.left - cRect.left,
bottom: vpRect.bottom - cRect.top,
width: vpRect.width,
height: vpRect.height,
};
setLinkPopover({ rect, mark: linkRange.mark, blockId, start: linkRange.start, end: linkRange.end });
}
@@ -1083,7 +1105,7 @@ export default function BlockEditor({
onMouseDownCapture={handleContainerMouseDown}
onMouseUp={handleContainerMouseUp}
style={minHeight != null ? { minHeight: typeof minHeight === 'number' ? `${minHeight}px` : minHeight } : undefined}
className={`block-editor border rounded-xl bg-white dark:bg-neutral-900/60 pl-0 pr-[45px] py-5 ${error ? 'border-red-500/50' : 'border-neutral-300 dark:border-neutral-700/50'} ${blocks.length === 1 && inlineLength(blocks[0].content ?? []) === 0 ? 'block-editor--sole-empty' : ''} ${className}`}
className={`block-editor relative border rounded-xl bg-white dark:bg-neutral-900/60 pl-0 pr-[45px] py-5 ${error ? 'border-red-500/50' : 'border-neutral-300 dark:border-neutral-700/50'} ${blocks.length === 1 && inlineLength(blocks[0].content ?? []) === 0 ? 'block-editor--sole-empty' : ''} ${className}`}
>
<BlockEditorStyles />
{blocks.map((block, i) => (
@@ -1120,6 +1142,30 @@ export default function BlockEditor({
enabledBlocks={enabledBlocks}
/>
))}
{toolbar && (() => {
const block = blocks.find(b => b.id === toolbar.blockId);
const marks = block ? marksInRange(block.content ?? [], toolbar.start, toolbar.end) : [];
return (
<InlineToolbar
rect={toolbar.rect}
activeMarks={marks}
usedColors={usedColors}
onToggleMark={applyToggleMark}
onSetMark={applySetMark}
onClearMarks={applyRemoveAllMarks}
onPinChange={(p) => { toolbarPinnedRef.current = p; }}
/>
);
})()}
{linkPopover && (
<LinkPopover
rect={linkPopover.rect}
mark={linkPopover.mark}
onSetLink={handleLinkPopoverSet}
onRemoveLink={handleLinkPopoverRemove}
onClose={() => setLinkPopover(null)}
/>
)}
</div>
{slashState && (
<SlashMenu
@@ -1131,30 +1177,6 @@ export default function BlockEditor({
onHoverIndex={(i) => 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 (
<InlineToolbar
rect={toolbar.rect}
activeMarks={marks}
usedColors={usedColors}
onToggleMark={applyToggleMark}
onSetMark={applySetMark}
onClearMarks={applyRemoveAllMarks}
onPinChange={(p) => { toolbarPinnedRef.current = p; }}
/>
);
})()}
{linkPopover && (
<LinkPopover
rect={linkPopover.rect}
mark={linkPopover.mark}
onSetLink={handleLinkPopoverSet}
onRemoveLink={handleLinkPopoverRemove}
onClose={() => setLinkPopover(null)}
/>
)}
{error && (
<p className="text-red-600 dark:text-red-400 text-xs">{error}</p>
)}
+29 -5
View File
@@ -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
`<input type="color">`)
- **◐** — 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 `<input type="color">`)
- **◐** — 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 `<a>` 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
@@ -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';