@kolkrabbi/kol-component 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,214 @@
1
+ /**
2
+ * colorMath — HSL/hex conversion + harmony generation for the KOL color
3
+ * pickers (SpectrumControls, PaletteHarmonyWheel).
4
+ *
5
+ * Plain functions, not a hook — it lives in src/hooks/ because that is the
6
+ * taxonomy's only non-component folder (same rationale as cssVar.js).
7
+ *
8
+ * Two harmony paths:
9
+ * - Deterministic — `harmonyColors(hue, harmony, {saturation, lightness})`
10
+ * / `generateHarmony(baseHex, harmony)`: role-offset hues off a base,
11
+ * no randomness, so dragging the harmony wheel is smooth + repeatable.
12
+ * - Seeded/jittered — `seedHarmony(baseHex, mode)`: slot 0 = the base,
13
+ * slots 1–5 derived via a mode with small random jitter, so consecutive
14
+ * "randomize" clicks produce related-but-different variants around a seed.
15
+ */
16
+
17
+ /* ── HSL / hex conversion ─────────────────────────────────────────────── */
18
+
19
+ /** '#RRGGBB' → { h: 0–360, s: 0–100, l: 0–100 }. */
20
+ export function hexToHsl(hex) {
21
+ const r = parseInt(hex.slice(1, 3), 16) / 255
22
+ const g = parseInt(hex.slice(3, 5), 16) / 255
23
+ const b = parseInt(hex.slice(5, 7), 16) / 255
24
+ const max = Math.max(r, g, b)
25
+ const min = Math.min(r, g, b)
26
+ let h = 0, s = 0
27
+ const l = (max + min) / 2
28
+ if (max !== min) {
29
+ const d = max - min
30
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
31
+ switch (max) {
32
+ case r: h = (g - b) / d + (g < b ? 6 : 0); break
33
+ case g: h = (b - r) / d + 2; break
34
+ case b: h = (r - g) / d + 4; break
35
+ }
36
+ h *= 60
37
+ }
38
+ return { h, s: s * 100, l: l * 100 }
39
+ }
40
+
41
+ /** (h: 0–360, s: 0–100, l: 0–100) → uppercase '#RRGGBB'. */
42
+ export function hslToHex(h, s, l) {
43
+ h = normHue(h)
44
+ s = Math.max(0, Math.min(100, s)) / 100
45
+ l = Math.max(0, Math.min(100, l)) / 100
46
+ const c = (1 - Math.abs(2 * l - 1)) * s
47
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
48
+ const m = l - c / 2
49
+ let r = 0, g = 0, b = 0
50
+ if (h < 60) { r = c; g = x; b = 0 }
51
+ else if (h < 120) { r = x; g = c; b = 0 }
52
+ else if (h < 180) { r = 0; g = c; b = x }
53
+ else if (h < 240) { r = 0; g = x; b = c }
54
+ else if (h < 300) { r = x; g = 0; b = c }
55
+ else { r = c; g = 0; b = x }
56
+ const toHex = (v) => Math.round((v + m) * 255).toString(16).padStart(2, '0')
57
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase()
58
+ }
59
+
60
+ /** RGB components (0–255) → uppercase '#RRGGBB'. Shared with the eyedropper. */
61
+ export function rgbToHex(r, g, b) {
62
+ return '#' + [r, g, b]
63
+ .map((n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0'))
64
+ .join('')
65
+ .toUpperCase()
66
+ }
67
+
68
+ /** Wrap any hue into [0, 360). */
69
+ export const normHue = (h) => ((h % 360) + 360) % 360
70
+
71
+ /* ── Harmony schemes ──────────────────────────────────────────────────── */
72
+
73
+ /**
74
+ * Hue offsets for the five palette roles (Primary / Secondary / Light /
75
+ * Dark / Accent) relative to the base hue. The wheel draws a satellite
76
+ * marker per unique offset; the generators emit one color per role.
77
+ */
78
+ export const HARMONIES = [
79
+ { id: 'analogous', label: 'Analogous', roleOffsets: [0, -30, -15, 15, 30] },
80
+ { id: 'complementary', label: 'Complementary', roleOffsets: [0, 0, 0, 180, 180] },
81
+ { id: 'split', label: 'Split complementary', roleOffsets: [0, 150, 210, 0, 150] },
82
+ { id: 'triadic', label: 'Triadic', roleOffsets: [0, 120, 240, 0, 120] },
83
+ { id: 'tetradic', label: 'Tetradic', roleOffsets: [0, 90, 180, 270, 0] },
84
+ ]
85
+
86
+ /** Resolve a harmony id string OR a harmony object to the object. */
87
+ export const harmonyById = (harmony) => {
88
+ if (harmony && typeof harmony === 'object') return harmony
89
+ return HARMONIES.find((h) => h.id === harmony) ?? HARMONIES[0]
90
+ }
91
+
92
+ /**
93
+ * Deterministic role colors for a harmony, driven by a hue + fixed S/L.
94
+ * Preserves saturation/lightness across roles (only hue rotates), so the
95
+ * output tracks the wheel's satellite markers exactly.
96
+ *
97
+ * @param {number} hue base hue, 0–360
98
+ * @param {string|object} harmony harmony id or object
99
+ * @param {{saturation?: number, lightness?: number}} opts base S/L (0–100)
100
+ * @returns {string[]} one '#RRGGBB' per role offset
101
+ */
102
+ export function harmonyColors(hue, harmony, { saturation = 100, lightness = 50 } = {}) {
103
+ const { roleOffsets } = harmonyById(harmony)
104
+ return roleOffsets.map((off) => hslToHex(normHue(hue + off), saturation, lightness))
105
+ }
106
+
107
+ /**
108
+ * Deterministic role colors derived from a base hex (S/L taken from the hex).
109
+ * Convenience wrapper over `harmonyColors` for callers holding a color, not
110
+ * a hue.
111
+ */
112
+ export function generateHarmony(baseHex, harmony) {
113
+ const { h, s, l } = hexToHsl(baseHex)
114
+ return harmonyColors(h, harmony, { saturation: s, lightness: l })
115
+ }
116
+
117
+ /* ── Seeded (jittered) modes ──────────────────────────────────────────── */
118
+
119
+ const jitter = (range) => (Math.random() - 0.5) * 2 * range
120
+
121
+ /**
122
+ * Seed-based generators — slot 0 = the base, slots 1–5 derived via the mode.
123
+ * Each call jitters parameters so repeated "randomize" produces visibly
124
+ * different but harmonically-related variants around the same seed.
125
+ */
126
+ const SEED_MODES = {
127
+ random: (base) => {
128
+ const { h, s, l } = hexToHsl(base)
129
+ return [
130
+ base,
131
+ hslToHex(normHue(h + Math.random() * 360), s, l),
132
+ hslToHex(normHue(h + Math.random() * 360), s, l),
133
+ hslToHex(h, s, Math.max(10, l - 25 + jitter(10))),
134
+ hslToHex(h, s, Math.min(90, l + 25 + jitter(10))),
135
+ hslToHex(h, s * (0.4 + Math.random() * 0.4), l),
136
+ ]
137
+ },
138
+
139
+ monochromatic: (base) => {
140
+ const { h, s, l } = hexToHsl(base)
141
+ const j = 8
142
+ return [
143
+ base,
144
+ hslToHex(h, s, Math.max(10, l - 30 + jitter(j))),
145
+ hslToHex(h, s, Math.max(20, l - 15 + jitter(j))),
146
+ hslToHex(h, s, Math.min(85, l + 15 + jitter(j))),
147
+ hslToHex(h, s, Math.min(95, l + 30 + jitter(j))),
148
+ hslToHex(h, s, Math.min(98, l + 45 + jitter(j))),
149
+ ]
150
+ },
151
+
152
+ analogous: (base) => {
153
+ const { h, s, l } = hexToHsl(base)
154
+ return [
155
+ base,
156
+ hslToHex(normHue(h - 30 + jitter(8)), s, l),
157
+ hslToHex(normHue(h - 15 + jitter(8)), s, l),
158
+ hslToHex(normHue(h + 15 + jitter(8)), s, l),
159
+ hslToHex(normHue(h + 30 + jitter(8)), s, l),
160
+ hslToHex(normHue(h + 45 + jitter(8)), s, l),
161
+ ]
162
+ },
163
+
164
+ complementary: (base) => {
165
+ const { h, s, l } = hexToHsl(base)
166
+ return [
167
+ base,
168
+ hslToHex(h, s, Math.max(15, l - 25 + jitter(8))),
169
+ hslToHex(h, s, Math.min(85, l + 25 + jitter(8))),
170
+ hslToHex(normHue(h + 180 + jitter(10)), s, l),
171
+ hslToHex(normHue(h + 180 + jitter(10)), s, Math.max(15, l - 15 + jitter(8))),
172
+ hslToHex(normHue(h + 180 + jitter(10)), s, Math.min(85, l + 15 + jitter(8))),
173
+ ]
174
+ },
175
+
176
+ triadic: (base) => {
177
+ const { h, s, l } = hexToHsl(base)
178
+ return [
179
+ base,
180
+ hslToHex(normHue(h + 120 + jitter(10)), s, l),
181
+ hslToHex(normHue(h + 240 + jitter(10)), s, l),
182
+ hslToHex(h, s, Math.min(85, l + 20 + jitter(8))),
183
+ hslToHex(normHue(h + 120 + jitter(10)), s, Math.max(15, l - 20 + jitter(8))),
184
+ hslToHex(normHue(h + 240 + jitter(10)), s, Math.min(85, l + 20 + jitter(8))),
185
+ ]
186
+ },
187
+
188
+ doubleComplementary: (base) => {
189
+ const { h, s, l } = hexToHsl(base)
190
+ return [
191
+ base,
192
+ hslToHex(normHue(h + 90 + jitter(10)), s, l),
193
+ hslToHex(normHue(h + 180 + jitter(10)), s, l),
194
+ hslToHex(normHue(h + 270 + jitter(10)), s, l),
195
+ hslToHex(h, s, Math.min(85, l + 20 + jitter(8))),
196
+ hslToHex(normHue(h + 180 + jitter(10)), s, Math.max(15, l - 20 + jitter(8))),
197
+ ]
198
+ },
199
+ }
200
+
201
+ /** The available seed-mode ids (for a consumer's mode selector). */
202
+ export const SEED_MODE_IDS = Object.keys(SEED_MODES)
203
+
204
+ /**
205
+ * Generate a jittered 6-color palette from a seed hex.
206
+ *
207
+ * @param {string} baseHex the seed color, occupies slot 0
208
+ * @param {string} mode one of SEED_MODE_IDS (falls back to 'random')
209
+ * @returns {string[]} six '#RRGGBB' values
210
+ */
211
+ export function seedHarmony(baseHex, mode = 'random') {
212
+ const gen = SEED_MODES[mode] ?? SEED_MODES.random
213
+ return gen(baseHex)
214
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * useEyedropper — cross-browser color sampling.
3
+ *
4
+ * Primary path is the native `window.EyeDropper` API (Chromium): one call,
5
+ * samples any pixel on screen, returns an sRGB hex. Where it is unavailable
6
+ * (Firefox, Safari) the hook falls back to an injected `fallback` sampler —
7
+ * e.g. `pickFromCanvasElement`, which samples a pixel from a <canvas> the
8
+ * consumer renders. The DS stays decoupled: it never reaches into an app's
9
+ * canvas/scene — the consumer supplies the fallback.
10
+ *
11
+ * Pairs with the EyedropPick affordance in SwatchControls: gate the button on
12
+ * `supported`, wire the button's `onPick` to `pick`.
13
+ *
14
+ * const { supported, pick } = useEyedropper({ fallback })
15
+ * // supported → render the eyedropper button
16
+ * const hex = await pick() // '#RRGGBB' or null (cancelled / unavailable)
17
+ */
18
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
19
+ import { rgbToHex } from './colorMath.js'
20
+
21
+ /* Native support probe — read at call time so it is correct even before the
22
+ * post-mount state (below) has settled. */
23
+ function hasNativeEyeDropper() {
24
+ return typeof window !== 'undefined' && 'EyeDropper' in window
25
+ }
26
+
27
+ /**
28
+ * Generic canvas-region sampler — the reusable core of a canvas eyedropper.
29
+ * Sets a crosshair cursor on `canvas`, awaits one pointer press on it, reads
30
+ * the pixel under the cursor via getImageData, and resolves the hex. Escape,
31
+ * a press outside the canvas bounds, or an aborted `signal` resolve to null.
32
+ *
33
+ * Backing-store aware: scales client coords by canvas.width/height ÷ CSS box,
34
+ * so a HiDPI or CSS-scaled canvas still samples the right pixel.
35
+ *
36
+ * @param {HTMLCanvasElement} canvas the canvas to sample from
37
+ * @param {{signal?: AbortSignal}} [opts]
38
+ * @returns {Promise<string|null>} uppercase '#RRGGBB' or null
39
+ */
40
+ export async function pickFromCanvasElement(canvas, { signal } = {}) {
41
+ if (!canvas || typeof canvas.getContext !== 'function') return null
42
+ const ctx = canvas.getContext('2d', { willReadFrequently: true })
43
+ if (!ctx) return null
44
+ if (signal?.aborted) return null
45
+
46
+ const prevCursor = canvas.style.cursor
47
+ canvas.style.cursor = 'crosshair'
48
+
49
+ return new Promise((resolve) => {
50
+ const cleanup = () => {
51
+ canvas.removeEventListener('pointerdown', onDown, true)
52
+ document.removeEventListener('keydown', onKey, true)
53
+ signal?.removeEventListener('abort', onAbort)
54
+ canvas.style.cursor = prevCursor
55
+ }
56
+
57
+ const onDown = (e) => {
58
+ e.preventDefault()
59
+ e.stopPropagation()
60
+ const rect = canvas.getBoundingClientRect()
61
+ const sx = rect.width ? canvas.width / rect.width : 1
62
+ const sy = rect.height ? canvas.height / rect.height : 1
63
+ const vx = Math.floor((e.clientX - rect.left) * sx)
64
+ const vy = Math.floor((e.clientY - rect.top) * sy)
65
+ if (vx < 0 || vy < 0 || vx >= canvas.width || vy >= canvas.height) {
66
+ cleanup(); resolve(null); return
67
+ }
68
+ let data
69
+ try { data = ctx.getImageData(vx, vy, 1, 1).data }
70
+ catch { cleanup(); resolve(null); return } /* tainted canvas */
71
+ cleanup()
72
+ resolve(rgbToHex(data[0], data[1], data[2]))
73
+ }
74
+
75
+ const onKey = (e) => {
76
+ if (e.key === 'Escape') { e.preventDefault(); cleanup(); resolve(null) }
77
+ }
78
+
79
+ const onAbort = () => { cleanup(); resolve(null) }
80
+
81
+ /* Capture phase so we beat the consumer's own canvas handlers. */
82
+ canvas.addEventListener('pointerdown', onDown, true)
83
+ document.addEventListener('keydown', onKey, true)
84
+ signal?.addEventListener('abort', onAbort)
85
+ })
86
+ }
87
+
88
+ /**
89
+ * @param {{fallback?: (opts?: {signal?: AbortSignal}) => Promise<string|null>}} [options]
90
+ * fallback — sampler used when the native EyeDropper API is absent. Return
91
+ * a hex or null. Without a fallback, unsupported browsers report
92
+ * `supported: false` and `pick` resolves null.
93
+ * @returns {{supported: boolean, nativeSupported: boolean,
94
+ * pick: (opts?: {signal?: AbortSignal}) => Promise<string|null>}}
95
+ */
96
+ export function useEyedropper({ fallback } = {}) {
97
+ /* Resolved after mount so SSR / first paint stays stable (starts false,
98
+ * flips true only where the native API exists). */
99
+ const [nativeSupported, setNativeSupported] = useState(false)
100
+ useEffect(() => { setNativeSupported(hasNativeEyeDropper()) }, [])
101
+
102
+ const fallbackRef = useRef(fallback)
103
+ fallbackRef.current = fallback
104
+
105
+ const supported = nativeSupported || !!fallback
106
+
107
+ const pick = useCallback(async ({ signal } = {}) => {
108
+ if (hasNativeEyeDropper()) {
109
+ try {
110
+ const res = await new window.EyeDropper().open(signal ? { signal } : undefined)
111
+ return res?.sRGBHex ? res.sRGBHex.toUpperCase() : null
112
+ } catch {
113
+ return null /* user cancelled (Escape) rejects the promise */
114
+ }
115
+ }
116
+ if (fallbackRef.current) return (await fallbackRef.current({ signal })) ?? null
117
+ return null
118
+ }, [])
119
+
120
+ return useMemo(
121
+ () => ({ supported, nativeSupported, pick }),
122
+ [supported, nativeSupported, pick],
123
+ )
124
+ }
package/src/index.js CHANGED
@@ -76,6 +76,7 @@ export { default as MediaRow } from './molecules/MediaRow.jsx'
76
76
  export { MenuItem, MenuDropdownItem, MenuDropdownDivider, MenuDropdownNest } from './molecules/MenuItem.jsx'
77
77
  export { MenuPopover } from './molecules/MenuPopover.jsx'
78
78
  export { ModalProvider, useModal } from './molecules/Modal.jsx'
79
+ export { default as PaletteHarmonyWheel } from './molecules/PaletteHarmonyWheel.jsx'
79
80
  export { default as PropertyInput } from './molecules/PropertyInput.jsx'
80
81
  export { default as ShapeDropdown } from './molecules/ShapeDropdown.jsx'
81
82
  export { default as ShellDrawer } from './molecules/ShellDrawer.jsx'
@@ -123,4 +124,12 @@ export { default as useReveal } from './hooks/useReveal.js'
123
124
  export { default as useScrollSpy } from './hooks/useScrollSpy.js'
124
125
  export { default as useTilt } from './hooks/useTilt.js'
125
126
  export { default as useAxisAnimation } from './hooks/useAxisAnimation.js'
127
+ export { useEyedropper, pickFromCanvasElement } from './hooks/useEyedropper.js'
126
128
  export { resolveCssVar, resolveCssColor, isLight } from './hooks/cssVar.js'
129
+
130
+ // color math (support module — HSL/hex conversion + harmony generation)
131
+ export {
132
+ hexToHsl, hslToHex, rgbToHex, normHue,
133
+ HARMONIES, harmonyById, harmonyColors, generateHarmony,
134
+ SEED_MODE_IDS, seedHarmony,
135
+ } from './hooks/colorMath.js'
@@ -0,0 +1,192 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import { HARMONIES, harmonyById, harmonyColors, normHue } from '../hooks/colorMath.js'
3
+
4
+ /* taxonomy-ok: a pure-canvas hue + harmony picker in the SpectrumControls
5
+ * color-picker family. It nests no KOL component — its only import is the
6
+ * colorMath support module in hooks/ — but belongs beside its siblings
7
+ * SpectrumControls / SwatchControls, not down in atoms. */
8
+
9
+ /*
10
+ * PaletteHarmonyWheel — a hue ring with a draggable base-hue handle and
11
+ * satellite markers at the active harmony's scheme hues. Dragging (or the
12
+ * arrow keys) rotates the base hue; every change emits the full set of
13
+ * harmony colors so the caller never re-derives them.
14
+ *
15
+ * The scheme table is DATA-INJECTED (`harmonies`, defaulting to the shared
16
+ * HARMONIES); saturation/lightness of the generated colors are injected too,
17
+ * so this stays a pure hue picker — the caller owns S/L.
18
+ *
19
+ * Controlled on `hue`; `onChange({ hue, colors })` fires on press, drag, and
20
+ * arrow keys. `colors` is one hex per role offset of the active harmony
21
+ * (see colorMath.harmonyColors), matching the satellite markers 1:1.
22
+ *
23
+ * The ring hues, marker outlines and handle halo are literal color math
24
+ * (hsl / #FFFFFF / rgba) on purpose — a spectrum is not themeable, and the
25
+ * markers sit on fully-saturated ring hues, not on the surface (same
26
+ * rationale as SpectrumControls). No token belongs here.
27
+ *
28
+ * @param {number} size px, square (default 248)
29
+ * @param {number} hue controlled base hue, 0–360
30
+ * @param {string|object} harmony active harmony id or object (default 'analogous')
31
+ * @param {number} saturation base saturation for emitted colors, 0–100 (default 100)
32
+ * @param {number} lightness base lightness for emitted colors, 0–100 (default 50)
33
+ * @param {Array} harmonies injectable scheme table (default HARMONIES)
34
+ * @param {Function} onChange ({ hue, colors }) => void
35
+ */
36
+
37
+ /* Marker outline — white for contrast against the fully-saturated ring hues
38
+ * (theme-independent: the wheel's colours, not the surface, sit behind it). */
39
+ const MARKER_STROKE = '#FFFFFF'
40
+
41
+ export default function PaletteHarmonyWheel({
42
+ size = 248,
43
+ hue,
44
+ harmony = 'analogous',
45
+ saturation = 100,
46
+ lightness = 50,
47
+ harmonies = HARMONIES,
48
+ onChange,
49
+ }) {
50
+ const canvasRef = useRef(null)
51
+ const draggingRef = useRef(false)
52
+ const emitRef = useRef(null)
53
+
54
+ const active = harmonyById(
55
+ typeof harmony === 'string'
56
+ ? (harmonies.find((h) => h.id === harmony) ?? harmonies[0])
57
+ : harmony,
58
+ )
59
+
60
+ /* Emit next hue + its harmony colors. Held in a ref so the pointer/key
61
+ * handlers stay stable while always seeing the latest props. */
62
+ emitRef.current = (nextHue) => {
63
+ const h = normHue(nextHue)
64
+ onChange?.({ hue: h, colors: harmonyColors(h, active, { saturation, lightness }) })
65
+ }
66
+
67
+ const outerR = size / 2 - 8
68
+ const innerR = outerR - 22
69
+ const midR = (outerR + innerR) / 2
70
+
71
+ useEffect(() => {
72
+ const canvas = canvasRef.current
73
+ if (!canvas) return
74
+ const dpr = window.devicePixelRatio || 1
75
+ canvas.width = size * dpr
76
+ canvas.height = size * dpr
77
+ const ctx = canvas.getContext('2d')
78
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
79
+ ctx.clearRect(0, 0, size, size)
80
+ const cx = size / 2
81
+ const cy = size / 2
82
+
83
+ /* Angle mapping: hue 0 at 12 o'clock, clockwise. */
84
+ const angleFor = (h) => ((h - 90) * Math.PI) / 180
85
+
86
+ /* Hue ring — 360 thin annulus wedges (1.5° overlap hides seams). */
87
+ for (let a = 0; a < 360; a++) {
88
+ const a0 = angleFor(a)
89
+ const a1 = angleFor(a + 1.5)
90
+ ctx.beginPath()
91
+ ctx.arc(cx, cy, outerR, a0, a1)
92
+ ctx.arc(cx, cy, innerR, a1, a0, true)
93
+ ctx.closePath()
94
+ ctx.fillStyle = `hsl(${a}, 100%, 50%)`
95
+ ctx.fill()
96
+ }
97
+
98
+ const baseHue = normHue(hue)
99
+ const offsets = [...new Set((active?.roleOffsets ?? [0]).map(normHue))]
100
+
101
+ /* Spokes + satellite markers at the scheme hues. */
102
+ for (const off of offsets) {
103
+ const h = normHue(baseHue + off)
104
+ const a = angleFor(h)
105
+ ctx.beginPath()
106
+ ctx.moveTo(cx, cy)
107
+ ctx.lineTo(cx + Math.cos(a) * innerR, cy + Math.sin(a) * innerR)
108
+ ctx.strokeStyle = 'rgba(128, 128, 128, 0.4)'
109
+ ctx.lineWidth = 1
110
+ ctx.stroke()
111
+ if (off === 0) continue /* base hue gets the big handle below */
112
+ const x = cx + Math.cos(a) * midR
113
+ const y = cy + Math.sin(a) * midR
114
+ ctx.beginPath()
115
+ ctx.arc(x, y, 5, 0, Math.PI * 2)
116
+ ctx.fillStyle = `hsl(${h}, 100%, 50%)`
117
+ ctx.fill()
118
+ ctx.strokeStyle = MARKER_STROKE
119
+ ctx.lineWidth = 1.5
120
+ ctx.stroke()
121
+ }
122
+
123
+ /* Base-hue handle. */
124
+ const ba = angleFor(baseHue)
125
+ const bx = cx + Math.cos(ba) * midR
126
+ const by = cy + Math.sin(ba) * midR
127
+ ctx.beginPath()
128
+ ctx.arc(bx, by, 9, 0, Math.PI * 2)
129
+ ctx.fillStyle = `hsl(${baseHue}, 100%, 50%)`
130
+ ctx.fill()
131
+ ctx.strokeStyle = MARKER_STROKE
132
+ ctx.lineWidth = 2
133
+ ctx.stroke()
134
+ ctx.beginPath()
135
+ ctx.arc(bx, by, 10.5, 0, Math.PI * 2)
136
+ ctx.strokeStyle = 'rgba(0, 0, 0, 0.35)'
137
+ ctx.lineWidth = 1
138
+ ctx.stroke()
139
+ }, [size, hue, active, outerR, innerR, midR])
140
+
141
+ const pointFromEvent = (e) => {
142
+ const rect = canvasRef.current.getBoundingClientRect()
143
+ const dx = e.clientX - rect.left - rect.width / 2
144
+ const dy = e.clientY - rect.top - rect.height / 2
145
+ return { dist: Math.hypot(dx, dy), hue: normHue((Math.atan2(dy, dx) * 180) / Math.PI + 90) }
146
+ }
147
+
148
+ const onPointerDown = (e) => {
149
+ const { dist, hue: h } = pointFromEvent(e)
150
+ if (dist < innerR - 14 || dist > outerR + 10) return
151
+ draggingRef.current = true
152
+ e.currentTarget.setPointerCapture(e.pointerId)
153
+ emitRef.current?.(h)
154
+ }
155
+
156
+ const onPointerMove = (e) => {
157
+ if (!draggingRef.current) return
158
+ emitRef.current?.(pointFromEvent(e).hue)
159
+ }
160
+
161
+ const endDrag = () => { draggingRef.current = false }
162
+
163
+ const onKeyDown = (e) => {
164
+ const step = e.shiftKey ? 15 : 3
165
+ if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
166
+ e.preventDefault()
167
+ emitRef.current?.(normHue(hue + step))
168
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
169
+ e.preventDefault()
170
+ emitRef.current?.(normHue(hue - step))
171
+ }
172
+ }
173
+
174
+ return (
175
+ <canvas
176
+ ref={canvasRef}
177
+ role="slider"
178
+ aria-label="Base hue"
179
+ aria-valuemin={0}
180
+ aria-valuemax={359}
181
+ aria-valuenow={Math.round(normHue(hue))}
182
+ tabIndex={0}
183
+ className="cursor-pointer touch-none"
184
+ style={{ width: size, height: size }}
185
+ onPointerDown={onPointerDown}
186
+ onPointerMove={onPointerMove}
187
+ onPointerUp={endDrag}
188
+ onPointerCancel={endDrag}
189
+ onKeyDown={onKeyDown}
190
+ />
191
+ )
192
+ }