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 textareaThe 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.
| Name | Button | What it does |
|---|---|---|
bold italic underline | Bold, Italic, Underline | Marks the selection; Ctrl/Cmd+B, +I, +U |
title subheading | Heading, Subheading | Turns the block into <h2> or <h3>; again, back to a paragraph |
list numbered | List, Numbered list | <ul> and <ol> |
left center right justify | Alignments | Aligns the block |
quote | Quote | <blockquote>; again, back to a paragraph |
code | Code | Inline code or code block — see below |
link | Link | Opens the address box; Ctrl/Cmd+K |
table | Insert table | Table with a header at the cursor |
clear | Clear formatting | Removes 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.
| Operation | Name in inTable() | Detail |
|---|---|---|
| Insert row above / below | rowAbove rowBelow | From the header, the new row goes into the body |
| Insert column left / right | colBefore colAfter | The new cell is th in the header and td in the body |
| Delete row | deleteRow | On the last row, the whole table goes away, instead of leaving an empty frame |
| Delete column | deleteColumn | Same, on the last column |
| Delete table | deleteTable |
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.
Link
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 does | How |
|---|---|
| Open the list from the toolbar | The { } button, which only exists when there are variables |
| Open while writing | Type {; the next letters filter, and the focus stays in the text |
| Pick one | Click, or ↓ and Enter. The typed { goes away with it |
| Give up | Esc closes the list and leaves you typing |
| Find a typo | editor.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.
| Input | Output |
|---|---|
p br h2 h3 strong em u s ul ol li blockquote code pre a table thead tbody tr th td | As they are, with no attributes |
b i | Become strong and em |
href on a | Only with an acceptable destination; otherwise the link goes away and the text stays |
text-align | Only 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 object | Removed 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>Contract scope
On-site survey with a photo report and unit measurements.
| Stage | Deadline | Amount |
|---|---|---|
| Inspection | 5 days | $2,400 |
| Measurement | 10 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.
- The button appears on hover and on focus: if it were permanent it would compete with the code, which is what the person came to read; but it is still reachable with
Tab, for keyboard users. On touch screens it is always visible, more subdued. - It copies the raw text, without the color markup. Outside a secure context, where the clipboard does not exist, it selects the block and leaves
Ctrl+Cone keystroke away. - The highlighter does not know any language, on purpose: it recognizes what shows up in almost all of them, which covers any language for 2 KB. The colors are the
--tuc-tok-*tokens, with their own palette in dark mode.
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 wayKeyboard 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.
| Key | Action |
|---|---|
Ctrl/Cmd+B +I +U | Bold, italic, underline |
Ctrl/Cmd+K | Insert or edit link |
Ctrl/Cmd+Z | Undo, through the browser's history |
Tab Shift+Tab | In a table, next and previous cell; in the last one, creates a row |
Enter | In the link box, confirms |
Esc | In 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.
[data-tuc-editor]new Tucano.Editor(alvo, opcoes)data-min-height data-placeholderinTable apply openVariables insertVariable unknownVariables getValue setValue destroyOptions
The notes in this table come from comments in the source code, which are written in Portuguese.
| Option | Default | What for |
|---|---|---|
toolbar | ['bold', 'italic', 'underline', 'title', 'subheading' | |
table | { rows: 3, cols: 3 } | |
minHeight | '9rem' | |
placeholder | '' | |
variables | null |