// 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()
}
}