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