Integrations

Your own toolbar

Try it in the demo →

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 null when 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: when bold is true it 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, strokeWidth and letterSpacing are 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's mousedown. 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, call blur() 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 canUndo and canRedo are always false and the library's undo() and redo() do nothing. Enable your buttons from your own history instead. See Your app's undo.

In the API reference