Whiteboard shapes and tools

Every shape is a node on the board, and the commands find shapes by their ids. This guide shows how to fill a board, add and change shapes, select them, and choose tools.

Shapes

ShapeNode typedata-typeToolHolds
Sticky notestickyNotesticky-notenoteText
Geometric shapegeogeorectangle, ellipse, diamondText
Free textfreeTextfree-texttextText
Pen linefreehandfreehandpenPoints
Doc framedocFramedoc-framedocA document of its own
ArrowarrowarrowarrowTwo ends

Every shape has an id, and a zIndex that orders the shapes from back to front. All shapes but doc frames have a color, and all shapes but arrows have a box: x, y, width and height, in board units.

  • Shapes with text hold paragraphs and lists. They also have a fontSize, a textAlign and a font.
  • Geometric shapes have a kind from geoKinds: rectangle, ellipse, diamond or triangle. The tools draw the first three, and a shape can change its kind later.
  • Free text has no shape around it. It grows as tall as its text, and its width wraps the lines.
  • Arrows connect shapes or free points. An end that connects a shape follows it. Each end also has a point, where a free end sits. When the shape of a connected end is deleted, the end stays where it was drawn.
  • Doc frames show a document in an editor of their own. See Doc frames.
  • Custom shapes are nodes of your own, with a component of your app inside. See Custom shapes.

See Types for all attributes.

Fill a board

boardContent builds the JSON of a board from the JSON of its shapes. stickyNoteContent and textShapeContent build shapes with one paragraph of text, and freehandFromPoints turns points into the box and points of a pen line:

import {
  boardContent,
  freehandFromPoints,
  stickyNoteContent,
  textShapeContent,
} from '@tiptap-pro/extension-whiteboard'

const wave = freehandFromPoints({
  points: [
    { x: 0, y: 260 },
    { x: 120, y: 300 },
  ],
  strokeWidth: 3,
})

const content = boardContent([
  stickyNoteContent({ id: 'idea', x: 0, y: 0, text: 'An idea' }),
  textShapeContent({
    type: 'geo',
    id: 'plan',
    kind: 'ellipse',
    x: 320,
    y: 0,
    color: 'green',
    text: 'A plan',
  }),
  { type: 'arrow', attrs: { id: 'idea-to-plan', fromId: 'idea', toId: 'plan' } },
  { type: 'freehand', attrs: { id: 'wave', ...wave, strokeWidth: 3 } },
])

editor.commands.setContent(content)

Each shape needs an id that no other shape on the board has. createShapeId returns a new random one. A board is a Tiptap document, so editor.getJSON() returns the JSON to save.

Add shapes

insertShape adds a shape of any shape node type, in front of all other shapes. It puts the caret inside a shape with text, and selects other shapes:

editor.commands.insertShape({ type: 'geo', kind: 'ellipse', x: 100, y: 100 })
editor.commands.insertShape({ type: 'freeText', x: 100, y: 100, width: 240 })
editor.commands.insertShape({ type: 'arrow', fromId: 'idea', toId: 'plan' })
editor.commands.insertShape({ type: 'arrow', fromX: 0, fromY: 0, toX: 200, toY: 100 })

The shape gets a new id unless you pass one, and the styles from setShapeStyle unless you pass them. It adds nothing when an attribute is NaN or infinite, when the type isn't a shape, or when a shape on the board has the id already.

insertStickyNote adds a sticky note. Without a size, the note gets stickyNoteSize, 220 by 160:

editor.commands.insertStickyNote({ x: 100, y: 100 })

Change shapes

updateShape changes attributes of one shape, and updateShapes of several in one step. They skip ids that are not on the board, updates that would change an id or hold NaN or an infinite number, attributes that a shape does not have, and shapes that have the attributes already. They return false when nothing would change.

editor.commands.updateShape({ id: 'plan', attributes: { kind: 'diamond' } })

editor.commands.updateShapes([
  { id: 'idea', attributes: { color: 'blue' } },
  { id: 'plan', attributes: { color: 'blue', fontSize: 'l' } },
])

The other commands take the ids of the shapes:

