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 behaviour | Call |
|---|---|
| Clicking runs the command | doc.commands.toggleBold() |
| Pressed when active | doc.query.isBold() |
| Disabled when it can’t apply | doc.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 [ ] BulletsRender 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.
Dropdowns and dialogs
- A block-type dropdown shows
doc.query.headingLevel(), or “Paragraph” when it’snullanddoc.query.isParagraph()istrue. - A link dialog prefills its field from
doc.query.linkHref(), then runsdoc.commands.setLink({ href })ordoc.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
- Keyboard shortcuts: show each button’s shortcut in its tooltip.
- Extension catalogue: every command and query you can bind.