Search page titles, headings and text across the docs.

Guides

On this page

Add a toolbar

In this guide you build a formatting toolbar: buttons that run commands, show when they’re active, and disable themselves when they can’t apply. canvist has no built-in toolbar, so it looks exactly like your app.

The three calls behind every button

Button behaviourCall
Clicking runs the commanddoc.commands.toggleBold()
Pressed when activedoc.query.isBold()
Disabled when it can’t applydoc.can.toggleBold()

Refresh the toolbar after every change and every selection change. Queries and can checks cost about a microsecond each, so refreshing every button on every keystroke is fine.

Describe the buttons once

Write the toolbar as data. The types come from your engine, so a button for an extension you didn’t register won’t compile:

import {
	bold,
	bulletList,
	createEngine,
	heading,
	italic,
} from "@canvist/headless";

const engine = await createEngine([bold, italic, heading, bulletList]);
type Editor = ReturnType<typeof engine.create>;

interface Button {
	label: string;
	run(doc: Editor): boolean;
	can(doc: Editor): boolean;
	active(doc: Editor): boolean;
}

const buttons: Button[] = [
	{
		label: "Bold",
		run: (doc) => doc.commands.toggleBold(),
		can: (doc) => doc.can.toggleBold(),
		active: (doc) => doc.query.isBold(),
	},
	{
		label: "Italic",
		run: (doc) => doc.commands.toggleItalic(),
		can: (doc) => doc.can.toggleItalic(),
		active: (doc) => doc.query.isItalic(),
	},
	{
		label: "Heading 2",
		run: (doc) => doc.commands.toggleHeading({ level: 2 }),
		can: (doc) => doc.can.toggleHeading({ level: 2 }),
		active: (doc) => doc.query.headingLevel() === 2,
	},
	{
		label: "Bullets",
		run: (doc) => doc.commands.toggleBulletList(),
		can: (doc) => doc.can.toggleBulletList(),
		active: (doc) => doc.query.isBulletList(),
	},
];

/** The toolbar as text: [x] for active, ( ) for disabled. */
function describe(doc: Editor): string {
	return buttons
		.map((button) => {
			const state = button.active(doc)
				? "[x]"
				: button.can(doc)
				? "[ ]"
				: "( )";
			return `${state} ${button.label}`;
		})
		.join("  ");
}

const doc = engine.parse("markdown", "## Plan\n\nShip **today**");
doc.select({ path: [1], offset: 6 });
console.log(describe(doc));
// [x] Bold  [ ] Italic  [ ] Heading 2  [ ] Bullets

doc.select({ path: [0], offset: 2 });
console.log(describe(doc));
// [ ] Bold  [ ] Italic  [x] Heading 2  [ ] Bullets

Render it in your UI

The same descriptions drive real buttons. Here they are as plain DOM buttons, with a Markdown preview standing in for the editor surface until the canvas editor ships for the web:

import { bold, createEngine, italic } from "@canvist/headless";

const engine = await createEngine([bold, italic]);
const doc = engine.parse("markdown", "Select some text, then press a button.");
doc.selectAll();

const toolbar = document.querySelector<HTMLElement>("#toolbar")!;
const preview = document.querySelector<HTMLElement>("#preview")!;

const buttons = [
	{
		label: "B",
		run: () => doc.commands.toggleBold(),
		can: () => doc.can.toggleBold(),
		active: () => doc.query.isBold(),
	},
	{
		label: "I",
		run: () => doc.commands.toggleItalic(),
		can: () => doc.can.toggleItalic(),
		active: () => doc.query.isItalic(),
	},
];

function refresh(): void {
	toolbar.replaceChildren(
		...buttons.map((button) => {
			const element = document.createElement("button");
			element.textContent = button.label;
			element.disabled = !button.can();
			element.setAttribute("aria-pressed", String(button.active()));
			element.addEventListener("click", () => {
				button.run();
				refresh();
			});
			return element;
		}),
	);
	preview.textContent = doc.export("markdown");
}

refresh();

Use aria-pressed for toggle buttons, so screen readers announce their state.

  • A block-type dropdown shows doc.query.headingLevel(), or “Paragraph” when it’s null and doc.query.isParagraph() is true.
  • A link dialog prefills its field from doc.query.linkHref(), then runs doc.commands.setLink({ href }) or doc.commands.unsetLink().

In Flutter

The pattern is the same in Dart. Keep the handles from Engine.starterKit(), and rebuild the toolbar after each change:

import 'package:canvist_flutter/canvist_flutter.dart';
import 'package:flutter/material.dart';

class BoldButton extends StatelessWidget {
  const BoldButton({super.key, required this.doc, required this.bold, required this.onChanged});

  final Doc doc;
  final BoldHandle bold;
  final VoidCallback onChanged;

  @override
  Widget build(BuildContext context) {
    final active = doc.query(bold.isBold());
    return IconButton(
      icon: const Icon(Icons.format_bold),
      isSelected: active,
      onPressed: doc.can(bold.toggleBold())
          ? () {
              doc.run(bold.toggleBold());
              onChanged();
            }
          : null,
    );
  }
}

Next

Docs