Skip to content

Sketchpad ​

Draw with a mouse, a finger or a pen; erase whole strokes; export the result as a PNG or an SVG, or hand it to the share sheet.

Almost none of this is about drawing ​

A stroke is not a stroke type. DrawTool fits the points and produces an ordinary path shape, which is why the application above contains no code for selecting, moving, resizing, undoing, serialising or exporting a stroke — those already worked for paths.

Switch to Select and drag a corner of something you drew. It resizes like any other shape, because it is any other shape.

The cost of that decision is real and worth stating: a path has one stroke width, so pressure-sensitive strokes are not supported. A varying-width stroke is not a stroked line at all but a filled outline, which is a different shape type — see the roadmap.

Erasing, and what "undo" should mean ​

Sweeping the eraser across five strokes is one gesture, so it is one entry in the history. The tool does not delete anything while the pointer is down; it dims the shapes it has crossed through the ephemeral layer and deletes them all in a single transaction on release.

ts
private mark(event: HcPointerEvent): void {
  const target = event.target
  if (target === null || this.marked.has(target)) return
  this.marked.add(target)
  this.editor.setEphemeral(new Map([...this.marked].map((id) => [id, { opacity: 0.15 }])))
}

onPointerUp(): void {
  const ids = [...this.marked]
  this.reset()
  if (ids.length > 0) this.editor.deleteShapes(ids)  // one transaction, one undo
}

Deleting as you go would have been fewer lines and the wrong behaviour: undo would then take five presses to reverse one sweep. History and snapping →

Sharing, honestly ​

There is no server behind this page, and no social network accepts an image through a link — a post's image has to come from the device. So "share" cannot mean "post to X", and the application tries three things in order:

  1. navigator.share with the file. Opens the OS share sheet, which is the real answer on phones and tablets. Requires HTTPS and a user gesture.
  2. The clipboard. A PNG written with ClipboardItem pastes straight into a post. This is the desktop path.
  3. A download. Always available.

A dismissed share sheet stops there rather than falling through — handing someone a file they just declined to send is not a fallback.

ts
if (typeof navigator.share === 'function' && navigator.canShare?.({ files: [file] })) {
  try {
    await navigator.share({ files: [file], text })
    return 'shared'
  } catch (error) {
    if (error instanceof DOMException && error.name === 'AbortError') return 'cancelled'
  }
}

Two exports, one drawing ​

editor.export() rasterises at any scale; editor.exportSvg() writes the strokes as vectors. Both take the same background and padding, so the PNG and the SVG frame the drawing identically.

The SVG is worth opening: the strokes come out as <path> elements with the curve data the fitting produced, not as a traced bitmap. Documents and export →

Source ​

The toolbar, palette, eraser and share helpers live in a shared chrome module, which is documentation-site code rather than library code — HeadlessCanvas ships no application UI at all.

The application
ts
import { DrawTool, type DrawToolOptions, Editor, type Vec } from '@headless-canvas/core'
import { createDefaultControls } from '@headless-canvas/ui'
import '@headless-canvas/ui/styles.css'
import { addStroke } from '../demos/seed'
import type { Demo } from '../demos/types'
import { t } from '../demos/types'
import { button, colorPicker, slider } from '../demos/ui'
import { appScaffold, download, EraseTool, radio, shareImage, shareOutcomeText } from './chrome'

/**
 * A sketchpad.
 *
 * Small on purpose: every stroke is an ordinary `path` shape, so selecting,
 * moving, resizing, undoing and exporting are already implemented before this
 * file starts. What is left for the application is the part a library should
 * not decide — the toolbar, the palette, and what "share" means.
 */

const SHARE_TEXT = 'Drawn with HeadlessCanvas — https://headlesscanvas.com/'

const PALETTE = ['#111827', '#2563eb', '#dc2626', '#16a34a', '#f59e0b', '#7c3aed']

type Mode = 'draw' | 'erase' | 'select'

/** Points around a circle, sampled the way a pointer moving in one would be. */
function ring(centre: Vec, radius: number, samples = 40, from = 0, to = Math.PI * 2): Vec[] {
  return Array.from({ length: samples }, (_, i) => {
    const angle = from + ((to - from) * i) / (samples - 1)
    return { x: centre.x + Math.cos(angle) * radius, y: centre.y + Math.sin(angle) * radius }
  })
}