import { getSelectedShapeIds } from '@tiptap-pro/extension-whiteboard'

const ids = getSelectedShapeIds(editor.state)

// Moves the shapes, in board units. Arrows move their free ends
editor.commands.moveShapes({ ids, delta: { x: 40, y: 0 } })

// Adds copies with new ids, 24 right and down, and selects them
editor.commands.duplicateShapes({ ids })

// Puts the shapes in front of or behind all others, in their order among each other
editor.commands.bringToFront(ids)
editor.commands.sendToBack(ids)

// Deletes the shapes in one step
editor.commands.deleteShapes(ids)

Arrows that connect a deleted shape stay, with a free end where they touched it. Copy, cut and paste work on the selected shapes too. Pasted shapes get new ids, and move down and right until they don't cover a shape at the same spot.

Select shapes

The board keeps the selected shapes as ids, apart from the text selection, so the selection survives changes by other users:

import { getSelectedShapeIds } from '@tiptap-pro/extension-whiteboard'

editor.commands.selectShapes(['idea', 'plan'])
getSelectedShapeIds(editor.state) // ['idea', 'plan']

editor.commands.toggleShapeSelection('plan')
editor.commands.selectAllShapes()
editor.commands.clearShapeSelection()

selectShapes ignores unknown ids, and keeps the order in which the shapes were selected.

With the pointer, a press on a shape selects it and drags it, together with the other selected shapes. Shift adds the shape to the selection or removes it. A click on the only selected shape edits its text where the pointer is, so a double click on a note starts writing. The corner handles resize the pressed shape, and the end handles of a selected arrow move its ends.

Edit text

While text is edited, keys type into one shape. Otherwise keys act on the board and the selected shapes. editShapeText puts the caret into a shape's text, at pos when it lies in that text, and at the end otherwise:

import { getEditedShapeId, isEditingText } from '@tiptap-pro/extension-whiteboard'

editor.chain().focus().editShapeText({ id: 'idea' }).run()

isEditingText(editor.state) // true
getEditedShapeId(editor.state) // 'idea'

Escape stops editing and selects the shape. selectShapeText selects all text of the edited shape, and selections never reach into another shape.

The kit brings the text extensions that shapes use, so their commands work on the edited text:

editor.commands.toggleBold()
editor.commands.setTextColor('red')
editor.commands.setHighlight({ color: 'blue' })
editor.commands.setLink({ href: 'https://tiptap.dev' })
editor.chain().editShapeText({ id: 'idea' }).toggleTaskList().run()

Text colors and highlights store the name of a board color, like shapes do, so your CSS decides how they look. A click with Control, or Cmd on macOS, opens a link in a new tab. Task checkboxes also toggle while no text is edited.

Tools

The tool decides what a press on the board does. The board starts with select:

import { getActiveTool, getToolShapeType, toolKeys } from '@tiptap-pro/extension-whiteboard'

editor.commands.setTool('pen')
getActiveTool(editor.state) // 'pen'

getToolShapeType('note') // 'stickyNote'
toolKeys.pen // 'p'

setTool also ends text editing, so keys act on the board. It doesn't move the focus, so a toolbar button calls the focusBoard() function after a click, like in Add tools. The tools and their default keys:

ToolKeyWhat a press does
selectVSelects and moves shapes
handHPans the board
noteNAdds a sticky note
textTAdds free text
docWAdds a doc frame
rectangleRAdds a rectangle
ellipseOAdds an ellipse
diamondDAdds a diamond
arrowADraws an arrow between two points or two shapes
penPDraws a pen line
eraserEErases the shapes it touches

After a tool adds a shape, the board goes back to select. The pen and the eraser stay active. With the arrow tool, each end connects the shape with text under it, or stays free on the board.

The doc tool needs the docs option. Without it, setTool('doc') returns false, and W goes on to the page.

To change the keys, set the toolKeys option of WhiteboardKit. A key you set replaces the default key of its tool, null leaves a tool without a key, and false turns all tool keys off:

WhiteboardKit.configure({ toolKeys: { pen: 'b', eraser: null } })

Styles for new shapes

