Elementi
RichEditor
RichEditor è un editor di testo ricco costruito secondo l’architettura di ProseMirror. L’app definisce di cosa è fatto un documento con uno schema (paragrafi, titoli, liste, menzioni, qualsiasi tipo di nodo e mark serva), e l’editor mantiene ogni documento valido rispetto ad esso. Le modifiche sono step raggruppati in transazioni, quindi si possono annullare, mappano le posizioni e viaggiano come JSON; il comportamento viene dai plugin (cronologia, keymap, input rule, scorciatoie Markdown, quelli dell’app); i comandi sono valori che una toolbar esegue e interroga. Lo stato dell’editor, i suoi comandi e i suoi plugin girano in Rust: digitazione, associazioni di tasti e input rule non aspettano mai JS, e l’app legge lo stato come JSON quando lo richiede.
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 editor ha due parti: la sessione (createRichEditor), che possiede lo stato e che il codice dell’app pilota, e la vista (<RichEditor editor={…} />), che renderizza la sessione in modo nativo e trasforma l’input della piattaforma in transazioni. Più viste possono mostrare una stessa sessione.
Il kernel dell’editor è un port in Rust di ProseMirror di Marijn Haverbeke, e il suo livello di estensioni segue Tiptap: le loro strutture dati, i comandi e i nomi vengono mantenuti, e con essi ciò che già conosci di entrambi. Entrambi sono rilasciati con licenza MIT, e le loro licenze si applicano a ciò che ne è stato portato.
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);
Senza schema un editor usa lo schema starter (starterSchema()); senza plugins ottiene lo starter kit (starterKit(): le input rule e le associazioni di tasti dello starter, le associazioni di tasti di base e la cronologia). Un array plugins vuoto crea un editor senza alcun plugin.
#Stile
L’editor disegna il documento con l’aspetto dell’app: classes e styles stilizzano le sue parti (root, content, caret, selection, placeholder, composition, selectedNode, listMarker, e per le tabelle selectedCell, tableGrip, tableResizeHandle, tableDropIndicator), i suoi nodi per tipo (nodes: { heading: "text-2xl font-bold", "heading[level=2]": "text-xl" }) and its marks (marks: { bold: "font-bold" }). Gli elementi delle liste il cui node spec imposta extra.listMarker su "bullet" o "ordered" (le liste dello schema starter) mostrano un marcatore nella parte listMarker: •, ◦, ▪ in base alla profondità, oppure numeri a partire da start della lista, posizionato prima dell’elemento, nel rientro della lista (pl-6 sulla lista, come un browser rientra 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 parte content è il riquadro del documento all’interno della scroll view dell’editor: il suo padding (content: "px-6 py-5") è lo spazio attorno al testo, e la barra di scorrimento corre lungo il bordo dell’editor stesso. Un trascinamento che seleziona oltre il bordo superiore o inferiore dell’editor fa scorrere il documento, tanto più velocemente quanto più il puntatore si allontana.
#Bubble menu
bubbleMenu è il bubble menu di Tiptap: il contenuto dell’app mostrato accanto alla selezione, per impostazione predefinita sopra di essa (bubbleMenuPlacement, passando sull’altro lato dove non c’è spazio) a bubbleMenuOffset punti (8), in un overlay che segue il testo mentre scorre. L’app lo passa finché vuole che sia mostrato, a partire dallo stato della sessione:
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 per i link modifica il link sotto il cursore con chain([extendMarkRange("link"), command("setLink", { href })]) e lo rimuove con command("unsetLink") dopo lo stesso extendMarkRange.
#Schemi
defineSchema(spec) legge uno schema nella forma di ProseMirror e lancia un errore indicando cosa c’è di sbagliato. I tipi di nodo indicano il proprio content (un’espressione di contenuto: "inline*", "paragraph+", "(heading | paragraph) block*"), il proprio group, se sono inline o un atom, i marks che ammettono e i propri attrs con valori predefiniti e validazione ("string", "number", "boolean"). Il primo tipo di blocco è quello predefinito: ciò che crea Invio e ciò che riempie il contenuto obbligatorio. Le chiavi che una spec non riconosce vengono conservate come dati extra del tipo, per i framework costruiti sopra l’editor.
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 legge e scrive documenti: parse(content, format?) (testo semplice per impostazione predefinita, "markdown", oppure JSON), parseMarkdown(markdown), serialize(doc, format?) (Markdown per impostazione predefinita, "text", "json") e check(doc), che restituisce il documento con i valori predefiniti degli attributi compilati. nodes, marks e topNode ne nominano i tipi. Il Markdown corrisponde ai nomi dello schema starter (paragraph, heading, blockquote, codeBlock, bulletList, orderedList, listItem, bold, italic, code, link, strike).
#Comandi
Un comando è un valore: toggleMark("bold") crea un oggetto che l’app conserva, associa ai tasti e passa all’editor. editor.run(command) lo esegue e restituisce se è stato applicato; editor.can(command) lo interroga senza modificare nulla, ed è così che una toolbar abilita i propri pulsanti. editor.isActive(name, attrs?) indica se un mark o un nodo è attivo sulla selezione (isActive("heading", { level: 2 })).
| Comando | Cosa fa |
|---|---|
toggleMark(name, attrs?) | Aggiunge il mark alla selezione, o lo rimuove dove è presente; con un cursore, vale per il testo digitato successivamente |
setMark(name, attrs?) / unsetMark(name) | Aggiunge o rimuove un mark |
setBlockType(name, attrs?) | Trasforma i textblock selezionati in un tipo (setBlockType("heading", { level: 2 })) |
wrapIn(name, attrs?) / lift() | Racchiude i blocchi selezionati in un nodo (una citazione) / li estrae dal loro genitore |
wrapInList(name, attrs?) | Racchiude i blocchi selezionati in una lista |
splitListItem(name) / sinkListItem(name) / liftListItem(name) | Divide, indenta o riduce il rientro degli elementi di lista |
undo() / redo() | Annulla o ripristina una modifica (con history()) |
selectAll(), setTextSelection(anchor, head?), setNodeSelection(pos) | Seleziona |
insertText(text), insertContent(content, format?) | Inserisce testo o contenuto sulla selezione |
setNodeAttribute(pos, attr, value) | Imposta un attributo del nodo in una posizione, come un’unica modifica annullabile |
command(name) | Un comando senza parametri, per nome: "splitBlock", "joinBackward", "exitCode", "newlineInCode", "selectParentNode", … |
chain(commands) | Esegue i comandi uno dopo l’altro come un’unica transazione (un solo passo di annullamento); si applica solo se ogni comando si applica |
first(commands) | Esegue il primo comando che si applica |
editor.run(chain([setBlockType("heading", { level: 1 }), toggleMark("bold")]));
const canIndent = editor.can(sinkListItem("listItem"));
#La sessione
| Membro | Cos’è |
|---|---|
state, doc, selection | Lo stato, il documento e la selezione come JSON, creati al momento della lettura |
revision | Quante transazioni ha applicato lo stato |
isEmpty, undoDepth, redoDepth, composing | Ciò che una UI mostra dell’editor |
setContent(content, format?) | Sostituisce il documento, come un’unica modifica annullabile |
insertContent(content, format?) | Inserisce contenuto sulla selezione |
getJSON(), getMarkdown(), getText(), textBetween(from?, to?, separator?) | Il documento in un formato |
dispatch(tr) | Applica una transazione data come JSON (i suoi step rispetto al documento corrente) |
on(event, listener) | Chiama listener con il payload di ogni event, nel turno successivo di JS: revision ({ revision }, dopo ogni modifica, da qualsiasi sorgente), update, selectionUpdate, transaction, focus, blur, delete. Restituisce una funzione che lo interrompe; un listener aggiunto mentre un componente viene renderizzato si interrompe con il componente |
focus(), blur() | Sposta il focus della tastiera verso o dalle viste che mostrano l’editor |
Le posizioni si contano come in ProseMirror: un testo conta le sue unità di codice UTF-16, un nodo foglia uno, un nodo con contenuto i suoi due confini. Tutto ciò che deve sopravvivere alle modifiche conserva un id negli attributi di un nodo o in un mark. Una query di posizione (resolve(pos), nodeAt(pos), coordsAtPos(pos), findParentNode(name, pos), …) lancia un RangeError per una posizione fuori dal documento (0 a doc.content.size), come fa resolve di ProseMirror, e risponde null dove non c’è nulla (nodeAt alla fine di un nodo, coordsAtPos senza alcuna vista che mostri l’editor). run e can lanciano un TypeError per un comando, un nodo o un tipo di mark che l’editor non conosce.
#Assumere il controllo delle transazioni
editor.dispatchTransaction = (tr) => { … } fa sì che sia l’app a eseguire il commit di ogni transazione: il gestore riceve ciascuna transazione come JSON e ne esegue il commit con editor.applyTransaction(tr), oppure la scarta. Collaborazione, logging e transazioni che devono superare i controlli dell’app lo usano; null restituisce il commit all’editor. Il gestore sente la digitazione nel turno successivo di JS, e il documento cambia quando esegue il commit.
#Estensioni
Le estensioni sono quelle di Tiptap: ciascuna porta insieme il proprio tipo di nodo o di mark, le proprie associazioni di tasti, le input rule e le paste rule, i plugin e i comandi con nome, e un editor creato da estensioni ottiene da esse il proprio schema, la keymap, le regole e la tabella dei comandi. Quelle integrate sono classi con i nomi di Tiptap, usate così come sono o configurate:
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 porta Document, Paragraph, il nodo text, Heading, Blockquote, BulletList, OrderedList, ListItem, TaskList, TaskItem, CodeBlock, HardBreak, HorizontalRule, RichImage (Image di Tiptap), Bold, Italic, Strike, Code, Underline, RichLink (Link di Tiptap), Highlight, Subscript, Superscript, RichTextStyle (TextStyle di Tiptap), TextAlign, History, TrailingNode, Typography e ListKeymap. Un’opzione con il nome di una di esse impostata su false la esclude, un oggetto la configura (heading: { levels }, history: { depth }, textAlign: { types }, codeBlock: { enableTabIndentation }). configure(options) e extend({...}) di ciascuna classe creano un RichExtension, che ha name, type, priority e options. Ogni editor ottiene inoltre la keymap di base (Invio, Backspace, Canc, seleziona tutto).
Ogni estensione aggiunge i comandi di Tiptap, che command(name, args) esegue: toggleBold, setItalic, unsetStrike, toggleHeading({ level }), setParagraph, toggleBlockquote, toggleBulletList, toggleOrderedList, toggleTaskList, toggleCodeBlock({ language }), setHardBreak, setHorizontalRule, setImage({ src }), toggleHighlight({ color }), setTextAlign("center"), unsetTextAlign, e i comandi per link, conteggio dei caratteri e suggerimenti riportati più sotto.
I toggle delle liste operano su liste intere, come i pulsanti di lista di un word processor: toggleOrderedList in una lista puntata la numera a ogni livello, comprese le liste annidate, e il toggle di una lista toglie i blocchi selezionati da ogni lista che li circonda.
L’ordine di extensions è la loro priorità a parità di livello: un’estensione elencata dopo associa i propri tasti prima di quelle che la precedono; priority (100 per impostazione predefinita) le ordina altrimenti. Con extensions, i plugins di un editor vengono dopo i plugin delle estensioni.
#Le estensioni dell’app
defineExtension ne crea una. Le sue funzioni vengono eseguite una volta, quando viene creato un editor con essa, con le opzioni dell’estensione, e restituiscono dati: comandi, regole e plugin così come li creano le factory descritte sopra.
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 o mark (nella forma di defineSchema) la rende un’estensione di nodo o di mark; addGlobalAttributes aggiunge attributi ad altri tipi; addProseMirrorPlugins restituisce plugin, compresi i RichEditorPlugin; addPasteRules restituisce regole textRule e markRule per il testo incollato. extend({...}) di una classe integrata sostituisce le parti che essa fornisce: Underline.extend({ addKeyboardShortcuts: () => ({ "Mod-Alt-u": toggleMark("underline") }) }).
Il crate di un’app registra le estensioni scritte in Rust tramite il proprio UI set, e JS le prende per nome:
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) trova anche ogni estensione integrata per nome ("text", "bold", …).
#Plugin
| Factory | Cosa aggiunge |
|---|---|
history({ depth?, newGroupDelay? }) | Cronologia di annullamento; le modifiche entro newGroupDelay millisecondi (500) si annullano insieme |
keymap(bindings) | Comandi per nome del tasto: "Mod-b" (Cmd sulle piattaforme Apple, Ctrl altrove), "Shift-Enter", "Alt-ArrowUp" |
baseKeymap() | Invio, Backspace, Canc, seleziona tutto e i tasti di modifica del testo della piattaforma |
starterKeymap() | Le associazioni dello schema starter: mark, titoli, liste, blocchi di codice, hard break |
inputRules(rules) | Il testo digitato prima del cursore che corrisponde a una regola diventa ciò che la regola produce; Backspace subito dopo lo annulla |
markdown(options?) | Markdown durante la digitazione, incolla Markdown e copia Markdown (blockRules, inlineRules, paste, copyAsMarkdown) |
starterKit() | Le input rule e le associazioni dello starter, le associazioni di base e la cronologia |
La posizione di un plugin nell’elenco è la sua priorità: le keymap e le input rule che compaiono prima nell’elenco gestiscono per prime un tasto o un testo. Le input rule provengono da markdownRules() (titoli, citazioni, liste, code fence, regole, i mark più comuni), typographyRules() (virgolette tipografiche, puntini di sospensione, trattino lungo), textRule(match, replacement), markRule(match, mark), wrappingRule(match, node) e textblockRule(match, node). Il match di una regola è un’espressione regolare che termina con $, confrontata con il testo prima del cursore.
#I plugin dell’app
new RichEditorPlugin(key) crea un plugin il cui comportamento è quello dell’app, impostato con chiamate concatenate:
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): finchécapturingè true l’editor consuma questi tasti e comunica ahandlerquale è stato premuto, subito dopo; la tastiera resta rapida come senza alcun plugin.editor.setDecorations(key, decorations): attributi inline, attributi di nodo e widget che la vista disegna sopra il documento senza modificarlo. Seguono ogni modifica finché non vengono impostati di nuovo.filterTransaction(hook)eappendTransaction(hook)vedono le transazioni prodotte dalle chiamate dell’app stessa (run,dispatch,setContent, …) in quella chiamata:filterTransactionpuò scartarne una,appendTransactionpuò restituirne una in più da applicare insieme.on("revision", …)sente ogni modifica, digitazione compresa.
#Link, conteggio dei caratteri e suggerimenti
Tre plugin offrono il comportamento di Link, CharacterCount e Suggestion di Tiptap, tutto in modo nativo:
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?): un URL digitato prima di uno spazio o di Invio diventa un link, così come gli URL nel testo incollato (autolink); un URL incollato su una selezione trasforma la selezione in un link (linkOnPaste); la pressione su un link con Mod premuto, o quando l’ editor è in sola lettura, lo apre tramite la gestione degli URL dell’app (openOnClick);enableClickSelectionseleziona l’intero link alla pressione. Gli URL vengono riconosciuti con o senza protocollo (nsh.dev,https://nsh.dev/docs, indirizzi e-mail), e il protocollo assegnato a un host senza protocollo èdefaultProtocol(http). I link accettano i protocollihttp,https,ftp,ftps,mailto,tel,callto,sms,cid,xmppe quelli inprotocols, quindisetLink({ href }),toggleLink(attrs?)e l’autolinking escludono gli script (javascript:) e gli URL di tipo data.unsetLink()rimuove il link attorno alla selezione.characterCount(options?):editor.characters(mode?)conta i caratteri del testo ("textSize", unità UTF-16 comelengthdi JS) oppure le posizioni del documento ("nodeSize");editor.words()ne conta le parole (confini di parola Unicode). Conlimit, le modifiche che supererebbero il limite vengono rifiutate e il contenuto incollato viene troncato a ciò che entra; un primo documento oltre il limite viene troncato (autoTrim).setCharacterLimit(limit)modifica il limite eeditor.characterLimitlo legge.suggestion(options?): segue un trigger (char, per impostazione predefinita@) digitato prima del cursore.allowSpacesconsente alla query di contenere spazi,allowedPrefixeselenca ciò che può precedere il trigger (per impostazione predefinita uno spazio;anyPrefixaccetta qualsiasi cosa),startOfLinelo richiede all’inizio della riga (un menu di comandi/).editor.suggestion(key)restituisce{ active, range, query, text, lastKey, keyCount }; l’intervallo attivo contiene ildecorationClassdell’app (edecorationEmptyClassfinché la query è vuota). Mentre un suggerimento è attivo, i tasti incaptureKeysappartengono all’elenco dell’app: l’editor li intercetta e registra l’ultimo nello stato, ed Esc chiude il suggerimento (lo fa ancheexitSuggestion(key)).replaceSuggestion(content, key?)sostituisce il trigger e la query con testo o contenuto JSON.withMention(schema, { name?, char? })aggiunge il nodo mention di Tiptap: un atomo inline conid,labele il relativo trigger, scritto come@labelnel testo e come<span data-type="mention">in HTML.insertMention(range, id, label?)sostituisce l’intervallo di un suggerimento con una mention e uno spazio.
Il popup appartiene all’app: legge lo stato del suggerimento, si posiziona in coordsAtPos(range.from) e disegna gli
elementi a modo suo.
#Tabelle
TableKit offre le tabelle di Tiptap: RichTable (Table di Tiptap), RichTableRow, RichTableHeader e
RichTableCell. Viene aggiunto accanto a 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());
La tabella accetta le opzioni resizable (true), cellMinWidth (25), lastColumnResizable (true),
allowTableNodeSelection (false) e HTMLAttributes; TableKit.configure({ tableHeader: false }) esclude una
singola estensione. Le celle contengono blocchi e hanno colspan, rowspan e colwidth (una larghezza in punti per ciascuna colonna che
occupano); le righe hanno un height.
Tab e Maiusc-Tab si spostano tra le celle, e Tab nell’ultima cella aggiunge una riga. I tasti freccia al bordo di una cella passano alla
cella successiva; Maiusc con una freccia, o un trascinamento sulle celle, seleziona un rettangolo di celle (una selezione di tipo
"cell", i cui anchor e head sono le posizioni prima della prima e dell’ultima cella). Backspace e Canc
svuotano le celle selezionate ed eliminano la tabella quando tutte le sue celle sono selezionate.
Nell’editor una tabella è una griglia delle sue celle: ogni colonna è larga quanto il colwidth delle sue celle, mentre le colonne prive di tale valore
si dividono lo spazio (con una larghezza minima di cellMinWidth), e ogni riga è alta almeno quanto il suo height. Il suo aspetto parte dal tema: una
griglia border da 1 punto, celle di intestazione su muted, celle con padding, e ogni cella selezionata sotto la parte selectedCell nel
colore della selezione. classes.nodes (table, tableCell, tableHeader) e le parti ridefiniscono
lo stile di tutto questo. Su e Giù sulla prima o sull’ultima riga di una cella portano alla cella sopra o sotto.
Quando il documento è modificabile:
- Sui desktop, il bordo destro di ogni colonna e il bordo inferiore di ogni riga le ridimensionano: il puntatore mostra il
cursore di ridimensionamento e la linea
tableResizeHandle, la tabella segue il trascinamento, e il rilascio impostacolwidthoheightin un unico passaggio che l’annullamento ripristina.resizable: falsedisattiva questa funzione,lastColumnResizable: falsemantiene la larghezza dell’ultima colonna. - La tabella in cui si trova la selezione mostra dei grip (
tableGrip) sul bordo superiore sopra ogni colonna e sul bordo sinistro accanto a ogni riga. Premendone uno si seleziona quella colonna o riga; trascinandolo sui desktop si mostra dove verrà rilasciata (tableDropIndicator) e al rilascio la si sposta lì.
<RichEditor
editor={editor}
classes={{
nodes: { tableHeader: "font-semibold", tableCell: "px-3 py-2" },
selectedCell: "bg-primary/10",
tableGrip: "bg-muted-foreground/60 rounded-full",
}}
/>
I comandi, ciascuno un helper di nsh/base:
insertTable({ rows, cols, withHeaderRow })(3 × 3 con una riga di intestazione per impostazione predefinita) edeleteTable().addColumnBefore(),addColumnAfter(),deleteColumn(),addRowBefore(),addRowAfter(),deleteRow().mergeCells(),splitCell()emergeOrSplit().toggleHeaderRow(),toggleHeaderColumn()etoggleHeaderCell().setCellAttribute(name, value),setCellSelection({ anchorCell, headCell }),goToNextCell(),goToPreviousCell()efixTables(), che ripara le tabelle con celle mancanti o sovrapposte.moveTableRow({ from, to, select })emoveTableColumn({ from, to, select })spostano una riga o una colonna, insieme alle righe o alle colonne occupate dalle sue celle unite;setColumnWidth({ col, width })esetRowHeight({ row, height })le dimensionano (nullle dimensiona in base al contenuto). Accettanotable, la posizione prima di una tabella, per agire su una tabella diversa da quella della selezione.
Le tabelle vengono lette e scritte in HTML (<table> con <th> / <td>, colspan, rowspan e data-colwidth) e
in Markdown: una tabella con una riga di intestazione le cui celle contengono ciascuna un solo paragrafo è una tabella GFM a pipe, e ogni altra tabella viene
scritta come HTML, che viene riletto in modo identico. Nel testo semplice una riga è una linea delle sue celle separate da tabulazioni.