Whiteboard stage and camera

A board lives in three elements. The stage is the element around the board. It takes pointer and wheel events, and clips everything outside it. Inside the stage, the viewport holds the editor. The camera moves and scales the viewport.

The camera

The camera is part of your app's state:

type Camera = {
  x: number
  y: number
  zoom: number
}

x and y are in screen pixels, and a zoom of 1 is 100%. The camera helpers keep the zoom between minZoom, which is 10%, and maxZoom, which is 400%.

Show the board with the camera: move the viewport with toCssTransform, and set --zoom to the zoom. Handles read --zoom to keep their size at every zoom.

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

toCssTransform({ x: 100, y: 50, zoom: 2 }) // 'translate(100px, 50px) scale(2)'

The viewport needs transform-origin: 0 0. See Add the stage CSS for the CSS of the stage and the viewport.

Attach the stage

attachStage connects the board of an editor to its stage. It returns a function that detaches everything again:

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

const detach = attachStage({ stage, editor, updateCamera: setCamera })

updateCamera changes the camera through an update function, like a React state setter. In React, pass the setter of a useState. Without React, apply the update yourself, see Basic setup.

Once attached, users can work with the board in the stage. Its keys work while the focus is in the stage, so the rest of the page, and other boards on it, keep theirs:

  • Pan: drag with space held, with the middle mouse button, or with the hand tool. Turning the wheel or scrolling on a trackpad pans too. On a touch screen, drag the empty board with one finger while the select tool is active, or move two fingers. A pan wins over drawing and selecting. A pan or a click with the hand tool gives the board the keyboard, like a click with select, so its keys work right after a toolbar button chose the tool.
  • Zoom: Control + wheel, or Cmd + wheel on macOS, or a pinch on a trackpad, zooms at the pointer. On a touch screen, a pinch with two fingers zooms at the middle between them. The second finger stops what the first one started, and undoes it, like moving a shape.
  • Zoom shortcuts: Control + = or + zooms in, Control + - zooms out, and Control + 0 goes back to 100%, around the middle of the stage, instead of zooming the page. On macOS, use Cmd instead of Control. Keys in a field are left to the field.
  • Fit: Shift + 1 fits all shapes into view while no text is edited.
  • Draw: with a shape tool, a drag on the board adds a shape. A click adds a note, text, doc frame or geometric shape at a fixed size for its tool, centered on the pointer. The pen draws lines, and the eraser erases the shapes it touches. A doc frame stays when the erase starts on it, so users can erase the pen lines on a document. Drawing also works over other shapes.
  • Select: with the select tool, a drag of the mouse or a pen on the empty board selects the shapes that its box touches. Hold Shift to add them to the selection. A click or a tap on the empty board clears the selection.

Escape cancels a drag while it runs, except a pan.

Keep room for your controls

Shift + 1 keeps 64 screen pixels free at each edge of the stage. When panels float over the stage, keep more room with fitInsets:

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

attachStage({
  stage,
  editor,
  updateCamera: setCamera,
  // Keeps shapes out from under the toolbar when Shift + 1 fits them into view
  fitInsets: () => ({ top: 80, right: 24, bottom: 24, left: 24 }),
})

Show the tool and the pan in CSS

The stage shows the active tool as data-tool, and a pan as data-pan: ready while space is held, and active while the board pans. Use them for cursors:

.stage:where([data-tool]:not([data-tool='select'], [data-tool='hand'])),
.stage:where([data-tool]:not([data-tool='select'], [data-tool='hand'])) * {
  cursor: crosshair;
}

.stage[data-tool='hand'],
.stage[data-tool='hand'] *,
.stage[data-pan='ready'],
.stage[data-pan='ready'] * {
  cursor: grab;
}

.stage[data-pan='active'],
.stage[data-pan='active'] * {
  cursor: grabbing;
}

While a drag runs, the stage holds a preview element: data-selection-box, data-shape-preview, data-arrow-preview or data-pen-preview. See Styling.

Zoom buttons

zoomAtStageCenter changes the zoom around the middle of the stage, so the board stays put there. stepZoom returns the next zoom step in a direction, from 10% to 400%:

import { stepZoom, zoomAtStageCenter } from '@tiptap-pro/extension-whiteboard'

// Zoom in one step
zoomAtStageCenter({
  stage,
  updateCamera: setCamera,
  nextZoom: (zoom) => stepZoom({ zoom, direction: 1 }),
})

// Back to 100%
zoomAtStageCenter({ stage, updateCamera: setCamera, nextZoom: () => 1 })

A zoom between two steps goes to the next step in that direction. For example, stepZoom({ zoom: 0.8, direction: -1 }) returns 0.75.

Fit shapes into view

zoomToFitShapes moves and zooms the camera so every shape is in the stage. It never zooms in beyond 100%, or out beyond minZoom, and does nothing on an empty board:

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

zoomToFitShapes({ stage, editor, updateCamera: setCamera })

scrollShapesIntoView moves the camera as little as it can, so the shapes with the given ids are in the stage. It zooms out only when they don't fit:

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

scrollShapesIntoView({
  stage,
  editor,
  ids: getSelectedShapeIds(editor.state),
  updateCamera: setCamera,
})

Both keep 64 screen pixels free at each edge. Pass insets to change that room, with one number for every edge or one for each.

To show a part of the board, like when the board opens, fitCameraToRect returns the camera that shows a rectangle in the middle of the stage:

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

const camera = fitCameraToRect({
  rect: { x: 0, y: 0, width: 1200, height: 800 },
  viewport: { width: stage.clientWidth, height: stage.clientHeight },
})

It zooms out until the rectangle fits, but not beyond minZoom, and by default never zooms in beyond 100%. Set largestZoom to allow more.

Convert coordinates

Shapes use board units. clientToBoard converts a point in window coordinates, like the clientX and clientY of a pointer event, to board units. It returns null while the board isn't on the page, like before useEditor's editor is mounted, or once the editor is destroyed:

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

stage.addEventListener('pointermove', (event) => {
  const point = clientToBoard({ editor, point: { x: event.clientX, y: event.clientY } })

  if (point) {
    console.log(`The pointer is at ${point.x}, ${point.y} on the board`)
  }
})

visibleBoardRect returns the part of the board that the stage shows. Use it to add a shape where users look:

import { stickyNoteSize, visibleBoardRect } from '@tiptap-pro/extension-whiteboard'

const seen = visibleBoardRect({
  camera,
  viewport: { width: stage.clientWidth, height: stage.clientHeight },
})
const { width, height } = stickyNoteSize

// A new note in the middle of the stage
editor.commands.insertStickyNote({
  x: seen.x + (seen.width - width) / 2,
  y: seen.y + (seen.height - height) / 2,
})

Next steps