Skip to content

Canvas

A pluggable, tiptap-flavored playground built on a single HTML <canvas>. The core owns the surface, node storage, render loop and pointer dispatch — everything else (tools, toolbars, node kinds, UI chrome) is registered by you.

No tools are included by default. You explicitly specify every tool the canvas should have — draw, text, undo, redo, clear, color picker, or your own custom tools. See Extending Canvas for authoring custom tools.

Live Demo

0 node(s) on the canvas

Pick the Draw tool and drag to draw, or the Text tool and click anywhere to drop text. Press Enter to commit, Esc to cancel. Use Ctrl/Cmd+Z to undo. Try Transform — click a node to select it, then drag corners to resize, the top circle to rotate, or the body to move.

Quick Start

vue
<script setup>
import { ref, shallowRef } from "vue";
import {
  drawTool,
  textTool,
  eraseTool,
  moveTool,
  undoTool,
  redoTool,
  clearTool,
} from "orio-ui/canvas";

const nodes = ref([]);
const tools = shallowRef([
  drawTool(),
  textTool(),
  eraseTool(),
  moveTool(),
  undoTool(),
  redoTool(),
  clearTool(),
]);
</script>

<template>
  <orio-canvas
    name="editor"
    v-model:nodes="nodes"
    :tools="tools"
    :width="800"
    :height="500"
  >
    <orio-canvas-toolbar canvas="editor" />
    <orio-canvas-stage />
  </orio-canvas>
</template>

You explicitly list every tool — the canvas is inert without them.

Props

PropTypeDefaultDescription
toolsCanvasTool[][]Tools available on the canvas. Empty by default.
widthnumber800Drawing surface width in CSS pixels.
heightnumber500Drawing surface height in CSS pixels.
defaultToolstringfirst interaction tool's idInitially active tool id.
backgroundstring"transparent"CSS background applied to the wrapper.
maxHistorynumber50Maximum number of undo steps to keep.
setup(api) => void | Promise<void>Called once on mount to seed initial nodes.

Model

ModelTypeDescription
nodesCanvasNode[]Persisted node list. Use v-model:nodes to round-trip.

Default Slot

The default slot lets you replace the entire layout (toolbar position, extra chrome, etc.). When no slot content is provided, the canvas renders <Toolbar /> and <Stage /> automatically.

Use useCanvasContext() inside any child component to access the full canvas API.

vue
<orio-canvas name="editor" :width="600" :height="400">
  <header style="display: flex; justify-content: space-between;">
    <orio-canvas-toolbar canvas="editor" />
  </header>
  <orio-canvas-stage />
</orio-canvas>

Sub-components

<orio-canvas> is composed from a few small SFCs that you can compose manually inside the default slot:

  • <orio-canvas-toolbar> — renders one <orio-canvas-tool-button> per tool. Slot exposes { tools, activeToolId, setActiveTool }.
  • <orio-canvas-stage> — the actual <canvas> element with pointer dispatch, keyboard handling, and DPR-aware rendering.
  • <orio-canvas-tool-button> — single tool button wrapped in <orio-tooltip>. For interaction/action tools it renders an <orio-button> with appearance="minimal". Widget tools render their toolbar component directly. Default slot exposes { tool, isActive, activate }.

<orio-canvas-stage> and <orio-canvas-tool-button> call useCanvasContext() internally, so they only work inside an <orio-canvas> parent.

<orio-canvas-toolbar> resolves its canvas via the canvas prop, which must match the name of an <orio-canvas> mounted anywhere in the app — header, sidebar, modal, completely separate route. It does not need to be a descendant of the canvas.

ToolButton behavior

  • Interaction tools — click activates the tool. The button uses variant="primary" when active, variant="subdued" otherwise.
  • Action tools — click fires tool.action(api). Button is disabled when tool.disabled(api) returns true.
  • Widget tools — renders <component :is="tool.toolbar" :tool="tool" /> instead of a button.
  • Tooltips — each button is wrapped in <orio-tooltip>. If the tool has a tooltip component, it's rendered in the #content slot. Otherwise the tool's label is shown. The tooltip auto-hides on click and re-appears on mouseout.

