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.
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:
navigator.sharewith the file. Opens the OS share sheet, which is the real answer on phones and tablets. Requires HTTPS and a user gesture.- The clipboard. A PNG written with
ClipboardItempastes straight into a post. This is the desktop path. - 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.
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
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)
/**
* 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')
}
}