Start here

Getting started

Try it in the demo →

Rich Text for Canvas adds editable rich text to an HTML canvas: a cursor, selection, keyboard navigation, copy and paste with styles, undo, spellcheck, lists, tab stops, curved text, fills and shadows. Letters are drawn as shapes from the font file, so every browser draws the same pixels and print matches the screen. The library works on a Konva stage or on a plain <canvas> with no canvas library. This page installs the library, adds your license key and puts a first text box on each surface.

Install

npm install @mycreativeshop/rich-text

An app built on Konva installs Konva (version 10) beside it:

npm install @mycreativeshop/rich-text konva
Import from For
@mycreativeshop/rich-text Apps that draw their own <canvas>, with no Konva
@mycreativeshop/rich-text/konva Apps built on Konva

The package includes TypeScript types.

Vite settings

The text engine is a WebAssembly module (harfbuzzjs) that loads with a top-level await. With Vite, leave it out of dependency pre-bundling and build for ES2022:

// vite.config.js
export default defineConfig({
  optimizeDeps: {
    exclude: ["harfbuzzjs"],
    esbuildOptions: { target: "es2022" },
  },
  build: { target: "es2022" },
});

If the module can't load, the console shows [rich-text:text-engine] harfbuzzjs FAILED to load and text is measured by the browser instead, so line breaks can differ from print.

Install with a script tag

A page with no bundler can load the browser build from jsDelivr. The browser build is a set of ES modules, so load it with <script type="module">. Replace 1.0.1 with the version you use.

<script type="module">
  import { configureRichText, createCanvasRichText, createDocumentFromText } from "https://cdn.jsdelivr.net/npm/@mycreativeshop/rich-text@1.0.1/dist/browser/index.js";
</script>

For Konva, load Konva first with its own script tag. The library uses the page's global Konva.

<script src="https://cdn.jsdelivr.net/npm/konva@10/konva.min.js"></script>
<script type="module">
  import { configureRichText, enableRichText, RichText } from "https://cdn.jsdelivr.net/npm/@mycreativeshop/rich-text@1.0.1/dist/browser/konva.js";
</script>

The browser build has the plain-canvas entry (index.js) and the Konva entry (konva.js).

Add your license key

Copy your key from your licenses page and set it once, before you create text boxes:

import { configureRichText } from "@mycreativeshop/rich-text"; // on Konva: "@mycreativeshop/rich-text/konva"

configureRichText({ licenseKey: "rt1.…" });

Everything works without a key. Each text box then shows a watermark across its text:

  • Development, faint, on localhost, on IP addresses, on test names (.localhost, .local, .test, .example, .invalid), on pages opened from a file, and on any domain that is not on your license, such as a staging site.
  • Unlicensed, larger and red, on any other site with no valid key, or when a license has ended.

The watermark is drawn over the text, inside each box, and is in exported images too. It never changes how text lays out. Add your production domains on your licenses page to remove it on those domains. A domain with an underscore (my_shop.example.com) can't be added; use a domain without one.

The key is checked against your domains in the browser. When a check is due, the library also asks our license server, in the background, whether the license is still active. That request sends only your license id, the page's domain and the library version. If our server can't be reached, nothing changes on your pages.

Konva or a plain canvas

The same text engine, layout and drawing run behind both entry points, so a document looks identical on either. Pick by what your app already draws with.

Konva Plain canvas
Import from @mycreativeshop/rich-text/konva @mycreativeshop/rich-text
Turn it on const rt = enableRichText(stage) const rt = createCanvasRichText({ onChange: render })
A text box is a Konva node, RichText a CanvasTextBox that your render paints
Selection and handles your own Konva.Transformer built in (turn them off with objectControls: false)
Pointer events Konva's, heard by the library yours, forwarded to rt
Formatting commands on rt.manager on rt

Formatting commands have the same names on both surfaces (toggleBold, setFontSize, setColor and the rest), and documents move between them unchanged. Keys and paste are heard on the window on both surfaces while a box is being edited.

Your first text box on Konva

import Konva from "konva";
import { configureRichText, createDocumentFromText, enableRichText, RichText } from "@mycreativeshop/rich-text/konva";

configureRichText({ licenseKey: "rt1.…" });

const stage = new Konva.Stage({ container: "editor", width: 800, height: 600 });
const layer = new Konva.Layer();
stage.add(layer);

// Editing, the cursor, keys, clipboard, spellcheck and undo for every text box on the stage
const rt = enableRichText(stage);

layer.add(new RichText({ x: 40, y: 40, width: 400, draggable: true, document: createDocumentFromText("Grand opening", 48) }));