Overriding toolbar buttons

vue
<orio-canvas name="editor" v-slot>
  <orio-canvas-toolbar
    canvas="editor"
    v-slot="{ tools, activeToolId, setActiveTool }"
  >
    <orio-button
      v-for="tool in tools"
      :key="tool.id"
      appearance="minimal"
      :variant="tool.id === activeToolId ? 'primary' : 'subdued'"
      @click="setActiveTool(tool.id)"
    >
      <orio-icon v-if="tool.icon" :name="tool.icon" />
      {{ tool.label }}
    </orio-button>
  </orio-canvas-toolbar>
  <orio-canvas-stage />
</orio-canvas>

Overriding tooltip content

vue
<orio-canvas-tool-button :tool="myTool">
  <template #tooltip>
    <orio-view-text type="title" size="medium">Custom tip</orio-view-text>
  </template>
</orio-canvas-tool-button>

Initial Setup (Scenarios)

Use the setup prop to seed the canvas with initial content — text labels, shapes, background drawings, etc. Nodes added during setup can be frozen: true to lock them from user interaction (the eraser, move, and highlight tools will skip frozen nodes).

The toolbar controls what the user can do. Setup can add any node type regardless of which tools are registered.

vue
<script setup>
import { drawTool, eraseTool } from "orio-ui/canvas";
import { shallowRef } from "vue";

const tools = shallowRef([drawTool(), eraseTool()]);

function onSetup(api) {
  // Add a frozen label the user can't erase or move
  api.addNode({
    type: "text",
    x: 100,
    y: 40,
    frozen: true,
    data: {
      text: "Sign here ↓",
      fontSize: 18,
      fontFamily: "system-ui",
      color: "#888",
      weight: "normal",
    },
  });
}
</script>

<template>
  <orio-canvas name="editor" :tools="tools" :setup="onSetup">
    <orio-canvas-toolbar canvas="editor" />
    <orio-canvas-stage />
  </orio-canvas>
</template>

setup also accepts an async function — useful if you need to load images or fetch initial data before adding nodes. After setup completes, the history baseline is reset so initial nodes don't count as an undoable action.

For more patterns — conditional scenarios, async loading, layering, restoring saved state — see Canvas Scenarios.

Working with Nodes

Nodes are plain objects you can serialize, persist and re-hydrate:

ts
interface CanvasNode<TData = unknown> {
  id: string;
  type: string;        // matches the tool that owns this node
  x: number;
  y: number;
  width?: number;
  height?: number;
  rotation?: number;
  frozen?: boolean;
  zIndex?: number;
  data: TData;         // tool-specific payload (stroke points, text content, ...)
}

Save and load

vue
<script setup>
import { ref, watch } from "vue";

const nodes = ref([]);

// Restore from localStorage
const saved = localStorage.getItem("my-canvas");
if (saved) {
  try {
    nodes.value = JSON.parse(saved);
  } catch {
    // Ignore malformed data — start with an empty canvas.
  }
}

watch(nodes, (next) => {
  localStorage.setItem("my-canvas", JSON.stringify(next));
}, { deep: true });
</script>

<template>
  <orio-canvas name="editor" v-model:nodes="nodes">
    <orio-canvas-toolbar canvas="editor" />
    <orio-canvas-stage />
  </orio-canvas>
</template>

Freezing nodes

frozen: true prevents interaction tools from affecting the node. The eraser skips frozen nodes, the move tool can't pick them up, and the highlight tool won't show bounds for them. Use it for background labels, watermarks, or template content the user shouldn't modify.

Built-in Tools

