diff --git a/src/shared/components/BlockEditor/BlockEditor.client.js b/src/shared/components/BlockEditor/BlockEditor.client.js index 3317bd4..eaceefa 100644 --- a/src/shared/components/BlockEditor/BlockEditor.client.js +++ b/src/shared/components/BlockEditor/BlockEditor.client.js @@ -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({ { toolbarPinnedRef.current = p; }} /> diff --git a/src/shared/components/BlockEditor/README.md b/src/shared/components/BlockEditor/README.md index 6e20282..f979768 100644 --- a/src/shared/components/BlockEditor/README.md +++ b/src/shared/components/BlockEditor/README.md @@ -60,11 +60,13 @@ Chaque nœud porte optionnellement des **marks** (formatage). | `strike` | — | `` | | `code` | — | `` (monospace, fond gris) | | `link` | `href: string` | `` (target="_blank") | -| `color` | `color: 'blue' \| 'green' \| 'amber' \| 'red'` | couleur du texte | -| `highlight` | `color: 'blue' \| 'green' \| 'amber' \| 'red'` | surlignage de fond | +| `color` | `color: \| '#rrggbb'` | couleur du texte | +| `highlight` | `color: \| '#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 + ``) +- **◐** — 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: , keywords: ['kpi', 'metric', 'stat'], isText: false, create: () => ({ id: newBlockId(), type: 'kpi', value: 0 }), diff --git a/src/shared/components/BlockEditor/blockRegistry.js b/src/shared/components/BlockEditor/blockRegistry.js index 2112bfb..eb5f4b2 100644 --- a/src/shared/components/BlockEditor/blockRegistry.js +++ b/src/shared/components/BlockEditor/blockRegistry.js @@ -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 diff --git a/src/shared/components/BlockEditor/blockTypes/BulletList.js b/src/shared/components/BlockEditor/blockTypes/BulletList.js index a276e2f..170f27d 100644 --- a/src/shared/components/BlockEditor/blockTypes/BulletList.js +++ b/src/shared/components/BlockEditor/blockTypes/BulletList.js @@ -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: , keywords: ['liste', 'list', 'puce', 'bullet', 'ul'], shortcut: '- ', isText: true, diff --git a/src/shared/components/BlockEditor/blockTypes/Checklist.js b/src/shared/components/BlockEditor/blockTypes/Checklist.js index fad76f6..6ececd5 100644 --- a/src/shared/components/BlockEditor/blockTypes/Checklist.js +++ b/src/shared/components/BlockEditor/blockTypes/Checklist.js @@ -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: , keywords: ['checklist', 'todo', 'tache', 'tâche', 'case', 'cocher', 'check'], shortcut: '[] ', isText: true, diff --git a/src/shared/components/BlockEditor/blockTypes/Code.js b/src/shared/components/BlockEditor/blockTypes/Code.js index 576a2c3..a6b9555 100644 --- a/src/shared/components/BlockEditor/blockTypes/Code.js +++ b/src/shared/components/BlockEditor/blockTypes/Code.js @@ -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: , keywords: ['code', 'pre', 'snippet'], shortcut: '``` ', isText: true, diff --git a/src/shared/components/BlockEditor/blockTypes/Divider.js b/src/shared/components/BlockEditor/blockTypes/Divider.js index 037ecd6..b1a4fd9 100644 --- a/src/shared/components/BlockEditor/blockTypes/Divider.js +++ b/src/shared/components/BlockEditor/blockTypes/Divider.js @@ -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: , keywords: ['separateur', 'divider', 'hr', 'ligne', 'line'], shortcut: '---', isText: false, diff --git a/src/shared/components/BlockEditor/blockTypes/Heading.js b/src/shared/components/BlockEditor/blockTypes/Heading.js index cbd840c..1d5a517 100644 --- a/src/shared/components/BlockEditor/blockTypes/Heading.js +++ b/src/shared/components/BlockEditor/blockTypes/Heading.js @@ -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: , keywords: [`titre ${level}`, `heading ${level}`, `h${level}`], isText: true, textTag: `h${level}`, diff --git a/src/shared/components/BlockEditor/blockTypes/Image.client.js b/src/shared/components/BlockEditor/blockTypes/Image.client.js index 063ade6..db43624 100644 --- a/src/shared/components/BlockEditor/blockTypes/Image.client.js +++ b/src/shared/components/BlockEditor/blockTypes/Image.client.js @@ -109,7 +109,7 @@ function ImageBlock({ block, onChange, disabled }) { const Image = { type: 'image', label: 'Image', - icon: '🖼', + icon: , keywords: ['image', 'photo', 'picture', 'img'], isText: false, create(init = {}) { diff --git a/src/shared/components/BlockEditor/blockTypes/NumberedList.js b/src/shared/components/BlockEditor/blockTypes/NumberedList.js index ee67cf0..e8b68ea 100644 --- a/src/shared/components/BlockEditor/blockTypes/NumberedList.js +++ b/src/shared/components/BlockEditor/blockTypes/NumberedList.js @@ -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: , keywords: ['liste numerotee', 'numbered list', 'ordonnee', 'ordered', 'ol'], shortcut: '1. ', isText: true, diff --git a/src/shared/components/BlockEditor/blockTypes/Paragraph.js b/src/shared/components/BlockEditor/blockTypes/Paragraph.js index 88581f1..2d330e6 100644 --- a/src/shared/components/BlockEditor/blockTypes/Paragraph.js +++ b/src/shared/components/BlockEditor/blockTypes/Paragraph.js @@ -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: , keywords: ['paragraphe', 'paragraph', 'texte', 'text', 'p'], isText: true, textTag: 'p', diff --git a/src/shared/components/BlockEditor/blockTypes/Quote.js b/src/shared/components/BlockEditor/blockTypes/Quote.js index 99be744..dfc8d0f 100644 --- a/src/shared/components/BlockEditor/blockTypes/Quote.js +++ b/src/shared/components/BlockEditor/blockTypes/Quote.js @@ -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: , keywords: ['citation', 'quote', 'blockquote'], shortcut: '> ', isText: true, diff --git a/src/shared/components/BlockEditor/index.js b/src/shared/components/BlockEditor/index.js index d323d72..adfda87 100644 --- a/src/shared/components/BlockEditor/index.js +++ b/src/shared/components/BlockEditor/index.js @@ -22,6 +22,8 @@ export { newBlockId } from './utils/ids.js'; export { INLINE_COLORS, INLINE_COLOR_KEYS, + isHexColor, + collectUsedColors, inlineLength, inlineToPlainText, inlineFromText, diff --git a/src/shared/components/BlockEditor/inline/Toolbar.client.js b/src/shared/components/BlockEditor/inline/Toolbar.client.js index 678cbb6..cba1d56 100644 --- a/src/shared/components/BlockEditor/inline/Toolbar.client.js +++ b/src/shared/components/BlockEditor/inline/Toolbar.client.js @@ -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' : ''}`} > - A + {popover === 'color' && ( m.type === 'color')?.color} + usedColors={usedColors?.color} onPick={handleColor} /> )} @@ -156,6 +158,7 @@ export default function InlineToolbar({ rect, activeMarks, onToggleMark, onPinCh 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 (
{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 ( ); })} + {used.length > 0 && ( + + )} + {used.map(value => ( + + ))} + + + e.stopPropagation()} + className="absolute opacity-0 pointer-events-none w-0 h-0" + tabIndex={-1} + aria-hidden + />
); } diff --git a/src/shared/components/BlockEditor/inline/serialize.js b/src/shared/components/BlockEditor/inline/serialize.js index dcff38e..7e15fb6 100644 --- a/src/shared/components/BlockEditor/inline/serialize.js +++ b/src/shared/components/BlockEditor/inline/serialize.js @@ -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); } diff --git a/src/shared/components/BlockEditor/inline/types.js b/src/shared/components/BlockEditor/inline/types.js index b06ecfd..5262a6d 100644 --- a/src/shared/components/BlockEditor/inline/types.js +++ b/src/shared/components/BlockEditor/inline/types.js @@ -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) { diff --git a/src/shared/icons/index.js b/src/shared/icons/index.js index d2f4776..53a3e64 100644 --- a/src/shared/icons/index.js +++ b/src/shared/icons/index.js @@ -295,13 +295,6 @@ export const CancelCircleIcon = (props) => ( ); -export const Link02Icon = (props) => ( - - - - -); - export const UserGroupIcon = (props) => ( @@ -485,12 +478,6 @@ export const Doc02Icon = (props) => ( ); -export const Image01Icon = (props) => ( - - - -); - export const PlaySquareIcon = (props) => ( @@ -554,4 +541,148 @@ export const Add01Icon = (props) => ( +); + +export const TextColorIcon = (props) => ( + + + + +); + +export const HighlighterIcon = (props) => ( + + + +); + +export const Link02Icon = (props) => ( + + + + +); + +export const TextIcon = (props) => ( + + + + + +); + +export const Heading01Icon = (props) => ( + + + + + + +); + +export const Heading02Icon = (props) => ( + + + + + + +); + +export const Heading03Icon = (props) => ( + + + + + + +); + +export const Heading04Icon = (props) => ( + + + + + + +); + +export const Heading05Icon = (props) => ( + + + + + + +); + +export const Heading06Icon = (props) => ( + + + + + + +); + +export const LeftToRightListBulletIcon = (props) => ( + + + + + + + + +); + +export const LeftToRightListNumberIcon = (props) => ( + + + + + + + +); + +export const CheckListIcon = (props) => ( + + + + + + + +); + +export const LeftToRightBlockQuoteIcon = (props) => ( + + + + + + +); + +export const SourceCodeIcon = (props) => ( + + + + + +); + +export const SeparatorHorizontalIcon = (props) => ( + + + + + +); + +export const Image01Icon = (props) => ( + + + ); \ No newline at end of file