OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid / webui / static / picker.js

346 lines · javascript ref:
// A Blender-style colour picker: hue/saturation wheel, a value bar beside it,
// and RGB / HSV / Hex fields underneath. Pointer events, so mouse and touch
// both drag. Values are 0..1 throughout, as in Blender.
//
//   const p = new Picker(el, { onChange: ({ h, s, v }) => ..., onStart, onEnd })
//   p.set({ h, s, v })   // from the server; ignored while the user drags
//   p.setEnabled(false)  // someone else holds the slot: no input
//
// onStart / onEnd bracket a wheel or value-bar drag, so the page can take the
// slot's grant for exactly as long as the pointer is down.

import { hsvToRgb, rgbToHsv, toHex, fromHex } from './color.js'
import { PALETTE, pushRecent, addSwatch, removeSwatch } from './swatches.js'

// Recent and saved swatches, per browser, shared by every picker on the page.
// Storage may be unavailable (private mode): then they last for this page load.
const mem = {}
function load (key) {
  try { return JSON.parse(localStorage.getItem(key) || '[]') } catch { return mem[key] || [] }
}
function save (key, list) {
  mem[key] = list
  try { localStorage.setItem(key, JSON.stringify(list)) } catch {}
}
const RECENT = 'cthuloid.recent'
const MINE = 'cthuloid.swatches'

const WHEEL_CSS = 184 // px
const TAU = Math.PI * 2

// The wheel at full value is the same for every picker: draw it once.
let wheelImage = null
function wheelBitmap (size) {
  if (wheelImage && wheelImage.width === size) return wheelImage
  const c = document.createElement('canvas')
  c.width = c.height = size
  const ctx = c.getContext('2d')
  const img = ctx.createImageData(size, size)
  const R = size / 2
  for (let y = 0; y < size; y++) {
    for (let x = 0; x < size; x++) {
      const dx = x + 0.5 - R
      const dy = y + 0.5 - R
      const d = Math.sqrt(dx * dx + dy * dy)
      if (d > R) continue
      // Hue 0 (red) at the right, increasing counter-clockwise.
      const h = (Math.atan2(-dy, dx) / TAU + 1) % 1
      const { r, g, b } = hsvToRgb(h, d / R, 1)
      const i = (y * size + x) * 4
      img.data[i] = r * 255
      img.data[i + 1] = g * 255
      img.data[i + 2] = b * 255
      img.data[i + 3] = Math.min(1, R - d) * 255 // soft edge
    }
  }
  ctx.putImageData(img, 0, 0)
  wheelImage = c
  return c
}

export class Picker {
  constructor (root, { onChange, onStart = () => {}, onEnd = () => {} }) {
    this.onChange = onChange
    this.onStart = onStart
    this.onEnd = onEnd
    this.enabled = true
    this.h = 0
    this.s = 0
    this.v = 1
    this.dragging = false
    this.tab = 'rgb'
    this.editing = false   // Swatches tab: show remove buttons on saved swatches

    root.classList.add('picker')
    root.innerHTML = `
      <div class="pk-top">
        <canvas class="pk-wheel" aria-label="Hue and saturation"></canvas>
        <div class="pk-vbar" aria-label="Value (brightness)"><div class="pk-vhandle"></div></div>
      </div>
      <div class="pk-tabs" role="tablist">
        <button type="button" data-tab="rgb">RGB</button>
        <button type="button" data-tab="hsv">HSV</button>
        <button type="button" data-tab="hex">Hex</button>
        <button type="button" data-tab="swatches">Swatches</button>
      </div>
      <div class="pk-fields"></div>`
    this.root = root
    this.canvas = root.querySelector('.pk-wheel')
    this.vbar = root.querySelector('.pk-vbar')
    this.vhandle = root.querySelector('.pk-vhandle')
    this.fields = root.querySelector('.pk-fields')

    const dpr = window.devicePixelRatio || 1
    this.px = Math.round(WHEEL_CSS * dpr)
    this.canvas.width = this.canvas.height = this.px
    this.canvas.style.width = this.canvas.style.height = WHEEL_CSS + 'px'
    this.ctx = this.canvas.getContext('2d')

    this._drag(this.canvas, (e) => this._fromWheel(e))
    this._drag(this.vbar, (e) => this._fromBar(e))
    for (const b of root.querySelectorAll('.pk-tabs button')) {
      b.onclick = () => { this.tab = b.dataset.tab; this._buildFields() }
    }
    this._buildFields()
    this._draw()
  }