setShapeStyle gives a style to the selected shapes, or to the shape whose text is edited, and to the shapes added later. Shapes without the style keep theirs:

import { getNextShapeStyles } from '@tiptap-pro/extension-whiteboard'

editor.commands.setShapeStyle({ name: 'color', value: 'blue' })
editor.commands.setShapeStyle({ name: 'fontSize', value: 'l' })

getNextShapeStyles(editor.state) // { color: 'blue', fontSize: 'l' }

shapeStyles lists the styles and the names of their values. Use it, or shapeColors, to build your menus:

StyleValues
colorblack, gray, red, orange, yellow, green, blue and violet
fontSizes, m, l and xl
textAlignstart, center and end
fontsans, serif and mono
arrowheadsnone, end and both

Shapes store the names, and your CSS decides how they look. See Styling.

Undo and redo

undoBoard and redoBoard undo and redo the last change, and select the shapes that it changed. An undo that only changed text goes back to editing that text:

editor.commands.undoBoard()
editor.commands.redoBoard()

The kit brings Tiptap's UndoRedo. Configure it with the undoRedo option, like WhiteboardKit.configure({ undoRedo: { depth: 50 } }). With collaboration, set undoRedo: false, and undoBoard and redoBoard use the undo of the collaboration instead. See Collaboration.

Build your own transactions

Code that builds its own transaction, like an AI tool, can change shapes with insertShapesInFront, removeShapes and restackShapes. They leave dispatching the transaction to you. With UndoRedo, each makes the transaction its own undo step. With collaboration, Yjs joins changes that come quickly after each other into one step:

import {
  createShapeId,
  insertShapesInFront,
  stickyNoteContent,
} from '@tiptap-pro/extension-whiteboard'

const { tr, schema } = editor.state
const note = stickyNoteContent({ id: createShapeId(), x: 0, y: 0, text: 'Idea' })

insertShapesInFront({ tr, shapes: [schema.nodeFromJSON(note)] })
editor.view.dispatch(tr)

insertShapesInFront throws a RangeError when a shape has no id, or an id that the board or another of the shapes has. Free text and doc frames grow as tall as their content after each change. For content that arrives later, like the document of a doc frame that an AI writes after it adds the frame, keepShapeHeights(tr) keeps the heights that the transaction gives its shapes.

Keyboard shortcuts

While no text is edited:

ActionWindows/LinuxmacOS
Choose a toolThe tool keysThe tool keys
Go back to the select tool, then clear the selectionEscapeEscape
Delete the selected shapesBackspace or DeleteBackspace or Delete
Duplicate the selected shapesControl + DCmd + D
Edit the text of the selected shape, when only one isEnterEnter
Select all shapesControl + ACmd + A
Bring the selected shapes to the front] or Control + Shift + ↑] or Cmd + Shift + ↑
Send the selected shapes to the back[ or Control + Shift + ↓[ or Cmd + Shift + ↓
Move the selected shapes by 1 board unit← → ↑ ↓← → ↑ ↓
Move the selected shapes by 10 board unitsShift + an arrow keyShift + an arrow key
UndoControl + ZCmd + Z
RedoShift + Control + Z or Control + YShift + Cmd + Z or Cmd + Y
PanHold SpaceHold Space

While text is edited, Escape selects the shape, Control + A (Cmd + A on macOS) selects its text, and undo and redo work the same. For the zoom shortcuts, see Stage and camera.

Read-only boards

setEditable(false) makes a board read-only, like any Tiptap editor:

editor.setEditable(false)

Users can still select shapes, pan, zoom and copy. Selected shapes show no handles, and the editor element has data-read-only, for your CSS. Only the select and hand tools work: the board goes back to select from other tools, and their keys go on to the page. Presses and keys change nothing, task checkboxes stay as they are, and nothing stays in editing: editShapeText selects the shape instead. An edit that runs when the board turns read-only stops, and its shape is selected. Other commands still change the board, so your app can, for example, load new content.

To tell whether the board is read-only, like for a button of your app that changes it, use isBoardEditable. editor.isEditable is false whenever the board has the keyboard.

Next steps