refactor(BlockEditor): replace emoji icons with react icon components and add free color picker support

- update blockRegistry to accept ReactNode icons instead of emoji strings
- replace emoji icons in all built-in block types with icon components from shared icons
- add `isHexColor` and `collectUsedColors` helpers to inline/types.js
- extend `color` and `highlight` marks to accept hex color strings in addition to palette keys
- pass `usedColors` (collected from document) to InlineToolbar
- update InlineToolbar color popover to show used colors and a free color input
- add new icons to shared icons index
- update README to reflect icon, color, and toolbar popover changes
This commit is contained in:
2026-04-25 18:52:59 -04:00
parent 3f93503996
commit 219fb36da1
17 changed files with 311 additions and 45 deletions
@@ -14,6 +14,7 @@ import {
concatInline,
toggleMark,
marksInRange,
collectUsedColors,
} from './inline/types.js';
registerBuiltInBlocks();
@@ -443,6 +444,10 @@ export default function BlockEditor({
// { blockId, start, end, rect, marks }
const toolbarPinnedRef = useRef(false);
// Couleurs déjà utilisées dans le document (hors palette par défaut).
// Présentées comme choix rapides dans le popover de couleur du toolbar.
const usedColors = useMemo(() => collectUsedColors(blocks), [blocks]);
const updateToolbar = useCallback(() => {
if (disabled) {
setToolbar(null);
@@ -841,6 +846,7 @@ export default function BlockEditor({
<InlineToolbar
rect={toolbar.rect}
activeMarks={marks}
usedColors={usedColors}
onToggleMark={applyToggleMark}
onPinChange={(p) => { toolbarPinnedRef.current = p; }}
/>
+12 -7
View File
@@ -60,11 +60,13 @@ Chaque nœud porte optionnellement des **marks** (formatage).
| `strike` | — | `<s>` |
| `code` | — | `<code>` (monospace, fond gris) |
| `link` | `href: string` | `<a href>` (target="_blank") |
| `color` | `color: 'blue' \| 'green' \| 'amber' \| 'red'` | couleur du texte |
| `highlight` | `color: 'blue' \| 'green' \| 'amber' \| 'red'` | surlignage de fond |
| `color` | `color: <key> \| '#rrggbb'` | couleur du texte |
| `highlight` | `color: <key> \| '#rrggbb'` | surlignage de fond |
Les couleurs sont des **clés de palette** (pas de hex libre) — résolues vers
les classes Tailwind du DESIGN system. Voir `inline/types.js:INLINE_COLORS`.
Le champ `color` accepte **soit** une clé de palette (`blue`, `green`, `amber`,
`red`) — résolue vers les classes Tailwind du DESIGN system —, **soit** une
string hex `#rrggbb` choisie par l'utilisateur, appliquée via `style` inline.
Voir `inline/types.js:INLINE_COLORS` et `isHexColor`.
Le contenu vide est `[]` (jamais `[{type:'text', text:''}]`).
@@ -75,6 +77,7 @@ import {
inlineLength, inlineToPlainText, inlineFromText,
sliceInline, concatInline, applyMark, toggleMark,
marksAtOffset, marksInRange, INLINE_COLORS,
isHexColor, collectUsedColors,
} from '@zen/core/shared/components/BlockEditor';
```
@@ -118,8 +121,10 @@ 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 de la palette)
- **◐** — surlignage (popover)
- **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)
L'état actif est calculé à partir des marks **communes à toute la plage**
@@ -158,7 +163,7 @@ import { registerBlock, newBlockId } from '@zen/core/shared/components/BlockEdit
registerBlock({
type: 'kpi',
label: 'KPI',
icon: '📊',
icon: <ChartIcon width={18} height={18} />,
keywords: ['kpi', 'metric', 'stat'],
isText: false,
create: () => ({ id: newBlockId(), type: 'kpi', value: 0 }),
@@ -6,7 +6,7 @@
// {
// type: string, // id unique (ex: 'paragraph', 'my_custom')
// label: string, // libellé affiché dans le slash menu
// icon: string, // glyphe court (emoji ou caractère)
// icon: ReactNode, // élément React rendu dans le slash menu (typiquement une icône de `@zen/core/shared/icons`)
// keywords: string[], // termes de recherche pour le slash menu
// shortcut?: string, // préfixe markdown qui convertit (ex: '# ', '- ')
// shortcutTransform?: (block, match) => block, // optionnel : transforme un bloc existant
@@ -1,9 +1,10 @@
import { LeftToRightListBulletIcon } from '@zen/core/shared/icons';
import { newBlockId } from '../utils/ids.js';
const BulletItem = {
type: 'bullet_item',
label: 'Liste à puces',
icon: '•',
icon: <LeftToRightListBulletIcon width={18} height={18} />,
keywords: ['liste', 'list', 'puce', 'bullet', 'ul'],
shortcut: '- ',
isText: true,
@@ -1,4 +1,4 @@
import { Tick02Icon } from '@zen/core/shared/icons';
import { Tick02Icon, CheckListIcon } from '@zen/core/shared/icons';
import { newBlockId } from '../utils/ids.js';
// Préfixe : case à cocher cliquable. `onPatch({ checked })` mute l'état du
@@ -33,7 +33,7 @@ function Checkbox({ block, onPatch, disabled }) {
const Checklist = {
type: 'checklist',
label: 'Case à cocher',
icon: '☐',
icon: <CheckListIcon width={18} height={18} />,
keywords: ['checklist', 'todo', 'tache', 'tâche', 'case', 'cocher', 'check'],
shortcut: '[] ',
isText: true,
@@ -1,9 +1,10 @@
import { SourceCodeIcon } from '@zen/core/shared/icons';
import { newBlockId } from '../utils/ids.js';
const Code = {
type: 'code',
label: 'Bloc de code',
icon: '</>',
icon: <SourceCodeIcon width={18} height={18} />,
keywords: ['code', 'pre', 'snippet'],
shortcut: '``` ',
isText: true,
@@ -1,3 +1,4 @@
import { SeparatorHorizontalIcon } from '@zen/core/shared/icons';
import { newBlockId } from '../utils/ids.js';
function DividerComponent() {
@@ -11,7 +12,7 @@ function DividerComponent() {
const Divider = {
type: 'divider',
label: 'Séparateur',
icon: '—',
icon: <SeparatorHorizontalIcon width={18} height={18} />,
keywords: ['separateur', 'divider', 'hr', 'ligne', 'line'],
shortcut: '---',
isText: false,
@@ -1,5 +1,22 @@
import {
Heading01Icon,
Heading02Icon,
Heading03Icon,
Heading04Icon,
Heading05Icon,
Heading06Icon,
} from '@zen/core/shared/icons';
import { newBlockId } from '../utils/ids.js';
const HEADING_ICONS = {
1: Heading01Icon,
2: Heading02Icon,
3: Heading03Icon,
4: Heading04Icon,
5: Heading05Icon,
6: Heading06Icon,
};
const HEADING_STYLES = {
1: 'text-3xl font-bold leading-tight text-neutral-900 dark:text-white',
2: 'text-2xl font-bold leading-tight text-neutral-900 dark:text-white',
@@ -10,10 +27,11 @@ const HEADING_STYLES = {
};
function makeHeading(level) {
const Icon = HEADING_ICONS[level];
return {
type: `heading_${level}`,
label: `Titre ${level}`,
icon: `H${level}`,
icon: <Icon width={18} height={18} />,
keywords: [`titre ${level}`, `heading ${level}`, `h${level}`],
isText: true,
textTag: `h${level}`,
@@ -109,7 +109,7 @@ function ImageBlock({ block, onChange, disabled }) {
const Image = {
type: 'image',
label: 'Image',
icon: '🖼',
icon: <Image01Icon width={18} height={18} />,
keywords: ['image', 'photo', 'picture', 'img'],
isText: false,
create(init = {}) {
@@ -1,9 +1,10 @@
import { LeftToRightListNumberIcon } from '@zen/core/shared/icons';
import { newBlockId } from '../utils/ids.js';
const NumberedItem = {
type: 'numbered_item',
label: 'Liste numérotée',
icon: '1.',
icon: <LeftToRightListNumberIcon width={18} height={18} />,
keywords: ['liste numerotee', 'numbered list', 'ordonnee', 'ordered', 'ol'],
shortcut: '1. ',
isText: true,
@@ -1,9 +1,10 @@
import { TextIcon } from '@zen/core/shared/icons';
import { newBlockId } from '../utils/ids.js';
const Paragraph = {
type: 'paragraph',
label: 'Texte',
icon: '¶',
icon: <TextIcon width={18} height={18} />,
keywords: ['paragraphe', 'paragraph', 'texte', 'text', 'p'],
isText: true,
textTag: 'p',
@@ -1,9 +1,10 @@
import { LeftToRightBlockQuoteIcon } from '@zen/core/shared/icons';
import { newBlockId } from '../utils/ids.js';
const Quote = {
type: 'quote',
label: 'Citation',
icon: '❝',
icon: <LeftToRightBlockQuoteIcon width={18} height={18} />,
keywords: ['citation', 'quote', 'blockquote'],
shortcut: '> ',
isText: true,
@@ -22,6 +22,8 @@ export { newBlockId } from './utils/ids.js';
export {
INLINE_COLORS,
INLINE_COLOR_KEYS,
isHexColor,
collectUsedColors,
inlineLength,
inlineToPlainText,
inlineFromText,
@@ -1,6 +1,7 @@
'use client';
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { TextColorIcon, HighlighterIcon, Link02Icon } from '@zen/core/shared/icons';
import { INLINE_COLORS, INLINE_COLOR_KEYS, markKey } from './types.js';
// Toolbar flottant de formatage. Affiché tant qu'une sélection non-vide
@@ -22,7 +23,7 @@ const SIMPLE_BUTTONS = [
{ type: 'code', label: '</>', title: 'Code (Ctrl+E)', className: 'font-mono text-[11px]' },
];
export default function InlineToolbar({ rect, activeMarks, onToggleMark, onPinChange }) {
export default function InlineToolbar({ rect, activeMarks, onToggleMark, onPinChange, usedColors }) {
const ref = useRef(null);
const [pos, setPos] = useState({ top: 0, left: 0, flipped: false });
const [popover, setPopover] = useState(null); // 'color' | 'highlight' | 'link' | null
@@ -124,7 +125,7 @@ export default function InlineToolbar({ rect, activeMarks, onToggleMark, onPinCh
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' : ''}`}
>
<span className="font-semibold">A</span>
<TextColorIcon width={16} height={16} />
</button>
<button
type="button"
@@ -133,7 +134,7 @@ export default function InlineToolbar({ rect, activeMarks, onToggleMark, onPinCh
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' : ''}`}
>
<span aria-hidden></span>
<HighlighterIcon width={16} height={16} />
</button>
<button
type="button"
@@ -142,13 +143,14 @@ export default function InlineToolbar({ rect, activeMarks, onToggleMark, onPinCh
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' : ''}`}
>
<span aria-hidden>🔗</span>
<Link02Icon width={16} height={16} />
</button>
{popover === 'color' && (
<ColorGrid
mode="text"
activeKey={activeMarks.find(m => m.type === 'color')?.color}
usedColors={usedColors?.color}
onPick={handleColor}
/>
)}
@@ -156,6 +158,7 @@ export default function InlineToolbar({ rect, activeMarks, onToggleMark, onPinCh
<ColorGrid
mode="highlight"
activeKey={activeMarks.find(m => m.type === 'highlight')?.color}
usedColors={usedColors?.highlight}
onPick={handleHighlight}
/>
)}
@@ -205,12 +208,27 @@ export default function InlineToolbar({ rect, activeMarks, onToggleMark, onPinCh
);
}
function ColorGrid({ mode, activeKey, onPick }) {
const USED_COLORS_LIMIT = 8;
function ColorGrid({ mode, activeKey, usedColors, onPick }) {
const pickerRef = useRef(null);
const used = Array.isArray(usedColors) ? usedColors.slice(0, USED_COLORS_LIMIT) : [];
const isText = mode === 'text';
function openCustomPicker() {
pickerRef.current?.click();
}
function handleCustomChange(e) {
const value = e.target.value;
if (value) onPick(value);
}
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">
{INLINE_COLOR_KEYS.map(key => {
const palette = INLINE_COLORS[key];
const tw = mode === 'text' ? palette.text : palette.highlight;
const tw = isText ? palette.text : palette.highlight;
return (
<button
key={key}
@@ -220,10 +238,45 @@ function ColorGrid({ mode, activeKey, onPick }) {
onClick={() => onPick(key)}
className={`w-6 h-6 flex items-center justify-center rounded ${tw} ${activeKey === key ? 'ring-2 ring-blue-500' : ''}`}
>
<span className={mode === 'text' ? 'font-semibold' : ''}>A</span>
<span className={isText ? 'font-semibold' : ''}>A</span>
</button>
);
})}
{used.length > 0 && (
<span className="mx-1 w-px h-5 bg-neutral-200 dark:bg-neutral-700" aria-hidden />
)}
{used.map(value => (
<button
key={value}
type="button"
title={`Utilisée : ${value}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onPick(value)}
style={isText ? { color: value } : { backgroundColor: value }}
className={`w-6 h-6 flex items-center justify-center rounded ${activeKey === value ? 'ring-2 ring-blue-500' : ''}`}
>
<span className={isText ? 'font-semibold' : ''}>A</span>
</button>
))}
<span className="mx-1 w-px h-5 bg-neutral-200 dark:bg-neutral-700" aria-hidden />
<button
type="button"
title="Couleur personnalisée"
onMouseDown={(e) => e.preventDefault()}
onClick={openCustomPicker}
className="w-6 h-6 flex items-center justify-center rounded border border-dashed border-neutral-300 dark:border-neutral-600 text-neutral-500 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800"
>
<span aria-hidden>+</span>
</button>
<input
ref={pickerRef}
type="color"
onChange={handleCustomChange}
onMouseDown={(e) => e.stopPropagation()}
className="absolute opacity-0 pointer-events-none w-0 h-0"
tabIndex={-1}
aria-hidden
/>
</div>
);
}
@@ -12,7 +12,7 @@
// Cet ordre est important pour que la sérialisation aller-retour soit
// stable : `inlineToDom(domToInline(x))` produit le même HTML que `x`.
import { INLINE_COLORS, normalize } from './types.js';
import { INLINE_COLORS, isHexColor, normalize } from './types.js';
const TAG_TO_MARK = {
STRONG: 'bold',
@@ -56,22 +56,26 @@ function buildNode(d, node) {
if (findMark(marks, 'italic')) el = wrap(d, el, 'em');
if (findMark(marks, 'bold')) el = wrap(d, el, 'strong');
// 2. Couleur du texte.
// 2. Couleur du texte. Hex → style inline ; clé palette → classe Tailwind.
const color = findMark(marks, 'color');
if (color) {
const tw = INLINE_COLORS[color.color]?.text;
const hex = isHexColor(color.color);
const tw = hex ? '' : (INLINE_COLORS[color.color]?.text || '');
el = wrap(d, el, 'span', {
className: tw || '',
className: tw,
style: hex ? { color: color.color } : null,
attrs: { 'data-color': color.color },
});
}
// 3. Surlignage.
// 3. Surlignage. Hex → style inline ; clé palette → classe Tailwind.
const highlight = findMark(marks, 'highlight');
if (highlight) {
const tw = INLINE_COLORS[highlight.color]?.highlight;
const hex = isHexColor(highlight.color);
const tw = hex ? '' : (INLINE_COLORS[highlight.color]?.highlight || '');
el = wrap(d, el, 'span', {
className: `rounded px-0.5 ${tw || ''}`,
className: `rounded px-0.5${tw ? ` ${tw}` : ''}`,
style: hex ? { backgroundColor: highlight.color } : null,
attrs: { 'data-highlight': highlight.color },
});
}
@@ -96,6 +100,9 @@ function buildNode(d, node) {
function wrap(d, child, tagName, opts = {}) {
const el = d.createElement(tagName);
if (opts.className) el.className = opts.className;
if (opts.style) {
for (const [k, v] of Object.entries(opts.style)) el.style[k] = v;
}
if (opts.attrs) {
for (const [k, v] of Object.entries(opts.attrs)) el.setAttribute(k, v);
}
@@ -19,6 +19,11 @@
// Palette des couleurs sémantiques du DESIGN system (cf. docs/DESIGN.md).
// Bleu / vert / ambre / rouge — utilisées avec parcimonie. Les classes
// Tailwind sont appliquées au rendu via `inlineToDom`.
//
// Le champ `color` des marks `color`/`highlight` accepte **soit** une clé
// de cette palette, **soit** une string `#rrggbb` (couleur libre choisie
// par l'utilisateur). À la sérialisation, un hex bascule sur un `style`
// inline ; une clé palette utilise les classes Tailwind ci-dessous.
export const INLINE_COLORS = {
blue: { text: 'text-blue-600 dark:text-blue-400', highlight: 'bg-blue-100 dark:bg-blue-900/40' },
green: { text: 'text-green-600 dark:text-green-400', highlight: 'bg-green-100 dark:bg-green-900/40' },
@@ -28,6 +33,39 @@ export const INLINE_COLORS = {
export const INLINE_COLOR_KEYS = Object.keys(INLINE_COLORS);
const HEX_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
export function isHexColor(c) {
return typeof c === 'string' && HEX_RE.test(c);
}
// Parcourt tous les blocs et collecte les valeurs de `color` et `highlight`
// utilisées dans le contenu inline. Conserve l'ordre d'apparition, déduplique,
// et exclut les clés de la palette par défaut (la palette est déjà affichée
// au-dessus dans le toolbar).
export function collectUsedColors(blocks) {
const out = { color: [], highlight: [] };
if (!Array.isArray(blocks)) return out;
const seen = { color: new Set(), highlight: new Set() };
const palette = new Set(INLINE_COLOR_KEYS);
for (const block of blocks) {
const content = block?.content;
if (!Array.isArray(content)) continue;
for (const node of content) {
if (!node?.marks) continue;
for (const mark of node.marks) {
if (mark.type !== 'color' && mark.type !== 'highlight') continue;
const value = mark.color;
if (!value || palette.has(value)) continue;
if (seen[mark.type].has(value)) continue;
seen[mark.type].add(value);
out[mark.type].push(value);
}
}
}
return out;
}
const SIMPLE_MARK_TYPES = ['bold', 'italic', 'underline', 'strike', 'code'];
function marksEqual(a, b) {