  setEnabled (on) {
    this.enabled = on
    this.root.classList.toggle('disabled', !on)
    for (const el of this.root.querySelectorAll('input, button')) el.disabled = !on
  }

  set ({ h, s, v }) {
    if (this.dragging) return
    this.h = h
    this.s = s
    this.v = v
    this._draw()
  }

  // ---- input --------------------------------------------------------------

  _drag (el, apply) {
    el.addEventListener('pointerdown', (e) => {
      e.preventDefault()
      if (!this.enabled) return
      el.setPointerCapture(e.pointerId)
      this.dragging = true
      this.onStart()
      apply(e)
    })
    el.addEventListener('pointermove', (e) => {
      if (this.dragging && el.hasPointerCapture(e.pointerId)) apply(e)
    })
    const end = (e) => {
      if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
      if (this.dragging) {
        this.dragging = false
        this._remember()
        this.onEnd()
      }
    }
    el.addEventListener('pointerup', end)
    el.addEventListener('pointercancel', end)
  }

  _fromWheel (e) {
    const r = this.canvas.getBoundingClientRect()
    const R = r.width / 2
    const dx = e.clientX - r.left - R
    const dy = e.clientY - r.top - R
    this.h = (Math.atan2(-dy, dx) / TAU + 1) % 1
    this.s = Math.min(1, Math.sqrt(dx * dx + dy * dy) / R)
    this._changed()
  }

  _fromBar (e) {
    const r = this.vbar.getBoundingClientRect()
    this.v = Math.max(0, Math.min(1, 1 - (e.clientY - r.top) / r.height))
    this._changed()
  }

  _changed () {
    this._draw()
    this.onChange({ h: this.h, s: this.s, v: this.v })
  }

  // ---- fields -------------------------------------------------------------

  _buildFields () {
    for (const b of this.root.querySelectorAll('.pk-tabs button')) {
      b.classList.toggle('on', b.dataset.tab === this.tab)
    }
    this.fields.innerHTML = ''
    if (this.tab === 'swatches') return this._buildSwatches()
    if (this.tab === 'hex') {
      const row = document.createElement('label')
      row.className = 'pk-row'
      row.innerHTML = '<span>Hex</span><input type="text" class="pk-hex" maxlength="7" spellcheck="false">'
      const input = row.querySelector('input')
      input.addEventListener('change', () => {
        const c = fromHex(input.value)
        if (!c) return this._syncFields()
        this._setRgb(c.r, c.g, c.b)
      })
      this.fields.appendChild(row)
    } else {
      const names = this.tab === 'rgb' ? ['R', 'G', 'B'] : ['H', 'S', 'V']
      for (let i = 0; i < 3; i++) {
        const row = document.createElement('label')
        row.className = 'pk-row'
        row.innerHTML = '<span>' + names[i] + '</span>' +
          '<input type="range" min="0" max="1" step="0.001">' +
          '<input type="number" min="0" max="1" step="0.01">'
        const [range, num] = row.querySelectorAll('input')
        const apply = (val) => {
          val = Math.max(0, Math.min(1, Number(val) || 0))
          if (this.tab === 'rgb') {
            const c = hsvToRgb(this.h, this.s, this.v)
            const rgb = [c.r, c.g, c.b]
            rgb[i] = val
            this._setRgb(rgb[0], rgb[1], rgb[2])
          } else {
            if (i === 0) this.h = val
            else if (i === 1) this.s = val
            else this.v = val
            this._changed()
          }
        }
        range.addEventListener('input', () => apply(range.value))
        num.addEventListener('change', () => apply(num.value))
        this.fields.appendChild(row)
      }
    }
    this._syncFields()
  }

  // ---- swatches -------------------------------------------------------------

  _color () {
    return hsvToRgb(this.h, this.s, 1)   // full-drive colour; brightness is separate
  }

  _remember () {
    save(RECENT, pushRecent(load(RECENT), this._color()))
  }

