Tucano v0.37.2

Editor de texto

Mostra o resultado enquanto se escreve: quem preenche uma descrição no sistema vê negrito em negrito, e não marcação. Com tabela e bloco de código. O <textarea> original continua no formulário guardando o HTML, então name, required e o POST funcionam sem mudar a view.

Exemplos

O mesmo <textarea>, com atributos ou opções diferentes.

Barra enxuta

toolbar escolhe os botões e a ordem — aqui, só o que um comentário precisa.

Vazio, mais baixo

data-placeholder e data-min-height="5rem".

Com erro

aria-invalid="true" no textarea pinta a moldura — o Django 5 já escreve o atributo.

Escreva a justificativa.

A peneira, ao vivo

Tucano.sanitize() sobre um HTML com onerror, javascript: e <script>.

Como usar

Marque o <textarea> e ele inicializa sozinho, inclusive o que chegar depois por HTMX. O conteúdo inicial é o HTML guardado, e também passa pela peneira — ele pode ter vindo do banco.

<textarea name="description" data-tuc-editor>{{ form.description.value|default:"" }}</textarea>

No formulário do Django

class ProjectForm(forms.ModelForm):
    class Meta:
        model = Project
        fields = ["description"]
        widgets = {
            "description": forms.Textarea(attrs={
                "data-tuc-editor": "",
                "data-placeholder": "Descreva o escopo...",
                "data-min-height": "12rem",
            }),
        }

Em JavaScript

Para escolher a barra e o tamanho da tabela, que não existem como atributo, ou para ler e escrever o valor.

const ed = new Tucano.Editor('#description', {
  toolbar: ['bold', 'italic', 'list', 'link', 'table'],
  table: { rows: 4, cols: 2 },     // linhas contando o cabeçalho
});

ed.getValue();                   // HTML já peneirado; '' se vazio
ed.setValue('<p>Texto novo</p>');  // peneira, pinta o código e atualiza o textarea
ed.apply('bold');                // o mesmo que o botão da barra
ed.inTable('rowBelow');          // operação na célula onde está o cursor
ed.destroy();                    // devolve o textarea original

A barra

Um botão aceso diz que o cursor está dentro daquela formatação — e que clicar de novo desfaz. Os nomes abaixo são os que a opção toolbar aceita, na ordem do padrão.

NomeBotãoO que faz
bold italic underlineNegrito, Itálico, SublinhadoMarca o trecho; Ctrl/Cmd+B, +I, +U
title subheadingTítulo, SubtítuloTransforma o bloco em <h2> ou <h3>; de novo, volta a parágrafo
list numberedLista, Lista numerada<ul> e <ol>
left center right justifyAlinhamentosAlinha o bloco
quoteCitação<blockquote>; de novo, volta a parágrafo
codeCódigoCódigo no meio da frase ou bloco de código — veja abaixo
linkLinkAbre a caixa de endereço; Ctrl/Cmd+K
tableInserir tabelaTabela com cabeçalho no lugar do cursor
clearLimpar formataçãoTira a formatação de texto do trecho selecionado

A barra fica numa linha só e rola quando não cabe, em vez de quebrar em duas: assim a altura não muda e quem procura um botão sabe que ele não trocou de fileira.

Colar e arrastar entram sempre como texto puro

É o que evita o HTML do Word e do Google Docs, e o de outra página arrastado para dentro, com tabelas de layout e estilos embutidos, que é onde editor caseiro quebra primeiro. Você perde a formatação da origem e ganha um documento que continua sendo o seu.

O Ctrl+Z é o do navegador

A formatação passa por execCommand, que está deprecado e é usado assim mesmo: é o único caminho com suporte universal e o único que se integra ao desfazer nativo. Reimplementar à mão seria reimplementar o Ctrl+Z junto. A exceção é tirar um bloco de código, que sai do histórico nessa ação só.

Tabela

O botão insere uma tabela com cabeçalho — tabela de sistema quase sempre tem um, e sem ele a primeira linha de dados acaba servindo de título. Com o cursor numa célula, aparece uma segunda barra só para ela.

OperaçãoNome em inTable()Detalhe
Inserir linha acima / abaixorowAbove rowBelowA partir do cabeçalho, a linha nova vai para o corpo
Inserir coluna à esquerda / à direitacolBefore colAfterCélula nova é th no cabeçalho e td no corpo
Excluir linhadeleteRowNa última linha, some a tabela inteira, em vez de sobrar a moldura
Excluir colunadeleteColumnIdem, na última coluna
Excluir tabeladeleteTable

