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
@@ -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) {