feat(BlockEditor): add inline formatting with rich content model
- migrate block content from plain strings to InlineNode[] structure - add inline toolbar (bold, italic, code, color, link) on text selection - add checklist block type with toggle support - add image block type (URL-based, phase 2) - add inline serialization helpers (inlineToDom, domToInline) - add inline types and length utilities - extend caret utils with range get/set support - update block registry and all existing block types for new content model - update demo blocks in ComponentsPage to use rich inline content - update README to reflect new architecture
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { INLINE_COLORS, INLINE_COLOR_KEYS, markKey } from './types.js';
|
||||
|
||||
// Toolbar flottant de formatage. Affiché tant qu'une sélection non-vide
|
||||
// existe dans un bloc. Ancré au-dessus du rect de sélection ; flip en
|
||||
// dessous si pas assez de place.
|
||||
//
|
||||
// Ne contient pas d'état métier — tous les changements remontent via
|
||||
// `onToggleMark(mark)`. Le parent recalcule `activeMarks` à chaque rendu.
|
||||
|
||||
const TOOLBAR_HEIGHT = 36;
|
||||
const TOOLBAR_GAP = 8;
|
||||
const VIEWPORT_MARGIN = 8;
|
||||
|
||||
const SIMPLE_BUTTONS = [
|
||||
{ type: 'bold', label: 'B', title: 'Gras (Ctrl+B)', className: 'font-bold' },
|
||||
{ type: 'italic', label: 'I', title: 'Italique (Ctrl+I)', className: 'italic' },
|
||||
{ type: 'underline', label: 'U', title: 'Soulignement (Ctrl+U)', className: 'underline' },
|
||||
{ type: 'strike', label: 'S', title: 'Barré', className: 'line-through' },
|
||||
{ type: 'code', label: '</>', title: 'Code (Ctrl+E)', className: 'font-mono text-[11px]' },
|
||||
];
|
||||
|
||||
export default function InlineToolbar({ rect, activeMarks, onToggleMark }) {
|
||||
const ref = useRef(null);
|
||||
const [pos, setPos] = useState({ top: 0, left: 0, flipped: false });
|
||||
const [popover, setPopover] = useState(null); // 'color' | 'highlight' | 'link' | null
|
||||
const [linkUrl, setLinkUrl] = useState('');
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!rect || typeof window === 'undefined') return;
|
||||
const width = ref.current?.offsetWidth ?? 280;
|
||||
const height = ref.current?.offsetHeight ?? TOOLBAR_HEIGHT;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const spaceAbove = rect.top - VIEWPORT_MARGIN;
|
||||
const flipBelow = spaceAbove < height + TOOLBAR_GAP;
|
||||
let top = flipBelow
|
||||
? rect.bottom + TOOLBAR_GAP
|
||||
: rect.top - height - TOOLBAR_GAP;
|
||||
let left = rect.left + rect.width / 2 - width / 2;
|
||||
if (left + width + VIEWPORT_MARGIN > vw) left = vw - width - VIEWPORT_MARGIN;
|
||||
if (left < VIEWPORT_MARGIN) left = VIEWPORT_MARGIN;
|
||||
if (top < VIEWPORT_MARGIN) top = VIEWPORT_MARGIN;
|
||||
if (top + height + VIEWPORT_MARGIN > vh) top = vh - height - VIEWPORT_MARGIN;
|
||||
setPos({ top, left, flipped: flipBelow });
|
||||
}, [rect]);
|
||||
|
||||
// Ferme un popover ouvert quand la sélection change (rect change).
|
||||
useEffect(() => { setPopover(null); }, [rect?.top, rect?.left]);
|
||||
|
||||
function isActive(type, payloadKey) {
|
||||
if (!Array.isArray(activeMarks)) return false;
|
||||
if (payloadKey) return activeMarks.some(m => markKey(m) === payloadKey);
|
||||
return activeMarks.some(m => m.type === type);
|
||||
}
|
||||
|
||||
function handleSimple(type) {
|
||||
onToggleMark?.({ type });
|
||||
}
|
||||
|
||||
function handleColor(color) {
|
||||
onToggleMark?.({ type: 'color', color });
|
||||
setPopover(null);
|
||||
}
|
||||
|
||||
function handleHighlight(color) {
|
||||
onToggleMark?.({ type: 'highlight', color });
|
||||
setPopover(null);
|
||||
}
|
||||
|
||||
function handleLinkSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (!linkUrl) return;
|
||||
onToggleMark?.({ type: 'link', href: linkUrl });
|
||||
setLinkUrl('');
|
||||
setPopover(null);
|
||||
}
|
||||
|
||||
function handleLinkRemove() {
|
||||
// Trouver le href actif pour reproduire la même mark (toggle off).
|
||||
const link = activeMarks.find(m => m.type === 'link');
|
||||
if (link) onToggleMark?.({ type: 'link', href: link.href });
|
||||
setPopover(null);
|
||||
}
|
||||
|
||||
function openLinkPopover() {
|
||||
const link = activeMarks.find(m => m.type === 'link');
|
||||
setLinkUrl(link?.href ?? '');
|
||||
setPopover(p => (p === 'link' ? null : 'link'));
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
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"
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{SIMPLE_BUTTONS.map(btn => (
|
||||
<button
|
||||
key={btn.type}
|
||||
type="button"
|
||||
title={btn.title}
|
||||
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' : ''}`}
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<span className="mx-1 w-px h-5 bg-neutral-200 dark:bg-neutral-700" aria-hidden />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title="Couleur du texte"
|
||||
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>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Surlignage"
|
||||
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>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Lien (Ctrl+K)"
|
||||
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>
|
||||
</button>
|
||||
|
||||
{popover === 'color' && (
|
||||
<ColorGrid
|
||||
mode="text"
|
||||
activeKey={activeMarks.find(m => m.type === 'color')?.color}
|
||||
onPick={handleColor}
|
||||
/>
|
||||
)}
|
||||
{popover === 'highlight' && (
|
||||
<ColorGrid
|
||||
mode="highlight"
|
||||
activeKey={activeMarks.find(m => m.type === 'highlight')?.color}
|
||||
onPick={handleHighlight}
|
||||
/>
|
||||
)}
|
||||
{popover === 'link' && (
|
||||
<form
|
||||
onSubmit={handleLinkSubmit}
|
||||
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"
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
type="url"
|
||||
placeholder="https://..."
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
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="submit"
|
||||
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>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorGrid({ mode, activeKey, onPick }) {
|
||||
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;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
title={key}
|
||||
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>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Sérialisation `InlineNode[]` ↔ DOM.
|
||||
//
|
||||
// Le contentEditable de chaque bloc texte contient un sous-arbre HTML que
|
||||
// l'utilisateur édite. À chaque frappe, on lit l'arbre via `domToInline`
|
||||
// pour reconstruire les nœuds. À chaque changement externe (undo, transform,
|
||||
// toolbar), on réécrit l'arbre via `inlineToDom`.
|
||||
//
|
||||
// Ordre canonique des wrappers (extérieur → intérieur) :
|
||||
// <a> > <span data-highlight> > <span data-color> > <strong> > <em> > <u>
|
||||
// > <s> > <code> > #text
|
||||
//
|
||||
// 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';
|
||||
|
||||
const SIMPLE_TAGS = {
|
||||
bold: 'STRONG',
|
||||
italic: 'EM',
|
||||
underline: 'U',
|
||||
strike: 'S',
|
||||
code: 'CODE',
|
||||
};
|
||||
|
||||
const TAG_TO_MARK = {
|
||||
STRONG: 'bold',
|
||||
B: 'bold',
|
||||
EM: 'italic',
|
||||
I: 'italic',
|
||||
U: 'underline',
|
||||
S: 'strike',
|
||||
STRIKE: 'strike',
|
||||
DEL: 'strike',
|
||||
CODE: 'code',
|
||||
};
|
||||
|
||||
function findMark(marks, type) {
|
||||
return marks?.find(m => m.type === type);
|
||||
}
|
||||
|
||||
// Construit un fragment DOM. Reçoit un Document optionnel (utile en SSR /
|
||||
// tests) ; sinon utilise `document` global.
|
||||
export function inlineToDom(nodes, doc) {
|
||||
const d = doc || (typeof document !== 'undefined' ? document : null);
|
||||
if (!d) throw new Error('inlineToDom: document requis');
|
||||
const fragment = d.createDocumentFragment();
|
||||
if (!Array.isArray(nodes) || nodes.length === 0) return fragment;
|
||||
for (const node of nodes) {
|
||||
fragment.appendChild(buildNode(d, node));
|
||||
}
|
||||
return fragment;
|
||||
}
|
||||
|
||||
function buildNode(d, node) {
|
||||
let el = d.createTextNode(node.text ?? '');
|
||||
const marks = node.marks || [];
|
||||
|
||||
// Ordre intérieur → extérieur (on enveloppe progressivement).
|
||||
// 1. Marks simples (code, strike, underline, italic, bold) — plus on est
|
||||
// interne, plus on est dans la cascade visuelle « précise ».
|
||||
if (findMark(marks, 'code')) el = wrap(d, el, 'code', { className: 'rounded px-1 py-0.5 font-mono text-[0.9em] bg-neutral-100 dark:bg-neutral-800/80' });
|
||||
if (findMark(marks, 'strike')) el = wrap(d, el, 's');
|
||||
if (findMark(marks, 'underline')) el = wrap(d, el, 'u');
|
||||
if (findMark(marks, 'italic')) el = wrap(d, el, 'em');
|
||||
if (findMark(marks, 'bold')) el = wrap(d, el, 'strong');
|
||||
|
||||
// 2. Couleur du texte.
|
||||
const color = findMark(marks, 'color');
|
||||
if (color) {
|
||||
const tw = INLINE_COLORS[color.color]?.text;
|
||||
el = wrap(d, el, 'span', {
|
||||
className: tw || '',
|
||||
attrs: { 'data-color': color.color },
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Surlignage.
|
||||
const highlight = findMark(marks, 'highlight');
|
||||
if (highlight) {
|
||||
const tw = INLINE_COLORS[highlight.color]?.highlight;
|
||||
el = wrap(d, el, 'span', {
|
||||
className: `rounded px-0.5 ${tw || ''}`,
|
||||
attrs: { 'data-highlight': highlight.color },
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Lien — toujours à l'extérieur.
|
||||
const link = findMark(marks, 'link');
|
||||
if (link) {
|
||||
el = wrap(d, el, 'a', {
|
||||
className: 'text-blue-600 dark:text-blue-400 underline underline-offset-2',
|
||||
attrs: {
|
||||
href: link.href,
|
||||
rel: 'noopener noreferrer',
|
||||
target: '_blank',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
function wrap(d, child, tagName, opts = {}) {
|
||||
const el = d.createElement(tagName);
|
||||
if (opts.className) el.className = opts.className;
|
||||
if (opts.attrs) {
|
||||
for (const [k, v] of Object.entries(opts.attrs)) el.setAttribute(k, v);
|
||||
}
|
||||
el.appendChild(child);
|
||||
return el;
|
||||
}
|
||||
|
||||
// Walk DOM → InlineNode[]. Accumule les marks dans une pile au fur et à
|
||||
// mesure qu'on descend. Émet un nœud par run de texte.
|
||||
export function domToInline(root) {
|
||||
if (!root) return [];
|
||||
const out = [];
|
||||
walk(root, [], out);
|
||||
return normalize(out);
|
||||
}
|
||||
|
||||
function walk(node, marks, out) {
|
||||
if (node.nodeType === 3 /* TEXT_NODE */) {
|
||||
if (node.nodeValue) {
|
||||
out.push({
|
||||
type: 'text',
|
||||
text: node.nodeValue,
|
||||
...(marks.length ? { marks: marks.map(m => ({ ...m })) } : {}),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== 1 /* ELEMENT_NODE */) return;
|
||||
|
||||
// <br> : émet un saut de ligne explicite. Notre modèle n'a pas de bloc
|
||||
// multi-lignes (Enter crée un nouveau bloc), mais Chrome injecte parfois
|
||||
// un <br> trailing dans un contentEditable vide — on l'ignore.
|
||||
if (node.tagName === 'BR') {
|
||||
if (node.nextSibling || node.previousSibling) {
|
||||
out.push({ type: 'text', text: '\n', ...(marks.length ? { marks: marks.map(m => ({ ...m })) } : {}) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const added = [];
|
||||
const tag = node.tagName;
|
||||
const simple = TAG_TO_MARK[tag];
|
||||
if (simple) added.push({ type: simple });
|
||||
|
||||
if (tag === 'A') {
|
||||
const href = node.getAttribute('href') || '';
|
||||
if (href) added.push({ type: 'link', href });
|
||||
}
|
||||
|
||||
if (tag === 'SPAN') {
|
||||
const color = node.getAttribute('data-color');
|
||||
const highlight = node.getAttribute('data-highlight');
|
||||
if (color) added.push({ type: 'color', color });
|
||||
if (highlight) added.push({ type: 'highlight', color: highlight });
|
||||
}
|
||||
|
||||
const nextMarks = added.length ? [...marks, ...added] : marks;
|
||||
for (const child of node.childNodes) {
|
||||
walk(child, nextMarks, out);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
// Format `InlineNode[]` : représentation du contenu inline d'un bloc texte.
|
||||
// Tableau plat de nœuds `text`, chacun pouvant porter des marks (gras,
|
||||
// italique, lien, couleur…). Un seul type de nœud — pas d'arbre imbriqué.
|
||||
//
|
||||
// Schéma :
|
||||
// InlineNode = { type: 'text', text: string, marks?: Mark[] }
|
||||
// Mark =
|
||||
// | { type: 'bold' }
|
||||
// | { type: 'italic' }
|
||||
// | { type: 'underline' }
|
||||
// | { type: 'strike' }
|
||||
// | { type: 'code' }
|
||||
// | { type: 'color', color: string } // clé palette
|
||||
// | { type: 'highlight', color: string } // clé palette
|
||||
// | { type: 'link', href: string }
|
||||
//
|
||||
// Le contenu vide est `[]` (jamais `[{type:'text', text:''}]`).
|
||||
|
||||
// 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`.
|
||||
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' },
|
||||
amber: { text: 'text-amber-600 dark:text-amber-400', highlight: 'bg-amber-100 dark:bg-amber-900/40' },
|
||||
red: { text: 'text-red-600 dark:text-red-400', highlight: 'bg-red-100 dark:bg-red-900/40' },
|
||||
};
|
||||
|
||||
export const INLINE_COLOR_KEYS = Object.keys(INLINE_COLORS);
|
||||
|
||||
const SIMPLE_MARK_TYPES = ['bold', 'italic', 'underline', 'strike', 'code'];
|
||||
|
||||
function marksEqual(a, b) {
|
||||
if (a === b) return true;
|
||||
if (!a && !b) return true;
|
||||
if (!a || !b) return (a?.length ?? 0) === 0 && (b?.length ?? 0) === 0;
|
||||
if (a.length !== b.length) return false;
|
||||
// Comparaison ensemble (ordre indifférent), via sérialisation déterministe.
|
||||
const ka = a.map(markKey).sort();
|
||||
const kb = b.map(markKey).sort();
|
||||
for (let i = 0; i < ka.length; i++) if (ka[i] !== kb[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function markKey(mark) {
|
||||
switch (mark.type) {
|
||||
case 'color':
|
||||
case 'highlight':
|
||||
return `${mark.type}:${mark.color}`;
|
||||
case 'link':
|
||||
return `link:${mark.href}`;
|
||||
default:
|
||||
return mark.type;
|
||||
}
|
||||
}
|
||||
|
||||
function cloneMarks(marks) {
|
||||
return marks ? marks.map(m => ({ ...m })) : undefined;
|
||||
}
|
||||
|
||||
function normalizeMarks(marks) {
|
||||
if (!marks || marks.length === 0) return undefined;
|
||||
// Déduplique : pour les marks paramétrées (color/highlight/link), garder la
|
||||
// dernière occurrence ; pour les simples, garder une seule.
|
||||
const byBucket = new Map();
|
||||
for (const m of marks) {
|
||||
const bucket = m.type === 'color' || m.type === 'highlight' || m.type === 'link' ? m.type : m.type;
|
||||
byBucket.set(bucket, m);
|
||||
}
|
||||
const out = Array.from(byBucket.values()).map(m => ({ ...m }));
|
||||
return out.length === 0 ? undefined : out;
|
||||
}
|
||||
|
||||
function makeNode(text, marks) {
|
||||
const node = { type: 'text', text };
|
||||
const norm = normalizeMarks(marks);
|
||||
if (norm) node.marks = norm;
|
||||
return node;
|
||||
}
|
||||
|
||||
// Longueur texte totale.
|
||||
export function inlineLength(nodes) {
|
||||
if (!Array.isArray(nodes)) return 0;
|
||||
let n = 0;
|
||||
for (const node of nodes) n += node.text?.length ?? 0;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Texte concaténé (pour copier/coller, comparaisons rapides).
|
||||
export function inlineToPlainText(nodes) {
|
||||
if (!Array.isArray(nodes)) return '';
|
||||
let out = '';
|
||||
for (const node of nodes) out += node.text ?? '';
|
||||
return out;
|
||||
}
|
||||
|
||||
// Construit un tableau `InlineNode[]` à partir d'une string brute.
|
||||
// `[]` si la string est vide — pas de nœud vide.
|
||||
export function inlineFromText(text) {
|
||||
if (!text) return [];
|
||||
return [{ type: 'text', text }];
|
||||
}
|
||||
|
||||
// Sous-tableau couvrant l'intervalle [start, end[. Découpe les nœuds aux
|
||||
// frontières si besoin. Préserve les marks.
|
||||
export function sliceInline(nodes, start, end) {
|
||||
if (!Array.isArray(nodes)) return [];
|
||||
const total = inlineLength(nodes);
|
||||
const a = Math.max(0, Math.min(start, total));
|
||||
const b = Math.max(a, Math.min(end ?? total, total));
|
||||
if (a === b) return [];
|
||||
const out = [];
|
||||
let pos = 0;
|
||||
for (const node of nodes) {
|
||||
const len = node.text?.length ?? 0;
|
||||
const nodeStart = pos;
|
||||
const nodeEnd = pos + len;
|
||||
pos = nodeEnd;
|
||||
if (nodeEnd <= a) continue;
|
||||
if (nodeStart >= b) break;
|
||||
const localStart = Math.max(0, a - nodeStart);
|
||||
const localEnd = Math.min(len, b - nodeStart);
|
||||
if (localEnd <= localStart) continue;
|
||||
out.push(makeNode(node.text.slice(localStart, localEnd), cloneMarks(node.marks)));
|
||||
}
|
||||
return normalize(out);
|
||||
}
|
||||
|
||||
// Concatène deux tableaux en fusionnant le dernier nœud de `a` et le premier
|
||||
// de `b` s'ils ont les mêmes marks.
|
||||
export function concatInline(a, b) {
|
||||
if (!Array.isArray(a) || a.length === 0) return Array.isArray(b) ? normalize(b) : [];
|
||||
if (!Array.isArray(b) || b.length === 0) return normalize(a);
|
||||
return normalize([...a.map(n => ({ ...n, marks: cloneMarks(n.marks) })),
|
||||
...b.map(n => ({ ...n, marks: cloneMarks(n.marks) }))]);
|
||||
}
|
||||
|
||||
// Fusionne les nœuds adjacents identiques et supprime les nœuds vides.
|
||||
export function normalize(nodes) {
|
||||
if (!Array.isArray(nodes)) return [];
|
||||
const out = [];
|
||||
for (const node of nodes) {
|
||||
if (!node.text) continue;
|
||||
const last = out[out.length - 1];
|
||||
if (last && marksEqual(last.marks, node.marks)) {
|
||||
last.text += node.text;
|
||||
} else {
|
||||
out.push({ type: 'text', text: node.text, ...(node.marks ? { marks: cloneMarks(node.marks) } : {}) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Retourne les marks actives à un offset donné (utile pour le toolbar).
|
||||
// À une frontière entre deux nœuds, on prend les marks du nœud à droite,
|
||||
// sauf en fin de tableau où on prend le nœud de gauche.
|
||||
export function marksAtOffset(nodes, offset) {
|
||||
if (!Array.isArray(nodes) || nodes.length === 0) return [];
|
||||
let pos = 0;
|
||||
for (const node of nodes) {
|
||||
const len = node.text?.length ?? 0;
|
||||
if (offset < pos + len) return node.marks ? node.marks.map(m => ({ ...m })) : [];
|
||||
pos += len;
|
||||
}
|
||||
// offset au-delà du dernier nœud → marks du dernier nœud.
|
||||
const last = nodes[nodes.length - 1];
|
||||
return last.marks ? last.marks.map(m => ({ ...m })) : [];
|
||||
}
|
||||
|
||||
// Marks communes à toute la plage [start, end[. Si la plage est vide,
|
||||
// retourne les marks à l'offset `start`. Utile pour afficher l'état actif
|
||||
// du toolbar.
|
||||
export function marksInRange(nodes, start, end) {
|
||||
if (start >= end) return marksAtOffset(nodes, start);
|
||||
if (!Array.isArray(nodes) || nodes.length === 0) return [];
|
||||
let common = null;
|
||||
let pos = 0;
|
||||
for (const node of nodes) {
|
||||
const len = node.text?.length ?? 0;
|
||||
const nodeStart = pos;
|
||||
const nodeEnd = pos + len;
|
||||
pos = nodeEnd;
|
||||
if (nodeEnd <= start) continue;
|
||||
if (nodeStart >= end) break;
|
||||
const ks = (node.marks || []).map(markKey);
|
||||
if (common === null) {
|
||||
common = new Set(ks);
|
||||
} else {
|
||||
for (const k of Array.from(common)) if (!ks.includes(k)) common.delete(k);
|
||||
}
|
||||
if (common.size === 0) return [];
|
||||
}
|
||||
if (!common) return [];
|
||||
// Reconstruit les objets mark depuis les nœuds couverts.
|
||||
const result = [];
|
||||
pos = 0;
|
||||
for (const node of nodes) {
|
||||
const len = node.text?.length ?? 0;
|
||||
const nodeStart = pos;
|
||||
const nodeEnd = pos + len;
|
||||
pos = nodeEnd;
|
||||
if (nodeEnd <= start) continue;
|
||||
if (nodeStart >= end) break;
|
||||
for (const m of node.marks || []) {
|
||||
if (common.has(markKey(m)) && !result.some(r => markKey(r) === markKey(m))) {
|
||||
result.push({ ...m });
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function addMarkToNode(node, mark) {
|
||||
const existing = node.marks || [];
|
||||
// Pour les marks paramétrées, on remplace (color/highlight/link sont
|
||||
// exclusives entre elles dans la même catégorie).
|
||||
const filtered = existing.filter(m => m.type !== mark.type);
|
||||
return makeNode(node.text, [...filtered, { ...mark }]);
|
||||
}
|
||||
|
||||
function removeMarkFromNode(node, type) {
|
||||
const existing = node.marks || [];
|
||||
const filtered = existing.filter(m => m.type !== type);
|
||||
return makeNode(node.text, filtered);
|
||||
}
|
||||
|
||||
function mapRange(nodes, start, end, fn) {
|
||||
if (start >= end || !Array.isArray(nodes)) return Array.isArray(nodes) ? normalize(nodes) : [];
|
||||
const out = [];
|
||||
let pos = 0;
|
||||
for (const node of nodes) {
|
||||
const len = node.text?.length ?? 0;
|
||||
const nodeStart = pos;
|
||||
const nodeEnd = pos + len;
|
||||
pos = nodeEnd;
|
||||
if (len === 0) continue;
|
||||
if (nodeEnd <= start || nodeStart >= end) {
|
||||
out.push(makeNode(node.text, cloneMarks(node.marks)));
|
||||
continue;
|
||||
}
|
||||
const localStart = Math.max(0, start - nodeStart);
|
||||
const localEnd = Math.min(len, end - nodeStart);
|
||||
if (localStart > 0) {
|
||||
out.push(makeNode(node.text.slice(0, localStart), cloneMarks(node.marks)));
|
||||
}
|
||||
if (localEnd > localStart) {
|
||||
const middle = makeNode(node.text.slice(localStart, localEnd), cloneMarks(node.marks));
|
||||
out.push(fn(middle));
|
||||
}
|
||||
if (localEnd < len) {
|
||||
out.push(makeNode(node.text.slice(localEnd), cloneMarks(node.marks)));
|
||||
}
|
||||
}
|
||||
return normalize(out);
|
||||
}
|
||||
|
||||
export function applyMark(nodes, start, end, mark) {
|
||||
return mapRange(nodes, start, end, n => addMarkToNode(n, mark));
|
||||
}
|
||||
|
||||
export function removeMark(nodes, start, end, type) {
|
||||
return mapRange(nodes, start, end, n => removeMarkFromNode(n, type));
|
||||
}
|
||||
|
||||
// Toggle : si toute la plage porte déjà la mark (au sens markKey strict),
|
||||
// on la retire ; sinon on l'applique partout.
|
||||
export function toggleMark(nodes, start, end, mark) {
|
||||
const active = marksInRange(nodes, start, end).some(m => markKey(m) === markKey(mark));
|
||||
if (active) {
|
||||
if (mark.type === 'color' || mark.type === 'highlight' || mark.type === 'link') {
|
||||
return removeMark(nodes, start, end, mark.type);
|
||||
}
|
||||
return removeMark(nodes, start, end, mark.type);
|
||||
}
|
||||
return applyMark(nodes, start, end, mark);
|
||||
}
|
||||
|
||||
// Insère du texte brut à un offset, héritant des marks de l'environnement.
|
||||
// Utilisé après une transformation pour réinjecter du texte sans perdre le
|
||||
// contexte. Phase 2 ne s'en sert pas en édition (la frappe passe par le
|
||||
// DOM puis `domToInline`), mais c'est utile pour les helpers programmatiques.
|
||||
export function insertText(nodes, offset, text) {
|
||||
if (!text) return normalize(nodes ?? []);
|
||||
const before = sliceInline(nodes, 0, offset);
|
||||
const after = sliceInline(nodes, offset, inlineLength(nodes));
|
||||
// Hérite des marks du nœud à gauche (continuité de la frappe).
|
||||
const marks = marksAtOffset(nodes, Math.max(0, offset - 1));
|
||||
const inserted = [makeNode(text, marks)];
|
||||
return concatInline(concatInline(before, inserted), after);
|
||||
}
|
||||
Reference in New Issue
Block a user