Skip to content

ドット絵エディタ ​

32×32 のマス目、16色、塗りつぶしとスポイト。アイコン用に 32px で、鑑賞用に 512px で保存できます。

1マスが1シェイプ ​

塗られたマスは、ただの rect です。ドット絵エディタとしては贅沢に聞こえますが、このアプリが短い理由がそれです。消しゴム・元に戻す・やり直す・クリア・書き出しは、1行目を書く前からすでに完成していました。全面を埋めても 1,024 個で、レンダラのベンチマーク(5,000個)の 1/5 です。

文書が絵そのものなので、アプリはビットマップを別に持ちません。必要になったら文書から読み直します。

ts
function readBoard(editor: Editor): Map<string, Cell> {
  const cells = new Map<string, Cell>()
  for (const id of editor.getChildren(null)) {
    const shape = editor.getShape(id)
    if (shape?.type !== 'rect') continue
    const fill = shape.props.fill
    cells.set(cellKey(Math.round(shape.x / CELL), Math.round(shape.y / CELL)), {
      id,
      color: fill.type === 'solid' ? fill.color : '#000000',
    })
  }
  return cells
}

キャッシュせず毎回導出しているのは意図的です。元に戻す・やり直す・ファイル読み込みは、アプリに断りなく文書を書き換えます。 キャッシュを持つと、まさに「元に戻す」を押した瞬間に間違った値を持つことになります。

1ストロークが Undo 1回 ​

30マスを横切るドラッグは、元に戻す1回で消えるべきです。しかし同時に、ユーザーは通過したマスをその場で見る必要があるので、書き込みを遅らせることはできません。この2つは、ストローク中の書き込みに同じ結合キーを与え、指を離したときに履歴を封じることで両立します。

ts
this.editor.transact(() => { /* この移動で通過したマスを塗る */ }, { mergeKey: 'pixel' })

// …そして離したとき:
this.editor.history.mark()   // 次のストロークは別の履歴になる

mark() がないと、1秒以内に描いた2本のストロークが1件にまとまってしまいます。履歴とスナップ →

ポインタイベントは1マスにつき1回など到底来ないので、前回の位置から今回の位置までのマスを補間しています(ツール内の素朴な Bresenham)。

大きく書き出してもボケない ​

editor.export({ scale }) は画像の拡大ではありません。 新しい倍率でシーンを描き直しています。ここでは1ドットが長方形なので、512px の書き出しは同じ長方形を16倍の大きさで描くことになります。エッジは画面で見えているとおりの硬さのままです。

ts
editor.export({
  format: 'png',
  scale: 16 / CELL,
  background: transparent ? null : '#ffffff',
  // 塗られた部分の外接矩形ではなく盤面全体。だから画像は常に 32×32 で、
  // スプライトは自分の位置を保てる。
  bounds: { x: 0, y: 0, width: SIZE, height: SIZE },
})

この bounds は見た目より重要です。指定しないと、隅に描いたスプライトはぴったり切り抜かれ、スプライトたらしめていた位置情報を失います。 ドキュメントと書き出し →

共有 ​

**「SNSなどで共有」**は 512px の PNG を、ブラウザが提供する手段に渡します。スマートフォン・タブレットでは OS の共有シート、デスクトップではクリップボード、どちらも無ければダウンロードです。このページの裏にサーバーはなく、画像をリンク経由で受け取る SNS も存在しないので、静的サイトで正直にできるのはここまでです。お絵描きと同じ3段構えです。

共有する画像は「背景を透明に」がオンでも白背景にしています。 透明はスプライトとして保存するためのもので、タイムラインに流すと、ダークテーマでは暗い色のドットが背景に溶けて消えるためです。

グリッドは1要素 ​

オーバーレイの中に置いてあり、そこにはビューポート変換がすでに掛かっているので、盤面と一緒にパン・ズームします。 マウント時に1回書いたきり、二度と触りません。JavaScript が設定するのは background-size だけです。