export const sketchpad: Demo = ({ root, lang }) => {
  const _ = t(lang)
  const { bar, stage, setStatus, hint, say } = appScaffold(root, { plain: true })

  const editor = new Editor({ container: stage })
  const controls = createDefaultControls(editor)

  /**
   * Held by reference and handed to a fresh tool whenever it changes.
   *
   * Re-registering under the same id replaces the live instance, which is how a
   * tool gets reconfigured — there is no settings API on the tool itself, and
   * an application tool would be configured the same way.
   */
  const pen: DrawToolOptions = {
    color: PALETTE[1] as string,
    width: 6,
    tolerance: 1,
    smoothing: 1,
    minDistance: 2,
  }
  const applyPen = () => editor.tools.register('draw', (e) => new DrawTool(e, { ...pen }))

  applyPen()
  editor.tools.register('erase', (e) => new EraseTool(e))
  editor.tools.setCurrent('draw')

  const setMode = radio<Mode>(
    bar,
    _(['Tool', 'ツール']),
    [
      { value: 'draw', label: _(['Draw', '描く']) },
      { value: 'erase', label: _(['Erase', '消す']) },
      { value: 'select', label: _(['Select', '選択']) },
    ],
    'draw',
    (mode) => editor.tools.setCurrent(mode === 'select' ? 'select' : mode),
  )

  colorPicker(bar, _(['Ink', 'インク']), pen.color, (value) => {
    pen.color = value
    applyPen()
  })

  slider(bar, _(['Width', '太さ']), { min: 1, max: 32, step: 1, value: pen.width }, (value) => {
    pen.width = value
    applyPen()
  })

  const swatches = document.createElement('div')
  swatches.className = 'hc-app-group'
  swatches.setAttribute('role', 'group')
  swatches.setAttribute('aria-label', _(['Palette', 'パレット']))
  for (const colour of PALETTE) {
    const swatch = document.createElement('button')
    swatch.type = 'button'
    swatch.className = 'hc-app-swatch'
    swatch.style.background = colour
    swatch.setAttribute('aria-label', colour)
    swatch.addEventListener('click', () => {
      pen.color = colour
      applyPen()
      // Picking a colour is a statement of intent to draw with it.
      setMode('draw')
      editor.tools.setCurrent('draw')
    })
    swatches.append(swatch)
  }
  bar.append(swatches)

  button(bar, _(['Undo', '元に戻す']), () => editor.history.undo())
  button(bar, _(['Redo', 'やり直す']), () => editor.history.redo())
  button(bar, _(['Clear', 'クリア']), () => editor.deleteShapes(editor.getChildren(null)))

  /** Both exports share a frame, so the two files show the same drawing. */
  const frame = { background: '#ffffff', padding: 24 } as const

  const empty = () => editor.getChildren(null).length === 0
  const nothingToExport = () => say(_(['Draw something first.', 'まず何か描いてください。']))

  button(bar, _(['Save PNG', 'PNG保存']), () => {
    if (empty()) return nothingToExport()
    editor
      .export({ format: 'png', scale: 2, ...frame })
      .then((blob) => {
        download('sketch.png', blob)
        say(_(['Saved sketch.png at 2×.', 'sketch.png を 2 倍で保存しました。']))
      })
      .catch((error: unknown) => say(String(error)))
  })

  // Vectors rather than pixels: the file is the strokes themselves, so it stays
  // sharp at any size and opens in a drawing program.
  button(bar, _(['Save SVG', 'SVG保存']), () => {
    if (empty()) return nothingToExport()
    const svg = editor.exportSvg(frame)
    download('sketch.svg', new Blob([svg], { type: 'image/svg+xml' }))
    say(
      _([
        `Saved sketch.svg — ${svg.length.toLocaleString()} characters of vector.`,
        `sketch.svg を保存しました(ベクタ ${svg.length.toLocaleString()} 文字)。`,
      ]),
    )
  })

  button(bar, _(['Share on social', 'SNSなどで共有']), () => {
    if (empty()) return nothingToExport()
    editor
      .export({ format: 'png', scale: 2, ...frame })
      .then((blob) => shareImage(blob, 'sketch.png', SHARE_TEXT))
      .then((outcome) => say(shareOutcomeText(_, outcome)))
      .catch((error: unknown) => say(String(error)))
  })

  hint(
    _([
      'Draw with a mouse, a finger or a pen. Erase sweeps away whole strokes — one undo brings back everything a single sweep removed.',
      'マウス・指・ペンで描けます。「消す」はストロークごと消えます。ひと続きのなぞりで消したものは、元に戻す1回でまとめて戻ります。',
    ]),
  )

  const report = () => {
    const ids = editor.getChildren(null)
    setStatus(
      _([
        `${ids.length} stroke(s) · ${editor.selection.ids.length} selected · ` +
          `${editor.overlayElement.querySelectorAll('*').length} overlay DOM nodes`,
        `${ids.length} 本のストローク · ${editor.selection.ids.length} 個選択中 · ` +
          `オーバーレイの DOM ノード ${editor.overlayElement.querySelectorAll('*').length} 個`,
      ]),
    )
  }

  // Something to look at, and something to select and resize without drawing
  // first. One transaction, so a single undo clears the lot.
  editor.transact(() => {
    addStroke(editor, ring({ x: 150, y: 150 }, 70), { color: '#f59e0b', width: 8 })
    addStroke(editor, ring({ x: 320, y: 210 }, 90, 30, Math.PI * 0.15, Math.PI * 0.85), {
      color: '#2563eb',
      width: 6,
    })
    addStroke(
      editor,
      [
        { x: 430, y: 120 },
        { x: 455, y: 148 },
        { x: 462, y: 155 },
        { x: 520, y: 70 },
      ],
      { color: '#16a34a', width: 9 },
    )
  })

  const stop = editor.subscribe(report)
  report()

  return {
    dispose() {
      stop()
      controls.dispose()
      editor.dispose()
    },
  }
}
Shared chrome (toolbar, eraser, share)
ts
/**
 * Chrome shared by the sample applications.
 *
 * None of this is library code. HeadlessCanvas ships no toolbars, panels or
 * dialogs at all (spec §3, non-goals), so an application that wants them builds
 * them — and these pages are applications. Keeping the two apart in the source
 * tree is the same boundary the pages describe in prose.
 */