  // One tap: take the swatch's colour, keep the slot's brightness (or full, if
  // it was off), and send it like any other change.
  _apply (c) {
    const hsv = rgbToHsv(c.r, c.g, c.b)
    if (hsv.s > 0) this.h = hsv.h
    this.s = hsv.s
    if (this.v === 0) this.v = 1
    this._changed()
    this._remember()
    if (this.tab === 'swatches') this._buildSwatches()
  }

  _buildSwatches () {
    const f = this.fields
    f.innerHTML = ''
    const section = (title, list, { removable = false } = {}) => {
      const h = document.createElement('div')
      h.className = 'pk-sw-title small'
      h.textContent = title
      const grid = document.createElement('div')
      grid.className = 'pk-swatches'
      list.forEach((c, i) => {
        const b = document.createElement('button')
        b.type = 'button'
        b.className = 'pk-swatch'
        b.style.background = toHex(c)
        b.title = c.name || toHex(c)
        b.setAttribute('aria-label', b.title)
        if (removable && this.editing) {
          b.classList.add('removing')
          b.textContent = '×'
          b.onclick = () => { save(MINE, removeSwatch(load(MINE), i)); this._buildSwatches() }
        } else {
          b.onclick = () => this._apply(c)
        }
        b.disabled = !this.enabled
        grid.appendChild(b)
      })
      f.append(h, grid)
    }
    section('Palette', PALETTE)
    const recent = load(RECENT)
    if (recent.length) section('Recent', recent)
    const mine = load(MINE)
    section('My swatches', mine, { removable: true })
    const row = document.createElement('div')
    row.className = 'pk-sw-actions'
    const add = document.createElement('button')
    add.type = 'button'
    add.textContent = '+ Save current'
    add.onclick = () => { save(MINE, addSwatch(load(MINE), this._color())); this._buildSwatches() }
    const edit = document.createElement('button')
    edit.type = 'button'
    edit.textContent = this.editing ? 'Done' : 'Edit'
    edit.hidden = mine.length === 0 && !this.editing
    edit.onclick = () => { this.editing = !this.editing; this._buildSwatches() }
    add.disabled = !this.enabled
    row.append(add, edit)
    f.appendChild(row)
  }

  _setRgb (r, g, b) {
    const hsv = rgbToHsv(r, g, b)
    // Grey or black has no hue: keep the one we had so the wheel doesn't jump.
    if (hsv.s > 0 && hsv.v > 0) this.h = hsv.h
    this.s = hsv.s
    this.v = hsv.v
    this._changed()
  }

  _syncFields () {
    if (this.tab === 'swatches') return
    const active = document.activeElement
    if (this.tab === 'hex') {
      const input = this.fields.querySelector('.pk-hex')
      if (input !== active) input.value = toHex(hsvToRgb(this.h, this.s, this.v))
      return
    }
    const c = hsvToRgb(this.h, this.s, this.v)
    const vals = this.tab === 'rgb' ? [c.r, c.g, c.b] : [this.h, this.s, this.v]
    this.fields.querySelectorAll('.pk-row').forEach((row, i) => {
      const [range, num] = row.querySelectorAll('input')
      if (range !== active) range.value = vals[i]
      if (num !== active) num.value = vals[i].toFixed(3)
    })
  }

  // ---- drawing ------------------------------------------------------------

  _draw () {
    const { ctx, px } = this
    const R = px / 2
    ctx.clearRect(0, 0, px, px)
    ctx.drawImage(wheelBitmap(px), 0, 0)
    // Like Blender, the wheel shows the current value: darken it toward V = 0.
    if (this.v < 1) {
      ctx.fillStyle = 'rgba(0,0,0,' + (1 - this.v) + ')'
      ctx.beginPath()
      ctx.arc(R, R, R, 0, TAU)
      ctx.fill()
    }
    const a = this.h * TAU
    const x = R + Math.cos(a) * this.s * R
    const y = R - Math.sin(a) * this.s * R
    const dpr = px / WHEEL_CSS
    ctx.lineWidth = 2 * dpr
    ctx.strokeStyle = this.v > 0.5 ? '#000' : '#fff'
    ctx.beginPath()
    ctx.arc(x, y, 6 * dpr, 0, TAU)
    ctx.stroke()

    const full = hsvToRgb(this.h, this.s, 1)
    this.vbar.style.background = 'linear-gradient(to top, #000, ' + toHex(full) + ')'
    this.vhandle.style.top = ((1 - this.v) * 100) + '%'
    this._syncFields()
  }
}