css
.hc-app-pixel-grid {
  background-image:
    linear-gradient(to right, var(--hc-app-grid-ink) calc(1px / var(--hc-zoom)), transparent 0),
    linear-gradient(to bottom, var(--hc-app-grid-ink) calc(1px / var(--hc-zoom)), transparent 0);
}

--hc-zoom で割ることで、どのズーム率でも線は画面上1px のままです。既定のハンドルがサイズを保つのとまったく同じ仕組みです。装飾ガイド →

ソース ​

アプリ本体
ts
import type { Editor as EditorInstance, HcPointerEvent, ShapeId, Tool } from '@headless-canvas/core'
import { Editor } from '@headless-canvas/core'
import { createDefaultControls } from '@headless-canvas/ui'
import '@headless-canvas/ui/styles.css'
import type { Demo } from '../demos/types'
import { t } from '../demos/types'
import { button, colorPicker } from '../demos/ui'
import { appScaffold, download, radio, shareImage, shareOutcomeText } from './chrome'

/**
 * A pixel editor.
 *
 * One painted cell is one `rect` shape, which sounds extravagant and is the
 * reason the application is short: erasing, undo, selection and export all
 * arrived already working. A full 32×32 board is 1,024 shapes, comfortably
 * inside what the renderer culls and indexes for a living.
 *
 * It also gets the export right for free. Exporting at 16× does not resample a
 * small bitmap — it draws the same rectangles sixteen times larger, so the
 * edges stay exactly as hard as they look here.
 */

const GRID = 32
const CELL = 12
const SIZE = GRID * CELL

/** Sixteen colours, so the palette is a row of buttons rather than a dialog. */
const PALETTE = [
  '#111827',
  '#ffffff',
  '#ef4444',
  '#f97316',
  '#f59e0b',
  '#fde047',
  '#22c55e',
  '#15803d',
  '#22d3ee',
  '#3b82f6',
  '#1e3a8a',
  '#a855f7',
  '#ec4899',
  '#fbcfe8',
  '#a16207',
  '#9ca3af',
]

type Mode = 'paint' | 'erase' | 'fill' | 'pick'

interface Cell {
  id: ShapeId
  color: string
}

const cellKey = (col: number, row: number): string => `${col},${row}`

const inside = (col: number, row: number): boolean =>
  col >= 0 && row >= 0 && col < GRID && row < GRID

/**
 * The board as it currently stands, read back from the document.
 *
 * Derived rather than cached, because undo, redo and load all rewrite the
 * document without asking the application first — a cache would be wrong
 * exactly when the user pressed undo.
 */
function readBoard(editor: EditorInstance): Map<string, Cell> {
  const cells = new Map<string, Cell>()
  for (const id of editor.getChildren(null)) {
    const shape = editor.getShape(id)
    if (shape?.type !== 'rect') continue
    const fill = shape.props.fill
    cells.set(cellKey(Math.round(shape.x / CELL), Math.round(shape.y / CELL)), {
      id,
      color: fill.type === 'solid' ? fill.color : '#000000',
    })
  }
  return cells
}

/** Cells a straight drag passed through, so a fast stroke leaves no gaps. */
function cellsBetween(
  from: { col: number; row: number },
  to: { col: number; row: number },
): Array<{ col: number; row: number }> {
  const out: Array<{ col: number; row: number }> = []
  const dx = Math.abs(to.col - from.col)
  const dy = Math.abs(to.row - from.row)
  const stepX = from.col < to.col ? 1 : -1
  const stepY = from.row < to.row ? 1 : -1
  let error = dx - dy
  let { col, row } = from

  // Bresenham. Pointer events arrive far too sparsely to paint one cell each.
  for (;;) {
    out.push({ col, row })
    if (col === to.col && row === to.row) return out
    const doubled = error * 2
    if (doubled > -dy) {
      error -= dy
      col += stepX
    }
    if (doubled < dx) {
      error += dx
      row += stepY
    }
  }
}

