Tucano v0.37.2

Text editor

It shows the result as you write: whoever fills in a description in the system sees bold as bold, not markup. With tables and code blocks. The original <textarea> stays in the form holding the HTML, so name, required and the POST work without changing the view.

Examples

The same <textarea>, with different attributes or options.

Slim toolbar

toolbar picks the buttons and their order — here, only what a comment needs.

Empty, shorter

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

With an error

aria-invalid="true" on the textarea paints the frame — Django 5 already writes the attribute.

Write the justification.

The sanitizer, live

Tucano.sanitize() on HTML with onerror, javascript: and <script>.

How to use

Mark the <textarea> and it initializes on its own, including anything that arrives later via HTMX. The initial content is the stored HTML, and it also goes through the sanitizer — it may have come from the database.

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

In a Django form

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

In JavaScript

To choose the toolbar and the table size, which do not exist as attributes, or to read and write the value.

const ed = new Tucano.Editor('#description', {
  toolbar: ['bold', 'italic', 'list', 'link', 'table'],
  table: { rows: 4, cols: 2 },     // rows including the header
});

ed.getValue();                   // HTML, already sanitized; '' when empty
ed.setValue('<p>New text</p>');  // sanitizes, highlights code and updates the textarea
ed.apply('bold');                // same as the toolbar button
ed.inTable('rowBelow');          // operation on the cell where the cursor is
ed.destroy();                    // gives back the original textarea

The toolbar

A lit button means the cursor is inside that formatting — and that clicking again undoes it. The names below are the ones the toolbar option accepts, in the default order.

NameButtonWhat it does
bold italic underlineBold, Italic, UnderlineMarks the selection; Ctrl/Cmd+B, +I, +U
title subheadingHeading, SubheadingTurns the block into <h2> or <h3>; again, back to a paragraph
list numberedList, Numbered list<ul> and <ol>
left center right justifyAlignmentsAligns the block
quoteQuote<blockquote>; again, back to a paragraph
codeCodeInline code or code block — see below
linkLinkOpens the address box; Ctrl/Cmd+K
tableInsert tableTable with a header at the cursor
clearClear formattingRemoves text formatting from the selection

The toolbar stays on a single line and scrolls when it does not fit, instead of wrapping onto two: that way the height does not change and anyone looking for a button knows it has not moved to another row. The button names are in Portuguese by default; Tucano.setTexts({ editor }) replaces them.

Pasting and dropping always come in as plain text

That is what keeps out the HTML from Word and Google Docs, and from another page dragged in, with layout tables and inline styles, which is where homemade editors break first. You lose the source formatting and gain a document that is still yours.

Ctrl+Z is the browser's

Formatting goes through execCommand, which is deprecated and used anyway: it is the only path with universal support and the only one that integrates with native undo. Reimplementing it by hand would mean reimplementing Ctrl+Z too. The exception is removing a code block, which leaves the history for that one action.

Table

The button inserts a table with a header — tables in business apps almost always have one, and without it the first row of data ends up acting as the title. With the cursor in a cell, a second toolbar appears just for it.

OperationName in inTable()Detail
Insert row above / belowrowAbove rowBelowFrom the header, the new row goes into the body
Insert column left / rightcolBefore colAfterThe new cell is th in the header and td in the body
Delete rowdeleteRowOn the last row, the whole table goes away, instead of leaving an empty frame
Delete columndeleteColumnSame, on the last column
Delete tabledeleteTable

In a table, Tab moves to the next cell and Shift+Tab goes back; in the last cell, Tab creates a new row — you can fill it in without taking your hands off the keyboard. Inserting a table with the cursor inside another one puts the new table after it, not nested. The table toolbar only appears when it is needed: always visible, it would fill the main toolbar with useless buttons most of the time.

Out of scope

Merging cells and resizing columns — that is what turns an editor into a project of its own. Columns have a fixed width on purpose: with automatic width, the text would resize the column on every keystroke and the whole row would jump around.

Code block

The same button does both, depending on the selection: a selection within one line becomes inline <code>; a selection spanning lines becomes <pre><code>, which is the element that preserves line breaks and indentation. With the cursor inside, the button undoes it.

The block is highlighted as you write — comments, quoted text, numbers, tags, attributes, keywords and template braces. The color is display only: the sanitizer dissolves <span>, so none of it reaches the saved value, nor should it, because color is the decision of whoever displays it.

The address is asked for in the library's own modal, not in the browser's prompt — which appears outside the page's design, ignores the theme and cannot be styled.

Select the text and use the button or Ctrl/Cmd+K. Enter in the field confirms. With the cursor on an existing link, the box opens with its address and gains "Remover" (remove). Only addresses starting with http:, https:, mailto:, tel:, # or / get through — everything else, javascript: and //other-site.com included, loses the link and keeps the text. An address typed without a scheme, such as example.com, gets https:// in front. Every saved link comes out with target="_blank" and rel="noopener noreferrer".

Variables

