Editor de texto
Muestra el resultado mientras se escribe: quien completa una descripción en el sistema ve la negrita como
negrita, y no marcado. Con tabla y bloque de código. El <textarea> original sigue en el
formulario guardando el HTML, así que name, required y el POST funcionan sin cambiar la view.
Ejemplos
El mismo <textarea>, con atributos u opciones distintos.
Barra reducida
toolbar elige los botones y el orden — aquí, solo lo que necesita un comentario.
Vacío, más bajo
data-placeholder y data-min-height="5rem".
Con error
aria-invalid="true" en el textarea pinta el marco — Django 5 ya escribe el atributo.
Escribe la justificación.
El filtro, en vivo
Tucano.sanitize() sobre un HTML con onerror, javascript: y <script>.
Cómo usar
Marca el <textarea> y se inicializa solo, incluso lo que llegue después por HTMX.
El contenido inicial es el HTML guardado, y también pasa por el filtro — puede haber venido de la base de datos.
<textarea name="description" data-tuc-editor>{{ form.description.value|default:"" }}</textarea>En el formulario de Django
class ProjectForm(forms.ModelForm):
class Meta:
model = Project
fields = ["description"]
widgets = {
"description": forms.Textarea(attrs={
"data-tuc-editor": "",
"data-placeholder": "Describe el alcance...",
"data-min-height": "12rem",
}),
}En JavaScript
Para elegir la barra y el tamaño de la tabla, que no existen como atributo, o para leer y escribir el valor.
const ed = new Tucano.Editor('#description', {
toolbar: ['bold', 'italic', 'list', 'link', 'table'],
table: { rows: 4, cols: 2 }, // filas contando el encabezado
});
ed.getValue(); // HTML ya filtrado; '' si está vacío
ed.setValue('<p>Texto nuevo</p>'); // filtra, colorea el código y actualiza el textarea
ed.apply('bold'); // lo mismo que el botón de la barra
ed.inTable('rowBelow'); // operación en la celda donde está el cursor
ed.destroy(); // devuelve el textarea originalLa barra
Un botón encendido indica que el cursor está dentro de ese formato — y que hacer clic de nuevo lo deshace. Los nombres
de abajo son los que acepta la opción toolbar, en el orden por defecto.
| Nombre | Botón | Qué hace |
|---|---|---|
bold italic underline | Negrita, Cursiva, Subrayado | Marca el fragmento; Ctrl/Cmd+B, +I, +U |
title subheading | Título, Subtítulo | Convierte el bloque en <h2> o <h3>; de nuevo, vuelve a párrafo |
list numbered | Lista, Lista numerada | <ul> y <ol> |
left center right justify | Alineaciones | Alinea el bloque |
quote | Cita | <blockquote>; de nuevo, vuelve a párrafo |
code | Código | Código en medio de la frase o bloque de código — ver abajo |
link | Enlace | Abre la caja de dirección; Ctrl/Cmd+K |
table | Insertar tabla | Tabla con encabezado en la posición del cursor |
clear | Limpiar formato | Quita el formato de texto del fragmento seleccionado |
La barra queda en una sola línea y se desplaza cuando no cabe, en lugar de partirse en dos: así la altura no cambia y quien
busca un botón sabe que no cambió de fila. Los nombres de los botones están en portugués por defecto; Tucano.setTexts({ editor }) los reemplaza.
Pegar y soltar siempre entran como texto plano
Es lo que evita el HTML de Word y de Google Docs, y el de otra página arrastrado adentro, con tablas de maquetación y estilos incrustados, que es donde un editor casero se rompe primero. Pierdes el formato de origen y ganas un documento que sigue siendo tuyo.
El Ctrl+Z es el del navegador
El formato pasa por execCommand, que está obsoleto y se usa igual: es el único camino con soporte universal y el único que se integra con el deshacer nativo. Reimplementarlo a mano sería reimplementar el Ctrl+Z también. La excepción es quitar un bloque de código, que sale del historial solo en esa acción.
Tabla
El botón inserta una tabla con encabezado — una tabla de sistema casi siempre tiene uno, y sin él la primera fila de datos termina haciendo de título. Con el cursor en una celda, aparece una segunda barra solo para ella.
| Operación | Nombre en inTable() | Detalle |
|---|---|---|
| Insertar fila arriba / abajo | rowAbove rowBelow | Desde el encabezado, la fila nueva va al cuerpo |
| Insertar columna a la izquierda / a la derecha | colBefore colAfter | La celda nueva es th en el encabezado y td en el cuerpo |
| Eliminar fila | deleteRow | En la última fila, desaparece la tabla entera, en lugar de quedar el marco |
| Eliminar columna | deleteColumn | Ídem, en la última columna |
| Eliminar tabla | deleteTable |
En la tabla, Tab avanza a la celda siguiente y Shift+Tab vuelve; en la última celda, Tab
crea una fila nueva — se puede completar sin quitar las manos del teclado. Insertar una tabla con el cursor dentro de
otra pone la nueva después de ella, y no anidada. La barra de tabla solo aparece cuando hace falta: siempre visible,
llenaría la barra principal de botones inútiles la mayor parte del tiempo.
Fuera del alcance
Combinar celdas y redimensionar columnas — es lo que convierte un editor en un proyecto aparte. Las columnas tienen ancho fijo a propósito: con ancho automático, el texto redimensionaría la columna con cada tecla y toda la fila bailaría.
Bloque de código
El mismo botón hace las dos cosas, según lo seleccionado: un fragmento dentro de una línea se vuelve
<code> en medio de la frase; una selección que atraviesa líneas se vuelve <pre><code>,
que es el elemento que conserva saltos de línea y sangría. Con el cursor dentro, el botón lo deshace.
El bloque se colorea mientras se escribe — comentarios, texto entre comillas, números, etiquetas, atributos, palabras clave y
llaves de template. El color es solo visual: el filtro disuelve <span>, así que nada de él llega al valor
guardado, ni debería, porque el color es decisión de quien muestra el contenido.
Enlace
La dirección se pide en un modal de la propia biblioteca, y no en el prompt del navegador — que
aparece fuera del diseño de la página, ignora el tema y no se puede estilizar.
Selecciona el texto y usa el botón o Ctrl/Cmd+K. Enter en el campo confirma. Con el cursor
en un enlace existente, la caja se abre con su dirección y suma "Remover" (quitar). Solo pasan direcciones que empiezan con
http:, https:, mailto:, tel:, # o / —
el resto, javascript: y //otro-sitio.com incluidos, pierde el enlace y conserva el texto.
Una dirección escrita sin esquema, como ejemplo.com, recibe https:// adelante. Todo enlace guardado sale con
target="_blank" y rel="noopener noreferrer".
Variables
Un texto que se vuelve mensaje para cada persona tiene partes que cambian: quién lo recibe, el plazo,
el título de la tarea. Declara esas variables y el editor gana un botón en la barra y una lista al escribir
{ — nadie tiene que memorizar nombres ni cerrar llaves a mano.
Pulsa el { } de la barra, o escribe { en el texto y empieza a escribir el nombre.
new Tucano.Editor('#message', {
variables: [
{ name: 'nome', label: 'Nombre del responsable', example: 'Junior' },
{ name: 'tarefa', label: 'Título de la tarea', example: 'Grabar el vídeo' },
{ name: 'prazo', label: 'Plazo', example: '21/09' },
],
});Lo que entra en el texto es {{nome}}, texto puro: tu plantilla en el servidor sigue cambiándolo por los
datos como ya hacía, y copiar, pegar y deshacer siguen funcionando. El label es lo que aparece en la
lista; el example queda guardado para la vista previa que arma tu proyecto.
| Qué hace | Cómo |
|---|---|
| Abrir la lista desde la barra | El botón { }, que solo existe cuando hay variables |
| Abrir mientras escribes | Escribe {; las letras siguientes filtran, y el foco no sale del texto |
| Elegir | Clic, o ↓ y Enter. La { escrita se va con ella |
| Desistir | Esc cierra la lista y te deja escribiendo |
| Encontrar una errata | editor.unknownVariables() devuelve lo que está en el texto y no en la lista |
En el texto, la variable gana un fondo suave para distinguirse del resto, y la que no está en la lista sale en el tono de error — el aviso aparece donde está el error. Es el navegador pintando sobre el tramo, no marcación en el contenido: el valor guardado sigue siendo exactamente lo que escribiste. En un navegador sin esa pintura (Safari por debajo de 17.2, Firefox por debajo de 140) el texto aparece sin fondo, y nada más cambia.
Dentro de un bloque de código la lista no se abre, y lo que esté escrito ahí queda fuera de
unknownVariables(): ahí se escribe código — incluido el {{ nome }} de una plantilla, como
ejemplo — y una lista saltando en cada llave estorbaría.
Sin variables no cambia nada: no hay botón, y { sigue siendo solo una llave.
Valor y formulario
Quien guarda el valor es el <textarea>, oculto dentro del editor. Con cada cambio
recibe el HTML filtrado y dispara los eventos nativos input y change.
POST description = <h2>Alcance del contrato</h2><p>Relevamiento con <strong>informe</strong>.</p>document.querySelector('#description').addEventListener('change', (e) => {
e.target.value; // el HTML que va en el POST
});El editor no dispara tucano:change: el valor es texto de formulario, y los eventos nativos del textarea ya
sirven al hx-trigger="change" de HTMX y a la validación.
Un editor vacío vale vacío: el textarea recibe '', y no un párrafo en blanco — así required
bloquea el envío, y el aviso del navegador lleva el foco al área. El reset del formulario devuelve el
editor al contenido original.
El filtro
La salida pasa por una lista cerrada de etiquetas en cada lectura, y no solo en lo que se escribió: el navegador tiene libertad para marcar como quiera al ejecutar un comando, y el resultado tiene que caber en lo que el editor promete.
| Entrada | Salida |
|---|---|
p br h2 h3 strong em u s ul ol li blockquote code pre a table thead tbody tr th td | Tal como están, sin atributos |
b i | Se convierten en strong y em |
href de a | Solo con un destino aceptable; si no, el enlace desaparece y el texto queda |
text-align | Solo left, center, right o justify, en p, h2, h3, li, blockquote, th y td — reescrito desde cero, nunca el style que vino |
script style iframe object | Desaparecen con su contenido |
Cualquier otra etiqueta (div, span, font, h1...) | Pierde la etiqueta y conserva el texto |
Cualquier otro atributo (onclick, onerror, class...) | Desaparece |
Tucano.sanitize('<p onclick="x()">Hola <img src=x onerror="steal()"><b>tú</b></p>');
// '<p>Hola <strong>tú</strong></p>'Sanitiza de nuevo en el servidor, antes de publicar
El filtro protege el editor, no la publicación. El HTML llega por POST, y nadie garantiza que vino de este editor — cualquiera arma la petición a mano. Pasa el valor por un sanitizador del lado del servidor, con la misma lista de etiquetas, antes de mostrarlo con |safe.
import nh3
TAGS = {"p", "br", "h2", "h3", "strong", "em", "u", "s", "ul", "ol", "li",
"blockquote", "code", "pre", "a", "table", "thead", "tbody", "tr", "th", "td"}
def sanitize(html):
return nh3.clean(html, tags=TAGS, attributes={"a": {"href"}},
url_schemes={"http", "https", "mailto", "tel"})Un ejemplo con nh3; cualquier sanitizador con lista cerrada sirve. Para conservar la alineación, permite también el style con solo text-align en los bloques.
Mostrar lo guardado
Lo que se guarda es HTML sin ninguna clase — a propósito, para servir a cualquier servidor y sobrevivir a
un cambio de front. El precio es que, publicado, heredaría el estilo de tu página. Envolver la salida en
.tuc-prose devuelve exactamente el aspecto que la persona vio al escribir.
<div class="tuc-prose">{{ project.description|safe }}</div>Alcance del contrato
Relevamiento en terreno con informe fotográfico y medición de las unidades.
| Etapa | Plazo | Importe |
|---|---|---|
| Inspección | 5 días | 2.400 € |
| Medición | 10 días | 5.800 € |
npm install tucano
npm run build # genera el dist
El plazo corre desde la firma.
Los dos aspectos no pueden divergir
Las reglas de .tuc-prose son las mismas del área de edición, compartidas en el CSS. También se defienden del CSS de quien las aloja: table { display: block } y th { text-transform: uppercase } son recetas comunes en proyectos, y heredadas aquí desarmarían la tabla y mentirían sobre lo que se escribió.
Una tabla con muchas columnas no aprieta el texto: cada columna tiene un ancho mínimo, y la tabla que supera el ancho
disponible se desplaza en horizontal, por sí sola, tanto en el editor como en .tuc-prose. La caja que se
desplaza es solo visual — el init() la pone alrededor de la tabla, y el HTML guardado sigue sin ella.
Código coloreado y copiar
El init() colorea cada <pre><code> dentro de .tuc-prose y pone un botón de
copiar en la esquina — incluso en lo que llegue después por HTMX. Pasa el ratón sobre el bloque de arriba para verlo.
- El botón aparece al pasar el ratón y con el foco: permanente, competiría con el código, que es lo que la persona vino a leer; pero sigue alcanzable con
Tab, para quien navega con teclado. En pantallas táctiles queda siempre visible, más discreto. - Copia el texto crudo, sin las marcas de color. Fuera de un contexto seguro, donde el portapapeles no existe, selecciona el bloque y deja el
Ctrl+Ca una tecla. - El resaltador no conoce ningún lenguaje, a propósito: reconoce lo que aparece en casi todos, lo que cubre cualquier lenguaje por 2 KB. Los colores son los tokens
--tuc-tok-*, con paleta propia en el modo oscuro.
Tucano.highlight('const x = 1;'); // HTML con <span class="tuc-tok-..."> para colorear a mano
Tucano.init(fragment); // colorea y pone el copiar en HTML insertado por otro caminoTeclado y accesibilidad
El área es un role="textbox" con aria-multiline; la barra es un
role="toolbar", y cada botón tiene nombre y aria-pressed que acompaña el formato bajo el cursor.
| Tecla | Acción |
|---|---|
Ctrl/Cmd+B +I +U | Negrita, cursiva, subrayado |
Ctrl/Cmd+K | Insertar o editar enlace |
Ctrl/Cmd+Z | Deshace, con el historial del navegador |
Tab Shift+Tab | En una tabla, celda siguiente y anterior; en la última, crea una fila |
Enter | En la caja de enlace, confirma |
Esc | En la caja de enlace, cierra sin aplicar |
Fuera de una tabla, Tab sale del editor, como en cualquier campo. Aplicar formato no desplaza la página:
el foco vuelve al área sin hacer scroll.
API
Generada a partir del código en cada build — si algo no está aquí, no existe.
[data-tuc-editor]new Tucano.Editor(alvo, opcoes)data-min-height data-placeholderinTable apply openVariables insertVariable unknownVariables getValue setValue destroyOpciones
Las notas de esta tabla salen de los comentarios del código fuente, que están escritos en portugués.
| Opción | Por defecto | Para qué |
|---|---|---|
toolbar | ['bold', 'italic', 'underline', 'title', 'subheading' | |
table | { rows: 3, cols: 3 } | |
minHeight | '9rem' | |
placeholder | '' | |
variables | null |