Rich Text for Canvas has no toolbar of its own. Your app keeps its own buttons, menus and panels, in its own design.
The library reports what the selected text looks like every time that changes, and each of your controls sends one
command, such as toggleBold() or setFontSize(24), to the selected box or to the selected words.
Keep your controls in step with the selection
Listen with onFormattingChange. On Konva the listener belongs to the context, the editing session the stage's
manager is part of; on a plain canvas it is on the object createCanvasRichText returns.
// Konva
import { defaultRichTextContext } from "@mycreativeshop/rich-text/konva";
const stop = defaultRichTextContext.onFormattingChange((f, box) => {
if (!f) return toolbar.disable(); // nothing is selected
toolbar.enable();
fontMenu.value = f.fontFamily;
boldButton.classList.toggle("active", f.bold);
undoButton.disabled = !f.canUndo;
});
// Plain canvas
rt.onFormattingChange((f, box) => { /* the same */ });
- The callback receives the formatting and the box it describes. Both are
nullwhen no text box is selected. - It is called once right away with the latest report, so your toolbar shows the current formatting from the start.
- It is called again after every command, when the cursor moves or the selection changes, when a box is selected or deselected, and after undo and redo. A report identical to the one before is not sent again.
- It returns a function that stops listening.
On Konva, defaultRichTextContext is the context of every stage enabled without one. If you created your own context
with createRichTextContext, for example for your app's undo, listen on that
context instead. rt.manager.context is the manager's context in either case:
rt.manager.context.onFormattingChange((f) => updateToolbar(f));
Read the formatting when you need it
getFormattingState() returns the same report on demand: from rt.manager.getFormattingState() on Konva, or
rt.getFormattingState() on a plain canvas. It returns null when no box is being edited or selected. Pass a box to
read that box instead: rt.manager.getFormattingState(box).
What the report describes
- While a box is being edited: the formatting the next letter typed would take. With words selected, that is the first selected letter; with only a cursor, the letter before the cursor. Paragraph fields come from the cursor's paragraph.
- When a box is selected but not being edited: its first visible letter and that letter's paragraph.
- A box with no text: the default formatting.
Note: A selection that mixes styles is reported by its first letter. There is no "mixed" value, so a Bold button shows the first letter's bold.
toggleBold()follows what the report shows: whenboldistrueit removes bold from all of the selection, otherwise it makes all of it bold. The other toggles work the same way.
Every field
| Field | What it holds |
|---|---|
fontFamily |
The font family name. |
fontSize |
The size in pixels, as it looks on screen (see the tip below). |
fontWeight |
The numeric weight, 100 to 900, when the text sets one. undefined when it sets none: show the weight bold implies. |
bold, italic, underline, strikethrough |
true when that style is on. |
color |
The letters' color, as a CSS color string. |
strokeWidth |
The outline's width in pixels, as it looks on screen. 0 means no outline. |
strokeColor |
The outline's color. |
alignment |
The paragraph's alignment: "left", "center", "right" or "justify". |
lineHeight |
The paragraph's line height, as a multiple of the font size. |
letterSpacing |
The paragraph's letter spacing, in pixels as it looks on screen. |
paragraphSpacing |
The space after the paragraph, as a multiple of its font size. |
tabStops |
The paragraph's tab stops, or an empty array when it has none. See Tab stops. |
isList |
true when the paragraph is a list item. |
listType |
"bullet" for a list item, unless the document marks it listType: "number". undefined for a paragraph. |
listStyle |
The list item's marker: "disc", "circle", "square", "decimal" (1. 2. 3.) or a character. undefined for a paragraph. |
hasSelection |
true when words are selected in the box being edited, and false with only a cursor. |
selectionLength |
How many characters are selected. An emoji counts as one. |
cursorIndex |
The cursor's position, as a character index in the box. 0 when the box is not being edited. |
canUndo, canRedo |
Whether the library's history has a step to undo or redo. |
Tip: Inside a scaled Konva group,
fontSize,strokeWidthandletterSpacingare reported and set as they look on screen. A value read, changed by 2 and written back moves by exactly 2.
Connect each kind of control
Each control reads one field of the report and sends one command. On Konva the commands are on rt.manager; on a
plain canvas they are on rt. The names are the same. First the report, shown on every control:
let state: FormattingState | null = null;
rt.manager.context.onFormattingChange((f) => { // plain canvas: rt.onFormattingChange
state = f;
for (const control of toolbar.controls) control.disabled = !f;
if (!f) return;
fontMenu.value = f.fontFamily;
sizeField.value = String(Math.round(f.fontSize));
weightMenu.value = String(f.fontWeight ?? (f.bold ? 700 : 400));
boldButton.classList.toggle("active", f.bold);
italicButton.classList.toggle("active", f.italic);
underlineButton.classList.toggle("active", f.underline);
colorInput.value = f.color;
for (const button of alignButtons) button.classList.toggle("active", button.dataset.align === f.alignment);
lineHeightMenu.value = String(f.lineHeight);
bulletsButton.classList.toggle("active", f.isList && f.listStyle !== "decimal");
numbersButton.classList.toggle("active", f.isList && f.listStyle === "decimal");
undoButton.disabled = !f.canUndo;
redoButton.disabled = !f.canRedo;
// A selection note
selectionNote.textContent = f.hasSelection
? `${f.selectionLength} characters selected`
: `Cursor at ${f.cursorIndex}`;
});
Then one command per control:
const cmd = rt.manager; // plain canvas: const cmd = rt;
// Menus and fields: a value in
fontMenu.onchange = () => cmd.setFontFamily(fontMenu.value); // loads the font first; returns a promise
sizeField.onchange = () => cmd.setFontSize(Number(sizeField.value));
weightMenu.onchange = () => cmd.setFontWeight(Number(weightMenu.value));
lineHeightMenu.onchange = () => cmd.setLineHeight(Number(lineHeightMenu.value)); // 1.2 is 120%
// Toggles
boldButton.onclick = () => cmd.toggleBold();
italicButton.onclick = () => cmd.toggleItalic();
underlineButton.onclick = () => cmd.toggleUnderline();
// Color: any CSS color string
colorInput.oninput = () => cmd.setColor(colorInput.value);
// Alignment: one button per value
for (const button of alignButtons) button.onclick = () => cmd.setAlignment(button.dataset.align as "left" | "center" | "right" | "justify");
// Lists: on, off, or from bullets to numbers
function listButton(style: string) {
const on = state?.isList && (state.listStyle === "decimal") === (style === "decimal");
if (on) cmd.toggleBulletList(); // off
else if (state?.isList) cmd.setBulletStyle(style);
else cmd.toggleBulletList(style);
}
bulletsButton.onclick = () => listButton("disc");
numbersButton.onclick = () => listButton("decimal");
// Undo and redo
undoButton.onclick = () => cmd.undo();
redoButton.onclick = () => cmd.redo();
toggleBulletList makes the reached paragraphs list items, or paragraphs again when all of them are list items
already. To tell a numbered list from bullets, read listStyle: "decimal" is numbered. Lists shows every
marker, and Character styles and Paragraphs list every command.
Keep the selection while people use the toolbar
A click on your toolbar should leave the keyboard focus where it was, so people can format some words and go on typing:
- Buttons: call
preventDefault()on the button'smousedown. The button then does not take the keyboard focus, and the keys people type keep going to the text. - Menus and fields: the library leaves the keys alone while the focus is in an input, a textarea, a select or an
element with
contenteditable, so people can type a font size during editing. After the value is applied, callblur()on the field so the next keys go back to the text.
toolbar.addEventListener("mousedown", (e) => {
if ((e.target as HTMLElement).closest("button")) e.preventDefault();
});
sizeField.addEventListener("change", () => {
rt.manager.setFontSize(Number(sizeField.value));
sizeField.blur();
});
Where a command lands
- While a box is being edited: on the selected words. With only a cursor and no selection, on the cursor's whole paragraph.
- When a box is selected but not being edited: on all of its text.
- On boxes you name: pass them as the last argument. Each named box is changed the same way: the box being edited through its selection or cursor, any other box in all of its text.
rt.manager.toggleBold([headline, subhead]);
rt.manager.setFontSize(24, [headline, subhead]);
rt.manager.toggleBulletList("disc", [list]);
// Plain canvas: rt.toggleBold([headline, subhead])
Paragraph settings (alignment, line height, letter spacing, paragraph spacing, tab stops and lists) always change whole paragraphs: every paragraph the selection touches. While Story Mode is open, commands reach the words selected in its panel.
Each command is one undo step, even when it changes several boxes. setFontFamily(family, boxes, options) takes the
boxes before the options, on both surfaces; Character styles describes the options.
Undo and redo buttons
rt.manager.undo() and rt.manager.redo() on Konva, rt.undo() and rt.redo() on a plain canvas, step through the
library's history. Enable the buttons from canUndo and canRedo in the report.
Note: In "host" history mode your app keeps the undo steps, so
canUndoandcanRedoare alwaysfalseand the library'sundo()andredo()do nothing. Enable your buttons from your own history instead. See Your app's undo.