Tucano v0.37.2

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 original

La 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.

NombreBotónQué hace
bold italic underlineNegrita, Cursiva, SubrayadoMarca el fragmento; Ctrl/Cmd+B, +I, +U
title subheadingTítulo, SubtítuloConvierte el bloque en <h2> o <h3>; de nuevo, vuelve a párrafo
list numberedLista, Lista numerada<ul> y <ol>
left center right justifyAlineacionesAlinea el bloque
quoteCita<blockquote>; de nuevo, vuelve a párrafo
codeCódigoCódigo en medio de la frase o bloque de código — ver abajo
linkEnlaceAbre la caja de dirección; Ctrl/Cmd+K
tableInsertar tablaTabla con encabezado en la posición del cursor
clearLimpiar formatoQuita 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ónNombre en inTable()Detalle
Insertar fila arriba / abajorowAbove rowBelowDesde el encabezado, la fila nueva va al cuerpo
Insertar columna a la izquierda / a la derechacolBefore colAfterLa celda nueva es th en el encabezado y td en el cuerpo
Eliminar filadeleteRowEn la última fila, desaparece la tabla entera, en lugar de quedar el marco
Eliminar columnadeleteColumnÍdem, en la última columna
Eliminar tabladeleteTable

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.

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é haceCómo
Abrir la lista desde la barraEl botón { }, que solo existe cuando hay variables
Abrir mientras escribesEscribe {; las letras siguientes filtran, y el foco no sale del texto
ElegirClic, o ↓ y Enter. La { escrita se va con ella
DesistirEsc cierra la lista y te deja escribiendo
Encontrar una errataeditor.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.

EntradaSalida
p br h2 h3 strong em u s ul ol li blockquote code pre a table thead tbody tr th tdTal como están, sin atributos
b iSe convierten en strong y em
href de aSolo con un destino aceptable; si no, el enlace desaparece y el texto queda
text-alignSolo 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 objectDesaparecen 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>
El mismo contenido, ya publicado

Alcance del contrato

Relevamiento en terreno con informe fotográfico y medición de las unidades.

EtapaPlazoImporte
Inspección5 días2.400 €
Medición10 días5.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.

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 camino

Teclado 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.

TeclaAcción
Ctrl/Cmd+B +I +UNegrita, cursiva, subrayado
Ctrl/Cmd+KInsertar o editar enlace
Ctrl/Cmd+ZDeshace, con el historial del navegador
Tab Shift+TabEn una tabla, celda siguiente y anterior; en la última, crea una fila
EnterEn la caja de enlace, confirma
EscEn 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.

Marcado
[data-tuc-editor]
En JS
new Tucano.Editor(alvo, opcoes)
Atributos
data-min-height data-placeholder
Métodos
inTable apply openVariables insertVariable unknownVariables getValue setValue destroy

Opciones

Las notas de esta tabla salen de los comentarios del código fuente, que están escritos en portugués.

OpciónPor defectoPara qué
toolbar['bold', 'italic', 'underline', 'title', 'subheading'
table{ rows: 3, cols: 3 }
minHeight'9rem'
placeholder''
variablesnull