お絵描き
マウス・指・ペンで描き、ストロークごと消し、PNG か SVG で書き出すか、共有シートに渡します。
ほとんど「描画のコード」ではありません
ストロークは専用のシェイプ型ではありません。DrawTool が点を整形して、ただの path シェイプを作ります。だから上のアプリには、ストロークの選択・移動・リサイズ・元に戻す・シリアライズ・書き出しのコードが1行もありません。パスに対しては、すでに全部動いていたからです。
**「選択」**に切り替えて、描いたものの角をドラッグしてみてください。他の図形と同じようにリサイズできます。実際、他の図形と同じものだからです。
この判断の代償ははっきりしているので、隠さずに書きます。path の線幅は1つなので、筆圧による可変幅には対応していません。 幅の変わるストロークは「線を引く」処理ではなく「輪郭を塗る」処理で、別のシェイプ型になります。ツールガイド →
消しゴムと、「元に戻す」の正しい粒度
消しゴムで5本まとめてなぞるのは1つの操作なので、履歴も1件です。このツールはポインタが下りている間は何も削除しません。通過したシェイプを**一時状態(ephemeral)**で薄くしておき、指を離した時に1つのトランザクションでまとめて削除します。
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) // トランザクション1回 = 履歴1件
}なぞりながら削除するほうがコードは短くなりますが、挙動としては誤りです。ひと続きのなぞりを取り消すのに、元に戻すを5回押させることになります。履歴とスナップ →
共有について、正直に
このページの裏にサーバーはありません。そして画像をリンク経由で受け取ってくれる SNS は存在しません。 投稿に付く画像は端末から渡すしかないので、「共有」は「X に投稿する」という意味にはなりえません。このアプリは次の3つを順に試します。
navigator.shareにファイルを渡す。 OS の共有シートが開きます。描画の大半が起きるスマートフォン・タブレットでは、これが本命です。HTTPS とユーザー操作が必要です。- クリップボード。
ClipboardItemで書いた PNG は投稿欄にそのまま貼り付けられます。デスクトップはこちらです。 - ダウンロード。 常に使えます。
共有シートを閉じた場合は、そこで止めます。渡すのをやめた相手にファイルを押し付けるのは、代替手段ではありません。
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'
}
}2つの書き出し、1つの絵
editor.export() は任意の倍率でラスタライズし、editor.exportSvg() はストロークをベクタとして書き出します。どちらも同じ background と padding を受け取るので、PNG と SVG の余白は一致します。
SVG は開いてみる価値があります。ビットマップをトレースしたものではなく、整形処理が生成した曲線データそのものが <path> として出てきます。 ドキュメントと書き出し →
ソース
ツールバー・パレット・消しゴム・共有の部品は共有のモジュールにあります。これはドキュメントサイトのコードであってライブラリのコードではありません。HeadlessCanvas はアプリケーション UI を一切同梱しないためです。
アプリ本体
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()
},
}
}共有部品(ツールバー・消しゴム・共有)
/**
* 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')
}
}