export interface PixelSettings {
  color: string
  mode: Mode
  /** Called when the eyedropper picks something up. */
  onPick(color: string): void
}

class PixelTool implements Tool {
  readonly id = 'pixel'

  private cells = new Map<string, Cell>()
  private last: { col: number; row: number } | null = null
  private painting = false

  constructor(
    private readonly editor: EditorInstance,
    private readonly settings: PixelSettings,
  ) {}

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

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

  onPointerDown(event: HcPointerEvent): void {
    if (event.button !== 0) return
    const at = this.cellAt(event)
    if (!at) return

    this.cells = readBoard(this.editor)

    if (this.settings.mode === 'pick') {
      const cell = this.cells.get(cellKey(at.col, at.row))
      if (cell) this.settings.onPick(cell.color)
      return
    }

    if (this.settings.mode === 'fill') {
      this.flood(at.col, at.row)
      return
    }

    this.painting = true
    this.editor.tools.setState('dragging')
    this.last = at
    this.apply([at])
  }

  onPointerMove(event: HcPointerEvent): void {
    if (!this.painting) return
    const at = this.cellAt(event)
    if (!at || !this.last) return
    if (at.col === this.last.col && at.row === this.last.row) return
    this.apply(cellsBetween(this.last, at))
    this.last = at
  }

  onPointerUp(): void {
    this.stop()
  }

  /**
   * One drag is one undo.
   *
   * Every cell is written as it is crossed, because the user has to see it, and
   * each write shares a merge key so the history folds them into a single
   * entry. Sealing it on release is what keeps the *next* stroke separate
   * (spec §5.9.3).
   */
  private apply(run: ReadonlyArray<{ col: number; row: number }>): void {
    const erasing = this.settings.mode === 'erase'
    const color = this.settings.color

    this.editor.transact(
      () => {
        for (const { col, row } of run) {
          if (!inside(col, row)) continue
          const key = cellKey(col, row)
          const cell = this.cells.get(key)

          if (erasing) {
            if (!cell) continue
            this.editor.deleteShapes([cell.id])
            this.cells.delete(key)
            continue
          }

          if (cell?.color === color) continue
          if (cell) {
            this.editor.updateShape(cell.id, { props: this.paint(color) })
            this.cells.set(key, { id: cell.id, color })
            continue
          }
          this.cells.set(key, { id: this.create(col, row, color), color })
        }
      },
      { mergeKey: 'pixel' },
    )
  }

  /** Flood the run of same-coloured cells the pointer landed in. */
  private flood(col: number, row: number): void {
    const target = this.cells.get(cellKey(col, row))?.color ?? null
    const color = this.settings.color
    if (target === color) return

    const seen = new Set<string>([cellKey(col, row)])
    const queue = [{ col, row }]
    const run: Array<{ col: number; row: number }> = []

    while (queue.length > 0) {
      const at = queue.pop() as { col: number; row: number }
      run.push(at)
      for (const [dx, dy] of [
        [1, 0],
        [-1, 0],
        [0, 1],
        [0, -1],
      ] as const) {
        const next = { col: at.col + dx, row: at.row + dy }
        const key = cellKey(next.col, next.row)
        if (!inside(next.col, next.row) || seen.has(key)) continue
        if ((this.cells.get(key)?.color ?? null) !== target) continue
        seen.add(key)
        queue.push(next)
      }
    }

    // A fill is one action however many cells it reaches, so no merge key: the
    // next thing the user does starts its own entry.
    this.editor.transact(() => {
      for (const { col, row } of run) {
        const key = cellKey(col, row)
        const cell = this.cells.get(key)
        if (cell) this.editor.updateShape(cell.id, { props: this.paint(color) })
        else this.cells.set(key, { id: this.create(col, row, color), color })
      }
    })
  }

  private create(col: number, row: number, color: string): ShapeId {
    return this.editor.createShape({
      type: 'rect',
      x: col * CELL,
      y: row * CELL,
      width: CELL,
      height: CELL,
      props: this.paint(color),
    })
  }

