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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user