Na tabela, Tab anda para a próxima célula e Shift+Tab volta; na última célula, Tab cria uma linha nova — dá para preencher sem tirar as mãos do teclado. Inserir tabela com o cursor dentro de outra põe a nova depois dela, e não aninhada. A barra de tabela só aparece quando faz falta: sempre visível, encheria a barra principal de botões inúteis na maior parte do tempo.

Fora do escopo

Mesclar células e redimensionar coluna — é o que faz editor virar projeto. As colunas têm largura fixa de propósito: com largura automática, o texto redimensionaria a coluna a cada tecla e a linha inteira dançaria.

Bloco de código

O mesmo botão faz as duas coisas, pelo que está selecionado: trecho dentro de uma linha vira <code> no meio da frase; seleção que atravessa linhas vira <pre><code>, que é o elemento que preserva quebra e recuo. Com o cursor dentro, o botão desfaz.

O bloco é colorido enquanto se escreve — comentário, texto entre aspas, número, tag, atributo, palavra-chave e chaves de template. A cor é só exibição: a peneira dissolve <span>, então nada dela chega ao valor salvo, e nem deveria, porque cor é decisão de quem exibe.

O endereço é pedido num modal da própria biblioteca, e não no prompt do navegador — que aparece fora do desenho da página, ignora o tema e não se estiliza.

Selecione o texto e use o botão ou Ctrl/Cmd+K. Enter no campo confirma. Com o cursor num link existente, a caixa abre com o endereço dele e ganha "Remover". Só passa endereço que começa com http:, https:, mailto:, tel:, # ou / — o resto, javascript: e //outro-site.com incluídos, perde o link e mantém o texto. Endereço digitado sem esquema, como exemplo.com, ganha https:// na frente. Todo link salvo sai com target="_blank" e rel="noopener noreferrer".

Variáveis

