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:
@@ -218,11 +218,20 @@ export default function ComponentsPage() {
|
||||
|
||||
function BlockEditorDemo() {
|
||||
const [blocks, setBlocks] = useState([
|
||||
{ id: 'demo-1', type: 'heading_1', content: 'Bienvenue dans BlockEditor' },
|
||||
{ id: 'demo-2', type: 'paragraph', content: "Tapez '/' pour ouvrir le menu de commandes." },
|
||||
{ id: 'demo-3', type: 'bullet_item', content: 'Glissez la poignée ⋮⋮ pour réordonner' },
|
||||
{ id: 'demo-4', type: 'bullet_item', content: 'Tapez `# ` au début pour un titre, `- ` pour une puce' },
|
||||
{ id: 'demo-5', type: 'paragraph', content: '' },
|
||||
{ id: 'demo-1', type: 'heading_1', content: [{ type: 'text', text: 'Bienvenue dans BlockEditor' }] },
|
||||
{ id: 'demo-2', type: 'paragraph', content: [
|
||||
{ type: 'text', text: "Tapez " },
|
||||
{ type: 'text', text: "'/'", marks: [{ type: 'code' }] },
|
||||
{ type: 'text', text: ' pour ouvrir le menu, ou ' },
|
||||
{ type: 'text', text: 'sélectionnez', marks: [{ type: 'bold' }] },
|
||||
{ type: 'text', text: ' pour ' },
|
||||
{ type: 'text', text: 'mettre en forme', marks: [{ type: 'italic' }, { type: 'color', color: 'blue' }] },
|
||||
{ type: 'text', text: '.' },
|
||||
] },
|
||||
{ id: 'demo-3', type: 'checklist', checked: true, content: [{ type: 'text', text: 'Format inline (gras, italique, couleur, lien)' }] },
|
||||
{ id: 'demo-4', type: 'checklist', checked: false, content: [{ type: 'text', text: 'Bloc image (URL uniquement en Phase 2)' }] },
|
||||
{ id: 'demo-5', type: 'bullet_item', content: [{ type: 'text', text: 'Glissez la poignée ⋮⋮ pour réordonner' }] },
|
||||
{ id: 'demo-6', type: 'paragraph', content: [] },
|
||||
]);
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
import React, { useEffect, useImperativeHandle, useRef, useState, forwardRef } from 'react';
|
||||
import { Add01Icon, DragDropVerticalIcon } from '@zen/core/shared/icons';
|
||||
import { getBlockDef } from './blockRegistry.js';
|
||||
import { inlineLength } from './inline/types.js';
|
||||
import { inlineToDom, domToInline } from './inline/serialize.js';
|
||||
import {
|
||||
getCaretOffset,
|
||||
getCaretRange,
|
||||
setCaretOffset,
|
||||
setCaretRange,
|
||||
focusEnd,
|
||||
isCaretAtStart,
|
||||
} from './utils/caret.js';
|
||||
@@ -17,7 +21,9 @@ import {
|
||||
// - le drop zone pour le drag-and-drop natif
|
||||
//
|
||||
// L'orchestrateur (BlockEditor) reçoit des évènements via les callbacks et
|
||||
// mute la liste des blocs en conséquence.
|
||||
// mute la liste des blocs en conséquence. Le contenu inline est un
|
||||
// `InlineNode[]` (cf. inline/types.js) — sérialisé vers le DOM via
|
||||
// `inlineToDom` / `domToInline`.
|
||||
|
||||
const Block = forwardRef(function Block(
|
||||
{
|
||||
@@ -29,6 +35,7 @@ const Block = forwardRef(function Block(
|
||||
isDragOverBottom,
|
||||
isSelected,
|
||||
onContentChange,
|
||||
onBlockPatch,
|
||||
onEnter,
|
||||
onBackspaceAtStart,
|
||||
onSlashOpen,
|
||||
@@ -49,6 +56,12 @@ const Block = forwardRef(function Block(
|
||||
const editableRef = useRef(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [draggable, setDraggable] = useState(false);
|
||||
// Référence vers le dernier contenu qu'on a écrit dans le DOM. Permet de
|
||||
// détecter si un changement de `block.content` provient d'un évènement
|
||||
// externe (undo, transform, toolbar) plutôt que de la frappe utilisateur.
|
||||
// Si la nouvelle valeur === lastWritten, on ne touche pas au DOM (sinon
|
||||
// on perdrait le caret).
|
||||
const lastWrittenRef = useRef(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: (offset) => {
|
||||
@@ -57,18 +70,26 @@ const Block = forwardRef(function Block(
|
||||
else focusEnd(editableRef.current);
|
||||
},
|
||||
getElement: () => editableRef.current,
|
||||
getCaretRange: () => getCaretRange(editableRef.current),
|
||||
setCaretRange: (start, end) => setCaretRange(editableRef.current, start, end),
|
||||
}), [def]);
|
||||
|
||||
// Synchronisation contrôlée → DOM : on n'écrit dans le contentEditable que
|
||||
// si la valeur externe diffère de ce qui est affiché (cas undo/redo, ou
|
||||
// transformation de bloc). Sinon on laisse le DOM gérer pour préserver le caret.
|
||||
// Synchronisation contrôlée → DOM. On compare par référence à
|
||||
// `lastWrittenRef.current` : si égal, c'est que la valeur reçue est celle
|
||||
// qu'on vient d'émettre (frappe), donc le DOM est déjà à jour. Sinon on
|
||||
// réécrit le sous-arbre et on restaure le caret quand l'élément a le focus.
|
||||
useEffect(() => {
|
||||
if (!def?.isText) return;
|
||||
const el = editableRef.current;
|
||||
if (!el) return;
|
||||
const next = block.content ?? '';
|
||||
if (el.textContent !== next) {
|
||||
el.textContent = next;
|
||||
if (lastWrittenRef.current === block.content) return;
|
||||
const hadFocus = document.activeElement === el;
|
||||
const range = hadFocus ? getCaretRange(el) : null;
|
||||
el.replaceChildren(inlineToDom(block.content ?? []));
|
||||
lastWrittenRef.current = block.content;
|
||||
if (hadFocus && range) {
|
||||
const total = inlineLength(block.content ?? []);
|
||||
setCaretRange(el, Math.min(range.start, total), Math.min(range.end, total));
|
||||
}
|
||||
}, [block.content, def?.isText]);
|
||||
|
||||
@@ -80,6 +101,11 @@ const Block = forwardRef(function Block(
|
||||
);
|
||||
}
|
||||
|
||||
function emitContent(content) {
|
||||
lastWrittenRef.current = content;
|
||||
onContentChange?.(block.id, content);
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
const el = editableRef.current;
|
||||
if (!el) return;
|
||||
@@ -97,7 +123,7 @@ const Block = forwardRef(function Block(
|
||||
// Markdown shortcut : si le contenu commence par un préfixe connu suivi
|
||||
// d'un espace, on demande à l'orchestrateur de transformer le bloc.
|
||||
if (!def.disableShortcuts) {
|
||||
const match = text.match(/^(#{1,6} |- |1\. |> |```\s|---)/);
|
||||
const match = text.match(/^(#{1,6} |- |1\. |> |```\s|---|\[ ?\] )/);
|
||||
if (match) {
|
||||
const prefix = match[1];
|
||||
onShortcutMatch?.({ blockId: block.id, prefix, rest: text.slice(prefix.length) });
|
||||
@@ -105,7 +131,7 @@ const Block = forwardRef(function Block(
|
||||
}
|
||||
}
|
||||
|
||||
onContentChange?.(block.id, text);
|
||||
emitContent(domToInline(el));
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
@@ -151,8 +177,11 @@ const Block = forwardRef(function Block(
|
||||
e.preventDefault();
|
||||
const el = editableRef.current;
|
||||
const offset = el ? getCaretOffset(el) : 0;
|
||||
const text = el?.textContent ?? '';
|
||||
onEnter?.({ blockId: block.id, offset, text });
|
||||
const content = el ? domToInline(el) : [];
|
||||
// On synchronise lastWritten : la valeur que `BlockEditor` va écrire
|
||||
// dans le bloc courant (le before) sera dérivée de ce contenu.
|
||||
lastWrittenRef.current = null;
|
||||
onEnter?.({ blockId: block.id, offset, content });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -164,7 +193,9 @@ const Block = forwardRef(function Block(
|
||||
// merge avec le bloc précédent.
|
||||
if (el && sel?.isCollapsed !== false && isCaretAtStart(el)) {
|
||||
e.preventDefault();
|
||||
onBackspaceAtStart?.({ blockId: block.id, text: el.textContent ?? '' });
|
||||
const content = domToInline(el);
|
||||
lastWrittenRef.current = null;
|
||||
onBackspaceAtStart?.({ blockId: block.id, content });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,7 +243,9 @@ const Block = forwardRef(function Block(
|
||||
}
|
||||
}
|
||||
|
||||
const isEmpty = def.isText && (!block.content || block.content.length === 0);
|
||||
const isEmpty = def.isText && inlineLength(block.content ?? []) === 0;
|
||||
const checklistChecked = block.type === 'checklist' && !!block.checked;
|
||||
const checklistClasses = checklistChecked ? ' line-through opacity-60' : '';
|
||||
|
||||
const dropIndicator = (
|
||||
<>
|
||||
@@ -276,10 +309,10 @@ const Block = forwardRef(function Block(
|
||||
{def.isText ? (
|
||||
def.renderPrefix ? (
|
||||
<div className="flex items-start">
|
||||
{def.renderPrefix({ block, index, numberedIndex })}
|
||||
{def.renderPrefix({ block, index, numberedIndex, disabled, onPatch: (patch) => onBlockPatch?.(block.id, patch) })}
|
||||
<TextEditable
|
||||
ref={editableRef}
|
||||
className={`flex-1 outline-none ${def.textClassName || ''} ${isEmpty ? 'block-empty' : ''}`}
|
||||
className={`flex-1 outline-none ${def.textClassName || ''}${checklistClasses} ${isEmpty ? 'block-empty' : ''}`}
|
||||
placeholder={def.placeholder}
|
||||
disabled={disabled}
|
||||
onInput={handleInput}
|
||||
@@ -291,7 +324,7 @@ const Block = forwardRef(function Block(
|
||||
) : (
|
||||
<TextEditable
|
||||
ref={editableRef}
|
||||
className={`outline-none ${def.textClassName || ''} ${isEmpty ? 'block-empty' : ''}`}
|
||||
className={`outline-none ${def.textClassName || ''}${checklistClasses} ${isEmpty ? 'block-empty' : ''}`}
|
||||
placeholder={def.placeholder}
|
||||
disabled={disabled}
|
||||
onInput={handleInput}
|
||||
@@ -304,7 +337,7 @@ const Block = forwardRef(function Block(
|
||||
<def.Component
|
||||
block={block}
|
||||
disabled={disabled}
|
||||
onChange={(patch) => onContentChange?.(block.id, patch)}
|
||||
onChange={(patch) => onBlockPatch?.(block.id, patch)}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-xs text-red-500">Bloc {block.type} sans rendu.</div>
|
||||
|
||||
@@ -3,9 +3,18 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Block from './Block.client.js';
|
||||
import SlashMenu, { getSlashItems } from './SlashMenu.client.js';
|
||||
import InlineToolbar from './inline/Toolbar.client.js';
|
||||
import { getBlockDef, DEFAULT_BLOCK_TYPE } from './blockRegistry.js';
|
||||
import { registerBuiltInBlocks } from './blockTypes/index.js';
|
||||
import { newBlockId } from './utils/ids.js';
|
||||
import {
|
||||
inlineLength,
|
||||
inlineToPlainText,
|
||||
inlineFromText,
|
||||
sliceInline,
|
||||
concatInline,
|
||||
toggleMark,
|
||||
marksInRange,
|
||||
} from './inline/types.js';
|
||||
|
||||
registerBuiltInBlocks();
|
||||
|
||||
@@ -25,6 +34,8 @@ const SHORTCUT_TO_TYPE = {
|
||||
'> ': 'quote',
|
||||
'```': 'code',
|
||||
'---': 'divider',
|
||||
'[] ': 'checklist',
|
||||
'[ ] ': 'checklist',
|
||||
};
|
||||
|
||||
function makeBlock(type, init) {
|
||||
@@ -176,31 +187,6 @@ export default function BlockEditor({
|
||||
}
|
||||
|
||||
// --- Mutations ---
|
||||
function updateBlock(id, patch) {
|
||||
const next = blocks.map(b => (b.id === id ? { ...b, ...patch } : b));
|
||||
commitChange(next);
|
||||
}
|
||||
|
||||
function replaceBlock(id, newBlock, focus = true) {
|
||||
const next = blocks.map(b => (b.id === id ? newBlock : b));
|
||||
commitChange(next, { immediate: true });
|
||||
if (focus) {
|
||||
setFocusBlockId(newBlock.id);
|
||||
setFocusOffset(0);
|
||||
}
|
||||
}
|
||||
|
||||
function insertAfter(id, newBlock, focus = true) {
|
||||
const idx = blocks.findIndex(b => b.id === id);
|
||||
if (idx < 0) return;
|
||||
const next = [...blocks.slice(0, idx + 1), newBlock, ...blocks.slice(idx + 1)];
|
||||
commitChange(next, { immediate: true });
|
||||
if (focus) {
|
||||
setFocusBlockId(newBlock.id);
|
||||
setFocusOffset(0);
|
||||
}
|
||||
}
|
||||
|
||||
function removeBlock(id, focusPrev = true) {
|
||||
const idx = blocks.findIndex(b => b.id === id);
|
||||
if (idx < 0) return;
|
||||
@@ -210,26 +196,34 @@ export default function BlockEditor({
|
||||
if (focusPrev) {
|
||||
const prev = idx > 0 ? blocks[idx - 1] : finalNext[0];
|
||||
setFocusBlockId(prev.id);
|
||||
setFocusOffset(prev.content?.length ?? 0);
|
||||
setFocusOffset(inlineLength(prev.content));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Évènements provenant des blocs ---
|
||||
function handleContentChange(id, text) {
|
||||
const next = blocks.map(b => (b.id === id ? { ...b, content: text } : b));
|
||||
function handleContentChange(id, content) {
|
||||
const next = blocks.map(b => (b.id === id ? { ...b, content } : b));
|
||||
commitChange(next);
|
||||
}
|
||||
|
||||
function handleEnter({ blockId, offset, text }) {
|
||||
// Patch arbitraire d'un bloc (ex : checklist `checked`, image `src`).
|
||||
function handleBlockPatch(id, patch) {
|
||||
const next = blocks.map(b => (b.id === id ? { ...b, ...patch } : b));
|
||||
commitChange(next, { immediate: true });
|
||||
}
|
||||
|
||||
function handleEnter({ blockId, offset, content }) {
|
||||
const idx = blocks.findIndex(b => b.id === blockId);
|
||||
if (idx < 0) return;
|
||||
const current = blocks[idx];
|
||||
const liveContent = content ?? current.content ?? [];
|
||||
|
||||
// Sur un item de liste vide, sortir de la liste (devient paragraphe)
|
||||
if (
|
||||
(current.type === 'bullet_item' || current.type === 'numbered_item') &&
|
||||
(text ?? '').length === 0
|
||||
) {
|
||||
// Sur un item de liste / checklist vide, sortir en paragraphe
|
||||
const isListLike =
|
||||
current.type === 'bullet_item' ||
|
||||
current.type === 'numbered_item' ||
|
||||
current.type === 'checklist';
|
||||
if (isListLike && inlineLength(liveContent) === 0) {
|
||||
const replaced = makeBlock(DEFAULT_BLOCK_TYPE);
|
||||
const next = blocks.map(b => (b.id === blockId ? replaced : b));
|
||||
commitChange(next, { immediate: true });
|
||||
@@ -238,18 +232,22 @@ export default function BlockEditor({
|
||||
return;
|
||||
}
|
||||
|
||||
const before = (text ?? '').slice(0, offset);
|
||||
const after = (text ?? '').slice(offset);
|
||||
const before = sliceInline(liveContent, 0, offset);
|
||||
const after = sliceInline(liveContent, offset, inlineLength(liveContent));
|
||||
|
||||
// Le bloc courant garde la partie avant le caret
|
||||
const updated = { ...current, content: before };
|
||||
|
||||
// Le nouveau bloc hérite du type pour les listes, sinon paragraphe
|
||||
const newType =
|
||||
current.type === 'bullet_item' || current.type === 'numbered_item'
|
||||
? current.type
|
||||
: DEFAULT_BLOCK_TYPE;
|
||||
const newBlock = makeBlock(newType, { content: after });
|
||||
// Pour la checklist, le nouvel item garde le type mais remet `checked: false`.
|
||||
let newType;
|
||||
let newInit = { content: after };
|
||||
if (current.type === 'bullet_item' || current.type === 'numbered_item') {
|
||||
newType = current.type;
|
||||
} else if (current.type === 'checklist') {
|
||||
newType = 'checklist';
|
||||
newInit = { content: after, checked: false };
|
||||
} else {
|
||||
newType = DEFAULT_BLOCK_TYPE;
|
||||
}
|
||||
const newBlock = makeBlock(newType, newInit);
|
||||
|
||||
const next = [
|
||||
...blocks.slice(0, idx),
|
||||
@@ -262,15 +260,16 @@ export default function BlockEditor({
|
||||
setFocusOffset(0);
|
||||
}
|
||||
|
||||
function handleBackspaceAtStart({ blockId, text }) {
|
||||
function handleBackspaceAtStart({ blockId, content }) {
|
||||
const idx = blocks.findIndex(b => b.id === blockId);
|
||||
if (idx < 0) return;
|
||||
const current = blocks[idx];
|
||||
const liveContent = content ?? current.content ?? [];
|
||||
|
||||
// Si le bloc est typé (heading, list, quote, code) et non vide → repasse
|
||||
// en paragraphe sans rien supprimer.
|
||||
// Si le bloc est typé (heading, list, quote, code, checklist) et non
|
||||
// vide → repasse en paragraphe sans rien supprimer.
|
||||
if (current.type !== DEFAULT_BLOCK_TYPE) {
|
||||
const replaced = makeBlock(DEFAULT_BLOCK_TYPE, { content: text ?? '' });
|
||||
const replaced = makeBlock(DEFAULT_BLOCK_TYPE, { content: liveContent });
|
||||
const next = blocks.map(b => (b.id === blockId ? replaced : b));
|
||||
commitChange(next, { immediate: true });
|
||||
setFocusBlockId(replaced.id);
|
||||
@@ -287,8 +286,8 @@ export default function BlockEditor({
|
||||
removeBlock(blockId, true);
|
||||
return;
|
||||
}
|
||||
const mergedOffset = (prev.content ?? '').length;
|
||||
const merged = { ...prev, content: (prev.content ?? '') + (text ?? '') };
|
||||
const mergedOffset = inlineLength(prev.content);
|
||||
const merged = { ...prev, content: concatInline(prev.content ?? [], liveContent) };
|
||||
const next = [...blocks.slice(0, idx - 1), merged, ...blocks.slice(idx + 1)];
|
||||
commitChange(next, { immediate: true });
|
||||
setFocusBlockId(merged.id);
|
||||
@@ -319,7 +318,7 @@ export default function BlockEditor({
|
||||
return;
|
||||
}
|
||||
|
||||
const replaced = def.create({ content: rest ?? '' });
|
||||
const replaced = def.create({ content: inlineFromText(rest ?? '') });
|
||||
const next = blocks.map(b => (b.id === blockId ? replaced : b));
|
||||
commitChange(next, { immediate: true });
|
||||
setFocusBlockId(replaced.id);
|
||||
@@ -363,18 +362,19 @@ export default function BlockEditor({
|
||||
|
||||
if (def.isText) {
|
||||
// Remplace le bloc courant en gardant son contenu (purgé du / de query)
|
||||
const text = (current.content ?? '').replace(/^\/\S*/, '');
|
||||
const replaced = def.create({ content: text });
|
||||
const text = inlineToPlainText(current.content ?? []).replace(/^\/\S*/, '');
|
||||
const replaced = def.create({ content: inlineFromText(text) });
|
||||
const next = blocks.map(b => (b.id === blockId ? replaced : b));
|
||||
commitChange(next, { immediate: true });
|
||||
setFocusBlockId(replaced.id);
|
||||
setFocusOffset(text.length);
|
||||
} else {
|
||||
// Bloc non-texte : insère après et nettoie le bloc courant
|
||||
const cleared = { ...current, content: (current.content ?? '').replace(/^\/\S*/, '') };
|
||||
const text = inlineToPlainText(current.content ?? []).replace(/^\/\S*/, '');
|
||||
const cleared = { ...current, content: inlineFromText(text) };
|
||||
const inserted = def.create();
|
||||
const after =
|
||||
cleared.content.length === 0
|
||||
text.length === 0
|
||||
? // remplacer le bloc courant par le bloc non-texte puis ajouter un paragraphe vide
|
||||
[
|
||||
...blocks.slice(0, idx),
|
||||
@@ -436,19 +436,101 @@ export default function BlockEditor({
|
||||
commitChange(next, { immediate: true });
|
||||
}
|
||||
|
||||
// --- Raccourcis globaux (Undo/Redo seulement) ---
|
||||
// --- Toolbar de formatage inline ---
|
||||
// Ancrée au-dessus de la sélection courante quand elle est non-vide et
|
||||
// qu'elle se trouve dans un contentEditable de l'éditeur.
|
||||
const [toolbar, setToolbar] = useState(null);
|
||||
// { blockId, start, end, rect, marks }
|
||||
|
||||
const updateToolbar = useCallback(() => {
|
||||
if (disabled) {
|
||||
setToolbar(null);
|
||||
return;
|
||||
}
|
||||
const sel = typeof window !== 'undefined' ? window.getSelection() : null;
|
||||
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
|
||||
setToolbar(null);
|
||||
return;
|
||||
}
|
||||
const range = sel.getRangeAt(0);
|
||||
const container = containerRef.current;
|
||||
if (!container || !container.contains(range.startContainer) || !container.contains(range.endContainer)) {
|
||||
setToolbar(null);
|
||||
return;
|
||||
}
|
||||
// Trouve le bloc qui contient la sélection (les deux extrémités doivent
|
||||
// être dans le même bloc).
|
||||
const startEl = range.startContainer.nodeType === 1
|
||||
? range.startContainer
|
||||
: range.startContainer.parentElement;
|
||||
const blockEl = startEl?.closest?.('[data-block-id]');
|
||||
if (!blockEl) { setToolbar(null); return; }
|
||||
const endEl = range.endContainer.nodeType === 1
|
||||
? range.endContainer
|
||||
: range.endContainer.parentElement;
|
||||
const endBlockEl = endEl?.closest?.('[data-block-id]');
|
||||
if (!endBlockEl || endBlockEl !== blockEl) { setToolbar(null); return; }
|
||||
|
||||
const blockId = blockEl.getAttribute('data-block-id');
|
||||
const ref = blockRefs.current.get(blockId);
|
||||
const r = ref?.getCaretRange?.();
|
||||
if (!r || r.start === r.end) { setToolbar(null); return; }
|
||||
const rect = range.getBoundingClientRect();
|
||||
setToolbar({ blockId, start: r.start, end: r.end, rect });
|
||||
}, [disabled]);
|
||||
|
||||
useEffect(() => {
|
||||
function onSel() { updateToolbar(); }
|
||||
document.addEventListener('selectionchange', onSel);
|
||||
return () => document.removeEventListener('selectionchange', onSel);
|
||||
}, [updateToolbar]);
|
||||
|
||||
function applyToggleMark(mark) {
|
||||
if (!toolbar) return;
|
||||
const { blockId, start, end } = toolbar;
|
||||
const block = blocks.find(b => b.id === blockId);
|
||||
if (!block) return;
|
||||
const next = toggleMark(block.content ?? [], start, end, mark);
|
||||
const nextBlocks = blocks.map(b => (b.id === blockId ? { ...b, content: next } : b));
|
||||
commitChange(nextBlocks, { immediate: true });
|
||||
// Restaure la sélection après le re-render.
|
||||
requestAnimationFrame(() => {
|
||||
const ref = blockRefs.current.get(blockId);
|
||||
ref?.setCaretRange?.(start, end);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Raccourcis globaux (Undo/Redo + formatting inline) ---
|
||||
function handleGlobalKeyDown(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
|
||||
const meta = e.ctrlKey || e.metaKey;
|
||||
if (meta && e.key === 'z') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) handleRedo();
|
||||
else handleUndo();
|
||||
return;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'y') {
|
||||
if (meta && e.key === 'y') {
|
||||
e.preventDefault();
|
||||
handleRedo();
|
||||
return;
|
||||
}
|
||||
if (!meta || e.altKey) return;
|
||||
const k = e.key.toLowerCase();
|
||||
let mark = null;
|
||||
if (k === 'b') mark = { type: 'bold' };
|
||||
else if (k === 'i') mark = { type: 'italic' };
|
||||
else if (k === 'u') mark = { type: 'underline' };
|
||||
else if (k === 'e') mark = { type: 'code' };
|
||||
if (mark && toolbar) {
|
||||
e.preventDefault();
|
||||
applyToggleMark(mark);
|
||||
return;
|
||||
}
|
||||
if (k === 'k' && toolbar) {
|
||||
e.preventDefault();
|
||||
const href = window.prompt('URL du lien :', '');
|
||||
if (href) applyToggleMark({ type: 'link', href });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sélection multi-blocs (souris) ---
|
||||
@@ -567,7 +649,7 @@ export default function BlockEditor({
|
||||
// Copy/cut : fallback simple — concatène le contenu texte.
|
||||
const text = blocks
|
||||
.filter(b => selectedBlockIds.has(b.id))
|
||||
.map(b => b.content ?? '')
|
||||
.map(b => inlineToPlainText(b.content ?? []))
|
||||
.join('\n');
|
||||
try { e.clipboardData?.setData?.('text/plain', text); } catch {}
|
||||
if (navigator.clipboard) navigator.clipboard.writeText(text).catch(() => {});
|
||||
@@ -583,7 +665,7 @@ export default function BlockEditor({
|
||||
e.preventDefault();
|
||||
const ch = e.key;
|
||||
const next = blocks.filter(b => !selectedBlockIds.has(b.id));
|
||||
const replaced = makeBlock(DEFAULT_BLOCK_TYPE, { content: ch });
|
||||
const replaced = makeBlock(DEFAULT_BLOCK_TYPE, { content: inlineFromText(ch) });
|
||||
const finalNext = next.length === 0 ? [replaced] : [replaced, ...next];
|
||||
commitChange(finalNext, { immediate: true });
|
||||
setSelectedBlockIds(new Set());
|
||||
@@ -601,10 +683,11 @@ export default function BlockEditor({
|
||||
const blockEl = target instanceof Element ? target.closest('[data-block-id]') : null;
|
||||
const editableEl = target instanceof Element ? target.closest('[contenteditable="true"]') : null;
|
||||
const onHandle = target instanceof Element ? target.closest('button') : null;
|
||||
const inToolbar = target instanceof Element ? target.closest('[data-inline-toolbar]') : null;
|
||||
|
||||
// Boutons (poignée +, drag handle…) : ne pas démarrer de sélection.
|
||||
if (onHandle) {
|
||||
if (selectedBlockIds.size > 0) clearBlockSelection();
|
||||
if (onHandle || inToolbar) {
|
||||
if (onHandle && selectedBlockIds.size > 0) clearBlockSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -704,7 +787,7 @@ export default function BlockEditor({
|
||||
className={`block-editor border rounded-xl bg-white dark:bg-neutral-900/60 px-3 py-6 ${error ? 'border-red-500/50' : 'border-neutral-300 dark:border-neutral-700/50'} ${className}`}
|
||||
>
|
||||
<BlockEditorStyles />
|
||||
{placeholder && blocks.length === 1 && !blocks[0].content && (
|
||||
{placeholder && blocks.length === 1 && inlineLength(blocks[0].content ?? []) === 0 && (
|
||||
// Placeholder global injecté via data-placeholder du paragraphe initial
|
||||
null
|
||||
)}
|
||||
@@ -720,6 +803,7 @@ export default function BlockEditor({
|
||||
isDragOverBottom={dragOver?.blockId === block.id && dragOver.position === 'bottom'}
|
||||
isSelected={selectedBlockIds.has(block.id)}
|
||||
onContentChange={handleContentChange}
|
||||
onBlockPatch={handleBlockPatch}
|
||||
onEnter={handleEnter}
|
||||
onBackspaceAtStart={handleBackspaceAtStart}
|
||||
onShortcutMatch={handleShortcutMatch}
|
||||
@@ -745,6 +829,17 @@ export default function BlockEditor({
|
||||
onHoverIndex={(i) => setSlashState(s => ({ ...s, selectedIndex: i }))}
|
||||
/>
|
||||
)}
|
||||
{toolbar && (() => {
|
||||
const block = blocks.find(b => b.id === toolbar.blockId);
|
||||
const marks = block ? marksInRange(block.content ?? [], toolbar.start, toolbar.end) : [];
|
||||
return (
|
||||
<InlineToolbar
|
||||
rect={toolbar.rect}
|
||||
activeMarks={marks}
|
||||
onToggleMark={applyToggleMark}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{error && (
|
||||
<p className="text-red-600 dark:text-red-400 text-xs">{error}</p>
|
||||
)}
|
||||
|
||||
@@ -16,23 +16,70 @@ const [blocks, setBlocks] = useState([]);
|
||||
`value` est un **tableau de blocs JSON**. C'est la source de vérité.
|
||||
`MarkdownEditor` reste disponible en parallèle pour les usages markdown.
|
||||
|
||||
## Format des blocs (Phase 1)
|
||||
## Format des blocs
|
||||
|
||||
Chaque bloc a un `id` (UUID) et un `type`. Selon le type :
|
||||
|
||||
| type | champs | description |
|
||||
|----------------|---------------|----------------------------|
|
||||
| `paragraph` | `content` | texte brut |
|
||||
|----------------|---------------------------------|----------------------------------|
|
||||
| `paragraph` | `content` | texte |
|
||||
| `heading_1..6` | `content` | titre niveau 1 à 6 |
|
||||
| `bullet_item` | `content` | élément de liste à puces |
|
||||
| `numbered_item`| `content` | élément de liste numérotée |
|
||||
| `checklist` | `content`, `checked` | case à cocher + texte |
|
||||
| `quote` | `content` | citation |
|
||||
| `code` | `content` | bloc de code (monospace) |
|
||||
| `divider` | — | séparateur horizontal |
|
||||
| `image` | `src`, `alt`, `caption` | image (URL uniquement) |
|
||||
|
||||
Phase 2 ajoutera : `checklist`, `image`, et le format de `content` passera de
|
||||
`string` à `InlineNode[]` pour supporter le formatting inline (gras, italique,
|
||||
couleur, lien). Phase 3 : `table`.
|
||||
`content` est un **tableau `InlineNode[]`** depuis Phase 2 — voir ci-dessous.
|
||||
|
||||
Phase 3 (à venir) : `table`.
|
||||
|
||||
## Format `InlineNode`
|
||||
|
||||
Le contenu inline d'un bloc texte est un tableau plat de nœuds `text`.
|
||||
Chaque nœud porte optionnellement des **marks** (formatage).
|
||||
|
||||
```js
|
||||
[
|
||||
{ type: 'text', text: 'Salut ' },
|
||||
{ type: 'text', text: 'monde', marks: [{ type: 'bold' }] },
|
||||
{ type: 'text', text: ' ' },
|
||||
{ type: 'text', text: 'lien', marks: [{ type: 'link', href: 'https://…' }] },
|
||||
]
|
||||
```
|
||||
|
||||
### Marks supportées
|
||||
|
||||
| type | payload | rendu |
|
||||
|---|---|---|
|
||||
| `bold` | — | `<strong>` |
|
||||
| `italic` | — | `<em>` |
|
||||
| `underline` | — | `<u>` |
|
||||
| `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 |
|
||||
|
||||
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 contenu vide est `[]` (jamais `[{type:'text', text:''}]`).
|
||||
|
||||
### Helpers exportés
|
||||
|
||||
```js
|
||||
import {
|
||||
inlineLength, inlineToPlainText, inlineFromText,
|
||||
sliceInline, concatInline, applyMark, toggleMark,
|
||||
marksAtOffset, marksInRange, INLINE_COLORS,
|
||||
} from '@zen/core/shared/components/BlockEditor';
|
||||
```
|
||||
|
||||
Tous les helpers sont **purs** : ils retournent un nouveau tableau normalisé
|
||||
(nœuds adjacents identiques fusionnés, vides supprimés).
|
||||
|
||||
## Props
|
||||
|
||||
@@ -47,18 +94,37 @@ couleur, lien). Phase 3 : `table`.
|
||||
|
||||
## Interactions clavier
|
||||
|
||||
- `/` → ouvre le menu de commandes (filtrable au clavier, ↑ ↓ Entrée pour valider, Échap pour fermer)
|
||||
- `/` → ouvre le menu de commandes (filtrable, ↑ ↓ Entrée pour valider, Échap pour fermer)
|
||||
- `# ` → titre 1, `## ` → 2, …, `###### ` → 6
|
||||
- `- ` → liste à puces
|
||||
- `1. ` → liste numérotée
|
||||
- `[] ` ou `[ ] ` → case à cocher
|
||||
- `> ` → citation
|
||||
- ` ``` ` → bloc de code
|
||||
- `---` → séparateur
|
||||
- `Ctrl/Cmd + B` → gras (sur sélection non vide)
|
||||
- `Ctrl/Cmd + I` → italique
|
||||
- `Ctrl/Cmd + U` → soulignement
|
||||
- `Ctrl/Cmd + E` → code inline
|
||||
- `Ctrl/Cmd + K` → lien (prompt pour l'URL)
|
||||
- `Backspace` au début d'un bloc typé → repasse en paragraphe ; au début d'un paragraphe, fusionne avec le bloc précédent (uniquement si la sélection est repliée — sinon le navigateur supprime le texte sélectionné, ex. après `Ctrl+A`)
|
||||
- `Entrée` sur un item de liste vide → sort de la liste
|
||||
- `Ctrl/Cmd + Z` / `Ctrl/Cmd + Shift + Z` → undo / redo
|
||||
- `Ctrl/Cmd + A` → 1er appui : sélectionne le contenu du bloc courant ; 2e appui : sélectionne **tous les blocs** (mode sélection multi-blocs)
|
||||
|
||||
## Toolbar de formatage
|
||||
|
||||
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)
|
||||
- **🔗** — lien (popover avec input URL ; ✕ pour retirer)
|
||||
|
||||
L'état actif est calculé à partir des marks **communes à toute la plage**
|
||||
(via `marksInRange`). Toggle off si toute la plage est déjà marquée.
|
||||
|
||||
## Sélection multi-blocs
|
||||
|
||||
Deux façons d'entrer en mode sélection multi-blocs :
|
||||
@@ -70,7 +136,7 @@ En mode sélection multi-blocs :
|
||||
- `Backspace` / `Delete` → supprime tous les blocs sélectionnés
|
||||
- `Escape` → quitte la sélection
|
||||
- `Ctrl/Cmd + A` → étend à tous les blocs (no-op si déjà tous sélectionnés)
|
||||
- `Ctrl/Cmd + C` / `Ctrl/Cmd + X` → copie/coupe le texte concaténé
|
||||
- `Ctrl/Cmd + C` / `Ctrl/Cmd + X` → copie/coupe le texte concaténé (texte plat)
|
||||
- frappe d'un caractère imprimable → remplace les blocs sélectionnés par un nouveau paragraphe contenant ce caractère
|
||||
- clic dans l'éditeur → quitte la sélection
|
||||
|
||||
@@ -109,24 +175,31 @@ registerBlock({
|
||||
|
||||
Le nouveau type apparaît automatiquement dans le slash menu de tout
|
||||
`<BlockEditor />` rendu après l'enregistrement. Pour les blocs **texte**, on
|
||||
fournit à la place `isText: true`, `textTag`, `textClassName`, et optionnellement
|
||||
`renderPrefix({ block, index, numberedIndex })` pour un préfixe (puce, numéro).
|
||||
fournit à la place `isText: true`, `textTag`, `textClassName`, et
|
||||
optionnellement `renderPrefix({ block, onPatch, index, numberedIndex, disabled })`
|
||||
pour un préfixe (puce, numéro, case à cocher). Le `content` initial doit
|
||||
être `[]` (un `InlineNode[]` vide).
|
||||
|
||||
## Architecture interne
|
||||
|
||||
```
|
||||
BlockEditor.client.js orchestrateur : value/onChange, undo, slash menu, drag-drop
|
||||
BlockEditor.client.js orchestrateur : value/onChange, undo, slash menu, drag-drop, toolbar
|
||||
Block.client.js wrapper d'un bloc : handles, contentEditable, paste sanitize
|
||||
SlashMenu.client.js menu flottant filtrable
|
||||
blockRegistry.js map type → définition, API publique d'extension
|
||||
blockTypes/ un fichier par type built-in
|
||||
inline/types.js InlineNode[] : palette, helpers purs (slice, concat, marks)
|
||||
inline/serialize.js DOM ↔ InlineNode[] (inlineToDom / domToInline)
|
||||
inline/Toolbar.client.js barre flottante de formatage
|
||||
utils/ids.js UUID pour les blocs
|
||||
utils/caret.js gestion du caret dans un contentEditable
|
||||
utils/caret.js gestion du caret (multi-Text-nodes)
|
||||
```
|
||||
|
||||
## Limitations connues (Phase 1)
|
||||
## Limitations connues
|
||||
|
||||
- Inline formatting (gras, italique, couleur, lien) **pas encore** : tout est texte brut. Phase 2.
|
||||
- Pas d'imbrication de listes.
|
||||
- Paste : seul le texte brut est conservé (sanitize HTML).
|
||||
- Paste : seul le texte brut est conservé (sanitize HTML). Le formatage
|
||||
inline copié depuis l'extérieur n'est pas préservé.
|
||||
- Image : URL uniquement, pas d'upload de fichier (Phase 2). La caption est
|
||||
une string plate (pas de formatage inline pour l'instant).
|
||||
- Tables : Phase 3.
|
||||
|
||||
@@ -16,9 +16,11 @@ const SHORTCUT_HINT = {
|
||||
heading_6: '######',
|
||||
bullet_item: '-',
|
||||
numbered_item: '1.',
|
||||
checklist: '[]',
|
||||
quote: '>',
|
||||
code: '```',
|
||||
divider: '---',
|
||||
image: '',
|
||||
};
|
||||
import { listBlocks } from './blockRegistry.js';
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ const BulletItem = {
|
||||
);
|
||||
},
|
||||
create(init = {}) {
|
||||
return { id: newBlockId(), type: 'bullet_item', content: '', ...init };
|
||||
return { id: newBlockId(), type: 'bullet_item', content: [], ...init };
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Tick02Icon } from '@zen/core/shared/icons';
|
||||
import { newBlockId } from '../utils/ids.js';
|
||||
|
||||
// Préfixe : case à cocher cliquable. `onPatch({ checked })` mute l'état du
|
||||
// bloc (consommé par BlockEditor → handleBlockPatch).
|
||||
function Checkbox({ block, onPatch, disabled }) {
|
||||
function toggle(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (disabled) return;
|
||||
onPatch?.({ checked: !block.checked });
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={!!block.checked}
|
||||
tabIndex={-1}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={toggle}
|
||||
disabled={disabled}
|
||||
className={`select-none mt-[0.45em] mr-2 flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center transition-colors ${
|
||||
block.checked
|
||||
? 'bg-blue-600 border-blue-600 text-white'
|
||||
: 'border-neutral-400 dark:border-neutral-500 hover:border-blue-500'
|
||||
}`}
|
||||
>
|
||||
{block.checked && <Tick02Icon width={12} height={12} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const Checklist = {
|
||||
type: 'checklist',
|
||||
label: 'Case à cocher',
|
||||
icon: '☐',
|
||||
keywords: ['checklist', 'todo', 'tache', 'tâche', 'case', 'cocher', 'check'],
|
||||
shortcut: '[] ',
|
||||
isText: true,
|
||||
textTag: 'div',
|
||||
textClassName: 'text-base leading-relaxed text-neutral-900 dark:text-white',
|
||||
placeholder: 'À faire…',
|
||||
renderPrefix({ block, onPatch, disabled }) {
|
||||
return <Checkbox block={block} onPatch={onPatch} disabled={disabled} />;
|
||||
},
|
||||
create(init = {}) {
|
||||
return { id: newBlockId(), type: 'checklist', content: [], checked: false, ...init };
|
||||
},
|
||||
};
|
||||
|
||||
export default Checklist;
|
||||
@@ -13,7 +13,7 @@ const Code = {
|
||||
'block w-full font-mono text-sm whitespace-pre-wrap break-words rounded-lg bg-neutral-100 dark:bg-neutral-800/80 px-4 py-3 text-neutral-900 dark:text-neutral-100',
|
||||
placeholder: 'Code…',
|
||||
create(init = {}) {
|
||||
return { id: newBlockId(), type: 'code', content: '', ...init };
|
||||
return { id: newBlockId(), type: 'code', content: [], ...init };
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ function makeHeading(level) {
|
||||
placeholder: `Titre ${level}`,
|
||||
shortcut: `${'#'.repeat(level)} `,
|
||||
create(init = {}) {
|
||||
return { id: newBlockId(), type: `heading_${level}`, content: '', ...init };
|
||||
return { id: newBlockId(), type: `heading_${level}`, content: [], ...init };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Image01Icon } from '@zen/core/shared/icons';
|
||||
import { newBlockId } from '../utils/ids.js';
|
||||
|
||||
// Bloc image. Phase 2 : URL uniquement (pas d'upload). État vide = formulaire
|
||||
// d'insertion d'URL. État rempli = image rendue + caption optionnelle.
|
||||
|
||||
function ImageBlock({ block, onChange, disabled }) {
|
||||
const [url, setUrl] = useState(block.src ?? '');
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!block.src && !disabled) {
|
||||
// Au montage initial du bloc vide, on focus l'input automatiquement.
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [block.src, disabled]);
|
||||
|
||||
function submit(e) {
|
||||
e.preventDefault();
|
||||
if (!url.trim()) return;
|
||||
onChange?.({ src: url.trim() });
|
||||
}
|
||||
|
||||
function handleAltChange(e) {
|
||||
onChange?.({ alt: e.target.value });
|
||||
}
|
||||
|
||||
function handleCaptionChange(e) {
|
||||
onChange?.({ caption: e.target.value });
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setUrl('');
|
||||
onChange?.({ src: '', alt: '', caption: '' });
|
||||
}
|
||||
|
||||
if (!block.src) {
|
||||
return (
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="flex items-center gap-2 rounded-lg border border-dashed border-neutral-300 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800/40 px-3 py-3"
|
||||
>
|
||||
<Image01Icon width={18} height={18} className="text-neutral-500" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="url"
|
||||
placeholder="URL de l'image (https://…)"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="flex-1 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"
|
||||
disabled={disabled || !url.trim()}
|
||||
className="px-3 py-1 text-sm rounded bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:hover:bg-blue-600 text-white"
|
||||
>
|
||||
Insérer
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/image relative">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={block.src}
|
||||
alt={block.alt ?? ''}
|
||||
className="rounded-lg max-w-full block"
|
||||
draggable={false}
|
||||
/>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
title="Retirer l'image"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover/image:opacity-100 transition-opacity w-7 h-7 flex items-center justify-center rounded-full bg-black/60 hover:bg-black/80 text-white text-sm"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Légende (optionnelle)"
|
||||
value={block.caption ?? ''}
|
||||
onChange={handleCaptionChange}
|
||||
disabled={disabled}
|
||||
className="w-full px-1 py-0.5 text-sm italic text-neutral-600 dark:text-neutral-400 bg-transparent outline-none focus:border-b focus:border-neutral-300 dark:focus:border-neutral-700"
|
||||
/>
|
||||
{!disabled && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Texte alternatif (accessibilité)"
|
||||
value={block.alt ?? ''}
|
||||
onChange={handleAltChange}
|
||||
className="w-full px-1 py-0.5 text-xs text-neutral-500 dark:text-neutral-500 bg-transparent outline-none focus:border-b focus:border-neutral-300 dark:focus:border-neutral-700"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Image = {
|
||||
type: 'image',
|
||||
label: 'Image',
|
||||
icon: '🖼',
|
||||
keywords: ['image', 'photo', 'picture', 'img'],
|
||||
isText: false,
|
||||
create(init = {}) {
|
||||
return { id: newBlockId(), type: 'image', src: '', alt: '', caption: '', ...init };
|
||||
},
|
||||
Component: ImageBlock,
|
||||
};
|
||||
|
||||
export default Image;
|
||||
@@ -22,7 +22,7 @@ const NumberedItem = {
|
||||
);
|
||||
},
|
||||
create(init = {}) {
|
||||
return { id: newBlockId(), type: 'numbered_item', content: '', ...init };
|
||||
return { id: newBlockId(), type: 'numbered_item', content: [], ...init };
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ const Paragraph = {
|
||||
textClassName: 'text-base leading-relaxed text-neutral-900 dark:text-white',
|
||||
placeholder: "Tapez '/' pour les commandes…",
|
||||
create(init = {}) {
|
||||
return { id: newBlockId(), type: 'paragraph', content: '', ...init };
|
||||
return { id: newBlockId(), type: 'paragraph', content: [], ...init };
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ const Quote = {
|
||||
'text-base leading-relaxed italic text-neutral-700 dark:text-neutral-300 border-l-4 border-neutral-300 dark:border-neutral-700 pl-4 py-1',
|
||||
placeholder: 'Citation…',
|
||||
create(init = {}) {
|
||||
return { id: newBlockId(), type: 'quote', content: '', ...init };
|
||||
return { id: newBlockId(), type: 'quote', content: [], ...init };
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import NumberedItem from './NumberedList.js';
|
||||
import Quote from './Quote.js';
|
||||
import Code from './Code.js';
|
||||
import Divider from './Divider.js';
|
||||
import Checklist from './Checklist.js';
|
||||
import ImageBlock from './Image.client.js';
|
||||
|
||||
let registered = false;
|
||||
|
||||
@@ -19,7 +21,9 @@ export function registerBuiltInBlocks() {
|
||||
HeadingList.forEach(registerBlock);
|
||||
registerBlock(BulletItem);
|
||||
registerBlock(NumberedItem);
|
||||
registerBlock(Checklist);
|
||||
registerBlock(Quote);
|
||||
registerBlock(Code);
|
||||
registerBlock(Divider);
|
||||
registerBlock(ImageBlock);
|
||||
}
|
||||
|
||||
@@ -19,3 +19,19 @@ export {
|
||||
DEFAULT_BLOCK_TYPE,
|
||||
} from './blockRegistry.js';
|
||||
export { newBlockId } from './utils/ids.js';
|
||||
export {
|
||||
INLINE_COLORS,
|
||||
INLINE_COLOR_KEYS,
|
||||
inlineLength,
|
||||
inlineToPlainText,
|
||||
inlineFromText,
|
||||
sliceInline,
|
||||
concatInline,
|
||||
applyMark,
|
||||
removeMark,
|
||||
toggleMark,
|
||||
marksAtOffset,
|
||||
marksInRange,
|
||||
normalize as normalizeInline,
|
||||
} from './inline/types.js';
|
||||
export { inlineToDom, domToInline } from './inline/serialize.js';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
// Helpers de gestion du caret pour les contentEditable mono-bloc.
|
||||
// On reste sur du texte brut au MVP : un seul Text node enfant (ou aucun).
|
||||
// Helpers de gestion du caret pour les contentEditable.
|
||||
// Depuis Phase 2, l'arbre interne peut contenir plusieurs Text nodes
|
||||
// emballés dans des wrappers (<strong>, <em>, <a>, <span data-color>...).
|
||||
// Les fonctions ci-dessous calculent / posent un offset texte global.
|
||||
|
||||
export function getCaretOffset(el) {
|
||||
const sel = typeof window !== 'undefined' ? window.getSelection() : null;
|
||||
@@ -12,22 +14,68 @@ export function getCaretOffset(el) {
|
||||
return pre.toString().length;
|
||||
}
|
||||
|
||||
export function getCaretRange(el) {
|
||||
const sel = typeof window !== 'undefined' ? window.getSelection() : null;
|
||||
if (!sel || sel.rangeCount === 0) return null;
|
||||
const range = sel.getRangeAt(0);
|
||||
if (!el.contains(range.startContainer) || !el.contains(range.endContainer)) return null;
|
||||
const startPre = range.cloneRange();
|
||||
startPre.selectNodeContents(el);
|
||||
startPre.setEnd(range.startContainer, range.startOffset);
|
||||
const endPre = range.cloneRange();
|
||||
endPre.selectNodeContents(el);
|
||||
endPre.setEnd(range.endContainer, range.endOffset);
|
||||
return { start: startPre.toString().length, end: endPre.toString().length };
|
||||
}
|
||||
|
||||
// Trouve le Text node + offset local correspondant à un offset global.
|
||||
// Si l'arbre est vide, retourne `{ node: el, offset: 0 }` pour positionner
|
||||
// le caret directement dans l'élément racine.
|
||||
function locateOffset(el, offset) {
|
||||
if (!el) return null;
|
||||
const walker = el.ownerDocument.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
let consumed = 0;
|
||||
let last = null;
|
||||
let textNode = walker.nextNode();
|
||||
while (textNode) {
|
||||
const len = textNode.nodeValue?.length ?? 0;
|
||||
if (offset <= consumed + len) {
|
||||
return { node: textNode, offset: Math.max(0, offset - consumed) };
|
||||
}
|
||||
consumed += len;
|
||||
last = textNode;
|
||||
textNode = walker.nextNode();
|
||||
}
|
||||
if (last) return { node: last, offset: last.nodeValue?.length ?? 0 };
|
||||
return { node: el, offset: 0 };
|
||||
}
|
||||
|
||||
export function setCaretOffset(el, offset) {
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
const sel = window.getSelection();
|
||||
if (!sel) return;
|
||||
const target = locateOffset(el, Math.max(0, offset || 0));
|
||||
if (!target) return;
|
||||
const range = document.createRange();
|
||||
const text = el.firstChild;
|
||||
if (!text) {
|
||||
range.setStart(el, 0);
|
||||
range.setStart(target.node, target.offset);
|
||||
range.collapse(true);
|
||||
} else {
|
||||
const max = text.textContent?.length ?? 0;
|
||||
const pos = Math.max(0, Math.min(offset, max));
|
||||
range.setStart(text, pos);
|
||||
range.collapse(true);
|
||||
}
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
// Pose une sélection couvrant [start, end[ dans l'élément.
|
||||
export function setCaretRange(el, start, end) {
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
const sel = window.getSelection();
|
||||
if (!sel) return;
|
||||
const a = locateOffset(el, Math.max(0, start || 0));
|
||||
const b = locateOffset(el, Math.max(0, end || 0));
|
||||
if (!a || !b) return;
|
||||
const range = document.createRange();
|
||||
range.setStart(a.node, a.offset);
|
||||
range.setEnd(b.node, b.offset);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user