import type { AnyShape, Editor, HcPointerEvent, ShapeId, Tool } from '@headless-canvas/core'
import type { Text } from '../demos/types'

/** Taller than a feature demo: these are applications, not illustrations. */
export const APP_HEIGHT = 440

export interface AppScaffold {
  bar: HTMLDivElement
  stage: HTMLDivElement
  /** Present only when `panel` was requested. */
  panel: HTMLDivElement | null
  status: HTMLDivElement
  setStatus(text: string): void
  /** A line of guidance under the toolbar. */
  hint(text: string): void
  /** Feedback for a one-off action, below the status line. */
  say(text: string): void
}

export interface AppScaffoldOptions {
  height?: number
  /** Add a column beside the stage for application UI. */
  panel?: boolean
  /** Plain white rather than the dotted stage the feature demos use. */
  plain?: boolean
}

export function appScaffold(root: HTMLElement, options: AppScaffoldOptions = {}): AppScaffold {
  const bar = document.createElement('div')
  bar.className = 'hc-demo-bar'

  const stage = document.createElement('div')
  stage.className = options.plain ? 'hc-demo-stage hc-app-plain' : 'hc-demo-stage'
  stage.style.height = `${options.height ?? APP_HEIGHT}px`

  const status = document.createElement('div')
  status.className = 'hc-demo-status'

  const note = document.createElement('p')
  note.className = 'hc-demo-hint hc-app-note'

  let panel: HTMLDivElement | null = null
  if (options.panel) {
    const split = document.createElement('div')
    split.className = 'hc-demo-split'
    panel = document.createElement('div')
    panel.className = 'hc-demo-panel'
    split.append(stage, panel)
    root.append(bar, split, status, note)
  } else {
    root.append(bar, stage, status, note)
  }

  return {
    bar,
    stage,
    panel,
    status,
    setStatus(text) {
      status.textContent = text
    },
    hint(text) {
      const line = document.createElement('p')
      line.className = 'hc-demo-hint'
      line.textContent = text
      bar.append(line)
    },
    say(text) {
      note.textContent = text
    },
  }
}

export interface RadioOption<T extends string> {
  value: T
  label: string
}

/**
 * A set of mutually exclusive buttons.
 *
 * `aria-pressed` inside a labelled group rather than a class, so the current
 * mode is announced rather than only drawn — the same reason the library puts
 * its own state on data attributes instead of class names.
 */
export function radio<T extends string>(
  bar: HTMLElement,
  groupLabel: string,
  options: readonly RadioOption<T>[],
  initial: T,
  onChange: (value: T) => void,
): (value: T) => void {
  const group = document.createElement('div')
  group.className = 'hc-app-group'
  group.setAttribute('role', 'group')
  group.setAttribute('aria-label', groupLabel)

  const buttons = new Map<T, HTMLButtonElement>()
  let current = initial

  const select = (value: T): void => {
    current = value
    for (const [key, element] of buttons) {
      element.setAttribute('aria-pressed', String(key === value))
    }
  }

  for (const option of options) {
    const element = document.createElement('button')
    element.type = 'button'
    element.className = 'hc-demo-button'
    element.textContent = option.label
    element.addEventListener('click', () => {
      if (option.value === current) return
      select(option.value)
      onChange(option.value)
    })
    buttons.set(option.value, element)
    group.append(element)
  }

  select(initial)
  bar.append(group)
  return select
}