Um texto que vira mensagem para cada pessoa tem trechos que mudam: o nome de quem recebe, o prazo, o título da tarefa. Declare essas variáveis e o editor ganha um botão na barra e uma lista ao digitar { — ninguém precisa decorar nome nem fechar chaves na mão.

Clique no { } da barra, ou digite { no texto e comece a escrever o nome.

new Tucano.Editor('#message', {
  variables: [
    { name: 'nome',   label: 'Nome do responsável', example: 'Junior' },
    { name: 'tarefa', label: 'Título da tarefa',    example: 'Gravar o vídeo' },
    { name: 'prazo',  label: 'Prazo',               example: '21/09' },
  ],
});

O que entra no texto é {{nome}}, texto puro: o seu template no servidor continua trocando pelos dados como já fazia, e copiar, colar e desfazer seguem funcionando. O label é o que aparece na lista; o example fica guardado para a prévia que o seu projeto monta.

O que fazComo
Abrir a lista pela barraBotão { }, que só existe quando há variáveis
Abrir enquanto escreveDigite {; as letras seguintes filtram, e o foco não sai do texto
EscolherClique, ou ↓ e Enter. O { digitado sai junto
DesistirEsc fecha a lista e deixa você digitando
Achar erro de digitaçãoeditor.unknownVariables() devolve o que está no texto e não na lista

No texto, a variável ganha um fundo leve para se distinguir do resto, e a que não está na lista sai no tom de erro — o aviso aparece onde o erro está. É pintura do navegador sobre o trecho, e não marcação no conteúdo: o valor salvo continua exatamente o que você escreveu. Em navegador sem essa pintura (Safari abaixo da 17.2, Firefox abaixo da 140) o texto aparece sem fundo, e mais nada muda.

Dentro de um bloco de código a lista não abre, e o que estiver escrito ali fica fora do unknownVariables(): ali se escreve código — inclusive o {{ nome }} de um template, como exemplo — e uma lista pulando na frente a cada chave atrapalharia.

Sem variables nada muda: não há botão, e { continua sendo só uma chave.

Valor e formulário

Quem guarda o valor é o <textarea>, escondido dentro do editor. A cada alteração ele recebe o HTML peneirado e dispara input e change nativos.

POST  description = <h2>Escopo do contrato</h2><p>Levantamento com <strong>relatório</strong>.</p>
document.querySelector('#description').addEventListener('change', (e) => {
  e.target.value;   // o HTML que vai no POST
});

O editor não dispara tucano:change: o valor é texto de formulário, e os eventos nativos do textarea já servem ao hx-trigger="change" do HTMX e à validação.

Editor vazio vale vazio: o textarea recebe '', e não um parágrafo em branco — então required barra o envio, e o aviso do navegador leva o foco à área. O reset do formulário devolve o editor ao conteúdo de origem.

A peneira

A saída passa por uma lista fechada de tags a cada leitura, e não só no que foi digitado: o navegador tem liberdade para marcar como quiser ao executar um comando, e o resultado precisa caber no que o editor promete.

EntradaSai
p br h2 h3 strong em u s ul ol li blockquote code pre a table thead tbody tr th tdComo estão, sem atributo
b iViram strong e em
href de aSó com destino aceitável; senão o link some e o texto fica
text-alignSó left, center, right ou justify, em p, h2, h3, li, blockquote, th e td — reescrito do zero, nunca o style que veio
script style iframe objectSomem com o conteúdo
Qualquer outra tag (div, span, font, h1...)Perde a tag e mantém o texto
Qualquer outro atributo (onclick, onerror, class...)Some
Tucano.sanitize('<p onclick="x()">Oi <img src=x onerror="steal()"><b>você</b></p>');
// '<p>Oi <strong>você</strong></p>'

Sanitize de novo no servidor, antes de publicar

A peneira protege o editor, não a publicação. O HTML chega por POST, e ninguém garante que veio deste editor — qualquer um monta a requisição à mão. Passe o valor por um sanitizador do lado do servidor, com a mesma lista de tags, antes de exibir com |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"})

Um exemplo com o nh3; qualquer sanitizador com lista fechada serve. Para manter o alinhamento, permita também o style com só text-align nos blocos.

Exibir o que foi salvo

O que é salvo é HTML sem classe nenhuma — de propósito, para servir a qualquer servidor e sobreviver a uma troca de front. O preço é que, publicado, ele herdaria o estilo da sua página. Envolver a saída em .tuc-prose devolve exatamente a aparência que a pessoa viu ao escrever.

<div class="tuc-prose">{{ project.description|safe }}</div>
O mesmo conteúdo, já publicado

Escopo do contrato

Levantamento em campo com relatório fotográfico e medição das unidades.

EtapaPrazoValor
Vistoria5 diasR$ 2.400
Medição10 diasR$ 5.800
npm install tucano
npm run build  # gera o dist
O prazo conta a partir da assinatura.

As duas aparências não têm como divergir

As regras de .tuc-prose são as mesmas da área de edição, compartilhadas no CSS. Elas também se defendem do CSS de quem hospeda: table { display: block } e th { text-transform: uppercase } são receitas comuns em projeto, e herdadas aqui desmontariam a tabela e mentiriam sobre o que foi escrito.

Tabela com muitas colunas não espreme o texto: cada coluna tem uma largura mínima, e a tabela que passa da largura rola na horizontal, sozinha, tanto no editor quanto no .tuc-prose. A caixa que rola é só exibição — o init() a põe em volta da tabela, e o HTML salvo continua sem ela.

Código colorido e copiar

O init() pinta cada <pre><code> dentro de .tuc-prose e põe um botão de copiar no canto — inclusive no que chegar depois por HTMX. Passe o mouse no bloco acima para ver.

Tucano.highlight('const x = 1;');   // HTML com <span class="tuc-tok-..."> para colorir à mão
Tucano.init(fragment);                // pinta e põe o copiar no HTML inserido por outro caminho

Teclado e acessibilidade

A área é um role="textbox" com aria-multiline; a barra é um role="toolbar", e cada botão tem nome e aria-pressed acompanhando a formatação sob o cursor.

TeclaAção
Ctrl/Cmd+B +I +UNegrito, itálico, sublinhado
Ctrl/Cmd+KInserir ou editar link
Ctrl/Cmd+ZDesfaz, pelo histórico do navegador
Tab Shift+TabNuma tabela, próxima e anterior célula; na última, cria linha
EnterNa caixa de link, confirma
EscNa caixa de link, fecha sem aplicar

Fora de uma tabela, Tab sai do editor, como em qualquer campo. Aplicar formatação não arrasta a página: o foco volta à área sem rolar.

API

Gerada do código a cada build — se algo não está aqui, não existe.

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

Opções

OpçãoPadrãoPara quê
toolbar['bold', 'italic', 'underline', 'title', 'subheading'
table{ rows: 3, cols: 3 }
minHeight'9rem'
placeholder''
variablesnull