A text that becomes a message for each person has parts that change: who receives it, the due date, the task title. Declare those variables and the editor gains a toolbar button and a list when you type { — nobody has to memorize names or close braces by hand.

Click the { } on the toolbar, or type { in the text and start writing the name.

new Tucano.Editor('#message', {
  variables: [
    { name: 'nome',   label: 'Assignee name', example: 'Junior' },
    { name: 'tarefa', label: 'Task title',    example: 'Record the video' },
    { name: 'prazo',  label: 'Due date',      example: '09/21' },
  ],
});

What goes into the text is {{nome}}, plain text: your server template keeps replacing it with the data as it already did, and copy, paste and undo keep working. The label is what shows in the list; the example is kept for the preview your project builds.

What it doesHow
Open the list from the toolbarThe { } button, which only exists when there are variables
Open while writingType {; the next letters filter, and the focus stays in the text
Pick oneClick, or ↓ and Enter. The typed { goes away with it
Give upEsc closes the list and leaves you typing
Find a typoeditor.unknownVariables() returns what is in the text but not in the list

In the text, a variable gets a light background so it stands out from the rest, and one that is not in the list comes out in the error tone — the warning shows where the error is. It is the browser painting over the range, not markup in the content: the saved value stays exactly what you wrote. In a browser without that painting (Safari below 17.2, Firefox below 140) the text shows with no background, and nothing else changes.

Inside a code block the list does not open, and whatever is written there stays out of unknownVariables(): that is where code goes — including a template's own {{ nome }}, as an example — and a list jumping in at every brace would get in the way.

Without variables nothing changes: no button, and { is still just a brace.

Value and form

What holds the value is the <textarea>, hidden inside the editor. On every change it receives the sanitized HTML and fires native input and change events.

POST  description = <h2>Contract scope</h2><p>Survey with a <strong>report</strong>.</p>
document.querySelector('#description').addEventListener('change', (e) => {
  e.target.value;   // the HTML that goes in the POST
});

The editor does not fire tucano:change: the value is form text, and the textarea's native events already serve HTMX's hx-trigger="change" and validation.

An empty editor is empty: the textarea gets '', not a blank paragraph — so required blocks the submit, and the browser's warning moves focus to the area. The form's reset brings the editor back to its original content.

The sanitizer

The output goes through a closed list of tags on every read, not just on what was typed: the browser is free to mark up however it likes when running a command, and the result has to fit what the editor promises.

InputOutput
p br h2 h3 strong em u s ul ol li blockquote code pre a table thead tbody tr th tdAs they are, with no attributes
b iBecome strong and em
href on aOnly with an acceptable destination; otherwise the link goes away and the text stays
text-alignOnly left, center, right or justify, on p, h2, h3, li, blockquote, th and td — rewritten from scratch, never the style that came in
script style iframe objectRemoved along with their content
Any other tag (div, span, font, h1...)Loses the tag and keeps the text
Any other attribute (onclick, onerror, class...)Removed
Tucano.sanitize('<p onclick="x()">Hi <img src=x onerror="steal()"><b>there</b></p>');
// '<p>Hi <strong>there</strong></p>'

Sanitize again on the server, before publishing

The sanitizer protects the editor, not the published page. The HTML arrives by POST, and nothing guarantees it came from this editor — anyone can build the request by hand. Run the value through a server-side sanitizer, with the same tag list, before displaying it with |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"})

An example with nh3; any sanitizer with a closed list will do. To keep alignment, also allow style with only text-align on blocks.

Displaying what was saved

What gets saved is HTML with no classes at all — on purpose, so it works with any server and survives a front-end rewrite. The cost is that, once published, it would inherit your page's styles. Wrapping the output in .tuc-prose gives back exactly the look the person saw while writing.

<div class="tuc-prose">{{ project.description|safe }}</div>
The same content, published

Contract scope

On-site survey with a photo report and unit measurements.

StageDeadlineAmount
Inspection5 days$2,400
Measurement10 days$5,800
npm install tucano
npm run build  # builds dist
The deadline counts from the signature date.

The two looks cannot drift apart

The .tuc-prose rules are the same as the editing area's, shared in the CSS. They also defend against the host's CSS: table { display: block } and th { text-transform: uppercase } are common recipes in projects, and inherited here they would break the table and misrepresent what was written.

A table with many columns doesn't squeeze the text: each column has a minimum width, and a table wider than the available space scrolls horizontally, on its own, both in the editor and in .tuc-prose. The scrolling box is display only — init() wraps it around the table, and the saved HTML stays without it.

Highlighted code and copy

init() highlights every <pre><code> inside .tuc-prose and adds a copy button in the corner — including anything that arrives later via HTMX. Hover over the block above to see it.

Tucano.highlight('const x = 1;');   // HTML with <span class="tuc-tok-..."> to color by hand
Tucano.init(fragment);                // highlights and adds copy to HTML inserted some other way

Keyboard and accessibility

The area is a role="textbox" with aria-multiline; the toolbar is a role="toolbar", and each button has a name and aria-pressed following the formatting under the cursor.

KeyAction
Ctrl/Cmd+B +I +UBold, italic, underline
Ctrl/Cmd+KInsert or edit link
Ctrl/Cmd+ZUndo, through the browser's history
Tab Shift+TabIn a table, next and previous cell; in the last one, creates a row
EnterIn the link box, confirms
EscIn the link box, closes without applying

Outside a table, Tab leaves the editor, as in any field. Applying formatting does not scroll the page: focus returns to the area without scrolling.

API

Generated from the code on every build — if something is not here, it does not exist.

Markup
[data-tuc-editor]
In JS
new Tucano.Editor(alvo, opcoes)
Attributes
data-min-height data-placeholder
Methods
inTable apply openVariables insertVariable unknownVariables getValue setValue destroy

Options

The notes in this table come from comments in the source code, which are written in Portuguese.

OptionDefaultWhat for
toolbar['bold', 'italic', 'underline', 'title', 'subheading'
table{ rows: 3, cols: 3 }
minHeight'9rem'
placeholder''
variablesnull