Every tool is a factory function you import and call. There are three kinds:

  • Interaction tools (drawTool, textTool, eraseTool, moveTool, rotateTool, resizeTool, transformTool, highlightTool) — become the active tool, receive pointer events. Erase and move show hover highlights; rotate/resize/transform use a click-to-select model and render handles around the selected node. transformTool combines move, resize, and rotate into a single dispatcher.
  • Action tools (undoTool, redoTool, clearTool, imageTool, exportTool) — fire on click, never become active. They can be disabled. imageTool opens a file picker (PNG, JPEG, WebP, GIF, SVG, AVIF, BMP) and adds the file as an image node. exportTool downloads the canvas as PNG, JPEG, or WebP.
  • Widget tools (colorPickerTool) — render a custom component in the toolbar instead of a button.

All built-in tools ship with tooltip components (using orio-view-text and orio-view-key-binds) showing descriptions and keyboard shortcuts on hover.

Connecting tools

Import the factories you need and pass them as a shallowRef array:

ts
import {
  drawTool,
  textTool,
  eraseTool,
  moveTool,
  rotateTool,
  resizeTool,
  transformTool,
  highlightTool,
  imageTool,
  exportTool,
  colorPickerTool,
  undoTool,
  redoTool,
  clearTool,
} from "orio-ui/canvas";

const tools = shallowRef([
  // Interaction tools — order determines toolbar button order
  drawTool({ color: "#1f7aec", size: 5 }),
  textTool({ fontSize: 28, color: "#222" }),
  eraseTool({ radius: 12 }),
  moveTool(),
  rotateTool(),
  resizeTool(),
  transformTool(),
  highlightTool(),

  // Widget tools
  colorPickerTool({ color: "#1f7aec", targets: ["draw", "text"] }),

  // Action tools
  imageTool(),
  exportTool({ format: "png", filename: "my-canvas" }),
  undoTool(),
  redoTool(),
  clearTool(),
]);

Each factory accepts an optional options object that overrides the tool's defaults. Pass only what you want to change — the rest uses sensible defaults.

drawTool(options?)

Freehand pen with smoothing. Renders a draw node with an array of points.

OptionTypeDefault
colorstring"#111111"
sizenumber4
opacitynumber (0–1)1
brush"pen" | "marker""pen"

textTool(options?)

Click anywhere to drop text. Opens an inline <input> overlay positioned over the click; Enter commits, Esc cancels.

OptionTypeDefault
fontSizenumber24
fontFamilystring"system-ui, sans-serif"
colorstring"#111111"
weight"normal" | "bold" | number"normal"

eraseTool(options?)

Interaction tool. Drag over nodes to erase them. Highlights the target node in red on hover via renderOverlay. Respects frozen — frozen nodes are never erased. Uses each tool's hitTest method for accurate detection; falls back to bounding box for tools without one.

OptionTypeDefault
radiusnumber10

moveTool(options?)

Interaction tool. Click and drag to move nodes. Picks the topmost non-frozen node under the pointer. Highlights the target node on hover via renderOverlay. Handles draw nodes by shifting all points.