// Your selection: put the clicked box on your transformer
const selection = new Konva.Transformer();
layer.add(selection);
stage.on("mousedown touchstart", (e) => {
  if (e.target instanceof RichText) selection.nodes([e.target]);
  else if (e.target === stage) selection.nodes([]);
});

enableRichText watches the stage. Any RichText you add, to a layer or inside a group, can be selected and edited, and a box you remove is released. The stage needs a layer before you call enableRichText; the text uses the stage's first layer unless you pass { layer }. Selection follows your own transformer: when you set its nodes, the library sees the change, so your app keeps its usual click-to-select.

Click a box to select it, and click it again to place the cursor and type. createDocumentFromText(text, fontSize, fontFamily, color) makes one paragraph per line of text; the font defaults to Poppins.

Your first text box on a plain canvas

import { configureRichText, createCanvasRichText, createDocumentFromText } from "@mycreativeshop/rich-text";

configureRichText({ licenseKey: "rt1.…" });

const canvas = document.querySelector("canvas")!;
const ctx = canvas.getContext("2d")!;

// A sharp canvas: one canvas pixel per device pixel, the scene drawn in CSS pixels
const ratio = window.devicePixelRatio || 1;
canvas.width = canvas.clientWidth * ratio;
canvas.height = canvas.clientHeight * ratio;

let frame = 0;
const rt = createCanvasRichText({ onChange: () => { frame ||= requestAnimationFrame(render); } });
rt.addBox({ x: 40, y: 40, width: 400, document: createDocumentFromText("Grand opening", 48) });

function render() {
  frame = 0;
  ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
  ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
  drawYourScene(ctx);
  rt.paint(ctx);        // the text boxes
  rt.paintOverlay(ctx); // the cursor, the text selection and the handles, on top
}

// Your pointer events, mapped back through the same scale the scene is drawn with
const sceneToCanvas = new DOMMatrix().scale(ratio);
const at = (e: MouseEvent) => rt.fromEvent(e, canvas, sceneToCanvas);
canvas.addEventListener("pointerdown", (e) => rt.pointerDown(at(e)));
// Move and release on the window, so a drag that leaves the canvas still ends
window.addEventListener("pointermove", (e) => {
  rt.pointerMove(at(e));
  canvas.style.cursor = rt.cursor();
});
window.addEventListener("pointerup", (e) => rt.pointerUp(at(e)));
canvas.addEventListener("click", (e) => rt.click(at(e)));
canvas.addEventListener("contextmenu", (e) => {
  if (rt.contextMenu(at(e), { x: e.clientX, y: e.clientY }).handled) e.preventDefault();
});

createCanvasRichText calls onChange whenever the canvas needs drawing again: a keystroke, a font arriving, the cursor blinking. Your render paints the boxes with rt.paint and the cursor, selection and handles with rt.paintOverlay. The contextmenu listener opens the library's right-click menu (spelling suggestions, Cut, Copy and Paste) on the box being edited.

  • Pixel density. Size the canvas for the screen's pixel density as above, or text is blurry on high-density screens. The cursor, the handles and the license mark are drawn a fixed size in CSS pixels. If you draw at a ratio other than window.devicePixelRatio, pass it as createCanvasRichText({ pixelRatio }).
  • Pan and zoom. Boxes are in scene coordinates. Set your scene's transform on the context before paint and paintOverlay, and pass the same transform (the pixel-density scale included) to rt.fromEvent(e, canvas, sceneToCanvas) so pointer positions match.
  • Your own handles. The built-in object controls select a box on click, move it on drag and resize it with handles. Turn them off with objectControls: false if your app draws its own, and select boxes with rt.select(box).

Read-only text boxes

A box your users should move but not retype, such as a logo's lettering or a fixed legal line, is read-only. Pass editable: false:

// Konva
layer.add(new RichText({ x: 40, y: 500, width: 300, editable: false, document: doc }));
box.setAttr("editable", true); // editable again

// Plain canvas
rt.addBox({ x: 40, y: 500, width: 300, editable: false, document: doc });
box.attrs.editable = true; // editable again

A read-only box is selected, moved and resized like any other box, but a click never starts editing, and rt.manager.edit(box) declines it. Formatting commands still reach a read-only box that is selected, so disable your toolbar for it if your users should not restyle it.

Save and load

Each text box holds a document: plain JSON you can store anywhere.

const saved = JSON.stringify(box.getDocument());
// later
box.setDocument(JSON.parse(saved));

box.setDocument is not an undo step, which suits loading. To replace a box's text as a step people can undo, use rt.manager.setDocument(document, [box]) on Konva or rt.setDocument(document, [box]) on a plain canvas. JSON documents describes the format. To save as people work, listen for onDocumentChange; see Your shortcuts and editing events.

In the API reference