  private paint(color: string) {
    // Square and unstroked: a pixel is a colour, not a drawing of a box.
    return {
      fill: { type: 'solid', color } as const,
      stroke: null,
      shadow: null,
      cornerRadius: 0,
    }
  }

  private cellAt(event: HcPointerEvent): { col: number; row: number } | null {
    const col = Math.floor(event.world.x / CELL)
    const row = Math.floor(event.world.y / CELL)
    return inside(col, row) ? { col, row } : null
  }

  private stop(): void {
    if (this.painting) this.editor.history.mark()
    this.painting = false
    this.last = null
    if (this.editor.tools.state === 'dragging') this.editor.tools.setState('idle')
  }
}

/** A sprite to open with, so the board is not a blank square. */
const SEED = [
  '..#.....#..',
  '...#...#...',
  '..#######..',
  '.##.###.##.',
  '###########',
  '#.#######.#',
  '#.#.....#.#',
  '...##.##...',
]

export const pixelArt: 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)

  let swatchButtons: HTMLButtonElement[] = []
  let colorInput: HTMLInputElement | null = null

  const settings: PixelSettings = {
    color: PALETTE[6] as string,
    mode: 'paint',
    onPick(color) {
      setColor(color)
      setMode('paint')
      settings.mode = 'paint'
    },
  }

  editor.tools.register('pixel', (e) => new PixelTool(e, settings))
  editor.tools.setCurrent('pixel')

  const setMode = radio<Mode>(
    bar,
    _(['Tool', 'ツール']),
    [
      { value: 'paint', label: _(['Pen', 'ペン']) },
      { value: 'fill', label: _(['Fill', '塗りつぶし']) },
      { value: 'pick', label: _(['Pick', 'スポイト']) },
      { value: 'erase', label: _(['Erase', '消しゴム']) },
    ],
    'paint',
    (mode) => {
      settings.mode = mode
    },
  )

  function setColor(color: string): void {
    settings.color = color
    if (colorInput) colorInput.value = color
    for (const swatch of swatchButtons) {
      swatch.setAttribute('aria-pressed', String(swatch.dataset.color === color))
    }
  }

  const swatches = document.createElement('div')
  swatches.className = 'hc-app-group hc-app-palette'
  swatches.setAttribute('role', 'group')
  swatches.setAttribute('aria-label', _(['Palette', 'パレット']))
  swatchButtons = PALETTE.map((colour) => {
    const swatch = document.createElement('button')
    swatch.type = 'button'
    swatch.className = 'hc-app-swatch'
    swatch.style.background = colour
    swatch.dataset.color = colour
    swatch.setAttribute('aria-label', colour)
    swatch.addEventListener('click', () => setColor(colour))
    swatches.append(swatch)
    return swatch
  })
  bar.append(swatches)

  colorInput = colorPicker(bar, _(['Colour', '色']), settings.color, setColor)

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

  let transparent = false
  const background = () => (transparent ? null : '#ffffff')

  const exportPng = (scale: number) => {
    if (editor.getChildren(null).length === 0) return
    editor
      .export({
        format: 'png',
        scale,
        background: background(),
        // The whole board, not the bounding box of what happens to be painted,
        // so the image is always 32×32 and a sprite keeps its offsets.
        bounds: { x: 0, y: 0, width: SIZE, height: SIZE },
      })
      .then((blob) => download(`pixel-${GRID * scale}.png`, blob))
      .catch(() => undefined)
  }

  // CELL world units per pixel, so scale 1/CELL is one image pixel per cell.
  button(bar, _(['Save 32px', '32px で保存']), () => exportPng(1 / CELL))
  button(bar, _(['Save 512px', '512px で保存']), () => exportPng(16 / CELL))

  /**
   * Shared at 512px and always on white, whatever the checkbox says.
   *
   * A 32-pixel image is a speck in a timeline, and a transparent one is at the
   * mercy of whatever the viewer's app puts behind it — on a dark theme, dark
   * pixels simply vanish. Transparency is for saving a sprite, not for posting.
   */
  button(bar, _(['Share on social', 'SNSなどで共有']), () => {
    if (editor.getChildren(null).length === 0) {
      say(_(['Paint something first.', 'まず何か描いてください。']))
      return
    }
    editor
      .export({
        format: 'png',
        scale: 16 / CELL,
        background: '#ffffff',
        bounds: { x: 0, y: 0, width: SIZE, height: SIZE },
      })
      .then((blob) =>
        shareImage(blob, 'pixel-art.png', 'Made with HeadlessCanvas — https://headlesscanvas.com/'),
      )
      .then((outcome) => say(shareOutcomeText(_, outcome)))
      .catch((error: unknown) => say(String(error)))
  })

  const transparency = document.createElement('label')
  transparency.className = 'hc-demo-field'
  const transparencyBox = document.createElement('input')
  transparencyBox.type = 'checkbox'
  transparencyBox.addEventListener('change', () => {
    transparent = transparencyBox.checked
  })
  const transparencyText = document.createElement('span')
  transparencyText.textContent = _(['Transparent', '背景を透明に'])
  transparency.append(transparencyText, transparencyBox)
  bar.append(transparency)

  hint(
    _([
      'One cell is one rectangle, so undo, erase and export were already written. Painting at 512px redraws the squares larger — it does not blow up a small image.',
      '1マスが1つの長方形です。だから元に戻すも消しゴムも書き出しも、最初から動いています。512px の書き出しは小さい画像を引き伸ばすのではなく、同じ四角を大きく描き直しています。',
    ]),
  )

  /**
   * The grid, as one element.
   *
   * It sits in the overlay, which already carries the viewport transform, so it
   * pans and zooms with the board without being touched again — this is set
   * once and never updated (invariant 3).
   */
  const grid = document.createElement('div')
  grid.className = 'hc-app-pixel-grid'
  grid.style.width = `${SIZE}px`
  grid.style.height = `${SIZE}px`
  grid.style.backgroundSize = `${CELL}px ${CELL}px`
  editor.overlayElement.append(grid)

  const stop = editor.subscribe(() => {
    const painted = editor.getChildren(null).length
    setStatus(
      _([
        `${painted} of ${GRID * GRID} cells painted · ${editor.overlayElement.querySelectorAll('*').length} overlay DOM nodes`,
        `${GRID * GRID} マス中 ${painted} マス · オーバーレイの DOM ノード ${editor.overlayElement.querySelectorAll('*').length} 個`,
      ]),
    )
  })

  editor.transact(() => {
    const offsetCol = Math.floor((GRID - (SEED[0]?.length ?? 0)) / 2)
    const offsetRow = Math.floor((GRID - SEED.length) / 2)
    SEED.forEach((line, row) => {
      ;[...line].forEach((mark, col) => {
        if (mark !== '#') return
        editor.createShape({
          type: 'rect',
          x: (offsetCol + col) * CELL,
          y: (offsetRow + row) * CELL,
          width: CELL,
          height: CELL,
          props: {
            fill: { type: 'solid', color: PALETTE[6] as string },
            stroke: null,
            shadow: null,
            cornerRadius: 0,
          },
        })
      })
    })
  })

  // Fit the board rather than the artwork: an empty board still has a size, and
  // zooming to whatever is painted would lurch about as cells are added.
  const width = editor.container.clientWidth || SIZE
  const height = editor.container.clientHeight || SIZE
  const zoom = Math.max(Math.min((width - 32) / SIZE, (height - 32) / SIZE), 0.1)
  editor.viewport.setCamera({
    z: zoom,
    x: SIZE / 2 - width / 2 / zoom,
    y: SIZE / 2 - height / 2 / zoom,
  })

  setColor(settings.color)

  return {
    dispose() {
      stop()
      grid.remove()
      controls.dispose()
      editor.dispose()
    },
  }
}

← サンプルアプリ一覧

MIT ライセンスで公開しています。