Elementos
RichEditor
RichEditor es un editor de texto enriquecido construido según la arquitectura de ProseMirror. La app define de qué se compone un documento mediante un esquema (párrafos, encabezados, listas, menciones, los tipos de nodo y de marca que necesite), y el editor mantiene cada documento válido respecto a él. Los cambios son pasos agrupados en transacciones, de modo que se pueden deshacer, mapean posiciones y viajan como JSON; el comportamiento procede de plugins (historial, keymaps, reglas de entrada, atajos de Markdown, los propios de la app); los comandos son valores que una barra de herramientas ejecuta y consulta. El estado del editor, sus comandos y sus plugins se ejecutan en Rust: la escritura, los atajos de teclado y las reglas de entrada nunca esperan a JS, y la app lee el estado como JSON cuando lo pide.
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 tiene dos partes: la sesión (createRichEditor), que posee el estado y que el código de la app maneja, y la vista (<RichEditor editor={…} />), que renderiza la sesión de forma nativa y convierte la entrada de la plataforma en transacciones. Varias vistas pueden mostrar una misma sesión.
El núcleo del editor es un port a Rust de ProseMirror de Marijn Haverbeke, y su capa de extensiones sigue a Tiptap: sus estructuras de datos, comandos y nombres se mantienen, y también lo que ya sabes de ellos. Ambos tienen licencia MIT, y sus licencias se aplican a lo que se ha portado de ellos.
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);
Sin schema un editor usa el esquema inicial (starterSchema()); sin plugins obtiene el kit inicial (starterKit(): las reglas de entrada y los atajos de teclado del esquema inicial, los atajos de teclado base y el historial). Un array plugins vacío crea un editor sin ningún plugin.
#Estilos
El editor dibuja el documento con el aspecto de la app: classes y styles dan estilo a sus partes (root, content, caret, selection, placeholder, composition, selectedNode, listMarker y, para las tablas, selectedCell, tableGrip, tableResizeHandle, tableDropIndicator), a sus nodos por tipo (nodes: { heading: "text-2xl font-bold", "heading[level=2]": "text-xl" }) and its marks (marks: { bold: "font-bold" }). Los elementos de las listas cuya especificación de nodo establece extra.listMarker en "bullet" o "ordered" (las listas del esquema inicial) muestran un marcador en la parte listMarker: •, ◦, ▪ según la profundidad, o números a partir del start de la lista, colocado antes del elemento, en la sangría de la lista (pl-6 en la lista, como un navegador sangra 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 es la caja del documento dentro de la vista con scroll del editor: su padding (content: "px-6 py-5") es el espacio alrededor del texto, y la barra de desplazamiento recorre el borde del propio editor. Un arrastre que selecciona más allá del borde superior o inferior del editor desplaza el documento, más rápido cuanto más se aleja el puntero.
#Bubble menu
bubbleMenu es el bubble menu de Tiptap: el contenido de la app se muestra junto a la selección, por encima de ella por defecto (bubbleMenuPlacement, pasando al otro lado cuando no hay espacio) a bubbleMenuOffset puntos (8), en un overlay que sigue al texto mientras se desplaza. La app lo pasa mientras quiere que se muestre, a partir del estado de la sesión:
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 menú de enlace edita el enlace bajo el cursor con chain([extendMarkRange("link"), command("setLink", { href })]) y lo elimina con command("unsetLink") tras el mismo extendMarkRange.
#Esquemas
defineSchema(spec) lee un esquema con la forma de ProseMirror y lanza un error indicando qué es lo incorrecto. Los tipos de nodo indican su content (una expresión de contenido: "inline*", "paragraph+", "(heading | paragraph) block*"), su group, si son inline o un atom, las marks que permiten y sus attrs con valores por defecto y validación ("string", "number", "boolean"). El primer tipo de bloque es el predeterminado: lo que crea Enter y lo que rellena el contenido requerido. Las claves que una especificación no conoce se conservan como datos adicionales del tipo, para los frameworks construidos sobre el 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 lee y escribe documentos: parse(content, format?) (texto plano por defecto, "markdown" o JSON), parseMarkdown(markdown), serialize(doc, format?) (Markdown por defecto, "text", "json") y check(doc), que devuelve el documento con los valores por defecto de sus atributos rellenados. nodes, marks y topNode nombran sus tipos. Markdown se corresponde con los nombres del esquema inicial (paragraph, heading, blockquote, codeBlock, bulletList, orderedList, listItem, bold, italic, code, link, strike).
#Comandos
Un comando es un valor: toggleMark("bold") crea un objeto que la app conserva, asocia a teclas y pasa al editor. editor.run(command) lo ejecuta y devuelve si se aplicó; editor.can(command) consulta sin cambiar nada, que es como una barra de herramientas habilita sus botones. editor.isActive(name, attrs?) indica si una marca o un nodo está activo en la selección (isActive("heading", { level: 2 })).
| Comando | Qué hace |
|---|---|
toggleMark(name, attrs?) |
Añade la marca a la selección, o la quita donde ya está; en un cursor, para el texto que se escriba a continuación |
setMark(name, attrs?) / unsetMark(name) |
Añade o quita una marca |
setBlockType(name, attrs?) |
Convierte los bloques de texto seleccionados en uno de un tipo (setBlockType("heading", { level: 2 })) |
wrapIn(name, attrs?) / lift() |
Envuelve los bloques seleccionados en un nodo (una cita) / los saca de su padre |
wrapInList(name, attrs?) |
Envuelve los bloques seleccionados en una lista |
splitListItem(name) / sinkListItem(name) / liftListItem(name) |
Divide, sangra o reduce la sangría de elementos de lista |
undo() / redo() |
Deshace o rehace un cambio (con history()) |
selectAll(), setTextSelection(anchor, head?), setNodeSelection(pos) |
Selecciona |
insertText(text), insertContent(content, format?) |
Inserta texto o contenido sobre la selección |
setNodeAttribute(pos, attr, value) |
Establece un atributo del nodo en una posición, como un único cambio que se puede deshacer |
command(name) |
Un comando sin parámetros, por nombre: "splitBlock", "joinBackward", "exitCode", "newlineInCode", "selectParentNode", … |
chain(commands) |
Ejecuta comandos uno tras otro como una sola transacción (un paso de deshacer); se aplica solo cuando todos se aplican |
first(commands) |
Ejecuta el primer comando que se aplique |
editor.run(chain([setBlockType("heading", { level: 1 }), toggleMark("bold")]));
const canIndent = editor.can(sinkListItem("listItem"));
#La sesión
| Miembro | Qué es |
|---|---|
state, doc, selection |
El estado, el documento y la selección como JSON, generados al leerlos |
revision |
Cuántas transacciones ha aplicado el estado |
isEmpty, undoDepth, redoDepth, composing |
Lo que una UI muestra sobre el editor |
setContent(content, format?) |
Reemplaza el documento, como un único cambio que se puede deshacer |
insertContent(content, format?) |
Inserta contenido sobre la selección |
getJSON(), getMarkdown(), getText(), textBetween(from?, to?, separator?) |
El documento en un formato |
dispatch(tr) |
Aplica una transacción dada como JSON (sus pasos contra el documento actual) |
on(event, listener) |
Llama a listener con la carga de cada event, en el siguiente turno de JS: revision ({ revision }, tras cada cambio, de cualquier origen), update, selectionUpdate, transaction, focus, blur, delete. Devuelve una función que lo detiene; un listener añadido mientras un componente se renderiza se detiene junto con el componente |
focus(), blur() |
Mueve el foco del teclado hacia las vistas que muestran el editor, o fuera de ellas |
Las posiciones se cuentan como en ProseMirror: un texto cuenta sus unidades de código UTF-16, un nodo hoja uno, un nodo con contenido sus dos límites. Cualquier cosa que deba sobrevivir a las ediciones conserva un id en los atributos de un nodo o en una marca. Una consulta de posición (resolve(pos), nodeAt(pos), coordsAtPos(pos), findParentNode(name, pos), …) lanza un RangeError para una posición fuera del documento (0 a doc.content.size), como hace resolve de ProseMirror, y responde null donde no hay nada (nodeAt al final de un nodo, coordsAtPos si ninguna vista muestra el editor). run y can lanzan un TypeError para un comando, tipo de nodo o tipo de marca que el editor no conoce.
#Tomar el control de las transacciones
editor.dispatchTransaction = (tr) => { … } hace que la app confirme cada transacción por sí misma: el handler recibe cada una como JSON y la confirma con editor.applyTransaction(tr), o la descarta. La colaboración, el registro y las transacciones que deben pasar las comprobaciones de la app lo usan; null devuelve la confirmación al editor. El handler oye la escritura en el siguiente turno de JS, y el documento cambia cuando confirma.
#Extensiones
Las extensiones son las de Tiptap: cada una aporta, junto, su tipo de nodo o de marca, sus atajos de teclado, reglas de entrada y de pegado, plugins y comandos con nombre, y un editor creado a partir de extensiones obtiene de ellas su esquema, su keymap, sus reglas y su tabla de comandos. Las integradas son clases con los nombres de Tiptap, que se usan tal cual o configuradas:
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 aporta Document, Paragraph, el nodo de texto, Heading, Blockquote, BulletList, OrderedList, ListItem, TaskList, TaskItem, CodeBlock, HardBreak, HorizontalRule, RichImage (Image de Tiptap), Bold, Italic, Strike, Code, Underline, RichLink (Link de Tiptap), Highlight, Subscript, Superscript, RichTextStyle (TextStyle de Tiptap), TextAlign, History, TrailingNode, Typography y ListKeymap. Una opción con el nombre de una de ellas establecida en false la deja fuera, y un objeto la configura (heading: { levels }, history: { depth }, textAlign: { types }, codeBlock: { enableTabIndentation }). Los configure(options) y extend({...}) de cada clase crean un RichExtension, que tiene name, type, priority y options. Todo editor obtiene además el keymap del núcleo (Enter, Backspace, Delete, seleccionar todo).
Cada extensión añade los comandos de Tiptap, que command(name, args) ejecuta: toggleBold, setItalic, unsetStrike, toggleHeading({ level }), setParagraph, toggleBlockquote, toggleBulletList, toggleOrderedList, toggleTaskList, toggleCodeBlock({ language }), setHardBreak, setHorizontalRule, setImage({ src }), toggleHighlight({ color }), setTextAlign("center"), unsetTextAlign, y los comandos de enlace, de recuento de caracteres y de sugerencias que se describen más abajo.
Las alternancias de lista actúan sobre listas completas, como los botones de lista de un procesador de texto: toggleOrderedList en una lista con viñetas la numera en todos los niveles, incluidas las listas anidadas, y la alternancia propia de una lista saca los bloques seleccionados de todas las listas que los rodean.
El orden de extensions es su prioridad entre iguales: una extensión listada más tarde asocia sus teclas antes que las anteriores; priority (100 por defecto) las ordena de otro modo. Con extensions, los plugins de un editor van después de los plugins de las extensiones.
#Las extensiones de la app
defineExtension crea una. Sus funciones se ejecutan una vez, cuando se crea un editor con ella, con las opciones de la extensión, y devuelven datos: comandos, reglas y plugins tal como los crean las factorías anteriores.
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 (en la forma de defineSchema) la convierte en una extensión de nodo o de marca; addGlobalAttributes añade atributos a otros tipos; addProseMirrorPlugins devuelve plugins, incluidos los RichEditorPlugin; addPasteRules devuelve reglas textRule y markRule para el texto pegado. El extend({...}) de una clase integrada reemplaza las partes que aporta: Underline.extend({ addKeyboardShortcuts: () => ({ "Mod-Alt-u": toggleMark("underline") }) }).
El crate de una app registra las extensiones escritas en Rust mediante su conjunto de UI, y JS las toma por nombre:
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) también encuentra cada extensión integrada por su nombre ("text", "bold", …).
#Plugins
| Factoría | Qué añade |
|---|---|
history({ depth?, newGroupDelay? }) |
Historial de deshacer; los cambios dentro de newGroupDelay milisegundos (500) se deshacen juntos |
keymap(bindings) |
Comandos por nombre de tecla: "Mod-b" (Cmd en las plataformas de Apple, Ctrl en las demás), "Shift-Enter", "Alt-ArrowUp" |
baseKeymap() |
Enter, Backspace, Delete, seleccionar todo y las teclas de edición de texto de la plataforma |
starterKeymap() |
Los atajos del esquema inicial: marcas, encabezados, listas, bloques de código, saltos de línea |
inputRules(rules) |
El texto escrito antes del cursor que coincide con una regla se convierte en lo que la regla produce; Backspace justo después lo deshace |
markdown(options?) |
Markdown mientras se escribe, pegado de Markdown y copia de Markdown (blockRules, inlineRules, paste, copyAsMarkdown) |
starterKit() |
Las reglas de entrada y los atajos del esquema inicial, los atajos base y el historial |
El lugar de un plugin en la lista es su prioridad: los keymaps y las reglas de entrada que están antes en la lista gestionan primero una tecla o un texto. Las reglas de entrada provienen de markdownRules() (encabezados, citas, listas, bloques de código delimitados, reglas, las marcas comunes), typographyRules() (comillas tipográficas, puntos suspensivos, raya), textRule(match, replacement), markRule(match, mark), wrappingRule(match, node) y textblockRule(match, node). El match de una regla es una expresión regular que termina en $, y se compara con el texto anterior al cursor.
#Los plugins de la app
new RichEditorPlugin(key) crea un plugin cuyo comportamiento es el de la app, establecido con llamadas encadenadas:
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): mientrascapturinges verdadero, el editor consume estas teclas e informa ahandlerde cuál se pulsó, justo después; el teclado sigue siendo tan rápido como sin plugin.editor.setDecorations(key, decorations): atributos en línea, atributos de nodo y widgets que la vista dibuja sobre el documento sin cambiarlo. Siguen a cada cambio hasta que se establecen de nuevo.filterTransaction(hook)yappendTransaction(hook)ven las transacciones que generan las propias llamadas de la app (run,dispatch,setContent, …) en esa llamada:filterTransactionpuede descartar una,appendTransactionpuede devolver una más para aplicarla junto con ella.on("revision", …)oye cada cambio, incluida la escritura.
#Enlaces, recuento de caracteres y sugerencias
Tres plugins incorporan el comportamiento de Link, CharacterCount y Suggestion de Tiptap, todo de forma nativa:
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?): una URL escrita antes de un espacio o de Enter se convierte en enlace, igual que las URLs del texto pegado (autolink); una URL pegada sobre una selección enlaza la selección (linkOnPaste); una pulsación sobre un enlace con Mod mantenida, o mientras el editor es de solo lectura, lo abre mediante el manejo de URLs de la aplicación (openOnClick);enableClickSelectionselecciona el enlace completo al pulsar. Las URLs se detectan con o sin protocolo (nsh.dev,https://nsh.dev/docs, direcciones de correo electrónico), y el protocolo que recibe un host sin protocolo esdefaultProtocol(http). Los enlaces admiten los protocoloshttp,https,ftp,ftps,mailto,tel,callto,sms,cid,xmppy los deprotocols, de modo quesetLink({ href }),toggleLink(attrs?)y el autoenlazado excluyen los scripts (javascript:) y las URLs de datos.unsetLink()elimina el enlace alrededor de la selección.characterCount(options?):editor.characters(mode?)cuenta los caracteres del texto ("textSize", unidades UTF-16 como ellengthde JS) o las posiciones del documento ("nodeSize");editor.words()cuenta sus palabras (límites de palabra Unicode). Conlimit, los cambios que lo superarían se rechazan y el contenido pegado se recorta a lo que cabe; un primer documento que supere el límite se recorta (autoTrim).setCharacterLimit(limit)cambia el límite yeditor.characterLimitlo lee.suggestion(options?): sigue un disparador (char,@por defecto) escrito antes del cursor.allowSpacespermite que la consulta contenga espacios,allowedPrefixesenumera lo que puede preceder al disparador (un espacio por defecto;anyPrefixadmite cualquier cosa),startOfLinelo exige al inicio de la línea (un menú de comandos/).editor.suggestion(key)proporciona{ active, range, query, text, lastKey, keyCount }; el rango activo lleva eldecorationClassde la aplicación (ydecorationEmptyClassmientras la consulta está vacía). Mientras una sugerencia está activa, las teclas decaptureKeyspertenecen a la lista de la aplicación: el editor las captura y registra la última en el estado, y Escape descarta la sugerencia (exitSuggestion(key)también).replaceSuggestion(content, key?)reemplaza el disparador y la consulta con texto o contenido JSON.withMention(schema, { name?, char? })añade el nodo de mención de Tiptap: un átomo en línea conid,labely su disparador, escrito como@labelen texto y como<span data-type="mention">en HTML.insertMention(range, id, label?)reemplaza el rango de una sugerencia por una mención y un espacio.
El popup es de la aplicación: lee el estado de la sugerencia, se coloca en coordsAtPos(range.from) y dibuja los
elementos a su manera.
#Tablas
TableKit incorpora las tablas de Tiptap: RichTable (el Table de Tiptap), RichTableRow, RichTableHeader y
RichTableCell. Se añade junto 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 tabla acepta las opciones resizable (true), cellMinWidth (25), lastColumnResizable (true),
allowTableNodeSelection (false) y HTMLAttributes; TableKit.configure({ tableHeader: false }) deja fuera una
extensión. Las celdas contienen bloques y tienen colspan, rowspan y colwidth (un ancho en puntos por cada columna que
abarcan); las filas tienen un height.
Tab y Shift-Tab se mueven entre celdas, y Tab en la última celda añade una fila. Las teclas de flecha en el borde de una celda pasan a la
siguiente celda; Shift con una flecha, o arrastrar a través de celdas, selecciona un rectángulo de celdas (una selección del tipo
"cell", cuyos anchor y head son las posiciones anteriores a su primera y última celda). Retroceso y Supr vacían
las celdas seleccionadas, y eliminan la tabla cuando todas sus celdas están seleccionadas.
En el editor, una tabla es una cuadrícula de sus celdas: cada columna tiene el ancho del colwidth de sus celdas, y las columnas sin él
se reparten el espacio (con un ancho mínimo de cellMinWidth), y cada fila mide al menos su height. Su aspecto parte del tema: una
cuadrícula de border de 1 punto, celdas de encabezado sobre muted, celdas con relleno, y cada celda seleccionada bajo la parte selectedCell con
el color de selección. classes.nodes (table, tableCell, tableHeader) y las partes modifican
todo el estilo. Arriba y Abajo en la primera o la última línea de una celda llevan a la celda superior o inferior.
Mientras el documento se pueda editar:
- En escritorio, el borde derecho de cada columna y el borde inferior de cada fila permiten redimensionarlos: el puntero muestra el
cursor de redimensionado y la línea
tableResizeHandle, la tabla sigue el arrastre, y al soltar se establececolwidthoheighten un solo paso que deshacer revierte.resizable: falsedesactiva esto,lastColumnResizable: falseconserva el ancho de la última columna. - La tabla en la que está la selección muestra tiradores (
tableGrip) en su borde superior sobre cada columna y en su borde izquierdo junto a cada fila. Al pulsar uno se selecciona esa columna o fila; al arrastrarlo en escritorio se muestra dónde se soltará (tableDropIndicator) y la mueve allí al soltar.
<RichEditor
editor={editor}
classes={{
nodes: { tableHeader: "font-semibold", tableCell: "px-3 py-2" },
selectedCell: "bg-primary/10",
tableGrip: "bg-muted-foreground/60 rounded-full",
}}
/>
Los comandos, cada uno un helper de nsh/base:
insertTable({ rows, cols, withHeaderRow })(3 × 3 con una fila de encabezado por defecto) ydeleteTable().addColumnBefore(),addColumnAfter(),deleteColumn(),addRowBefore(),addRowAfter(),deleteRow().mergeCells(),splitCell()ymergeOrSplit().toggleHeaderRow(),toggleHeaderColumn()ytoggleHeaderCell().setCellAttribute(name, value),setCellSelection({ anchorCell, headCell }),goToNextCell(),goToPreviousCell()yfixTables(), que repara las tablas con celdas faltantes o superpuestas.moveTableRow({ from, to, select })ymoveTableColumn({ from, to, select })mueven una fila o columna, junto con las filas o columnas que abarcan sus celdas combinadas;setColumnWidth({ col, width })ysetRowHeight({ row, height })les dan tamaño (nulllas ajusta a su contenido). Estos aceptantable, la posición anterior a una tabla, para actuar sobre una tabla distinta de la de la selección.
Las tablas se leen y se escriben en HTML (<table> con <th> / <td>, colspan, rowspan y data-colwidth) y
en Markdown: una tabla con una fila de encabezado cuyas celdas son cada una un párrafo es una tabla de barras GFM, y cualquier otra tabla se
escribe como su HTML, que se vuelve a leer igual. En texto plano, una fila es una línea con sus celdas separadas por tabuladores.