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 React, { useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, forwardRef } from 'react';
|
||||||
import { Add01Icon, ArrowRight01Icon, Copy01Icon, Delete02Icon, DragDropVerticalIcon, RepeatIcon } from '@zen/core/shared/icons';
|
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.
|
// Style « boîte » pour l'icône d'un type de bloc, repris du SlashMenu.
|
||||||
const TYPE_ICON_BOX_CLASS = 'w-8 h-8 flex items-center justify-center rounded-md border border-neutral-200 dark:border-neutral-700 text-xs font-medium text-neutral-700 dark:text-neutral-300 flex-shrink-0';
|
const 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 && (
|
{open && (
|
||||||
<div
|
<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 }}
|
style={{ maxHeight }}
|
||||||
>
|
>
|
||||||
<div className="p-1.5 flex flex-col gap-0.5 overflow-y-auto">
|
<div className="p-1.5 flex flex-col gap-0.5 overflow-y-auto">
|
||||||
@@ -251,7 +252,7 @@ function BlockActionsMenu({
|
|||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<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">
|
<div className="p-1.5 flex flex-col gap-0.5">
|
||||||
{transformOptions.length > 0 && (
|
{transformOptions.length > 0 && (
|
||||||
@@ -263,7 +264,7 @@ function BlockActionsMenu({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
role="button"
|
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" />
|
<RepeatIcon className="w-4 h-4 shrink-0" />
|
||||||
<span className="flex-1">Transformer</span>
|
<span className="flex-1">Transformer</span>
|
||||||
@@ -272,7 +273,7 @@ function BlockActionsMenu({
|
|||||||
{submenuOpen && (
|
{submenuOpen && (
|
||||||
<div
|
<div
|
||||||
ref={submenuPanelRef}
|
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}
|
onMouseEnter={cancelSubmenuClose}
|
||||||
onMouseLeave={scheduleSubmenuClose}
|
onMouseLeave={scheduleSubmenuClose}
|
||||||
>
|
>
|
||||||
@@ -297,18 +298,18 @@ function BlockActionsMenu({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={selectAndClose(onDuplicate)}
|
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" />
|
<Copy01Icon className="w-4 h-4 shrink-0" />
|
||||||
Dupliquer
|
Dupliquer
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="h-px bg-black/6 dark:bg-white/6 my-0.5" />
|
<div className={SEPARATOR_CLASS} />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={selectAndClose(onDelete)}
|
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" />
|
<Delete02Icon className="w-4 h-4 shrink-0" />
|
||||||
Supprimer
|
Supprimer
|
||||||
|
|||||||
@@ -607,7 +607,15 @@ export default function BlockEditor({
|
|||||||
const ref = blockRefs.current.get(blockId);
|
const ref = blockRefs.current.get(blockId);
|
||||||
const r = ref?.getCaretRange?.();
|
const r = ref?.getCaretRange?.();
|
||||||
if (!r || r.start === r.end) { setToolbar(null); return; }
|
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);
|
setLinkPopover(null);
|
||||||
setToolbar({ blockId, start: r.start, end: r.end, rect });
|
setToolbar({ blockId, start: r.start, end: r.end, rect });
|
||||||
}, [disabled]);
|
}, [disabled]);
|
||||||
@@ -1001,7 +1009,21 @@ export default function BlockEditor({
|
|||||||
if (!r) return;
|
if (!r) return;
|
||||||
const linkRange = linkRangeAt(block.content ?? [], r.start);
|
const linkRange = linkRangeAt(block.content ?? [], r.start);
|
||||||
if (!linkRange) return;
|
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 });
|
setLinkPopover({ rect, mark: linkRange.mark, blockId, start: linkRange.start, end: linkRange.end });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1083,7 +1105,7 @@ export default function BlockEditor({
|
|||||||
onMouseDownCapture={handleContainerMouseDown}
|
onMouseDownCapture={handleContainerMouseDown}
|
||||||
onMouseUp={handleContainerMouseUp}
|
onMouseUp={handleContainerMouseUp}
|
||||||
style={minHeight != null ? { minHeight: typeof minHeight === 'number' ? `${minHeight}px` : minHeight } : undefined}
|
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 />
|
<BlockEditorStyles />
|
||||||
{blocks.map((block, i) => (
|
{blocks.map((block, i) => (
|
||||||
@@ -1120,6 +1142,30 @@ export default function BlockEditor({
|
|||||||
enabledBlocks={enabledBlocks}
|
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>
|
</div>
|
||||||
{slashState && (
|
{slashState && (
|
||||||
<SlashMenu
|
<SlashMenu
|
||||||
@@ -1131,30 +1177,6 @@ export default function BlockEditor({
|
|||||||
onHoverIndex={(i) => setSlashState(s => ({ ...s, selectedIndex: i }))}
|
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 && (
|
{error && (
|
||||||
<p className="text-red-600 dark:text-red-400 text-xs">{error}</p>
|
<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 :
|
apparaît au-dessus. Il propose :
|
||||||
|
|
||||||
- **B I U S `</>`** — marks simples (toggle)
|
- **B I U S `</>`** — marks simples (toggle)
|
||||||
- **A** — couleur du texte (popover : palette par défaut + couleurs déjà
|
- **A** — couleur du texte (sous-menu drop-down : palette par défaut +
|
||||||
utilisées dans le document + bouton `+` pour une couleur libre via
|
couleurs déjà utilisées dans le document + bouton `+` pour une couleur
|
||||||
`<input type="color">`)
|
libre via `<input type="color">`)
|
||||||
- **◐** — surlignage (même structure de popover)
|
- **◐** — surlignage (même structure de sous-menu)
|
||||||
- **🔗** — lien (popover avec input URL ; ✕ pour retirer)
|
- **🔗** — lien (sous-menu drop-down avec input URL ; ✕ pour retirer)
|
||||||
- **T/** — effacer tout le formatage de la sélection (supprime toutes les marks)
|
- **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**
|
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.
|
(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
|
## Sélection multi-blocs
|
||||||
|
|
||||||
Deux façons d'entrer en mode 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
|
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.
|
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
|
## Étendre — enregistrer un bloc custom
|
||||||
|
|
||||||
```js
|
```js
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
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 GAP = 8;
|
||||||
const VIEWPORT_MARGIN = 8;
|
const VIEWPORT_MARGIN = 8;
|
||||||
@@ -13,16 +20,22 @@ export default function LinkPopover({ rect, mark, onSetLink, onRemoveLink, onClo
|
|||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!rect || typeof window === 'undefined') return;
|
if (!rect || typeof window === 'undefined') return;
|
||||||
const width = ref.current?.offsetWidth ?? 280;
|
const el = ref.current;
|
||||||
const height = ref.current?.offsetHeight ?? 80;
|
if (!el) return;
|
||||||
const vw = window.innerWidth;
|
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;
|
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;
|
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 (left < VIEWPORT_MARGIN) left = VIEWPORT_MARGIN;
|
||||||
if (top + height + VIEWPORT_MARGIN > vh) top = rect.top - height - GAP;
|
if (top < 0) top = 0;
|
||||||
if (top < VIEWPORT_MARGIN) top = VIEWPORT_MARGIN;
|
|
||||||
setPos({ top, left });
|
setPos({ top, left });
|
||||||
}, [rect]);
|
}, [rect]);
|
||||||
|
|
||||||
@@ -44,8 +57,8 @@ export default function LinkPopover({ rect, mark, onSetLink, onRemoveLink, onClo
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
data-link-popover
|
data-link-popover
|
||||||
style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 50 }}
|
style={{ top: pos.top, left: pos.left }}
|
||||||
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"
|
className={`absolute z-50 flex flex-col gap-1.5 p-2 ${BOX_CLASS}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,19 +1,33 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
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 { 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
|
// Toolbar flottant de formatage. Rendu en `position: absolute` à l'intérieur
|
||||||
// existe dans un bloc. Ancré au-dessus du rect de sélection ; flip en
|
// du container relatif du BlockEditor : il scrolle naturellement avec le
|
||||||
// dessous si pas assez de place.
|
// 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
|
||||||
// Ne contient pas d'état métier — tous les changements remontent via
|
// utilise la position viewport du container pour rester proche de la
|
||||||
// `onToggleMark(mark)`. Le parent recalcule `activeMarks` à chaque rendu.
|
// sélection visible.
|
||||||
|
|
||||||
const TOOLBAR_HEIGHT = 36;
|
const TOOLBAR_HEIGHT = 36;
|
||||||
const TOOLBAR_GAP = 8;
|
const TOOLBAR_GAP = 8;
|
||||||
const VIEWPORT_MARGIN = 8;
|
const VIEWPORT_MARGIN = 8;
|
||||||
|
const SUBMENU_CLOSE_DELAY = 120;
|
||||||
|
|
||||||
const SIMPLE_BUTTONS = [
|
const SIMPLE_BUTTONS = [
|
||||||
{ type: 'bold', label: 'B', title: 'Gras (Ctrl+B)', className: 'font-bold' },
|
{ 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 }) {
|
export default function InlineToolbar({ rect, activeMarks, onToggleMark, onSetMark, onClearMarks, onPinChange, usedColors }) {
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
const [pos, setPos] = useState({ top: 0, left: 0, flipped: false });
|
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 [linkUrl, setLinkUrl] = useState('');
|
||||||
const [linkNewTab, setLinkNewTab] = useState(false);
|
const [linkNewTab, setLinkNewTab] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onPinChange?.(popover !== null);
|
onPinChange?.(openSubmenu !== null);
|
||||||
}, [popover, onPinChange]);
|
}, [openSubmenu, onPinChange]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!rect || typeof window === 'undefined') return;
|
if (!rect || typeof window === 'undefined') return;
|
||||||
const width = ref.current?.offsetWidth ?? 280;
|
const el = ref.current;
|
||||||
const height = ref.current?.offsetHeight ?? TOOLBAR_HEIGHT;
|
if (!el) return;
|
||||||
const vw = window.innerWidth;
|
const w = el.offsetWidth || 280;
|
||||||
const vh = window.innerHeight;
|
const h = el.offsetHeight || TOOLBAR_HEIGHT;
|
||||||
const spaceAbove = rect.top - VIEWPORT_MARGIN;
|
const container = el.offsetParent;
|
||||||
const flipBelow = spaceAbove < height + TOOLBAR_GAP;
|
const containerW = container?.clientWidth ?? Infinity;
|
||||||
let top = flipBelow
|
const containerVPTop = container?.getBoundingClientRect?.().top ?? 0;
|
||||||
? rect.bottom + TOOLBAR_GAP
|
const selectionVPTop = containerVPTop + rect.top;
|
||||||
: rect.top - height - TOOLBAR_GAP;
|
const spaceAbove = selectionVPTop - VIEWPORT_MARGIN;
|
||||||
let left = rect.left + rect.width / 2 - width / 2;
|
const flipBelow = spaceAbove < h + TOOLBAR_GAP;
|
||||||
if (left + width + VIEWPORT_MARGIN > vw) left = vw - width - VIEWPORT_MARGIN;
|
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 (left < VIEWPORT_MARGIN) left = VIEWPORT_MARGIN;
|
||||||
if (top < VIEWPORT_MARGIN) top = VIEWPORT_MARGIN;
|
if (top < 0) top = 0;
|
||||||
if (top + height + VIEWPORT_MARGIN > vh) top = vh - height - VIEWPORT_MARGIN;
|
|
||||||
setPos({ top, left, flipped: flipBelow });
|
setPos({ top, left, flipped: flipBelow });
|
||||||
}, [rect]);
|
}, [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) {
|
function isActive(type, payloadKey) {
|
||||||
if (!Array.isArray(activeMarks)) return false;
|
if (!Array.isArray(activeMarks)) return false;
|
||||||
@@ -66,41 +110,33 @@ export default function InlineToolbar({ rect, activeMarks, onToggleMark, onSetMa
|
|||||||
|
|
||||||
function handleColor(color) {
|
function handleColor(color) {
|
||||||
onToggleMark?.({ type: 'color', color });
|
onToggleMark?.({ type: 'color', color });
|
||||||
setPopover(null);
|
closeSubmenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleHighlight(color) {
|
function handleHighlight(color) {
|
||||||
onToggleMark?.({ type: 'highlight', color });
|
onToggleMark?.({ type: 'highlight', color });
|
||||||
setPopover(null);
|
closeSubmenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLinkSubmit(e) {
|
function handleLinkSubmit(e) {
|
||||||
e.preventDefault?.();
|
e?.preventDefault?.();
|
||||||
if (!linkUrl) return;
|
if (!linkUrl) return;
|
||||||
onSetMark?.({ type: 'link', href: linkUrl, newTab: linkNewTab });
|
onSetMark?.({ type: 'link', href: linkUrl, newTab: linkNewTab });
|
||||||
setLinkUrl('');
|
setLinkUrl('');
|
||||||
setPopover(null);
|
closeSubmenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLinkRemove() {
|
function handleLinkRemove() {
|
||||||
// Trouver la mark active pour reproduire la même clé (toggle off).
|
|
||||||
const link = activeMarks.find(m => m.type === 'link');
|
const link = activeMarks.find(m => m.type === 'link');
|
||||||
if (link) onToggleMark?.({ type: 'link', href: link.href, ...(link.newTab ? { newTab: true } : {}) });
|
if (link) onToggleMark?.({ type: 'link', href: link.href, ...(link.newTab ? { newTab: true } : {}) });
|
||||||
setPopover(null);
|
closeSubmenu();
|
||||||
}
|
|
||||||
|
|
||||||
function openLinkPopover() {
|
|
||||||
const link = activeMarks.find(m => m.type === 'link');
|
|
||||||
setLinkUrl(link?.href ?? '');
|
|
||||||
setLinkNewTab(link ? !!link.newTab : false);
|
|
||||||
setPopover(p => (p === 'link' ? null : 'link'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
data-inline-toolbar
|
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 }}
|
style={{ top: pos.top, left: pos.left }}
|
||||||
>
|
>
|
||||||
{SIMPLE_BUTTONS.map(btn => (
|
{SIMPLE_BUTTONS.map(btn => (
|
||||||
@@ -110,117 +146,166 @@ export default function InlineToolbar({ rect, activeMarks, onToggleMark, onSetMa
|
|||||||
title={btn.title}
|
title={btn.title}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => handleSimple(btn.type)}
|
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}
|
{btn.label}
|
||||||
</button>
|
</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
|
<SubmenuTrigger
|
||||||
type="button"
|
|
||||||
title="Couleur du texte"
|
title="Couleur du texte"
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
active={isActive('color')}
|
||||||
onClick={() => setPopover(p => (p === 'color' ? null : 'color'))}
|
open={openSubmenu === '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' : ''}`}
|
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
|
<ColorGrid
|
||||||
mode="text"
|
mode="text"
|
||||||
activeKey={activeMarks.find(m => m.type === 'color')?.color}
|
activeKey={activeMarks.find(m => m.type === 'color')?.color}
|
||||||
usedColors={usedColors?.color}
|
usedColors={usedColors?.color}
|
||||||
onPick={handleColor}
|
onPick={handleColor}
|
||||||
/>
|
/>
|
||||||
)}
|
</SubmenuTrigger>
|
||||||
{popover === 'highlight' && (
|
|
||||||
|
<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
|
<ColorGrid
|
||||||
mode="highlight"
|
mode="highlight"
|
||||||
activeKey={activeMarks.find(m => m.type === 'highlight')?.color}
|
activeKey={activeMarks.find(m => m.type === 'highlight')?.color}
|
||||||
usedColors={usedColors?.highlight}
|
usedColors={usedColors?.highlight}
|
||||||
onPick={handleHighlight}
|
onPick={handleHighlight}
|
||||||
/>
|
/>
|
||||||
)}
|
</SubmenuTrigger>
|
||||||
{popover === 'link' && (
|
|
||||||
|
<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
|
<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">
|
{children}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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;
|
const USED_COLORS_LIMIT = 8;
|
||||||
|
|
||||||
function ColorGrid({ mode, activeKey, usedColors, onPick }) {
|
function ColorGrid({ mode, activeKey, usedColors, onPick }) {
|
||||||
@@ -238,7 +323,7 @@ function ColorGrid({ mode, activeKey, usedColors, onPick }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 => {
|
{INLINE_COLOR_KEYS.map(key => {
|
||||||
const palette = INLINE_COLORS[key];
|
const palette = INLINE_COLORS[key];
|
||||||
const tw = isText ? palette.text : palette.highlight;
|
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