Éléments
RichEditor
RichEditor est un éditeur de texte riche construit sur l’architecture de ProseMirror. L’app définit de quoi un document est composé avec un schéma (paragraphes, titres, listes, mentions, tous les types de nœuds et de marques dont elle a besoin), et l’éditeur maintient chaque document valide vis-à-vis de ce schéma. Les modifications sont des étapes (steps) regroupées en transactions, de sorte qu’elles s’annulent, mappent les positions et circulent en JSON ; le comportement vient des plugins (historique, keymaps, input rules, raccourcis Markdown, ceux de l’app) ; les commandes sont des valeurs qu’une barre d’outils exécute et interroge. L’état de l’éditeur, ses commandes et ses plugins s’exécutent en Rust : la saisie, les raccourcis clavier et les input rules n’attendent jamais le JS, et l’app lit l’état en JSON quand elle le demande.
import { mount, signal, Text, View } from "nsh";
import { RichEditor, StarterKit, command, createRichEditor, redo, undo } from "nsh/base";
import { button, buttonText, desktop, Log, Panel, Split, ui, wide } from "./palette";
const CONTENT = `# A document
Type **Markdown** as you go: \`#\` makes a heading, \`-\` a list, \`[ ]\` a task, each followed by a space.
- Select text and press Bold
- Undo and redo keep every step
`;
// The session owns the document; the view renders it and turns input into
// transactions. The toolbar runs Tiptap's named commands on the session.
function App() {
const editor = createRichEditor({ extensions: [StarterKit], content: CONTENT, format: "markdown" });
const markdown = signal(editor.getMarkdown());
const events = signal<string[]>([]);
const log = (line: string) => events.set([line, ...events()].slice(0, 4));
editor.on("revision", () => markdown.set(editor.getMarkdown()));
editor.on("focus", () => log("focus"));
editor.on("blur", () => log("blur"));
const action = (label: string, run: () => void) => (
<View onPress={run} role="button" accessibilityLabel={label} {...button("secondary", { size: "sm" })}>
<Text style={buttonText()}>{label}</Text>
</View>
);
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>rich editor</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Click into the document and type; Cmd/Ctrl+B makes bold" : "Tap into the document and type"}
</Text>
<Split
grow
width={640}
inspector={
<>
<Panel title="markdown">
<Text style={{ fontSize: 12, fontFamily: "monospace", color: ui().text }}>{markdown()}</Text>
</Panel>
<Log title="events" lines={events()} minHeight={72} />
</>
}
>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
{action("Bold", () => editor.run(command("toggleBold")))}
{action("Italic", () => editor.run(command("toggleItalic")))}
{action("Heading", () => editor.run(command("toggleHeading", { level: 2 })))}
{action("List", () => editor.run(command("toggleBulletList")))}
{action("Task", () => editor.run(command("toggleTaskList")))}
{action("Undo", () => editor.run(undo))}
{action("Redo", () => editor.run(redo))}
</View>
<View style={{ height: wide() ? 360 : 300, ...ui().card, borderRadius: 14, overflow: "hidden" }}>
<RichEditor
editor={editor}
accessibilityLabel="Document"
placeholder="Write something"
classes={CLASSES}
style={{ flex: 1 }}
/>
</View>
</Split>
</View>
);
}
/** The document's look: NSH's editor draws nothing of its own. */
const CLASSES = {
content: "p-4 gap-3",
taskCheckbox: "size-4 mr-2 mt-0.5 rounded border border-border checked:bg-primary checked:border-primary",
listMarker: "text-muted-foreground",
nodes: {
"heading[level=1]": "text-2xl font-bold",
"heading[level=2]": "text-xl font-semibold",
"heading[level=3]": "text-lg font-semibold",
blockquote: "border-l-4 border-border pl-3 text-muted-foreground",
codeBlock: "font-mono text-sm bg-muted rounded-md px-3 py-2",
bulletList: "gap-1 pl-5",
orderedList: "gap-1 pl-5",
taskList: "gap-1",
taskItem: "flex-row",
horizontalRule: "h-px bg-border my-2",
},
marks: {
bold: "font-semibold",
italic: "italic",
strike: "line-through",
code: "font-mono text-sm bg-muted rounded px-1",
link: "text-primary underline",
},
};
mount(App);Un éditeur a deux parties : la session (createRichEditor), qui possède l’état et que pilote le code de l’app, et la vue (<RichEditor editor={…} />), qui affiche la session nativement et transforme les entrées de la plateforme en transactions. Plusieurs vues peuvent afficher une même session.
Le noyau de l’éditeur est un portage en Rust de ProseMirror par Marijn Haverbeke, et sa couche d’extensions suit Tiptap : leurs structures de données, leurs commandes et leurs noms sont repris, et ce que vous en savez aussi. Les deux sont sous licence MIT, et leurs licences s’appliquent à ce qui en est porté.
import { mount } from "nsh";
import { RichEditor, createRichEditor, history, inputRules, keymap, markdown, markdownRules, starterKeymap, baseKeymap, toggleMark } from "nsh/base";
const editor = createRichEditor({
content: "# Notes\n\nStart typing…",
format: "markdown",
plugins: [
history(),
keymap({ "Mod-b": toggleMark("bold"), "Mod-i": toggleMark("italic") }),
starterKeymap(),
baseKeymap(),
inputRules(markdownRules()),
markdown(),
],
});
function App() {
return <RichEditor editor={editor} class="flex-1" />;
}
mount(App);
Sans schema, un éditeur utilise le schéma de départ (starterSchema()) ; sans plugins, il reçoit le kit de départ (starterKit() : les input rules et les raccourcis clavier du schéma de départ, les raccourcis de base et l’historique). Un tableau plugins vide donne un éditeur sans aucun plugin.
#Style
L’éditeur dessine le document avec l’apparence de l’app : classes et styles stylent ses parties (root, content, caret, selection, placeholder, composition, selectedNode, listMarker, et pour les tableaux selectedCell, tableGrip, tableResizeHandle, tableDropIndicator), ses nœuds par type (nodes: { heading: "text-2xl font-bold", "heading[level=2]": "text-xl" }) and its marks (marks: { bold: "font-bold" }). Les éléments des listes dont la spec de nœud définit extra.listMarker à "bullet" ou "ordered" (les listes du schéma de départ) affichent un marqueur dans la partie listMarker : •, ◦, ▪ selon la profondeur, ou des numéros à partir du start de la liste, placé avant l’élément, dans l’indentation de la liste (pl-6 sur la liste, comme un navigateur indente ul).
<RichEditor
editor={editor}
classes={{
nodes: { heading: "text-2xl font-bold", bulletList: "pl-6", codeBlock: "font-mono bg-muted p-3 rounded-md" },
marks: { bold: "font-bold", italic: "italic", code: "font-mono bg-muted" },
listMarker: "text-muted-foreground",
}}
/>
La partie content est la boîte du document à l’intérieur de la vue défilante de l’éditeur : son padding (content: "px-6 py-5") est l’espace autour du texte, et la barre de défilement longe le bord de l’éditeur lui-même. Un glissement qui sélectionne au-delà du haut ou du bas de l’éditeur fait défiler le document, d’autant plus vite que le pointeur s’éloigne.
#Bubble menu
bubbleMenu est le bubble menu de Tiptap : le contenu de l’app affiché à côté de la sélection, au-dessus par défaut (bubbleMenuPlacement, passant de l’autre côté s’il n’y a pas de place) à bubbleMenuOffset points (8), dans un overlay qui suit le texte pendant le défilement. L’app le passe tant qu’elle veut l’afficher, à partir de l’état de la session :
const revision = signal(editor.revision);
editor.on("revision", ({ revision: r }) => revision.set(r));
const inLink = () => (revision(), editor.isActive("link"));
<RichEditor
editor={editor}
bubbleMenu={inLink() ? <LinkMenu href={editor.getAttributes("link").href} /> : undefined}
bubbleMenuPlacement="bottom"
/>
Un menu de lien modifie le lien sous le curseur avec chain([extendMarkRange("link"), command("setLink", { href })]) et le supprime avec command("unsetLink") après le même extendMarkRange.
#Schémas
defineSchema(spec) lit un schéma au format de ProseMirror et lève une erreur indiquant ce qui ne va pas. Les types de nœuds nomment leur content (une expression de contenu : "inline*", "paragraph+", "(heading | paragraph) block*"), leur group, s’ils sont inline ou un atom, les marks qu’ils autorisent et leurs attrs avec valeurs par défaut et validation ("string", "number", "boolean"). Le premier type de bloc est celui par défaut : ce que crée Entrée et ce qui remplit le contenu requis. Les clés qu’une spec ne connaît pas sont conservées comme données supplémentaires du type, pour les frameworks construits sur l’éditeur.
import { createRichEditor, defineSchema } from "nsh/base";
const schema = defineSchema({
nodes: {
doc: { content: "block+" },
paragraph: { content: "inline*", group: "block" },
mention: { group: "inline", inline: true, atom: true, attrs: { id: {}, label: { default: "" } } },
text: { group: "inline" },
},
marks: { bold: {}, link: { attrs: { href: {} }, inclusive: false } },
});
const editor = createRichEditor({ schema, doc: schema.parse("Hello") });
Un RichSchema lit et écrit des documents : parse(content, format?) (texte brut par défaut, "markdown", ou JSON), parseMarkdown(markdown), serialize(doc, format?) (Markdown par défaut, "text", "json") et check(doc), qui renvoie le document avec les valeurs par défaut de ses attributs renseignées. nodes, marks et topNode nomment ses types. Le Markdown correspond aux noms du schéma de départ (paragraph, heading, blockquote, codeBlock, bulletList, orderedList, listItem, bold, italic, code, link, strike).
#Commandes
Une commande est une valeur : toggleMark("bold") crée un objet que l’app conserve, associe à des touches et passe à l’éditeur. editor.run(command) l’exécute et renvoie si elle s’est appliquée ; editor.can(command) pose la question sans rien modifier, ce qui permet à une barre d’outils d’activer ses boutons. editor.isActive(name, attrs?) indique si une marque ou un nœud est actif à la sélection (isActive("heading", { level: 2 })).
| Commande | Rôle |
|---|---|
toggleMark(name, attrs?) | Ajoute la marque à la sélection, ou la retire là où elle est présente ; avec un curseur, pour le texte saisi ensuite |
setMark(name, attrs?) / unsetMark(name) | Ajoute ou retire une marque |
setBlockType(name, attrs?) | Convertit les blocs de texte sélectionnés en un type donné (setBlockType("heading", { level: 2 })) |
wrapIn(name, attrs?) / lift() | Enveloppe les blocs sélectionnés dans un nœud (une citation) / les sort de leur parent |
wrapInList(name, attrs?) | Enveloppe les blocs sélectionnés dans une liste |
splitListItem(name) / sinkListItem(name) / liftListItem(name) | Scinde, indente ou désindente des éléments de liste |
undo() / redo() | Annule ou rétablit une modification (avec history()) |
selectAll(), setTextSelection(anchor, head?), setNodeSelection(pos) | Sélectionne |
insertText(text), insertContent(content, format?) | Insère du texte ou du contenu sur la sélection |
setNodeAttribute(pos, attr, value) | Définit un attribut du nœud à une position, en une seule modification annulable |
command(name) | Une commande sans paramètre, désignée par son nom : "splitBlock", "joinBackward", "exitCode", "newlineInCode", "selectParentNode", … |
chain(commands) | Exécute des commandes l’une après l’autre en une seule transaction (une étape d’annulation) ; ne s’applique que si chacune s’applique |
first(commands) | Exécute la première commande qui s’applique |
editor.run(chain([setBlockType("heading", { level: 1 }), toggleMark("bold")]));
const canIndent = editor.can(sinkListItem("listItem"));
#La session
| Membre | Description |
|---|---|
state, doc, selection | L’état, le document et la sélection en JSON, produits à la lecture |
revision | Nombre de transactions que l’état a appliquées |
isEmpty, undoDepth, redoDepth, composing | Ce qu’une UI affiche de l’éditeur |
setContent(content, format?) | Remplace le document, en une seule modification annulable |
insertContent(content, format?) | Insère du contenu sur la sélection |
getJSON(), getMarkdown(), getText(), textBetween(from?, to?, separator?) | Le document dans un format donné |
dispatch(tr) | Applique une transaction donnée en JSON (ses steps appliqués au document courant) |
on(event, listener) | Appelle listener avec la charge utile de chaque event, au prochain tour de JS : revision ({ revision }, après chaque modification, quelle qu’en soit la source), update, selectionUpdate, transaction, focus, blur, delete. Renvoie une fonction qui l’arrête ; un écouteur ajouté pendant le rendu d’un composant s’arrête avec le composant |
focus(), blur() | Déplace le focus clavier vers les vues qui affichent l’éditeur, ou hors de celles-ci |
Les positions se comptent comme dans ProseMirror : un texte compte ses unités de code UTF-16, un nœud feuille en compte une, un nœud avec contenu ses deux frontières. Tout ce qui doit survivre aux modifications garde un id dans les attributs d’un nœud ou d’une marque. Une requête de position (resolve(pos), nodeAt(pos), coordsAtPos(pos), findParentNode(name, pos), …) lève une RangeError pour une position hors du document (0 à doc.content.size), comme le resolve de ProseMirror, et répond null là où il n’y a rien (nodeAt à la fin d’un nœud, coordsAtPos sans vue affichant l’éditeur). run et can lèvent une TypeError pour une commande, un type de nœud ou un type de marque que l’éditeur ne connaît pas.
#Prendre le contrôle des transactions
editor.dispatchTransaction = (tr) => { … } fait valider chaque transaction par l’app elle-même : le gestionnaire reçoit chacune en JSON et la valide avec editor.applyTransaction(tr), ou l’abandonne. La collaboration, la journalisation et les transactions qui doivent passer les contrôles de l’app l’utilisent ; null rend la validation à l’éditeur. Le gestionnaire entend la saisie au prochain tour de JS, et le document change quand il valide.
#Extensions
Les extensions sont celles de Tiptap : chacune apporte ensemble son type de nœud ou de marque, ses raccourcis clavier, ses input rules et paste rules, ses plugins et ses commandes nommées, et un éditeur construit à partir d’extensions en tire son schéma, son keymap, ses règles et sa table de commandes. Les extensions intégrées sont des classes nommées comme celles de Tiptap, utilisées telles quelles ou configurées :
import { RichEditor, StarterKit, Underline, command, createRichEditor } from "nsh/base";
const editor = createRichEditor({
extensions: [StarterKit.configure({ heading: { levels: [1, 2, 3] }, codeBlock: false })],
content: "Hello",
editable: true,
});
editor.run(command("toggleHeading", { level: 2 }));
editor.run(command("toggleBold"));
StarterKit apporte Document, Paragraph, le nœud text, Heading, Blockquote, BulletList, OrderedList, ListItem, TaskList, TaskItem, CodeBlock, HardBreak, HorizontalRule, RichImage (le Image de Tiptap), Bold, Italic, Strike, Code, Underline, RichLink (le Link de Tiptap), Highlight, Subscript, Superscript, RichTextStyle (le TextStyle de Tiptap), TextAlign, History, TrailingNode, Typography et ListKeymap. Une option portant le nom de l’une d’elles, définie à false, l’omet ; un objet la configure (heading: { levels }, history: { depth }, textAlign: { types }, codeBlock: { enableTabIndentation }). Le configure(options) et le extend({...}) de chaque classe produisent un RichExtension, qui a name, type, priority et options. Chaque éditeur reçoit aussi le keymap de base (Entrée, Retour arrière, Suppr, tout sélectionner).
Chaque extension ajoute les commandes de Tiptap, que command(name, args) exécute : toggleBold, setItalic, unsetStrike, toggleHeading({ level }), setParagraph, toggleBlockquote, toggleBulletList, toggleOrderedList, toggleTaskList, toggleCodeBlock({ language }), setHardBreak, setHorizontalRule, setImage({ src }), toggleHighlight({ color }), setTextAlign("center"), unsetTextAlign, ainsi que les commandes de lien, de comptage de caractères et de suggestion ci-dessous.
Les bascules de liste agissent sur des listes entières, comme les boutons de liste d’un traitement de texte : toggleOrderedList dans une liste à puces la numérote à tous les niveaux, listes imbriquées comprises, et la bascule propre à une liste sort les blocs sélectionnés de toutes les listes qui les entourent.
L’ordre des extensions fixe leur priorité à égalité : une extension listée plus tard associe ses touches avant celles qui la précèdent ; priority (100 par défaut) les classe autrement. Avec extensions, les plugins d’un éditeur viennent après les plugins des extensions.
#Les extensions de l’app
defineExtension en crée une. Ses fonctions s’exécutent une fois, quand un éditeur est créé avec elle, avec les options de l’extension, et renvoient des données : commandes, règles et plugins tels que les produisent les factories ci-dessus.
import { StarterKit, command, createRichEditor, defineExtension, insertText, textRule } from "nsh/base";
const Signature = defineExtension({
name: "signature",
priority: 200,
addOptions: () => ({ text: "— Sent from NSH" }),
addCommands: (options) => ({ insertSignature: insertText(options.text) }),
addKeyboardShortcuts: () => ({ "Mod-Alt-s": command("insertSignature") }),
addInputRules: () => [textRule("\\(sig\\)$", "✍")],
});
const editor = createRichEditor({ extensions: [StarterKit, Signature.configure({ text: "Best, Ana" })] });
node ou mark (sous la forme de defineSchema) en fait une extension de nœud ou de marque ; addGlobalAttributes ajoute des attributs à d’autres types ; addProseMirrorPlugins renvoie des plugins, RichEditorPlugin compris ; addPasteRules renvoie des règles textRule et markRule pour le texte collé. Le extend({...}) d’une classe intégrée remplace les parties qu’elle fournit : Underline.extend({ addKeyboardShortcuts: () => ({ "Mod-Alt-u": toggleMark("underline") }) }).
Le crate d’une app enregistre des extensions écrites en Rust via son jeu d’UI, et JS les prend par leur nom :
use nsh_base::rich_editor::extensions::{Extension, RichExtensionRegistry};
#[native_module("app/native")]
fn native(registry: &mut Registry) {
registry.rich_extension("mention", || Extension::new("mention") /* .add_plugins(...) */);
}
const editor = createRichEditor({ extensions: [StarterKit, richExtension("mention", { char: "@" })] });
richExtension(name, options) trouve aussi chaque extension intégrée par son nom ("text", "bold", …).
#Plugins
| Factory | Ce qu’elle ajoute |
|---|---|
history({ depth?, newGroupDelay? }) | Historique d’annulation ; les modifications faites à moins de newGroupDelay millisecondes d’intervalle (500) s’annulent ensemble |
keymap(bindings) | Commandes par nom de touche : "Mod-b" (Cmd sur les plateformes Apple, Ctrl ailleurs), "Shift-Enter", "Alt-ArrowUp" |
baseKeymap() | Entrée, Retour arrière, Suppr, tout sélectionner et les touches d’édition de texte de la plateforme |
starterKeymap() | Les raccourcis du schéma de départ : marques, titres, listes, blocs de code, sauts de ligne forcés |
inputRules(rules) | Le texte saisi avant le curseur qui correspond à une règle devient ce que la règle produit ; Retour arrière juste après l’annule |
markdown(options?) | Markdown à la saisie, collage Markdown et copie Markdown (blockRules, inlineRules, paste, copyAsMarkdown) |
starterKit() | Les input rules et raccourcis du schéma de départ, les raccourcis de base et l’historique |
La place d’un plugin dans la liste est sa priorité : les keymaps et input rules placés plus tôt traitent une touche ou un texte en premier. Les input rules viennent de markdownRules() (titres, citations, listes, blocs de code, règles, marques courantes), typographyRules() (guillemets typographiques, points de suspension, tiret cadratin), textRule(match, replacement), markRule(match, mark), wrappingRule(match, node) et textblockRule(match, node). Le match d’une règle est une expression régulière se terminant par $, appliquée au texte situé avant le curseur.
#Les plugins de l’app
new RichEditorPlugin(key) crée un plugin dont le comportement est celui de l’app, défini par des appels chaînés :
import { RichEditorPlugin, createRichEditor, starterKit } from "nsh/base";
const suggestions = new RichEditorPlugin("suggestions")
.captureKeys(["ArrowUp", "ArrowDown", "Enter", "Escape"], (key) => moveSuggestion(key))
.filterTransaction((tr) => tr.meta?.locked !== true)
.appendTransaction((trs, state) => null);
const editor = createRichEditor({ plugins: [starterKit(), suggestions] });
// While the suggestion list is open, its keys are the list's.
suggestions.capturing = true;
editor.setDecorations("suggestions", [{ from: 5, to: 9, kind: "inline", attrs: { class: "bg-accent" } }]);
captureKeys(keys, handler): tant quecapturingest vrai, l’éditeur consomme ces touches et indique àhandlerlaquelle a été pressée, juste après ; le clavier reste aussi rapide que sans plugin.editor.setDecorations(key, decorations): attributs inline, attributs de nœud et widgets que la vue dessine par-dessus le document sans le modifier. Ils suivent chaque modification jusqu’à ce qu’on les redéfinisse.filterTransaction(hook)etappendTransaction(hook)voient les transactions que font les appels propres de l’app (run,dispatch,setContent, …) dans cet appel :filterTransactionpeut en abandonner une,appendTransactionpeut en renvoyer une de plus à appliquer avec elle.on("revision", …)entend chaque modification, saisie comprise.
#Liens, décomptes de caractères et suggestions
Trois plugins portent les comportements Link, CharacterCount et Suggestion de Tiptap, tous natifs :
import { RichEditor, characterCount, createRichEditor, insertMention, link, starterKit, starterSchema, suggestion, withMention } from "nsh/base";
const editor = createRichEditor({
schema: withMention(starterSchema()),
plugins: [
starterKit(),
link({ defaultProtocol: "https" }),
characterCount({ limit: 280 }),
suggestion({ key: "mention", char: "@", decorationClass: "text-primary", captureKeys: ["ArrowUp", "ArrowDown", "Enter"] }),
],
});
editor.on("transaction", () => {
const s = editor.suggestion("mention");
if (s?.active) showPeople(s.query, editor.coordsAtPos(s.range.from), s.lastKey);
setCount(`${editor.characters()} / ${editor.characterLimit}`);
});
// The list picked someone:
editor.run(insertMention(editor.suggestion("mention")!.range, "u1", "Joan"));
link(options?): une URL saisie avant une espace ou Entrée devient un lien, tout comme les URL d’un texte collé (autolink) ; une URL collée sur une sélection transforme celle-ci en lien (linkOnPaste) ; un appui sur un lien avec Mod maintenu, ou lorsque l’éditeur est en lecture seule, l’ouvre via la gestion des URL de l’app (openOnClick) ;enableClickSelectionsélectionne tout le lien lors d’un appui. Les URL sont détectées avec ou sans protocole (nsh.dev,https://nsh.dev/docs, adresses e-mail), et le protocole attribué à un hôte nu estdefaultProtocol(http). Les liens acceptent les protocoleshttp,https,ftp,ftps,mailto,tel,callto,sms,cid,xmppainsi que ceux deprotocols, de sorte quesetLink({ href }),toggleLink(attrs?)et l’autolink excluent les scripts (javascript:) et les URL de données.unsetLink()supprime le lien autour de la sélection.characterCount(options?):editor.characters(mode?)compte les caractères du texte ("textSize", unités UTF-16 commelengthde JS) ou les positions du document ("nodeSize") ;editor.words()compte ses mots (limites de mots Unicode). Aveclimit, les modifications qui le dépasseraient sont refusées et le contenu collé est tronqué à ce qui tient ; un premier document au-delà de la limite est tronqué (autoTrim).setCharacterLimit(limit)modifie la limite eteditor.characterLimitla lit.suggestion(options?): suit un déclencheur (char,@par défaut) saisi avant le curseur.allowSpacespermet à la requête de contenir des espaces,allowedPrefixesliste ce qui peut précéder le déclencheur (une espace par défaut ;anyPrefixaccepte n’importe quoi),startOfLinel’exige en début de ligne (un menu de commandes/).editor.suggestion(key)donne{ active, range, query, text, lastKey, keyCount }; la plage active porte ledecorationClassde l’app (etdecorationEmptyClasstant que la requête est vide). Tant qu’une suggestion est active, les touches decaptureKeysappartiennent à la liste de l’app : l’éditeur les capture et enregistre la dernière dans l’état, et Échap ferme la suggestion (exitSuggestion(key)aussi).replaceSuggestion(content, key?)remplace le déclencheur et la requête par du texte ou du contenu JSON.withMention(schema, { name?, char? })ajoute le nœud mention de Tiptap : un atome en ligne avecid,labelet son déclencheur, écrit@labeldans le texte et<span data-type="mention">en HTML.insertMention(range, id, label?)remplace la plage d’une suggestion par une mention suivie d’une espace.
La fenêtre contextuelle appartient à l’app : elle lit l’état de la suggestion, se place à coordsAtPos(range.from) et dessine les
éléments à sa façon.
#Tableaux
TableKit apporte les tableaux de Tiptap : RichTable (Table de Tiptap), RichTableRow, RichTableHeader et
RichTableCell. Il s’ajoute à côté de StarterKit :
import { RichEditor, StarterKit, TableKit, addRowAfter, createRichEditor, insertTable, mergeCells } from "nsh/base";
const editor = createRichEditor({
extensions: [StarterKit, TableKit.configure({ table: { cellMinWidth: 40 } })],
});
editor.run(insertTable({ rows: 3, cols: 3, withHeaderRow: true }));
editor.run(addRowAfter());
Le tableau accepte les options resizable (true), cellMinWidth (25), lastColumnResizable (true),
allowTableNodeSelection (false) et HTMLAttributes ; TableKit.configure({ tableHeader: false }) exclut une
extension. Les cellules contiennent des blocs et ont colspan, rowspan et colwidth (une largeur en points pour chaque colonne
couverte) ; les lignes ont un height.
Tab et Maj-Tab déplacent entre les cellules, et Tab dans la dernière cellule ajoute une ligne. Les flèches au bord d’une cellule passent à la
cellule suivante ; Maj avec une flèche, ou un glissement sur plusieurs cellules, sélectionne un rectangle de cellules (une sélection de type
"cell", dont anchor et head sont les positions avant sa première et sa dernière cellule). Retour arrière et Suppr vident
les cellules sélectionnées, et suppriment le tableau lorsque toutes ses cellules sont sélectionnées.
Dans l’éditeur, un tableau est une grille de ses cellules : chaque colonne est aussi large que les colwidth de ses cellules, les colonnes sans largeur
se partageant la place restante (avec au moins cellMinWidth de large), chaque ligne ayant au moins sa height. Son apparence part du thème : une
grille border de 1 point, des cellules d’en-tête sur muted, des cellules avec marge intérieure, et chaque cellule sélectionnée sous la partie selectedCell dans
la couleur de sélection. classes.nodes (table, tableCell, tableHeader) et les parties permettent de tout restyler.
Haut et Bas sur la première ou la dernière ligne d’une cellule mènent à la cellule au-dessus ou en dessous.
Tant que le document est modifiable :
- Sur ordinateur, la bordure droite de chaque colonne et la bordure inférieure de chaque ligne permettent de les redimensionner : le pointeur affiche le
curseur de redimensionnement et la ligne
tableResizeHandle, le tableau suit le glissement, et le relâchement définitcolwidthouheighten une seule étape que l’annulation peut défaire.resizable: falsedésactive ce comportement,lastColumnResizable: falseconserve la largeur de la dernière colonne. - Le tableau contenant la sélection affiche des poignées (
tableGrip) sur sa bordure supérieure au-dessus de chaque colonne et sur sa bordure gauche à côté de chaque ligne. Appuyer sur l’une d’elles sélectionne cette colonne ou cette ligne ; la faire glisser sur ordinateur montre où elle sera déposée (tableDropIndicator) et l’y déplace au relâchement.
<RichEditor
editor={editor}
classes={{
nodes: { tableHeader: "font-semibold", tableCell: "px-3 py-2" },
selectedCell: "bg-primary/10",
tableGrip: "bg-muted-foreground/60 rounded-full",
}}
/>
Les commandes, chacune étant un assistant de nsh/base :
insertTable({ rows, cols, withHeaderRow })(3 × 3 avec une ligne d’en-tête par défaut) etdeleteTable().addColumnBefore(),addColumnAfter(),deleteColumn(),addRowBefore(),addRowAfter(),deleteRow().mergeCells(),splitCell()etmergeOrSplit().toggleHeaderRow(),toggleHeaderColumn()ettoggleHeaderCell().setCellAttribute(name, value),setCellSelection({ anchorCell, headCell }),goToNextCell(),goToPreviousCell()etfixTables(), qui répare les tableaux dont des cellules sont manquantes ou se chevauchent.moveTableRow({ from, to, select })etmoveTableColumn({ from, to, select })déplacent une ligne ou une colonne, avec les lignes ou colonnes couvertes par ses cellules fusionnées ;setColumnWidth({ col, width })etsetRowHeight({ row, height })les dimensionnent (nullles ajuste à leur contenu). Elles acceptenttable, la position avant un tableau, pour agir sur un tableau autre que celui de la sélection.
Les tableaux se lisent et s’écrivent en HTML (<table> avec <th> / <td>, colspan, rowspan et data-colwidth) et en
Markdown : un tableau avec une ligne d’en-tête dont les cellules contiennent chacune un paragraphe est un tableau GFM à barres verticales, et tout autre tableau est
écrit en HTML, qui se relit à l’identique. En texte brut, une ligne est une ligne de texte dont les cellules sont séparées par des tabulations.