export function download(filename: string, blob: Blob): void {
  const url = URL.createObjectURL(blob)
  const link = document.createElement('a')
  link.href = url
  link.download = filename
  link.click()
  URL.revokeObjectURL(url)
}

export type ShareOutcome = 'shared' | 'copied' | 'downloaded' | 'cancelled'

/** What to tell the user after `shareImage`, so every application says it the same way. */
export function shareOutcomeText(_: (text: Text) => string, outcome: ShareOutcome): string {
  switch (outcome) {
    case 'shared':
      return _(['Handed to the share sheet.', '共有シートに渡しました。'])
    case 'copied':
      return _([
        'Copied to the clipboard — paste it into a post.',
        'クリップボードにコピーしました。投稿欄に貼り付けてください。',
      ])
    case 'downloaded':
      return _([
        'Downloaded — this browser offers neither sharing nor image copy.',
        'ダウンロードしました。このブラウザは共有もクリップボードもサポートしていません。',
      ])
    case 'cancelled':
      return _(['Sharing cancelled.', '共有を取り消しました。'])
  }
}

/**
 * Hand an image to whatever the browser can hand it to.
 *
 * There is no upload endpoint behind this page, and no social network accepts
 * an image through a link — the file has to reach the post from the device. So
 * the useful thing is not "post to X" but "get this file into the share sheet",
 * and the three rungs below are what browsers actually offer:
 *
 * 1. `navigator.share` with a file, which opens the OS share sheet. This is the
 *    real answer on phones and tablets, where most drawing happens.
 * 2. The clipboard, where a PNG can be pasted straight into a post. Desktop.
 * 3. A download, which always works.
 *
 * A dismissed share sheet stops here rather than falling through: handing
 * someone a file they just declined to send is not a fallback.
 */
export async function shareImage(
  blob: Blob,
  filename: string,
  text: string,
): Promise<ShareOutcome> {
  const file = new File([blob], filename, { type: blob.type })

  if (typeof navigator.share === 'function' && navigator.canShare?.({ files: [file] })) {
    try {
      await navigator.share({ files: [file], text })
      return 'shared'
    } catch (error) {
      if (error instanceof DOMException && error.name === 'AbortError') return 'cancelled'
    }
  }

  if (blob.type === 'image/png' && typeof ClipboardItem === 'function') {
    try {
      await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })])
      return 'copied'
    } catch {
      // Denied, or unsupported despite the feature test. Fall through.
    }
  }

  download(filename, blob)
  return 'downloaded'
}

/**
 * Delete whatever the pointer is dragged across.
 *
 * The marked shapes are dimmed through the ephemeral layer while the pointer is
 * down and deleted in one transaction on release, so a swipe that clears five
 * strokes is one entry in the history rather than five — which is what the user
 * means by "undo that" (spec §5.2.4).
 */
export class EraseTool implements Tool {
  readonly id = 'erase'

  private readonly marked = new Set<ShapeId>()
  private erasing = false

  constructor(private readonly editor: Editor) {}

  onExit(): void {
    this.reset()
  }

  onCancel(): void {
    this.reset()
  }

  onPointerDown(event: HcPointerEvent): void {
    if (event.button !== 0) return
    this.erasing = true
    this.editor.tools.setState('dragging')
    this.mark(event)
  }

  onPointerMove(event: HcPointerEvent): void {
    if (this.erasing) this.mark(event)
  }

  onPointerUp(): void {
    if (!this.erasing) return
    const ids = [...this.marked]
    this.reset()
    if (ids.length > 0) this.editor.deleteShapes(ids)
  }

  private mark(event: HcPointerEvent): void {
    const target = event.target
    if (target === null || this.marked.has(target)) return
    this.marked.add(target)
    const dimmed: Array<[ShapeId, Partial<AnyShape>]> = [...this.marked].map((id) => [
      id,
      { opacity: 0.15 },
    ])
    this.editor.setEphemeral(new Map(dimmed))
  }

  private reset(): void {
    this.marked.clear()
    this.erasing = false
    this.editor.clearEphemeral()
    if (this.editor.tools.state === 'dragging') this.editor.tools.setState('idle')
  }
}

← All sample applications

Released under the MIT License.