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 ascreateCanvasRichText({ pixelRatio }). - Pan and zoom. Boxes are in scene coordinates. Set your scene's transform on the context before
paintandpaintOverlay, and pass the same transform (the pixel-density scale included) tort.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: falseif your app draws its own, and select boxes withrt.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.