Press ] to bring the hovered or dragged node forward or [ to send it backward (changes zIndex). Uses onKeyDown — requires stage focus.

OptionTypeDefault
radiusnumber10

highlightTool(options?)

Interaction tool. Hover over nodes to see a dashed bounding rect via renderOverlay. Useful for inspecting node positions. Skips frozen nodes. Clears highlight on onDeactivate.

OptionTypeDefault
radiusnumber10
strokeColorstring"rgba(31, 122, 236, 0.8)"
fillColorstring"rgba(31, 122, 236, 0.08)"
lineWidthnumber2

undoTool()

Action tool. Calls api.undo(). Disabled when api.canUndo is false.

redoTool()

Action tool. Calls api.redo(). Disabled when api.canRedo is false.

clearTool()

Action tool. Calls api.clear(). Disabled when all nodes are frozen (or none exist). Frozen nodes are preserved.

colorPickerTool(options?)

Widget tool. Renders a native color <input> in the toolbar via the toolbar component property. Syncs the picked color to the color option of the specified target tools using api.getToolOptions().

OptionTypeDefault
colorstring"#111111"
targetsstring[][]
ts
colorPickerTool({ color: "#1f7aec", targets: ["draw", "text"] })

Tool kinds

When authoring a custom tool, set kind on the tool object:

ts
defineCanvasTool({
  id: "my-action",
  label: "Do thing",
  kind: "action",
  action(api) { /* fired on click */ },
  disabled(api) { return false; },
});
KindToolbar behaviorReceives pointer events?
"interaction"Toggles active via orio-button (default)Yes
"action"Fires action() on click via orio-buttonNo
"widget"Renders toolbar component directlyNo

Custom fonts

Text nodes are rendered to <canvas> via ctx.fillText, so any font that the browser knows about is fair game. To register a custom font at runtime, use the FontFace API:

ts
const font = new FontFace("Caveat", "url(/fonts/Caveat.woff2)");
await font.load();
document.fonts.add(font);

Then pass fontFamily: "Caveat" to textTool({ ... }). Once the font is added, the next requestRender() will paint with it. Webfonts loaded via @font-face in your stylesheet work too — just make sure they're loaded before drawing.

Programmatic Control

<orio-canvas> exposes its API via defineExpose, so a template ref gives you everything the tools have:

vue
<script setup>
import { ref } from "vue";

const canvasRef = ref();

function addLabel() {
  canvasRef.value?.addNode({
    type: "text",
    x: 100,
    y: 100,
    data: {
      text: "Hello",
      fontSize: 32,
      fontFamily: "system-ui",
      color: "#0070f3",
      weight: "bold",
    },
  });
}
</script>

<template>
  <orio-canvas name="editor" ref="canvasRef">
    <orio-canvas-toolbar canvas="editor" />
    <orio-canvas-stage />
  </orio-canvas>
  <orio-button @click="addLabel">Add label</orio-button>
</template>

Exposed API

Method / PropertyTypeDescription
addNode(node)(node) => CanvasNodeAdd a node to the canvas.
updateNode(id, patch)(id, patch) => voidUpdate a node by id.
removeNode(id)(id) => voidRemove a node by id.
getNode(id)(id) => CanvasNode | undefinedLook up a node by id.
clear()() => voidRemove all nodes.
nodesRef<CanvasNode[]>Reactive node list.
getToolOptions(id)(id) => TRead/write a tool's reactive options.
activeToolComputedRef<CanvasTool | null>Currently active tool (computed).
setActiveTool(id)(id: string | null) => voidSwitch the active tool. Calls onDeactivate/onActivate.
undo()() => voidUndo the last action.
redo()() => voidRedo the last undone action.
canUndoComputedRef<boolean>Whether there's anything to undo.
canRedoComputedRef<boolean>Whether there's anything to redo.

Undo / Redo

Built-in. Snapshots are captured automatically whenever the nodes array changes (add, update, remove, clear). Pointer actions (draw strokes, drags) are batched into a single undo step via beginAction() / endAction() — the snapshot is taken on pointer up.

Keyboard: Ctrl+Z / Cmd+Z to undo, Ctrl+Shift+Z / Cmd+Shift+Z to redo. The stage must be focused (it auto-focuses on pointer down). The active tool's onKeyDown handler runs first — if it calls e.preventDefault(), the undo/redo shortcut won't fire.

Programmatic:

vue
<script setup>
import { ref } from "vue";

const canvasRef = ref();
</script>

<template>
  <orio-canvas name="editor" ref="canvasRef" :max-history="100">
    <orio-canvas-toolbar canvas="editor" />
    <orio-canvas-stage />
  </orio-canvas>

  <orio-button :disabled="!canvasRef?.canUndo" @click="canvasRef?.undo()">
    Undo
  </orio-button>
  <orio-button :disabled="!canvasRef?.canRedo" @click="canvasRef?.redo()">
    Redo
  </orio-button>
</template>

Via tools (recommended):

Just add undoTool() and redoTool() to your tools list — they render as toolbar buttons with automatic disabled state and keyboard shortcut tooltips.

History is capped at maxHistory entries (default 50). Performing a new action after undoing clears the redo stack — same as any editor.

Internal Architecture

The canvas component is built from three composables that handle distinct concerns. These are internal to the component — you interact with them via props, v-model, and the exposed API.

useCanvasHistory

Manages undo/redo with snapshot-based state tracking. Uses JSON.parse(JSON.stringify()) for deep cloning.

ReturnTypeDescription
undo()() => voidPop the last snapshot and restore it.
redo()() => voidRe-apply the last undone snapshot.
canUndoComputedRef<boolean>Whether the undo stack has entries.
canRedoComputedRef<boolean>Whether the redo stack has entries.
autoCommit()() => voidCommit if not inside a pointer action.
beginAction()() => voidMark the start of a pointer drag (batches commits).
endAction()() => voidMark the end of a pointer drag (commits once).
resetBaseline()() => voidSet the current state as the baseline (after setup).
onKeyDown(e)(e: KeyboardEvent) => voidHandles Ctrl+Z / Ctrl+Shift+Z.

useCanvasNodes

CRUD operations for the node list. Every mutation calls the onMutate callback which triggers autoCommit in the history composable. Node ids are generated with crypto.randomUUID(). New nodes get zIndex: nodes.value.length by default.

ReturnTypeDescription
addNode(node)(node: Omit<CanvasNode, "id">) => CanvasNodeCreate a node with auto-generated id.
updateNode(id, patch)(id: string, patch: Partial<CanvasNode>) => voidMerge a patch into a node.
getNode(id)(id: string) => CanvasNode | undefinedLook up a node by id.
removeNode(id)(id: string) => voidRemove a node by id.
clear()() => voidRemove all nodes.

useCanvasSetup

Runs the setup prop callback once on onMounted. Supports both sync and async functions. After setup completes, it calls resetBaseline() so the initial nodes don't count as an undoable action.

useCanvasContext

Provides the full canvas state to any descendant component via Vue's provide / inject. The injection key is Symbol("OrioCanvas"). Throws if called outside an <orio-canvas> parent.

ts
import { useCanvasContext } from "orio-ui/canvas";

const ctx = useCanvasContext();
// ctx.nodes, ctx.activeToolId, ctx.setActiveTool, ctx.getToolApi, ...
PropertyTypeDescription
toolsRef<CanvasTool[]>All registered tools (computed from props).
activeToolIdRef<string | null>Currently active tool id.
setActiveTool(id: string | null) => voidSwitch the active tool.
nodesRef<CanvasNode[]>Reactive node list (the defineModel).
addNode(node) => CanvasNodeAdd a node.
updateNode(id, patch) => voidUpdate a node.
removeNode(id) => voidRemove a node.
getNode(id) => CanvasNode | undefinedLook up a node.
clear() => voidRemove all nodes.
requestRender() => voidSchedule a canvas redraw via requestAnimationFrame.
installRenderer(fn: () => void) => voidStage installs its render function here.
getToolOptions(id) => TRead/write a tool's reactive options.
getToolApi(id) => CanvasToolApi<T>Full tool API scoped to a tool id (cached per id).
stageElRef<HTMLElement | null>The stage root DOM element.
sizeRef<{ width, height }>Canvas size in CSS pixels (computed from props).
undo() => voidUndo.
redo() => voidRedo.
canUndoComputedRef<boolean>Undo availability.
canRedoComputedRef<boolean>Redo availability.
beginAction() => voidStart a pointer action batch.
endAction() => voidEnd a pointer action batch.
onKeyDown(e: KeyboardEvent) => voidDispatches to active tool's onKeyDown first, then to history undo/redo handler.

Roadmap

Things explicitly not in v1 but the architecture is ready for:

  • Zoom & pan — viewport transform; tools already work in canvas-space.

If you want this now, you can write it as a tool — see Extending Canvas.