@kolkrabbi/kol-component 0.2.0 → 0.4.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 +11 -3
- package/src/atoms/AnimatedTitle.jsx +108 -0
- package/src/atoms/AssetGrid.jsx +29 -0
- package/src/atoms/Button.jsx +17 -6
- package/src/atoms/CurveOverlay.jsx +180 -0
- package/src/atoms/DocsToc.jsx +48 -0
- package/src/atoms/EmptyState.jsx +22 -0
- package/src/atoms/Figure.jsx +27 -0
- package/src/atoms/HlsVideo.jsx +56 -0
- package/src/atoms/OverlayGlassPanel.jsx +40 -0
- package/src/atoms/PriceDisplay.jsx +34 -0
- package/src/atoms/ProsePreview.jsx +53 -0
- package/src/atoms/QuantityInput.jsx +76 -114
- package/src/atoms/RotaryDial.jsx +150 -0
- package/src/atoms/SearchInput.jsx +108 -0
- package/src/atoms/TextPressure.jsx +331 -0
- package/src/atoms/TiltCard.jsx +127 -0
- package/src/atoms/TypeSample.jsx +50 -0
- package/src/atoms/TypeSpecCard.jsx +42 -0
- package/src/hooks/cssVar.js +53 -0
- package/src/hooks/useAxisAnimation.js +91 -0
- package/src/hooks/usePrefersReducedMotion.js +21 -0
- package/src/hooks/useTilt.js +49 -0
- package/src/index.js +66 -2
- package/src/molecules/AlignmentGrid.jsx +53 -0
- package/src/molecules/ArticleCard.jsx +178 -0
- package/src/molecules/CardFeatureItem.jsx +130 -0
- package/src/molecules/ColorInputRow.jsx +179 -0
- package/src/molecules/ColorRamp.jsx +114 -0
- package/src/molecules/FramedMediaBand.jsx +56 -0
- package/src/molecules/ImageBlock.jsx +34 -0
- package/src/molecules/MenuPopover.jsx +4 -27
- package/src/molecules/SelectionOverlay.jsx +108 -0
- package/src/molecules/ShapeDropdown.jsx +92 -0
- package/src/molecules/ShellDrawer.jsx +169 -0
- package/src/molecules/ShellSearchOverlay.jsx +177 -0
- package/src/molecules/SpecList.jsx +30 -0
- package/src/molecules/SpectrumControls.jsx +504 -0
- package/src/molecules/SwatchControls.jsx +217 -0
- package/src/molecules/TabsRow.jsx +87 -0
- package/src/molecules/VideoBlock.jsx +86 -0
- package/src/molecules/WorkListItem.jsx +83 -0
- package/src/molecules/foundry/SpecimenSectionHeader.jsx +89 -0
- package/src/organisms/ArticleHeader.jsx +95 -0
- package/src/organisms/AsciiCursor.jsx +526 -0
- package/src/organisms/BentoCard.jsx +187 -0
- package/src/organisms/Canvas.jsx +299 -0
- package/src/organisms/ColorLoader.jsx +155 -0
- package/src/organisms/CtaGlobal.jsx +67 -0
- package/src/organisms/DiagonalMarqueeRiver.jsx +138 -0
- package/src/organisms/EditorShell.jsx +111 -0
- package/src/organisms/ErrorBoundary.jsx +70 -0
- package/src/organisms/FeatureSplit.jsx +82 -0
- package/src/organisms/FeaturedCarousel.jsx +258 -0
- package/src/organisms/FeaturesCardSection.jsx +90 -0
- package/src/organisms/FullBleedHero.jsx +111 -0
- package/src/organisms/GalleryCarousel.jsx +83 -0
- package/src/organisms/LoaderOverlay.jsx +30 -0
- package/src/organisms/MediaViewer.jsx +95 -0
- package/src/organisms/NewsletterBand.jsx +122 -0
- package/src/organisms/ParallaxShelf.jsx +141 -0
- package/src/organisms/PortableTextRenderer.jsx +115 -0
- package/src/organisms/ProductDetailLayout.jsx +189 -0
- package/src/organisms/ScrollDriftGallery.jsx +214 -0
- package/src/organisms/SpectrumGrid.jsx +90 -0
- package/src/organisms/StackHero.jsx +83 -0
- package/src/organisms/WorkCard.jsx +120 -0
- package/src/organisms/WorkViewToggle.jsx +170 -0
- package/src/organisms/foundry/FontPreviewSection.jsx +187 -0
- package/src/organisms/foundry/FoundryCharacterSets.jsx +113 -0
- package/src/organisms/foundry/GlyphMetricsGrid.jsx +335 -0
- package/src/organisms/foundry/TypefaceHero.jsx +107 -0
- package/src/organisms/foundry/TypefaceStyleSection.jsx +163 -0
- package/src/organisms/foundry/VariableFontSection.jsx +158 -0
- package/src/organisms/foundry/glyphData.js +30 -0
- package/src/organisms/foundry/index.js +21 -0
- package/src/atoms/QuantityStepper.jsx +0 -149
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import { useCallback, useEffect, useId, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
/* taxonomy-ok: the default composed control nests the same-file HueStrip and
|
|
4
|
+
* SBSquare (KOL named exports the relative-import check can't see). */
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* SpectrumControls — the HSV color-picker family: three hand-tuned widgets
|
|
8
|
+
* plus the composed classic picker. Hand-tuned visuals (specific gradients,
|
|
9
|
+
* inset math, handle sizes, barycentric SV math, conic-gradient ring with
|
|
10
|
+
* clip-path mask). DO NOT refactor pieces to atoms — the look and the math
|
|
11
|
+
* are intentional and tuned as a unit.
|
|
12
|
+
*
|
|
13
|
+
* <SpectrumControls value onChange /> (default export)
|
|
14
|
+
* — HueStrip stacked over a fill-height SBSquare (the classic square
|
|
15
|
+
* picker, per the source panel's Hue mode layout).
|
|
16
|
+
*
|
|
17
|
+
* <HueStrip hue onChange />
|
|
18
|
+
* — 1D hue slider (12px tall) with a knob constrained inside the box.
|
|
19
|
+
*
|
|
20
|
+
* <SBSquare hue sat val onChange />
|
|
21
|
+
* — 2D saturation/value picker. Uses available width × height of its
|
|
22
|
+
* container; gradient is white→hue horizontally with a black overlay
|
|
23
|
+
* fading to transparent upward.
|
|
24
|
+
*
|
|
25
|
+
* <WheelTriangle hue sat val onChangeHue onChangeSV />
|
|
26
|
+
* — Hue ring + inscribed rotating HSV triangle. Ring snaps-on-click;
|
|
27
|
+
* triangle uses grab-with-offset (handle stays put on click, drag
|
|
28
|
+
* preserves cursor↔handle delta) so fine sat/val tuning works.
|
|
29
|
+
*
|
|
30
|
+
* All colors here are literal color math (hsl/#000/#fff/#505050) on purpose —
|
|
31
|
+
* a spectrum is not themeable. The only token is the handle's `bg-fg`.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/* Handle radius (px) used to inset both the handle's positioning area AND
|
|
35
|
+
* the drag hit-test math so the knob always renders inside the spectrum
|
|
36
|
+
* frame. Without this the handle's center maps to 0/100% of the box and
|
|
37
|
+
* half the knob hangs off the edge. */
|
|
38
|
+
const HANDLE_R = 7
|
|
39
|
+
|
|
40
|
+
const HUE_GRADIENT =
|
|
41
|
+
'linear-gradient(to right, hsl(0,100%,50%), hsl(60,100%,50%), hsl(120,100%,50%), hsl(180,100%,50%), hsl(240,100%,50%), hsl(300,100%,50%), hsl(360,100%,50%))'
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* HueStrip — 1D hue slider: a rainbow bar with a draggable knob. Pointer
|
|
45
|
+
* drag anywhere on the strip; focus + Left/Right (or Down/Up) arrow keys
|
|
46
|
+
* nudge hue by 1° (role="slider").
|
|
47
|
+
*
|
|
48
|
+
* @param {number} hue controlled hue, 0–360
|
|
49
|
+
* @param {Function} onChange (hue: number) => void — fires on press, drag, and arrow keys
|
|
50
|
+
*/
|
|
51
|
+
export function HueStrip({ hue, onChange }) {
|
|
52
|
+
const ref = useRef(null)
|
|
53
|
+
const dragging = useRef(false)
|
|
54
|
+
|
|
55
|
+
const update = useCallback((clientX) => {
|
|
56
|
+
const node = ref.current
|
|
57
|
+
if (!node) return
|
|
58
|
+
const rect = node.getBoundingClientRect()
|
|
59
|
+
const inner = rect.width - 2 * HANDLE_R
|
|
60
|
+
if (inner <= 0) return
|
|
61
|
+
const ratio = Math.max(0, Math.min(1, (clientX - rect.left - HANDLE_R) / inner))
|
|
62
|
+
onChange(ratio * 360)
|
|
63
|
+
}, [onChange])
|
|
64
|
+
|
|
65
|
+
const onDown = (e) => {
|
|
66
|
+
dragging.current = true
|
|
67
|
+
update(e.clientX)
|
|
68
|
+
e.preventDefault()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const onMove = (e) => { if (dragging.current) update(e.clientX) }
|
|
73
|
+
const onUp = () => { dragging.current = false }
|
|
74
|
+
window.addEventListener('pointermove', onMove)
|
|
75
|
+
window.addEventListener('pointerup', onUp)
|
|
76
|
+
window.addEventListener('pointercancel', onUp)
|
|
77
|
+
return () => {
|
|
78
|
+
window.removeEventListener('pointermove', onMove)
|
|
79
|
+
window.removeEventListener('pointerup', onUp)
|
|
80
|
+
window.removeEventListener('pointercancel', onUp)
|
|
81
|
+
}
|
|
82
|
+
}, [update])
|
|
83
|
+
|
|
84
|
+
const onKeyDown = (e) => {
|
|
85
|
+
let next = null
|
|
86
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') next = Math.min(360, hue + 1)
|
|
87
|
+
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') next = Math.max(0, hue - 1)
|
|
88
|
+
if (next === null) return
|
|
89
|
+
e.preventDefault()
|
|
90
|
+
onChange(next)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const pos = (hue / 360) * 100
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div
|
|
97
|
+
ref={ref}
|
|
98
|
+
role="slider"
|
|
99
|
+
tabIndex={0}
|
|
100
|
+
aria-label="Hue"
|
|
101
|
+
aria-valuemin={0}
|
|
102
|
+
aria-valuemax={360}
|
|
103
|
+
aria-valuenow={Math.round(hue)}
|
|
104
|
+
onPointerDown={onDown}
|
|
105
|
+
onKeyDown={onKeyDown}
|
|
106
|
+
className="relative rounded-[2px] cursor-pointer touch-none"
|
|
107
|
+
style={{ height: 12, background: HUE_GRADIENT }}
|
|
108
|
+
>
|
|
109
|
+
{/* Handle positions inside an inset region so 0..100% maps to the
|
|
110
|
+
* area where the knob can sit without spilling out. */}
|
|
111
|
+
<div className="absolute inset-y-0" style={{ left: HANDLE_R, right: HANDLE_R }}>
|
|
112
|
+
<Handle left={`${pos}%`} top="50%" />
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* SBSquare — 2D saturation/value picker: white→hue horizontal gradient with
|
|
120
|
+
* a black overlay fading upward, crosshair handle. Fills its container
|
|
121
|
+
* (consumer owns the size — and the rounding: apply `rounded-[2px]
|
|
122
|
+
* overflow-hidden` on the wrapper). Focus + arrow keys nudge: Left/Right =
|
|
123
|
+
* saturation ±1, Up/Down = value ±1 (role="slider").
|
|
124
|
+
*
|
|
125
|
+
* @param {number} hue hue for the gradient endpoint, 0–360
|
|
126
|
+
* @param {number} sat controlled saturation, 0–100
|
|
127
|
+
* @param {number} val controlled value/brightness, 0–100
|
|
128
|
+
* @param {Function} onChange (sat: number, val: number) => void
|
|
129
|
+
*/
|
|
130
|
+
export function SBSquare({ hue, sat, val, onChange }) {
|
|
131
|
+
const ref = useRef(null)
|
|
132
|
+
const dragging = useRef(false)
|
|
133
|
+
|
|
134
|
+
const update = useCallback((clientX, clientY) => {
|
|
135
|
+
const node = ref.current
|
|
136
|
+
if (!node) return
|
|
137
|
+
const rect = node.getBoundingClientRect()
|
|
138
|
+
const innerW = rect.width - 2 * HANDLE_R
|
|
139
|
+
const innerH = rect.height - 2 * HANDLE_R
|
|
140
|
+
if (innerW <= 0 || innerH <= 0) return
|
|
141
|
+
const sx = Math.max(0, Math.min(1, (clientX - rect.left - HANDLE_R) / innerW))
|
|
142
|
+
const sy = Math.max(0, Math.min(1, (clientY - rect.top - HANDLE_R) / innerH))
|
|
143
|
+
onChange(sx * 100, (1 - sy) * 100)
|
|
144
|
+
}, [onChange])
|
|
145
|
+
|
|
146
|
+
const onDown = (e) => {
|
|
147
|
+
dragging.current = true
|
|
148
|
+
update(e.clientX, e.clientY)
|
|
149
|
+
e.preventDefault()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
const onMove = (e) => { if (dragging.current) update(e.clientX, e.clientY) }
|
|
154
|
+
const onUp = () => { dragging.current = false }
|
|
155
|
+
window.addEventListener('pointermove', onMove)
|
|
156
|
+
window.addEventListener('pointerup', onUp)
|
|
157
|
+
window.addEventListener('pointercancel', onUp)
|
|
158
|
+
return () => {
|
|
159
|
+
window.removeEventListener('pointermove', onMove)
|
|
160
|
+
window.removeEventListener('pointerup', onUp)
|
|
161
|
+
window.removeEventListener('pointercancel', onUp)
|
|
162
|
+
}
|
|
163
|
+
}, [update])
|
|
164
|
+
|
|
165
|
+
const onKeyDown = (e) => {
|
|
166
|
+
const clamp = (v) => Math.max(0, Math.min(100, v))
|
|
167
|
+
if (e.key === 'ArrowRight') onChange(clamp(sat + 1), val)
|
|
168
|
+
else if (e.key === 'ArrowLeft') onChange(clamp(sat - 1), val)
|
|
169
|
+
else if (e.key === 'ArrowUp') onChange(sat, clamp(val + 1))
|
|
170
|
+
else if (e.key === 'ArrowDown') onChange(sat, clamp(val - 1))
|
|
171
|
+
else return
|
|
172
|
+
e.preventDefault()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const sb = `linear-gradient(to bottom, transparent 0%, #000 100%), linear-gradient(to right, #fff 0%, hsl(${hue},100%,50%) 100%)`
|
|
176
|
+
/* Gradient field is rectangular — rounding is the consumer's job (apply
|
|
177
|
+
* `rounded-[2px] overflow-hidden` on the wrapper). Putting border-radius
|
|
178
|
+
* on the gradient itself produces tiny antialiasing artifacts at the
|
|
179
|
+
* corners; clipping via overflow-hidden on the parent is cleaner. */
|
|
180
|
+
return (
|
|
181
|
+
<div
|
|
182
|
+
ref={ref}
|
|
183
|
+
role="slider"
|
|
184
|
+
tabIndex={0}
|
|
185
|
+
aria-label="Saturation and brightness"
|
|
186
|
+
aria-valuemin={0}
|
|
187
|
+
aria-valuemax={100}
|
|
188
|
+
aria-valuenow={Math.round(sat)}
|
|
189
|
+
aria-valuetext={`Saturation ${Math.round(sat)}%, brightness ${Math.round(val)}%`}
|
|
190
|
+
onPointerDown={onDown}
|
|
191
|
+
onKeyDown={onKeyDown}
|
|
192
|
+
className="relative cursor-crosshair touch-none w-full h-full"
|
|
193
|
+
style={{ background: sb }}
|
|
194
|
+
>
|
|
195
|
+
<div className="absolute" style={{ inset: HANDLE_R }}>
|
|
196
|
+
<Handle left={`${sat}%`} top={`${100 - val}%`} />
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* Wheel + rotating HSV triangle.
|
|
203
|
+
*
|
|
204
|
+
* Layout — viewBox 100×100, all coords in viewBox space.
|
|
205
|
+
* Center (50, 50). Outer ring radius = 48. Inner hole radius = 40.
|
|
206
|
+
* Triangle inscribed in a circle of radius TRI_R = 40 (vertices touch
|
|
207
|
+
* the inner ring edge).
|
|
208
|
+
*
|
|
209
|
+
* Triangle vertices rotate so the hue vertex always points at the active
|
|
210
|
+
* hue position on the ring. With angle = hue degrees from east (CCW negated
|
|
211
|
+
* for screen y-down):
|
|
212
|
+
* V_hue at angle θ
|
|
213
|
+
* V_white at θ - 120° (upper-left when θ = 0)
|
|
214
|
+
* V_black at θ + 120° (lower-left when θ = 0)
|
|
215
|
+
*
|
|
216
|
+
* SV mapping uses barycentric coords inside the triangle:
|
|
217
|
+
* P = a·V_hue + b·V_white + c·V_black, a+b+c = 1
|
|
218
|
+
* value = a + b (1 − c, height-from-black-edge in HSV space)
|
|
219
|
+
* saturation = a / (a + b) (0 = white side, 1 = hue side)
|
|
220
|
+
*
|
|
221
|
+
* Render: SVG with three stacked polygons —
|
|
222
|
+
* 1. solid hue
|
|
223
|
+
* 2. white-fade (linear gradient: opaque at V_white, 0 toward opposite edge)
|
|
224
|
+
* 3. black-fade (linear gradient: opaque at V_black, 0 toward opposite edge)
|
|
225
|
+
*/
|
|
226
|
+
|
|
227
|
+
const RING_OUTER = 48
|
|
228
|
+
const RING_INNER = 40 /* thin ring band — triangle vertices land on this edge */
|
|
229
|
+
const TRI_R = RING_INNER /* vertices touch the inner ring edge directly */
|
|
230
|
+
|
|
231
|
+
function vertexAt(angleRad) {
|
|
232
|
+
return [50 + TRI_R * Math.cos(angleRad), 50 + TRI_R * Math.sin(angleRad)]
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* WheelTriangle — HSV color wheel: conic hue ring with an inscribed,
|
|
237
|
+
* rotating HSV triangle for saturation/value. Ring press snaps hue to the
|
|
238
|
+
* cursor angle and drags; triangle press grabs the SV handle with a
|
|
239
|
+
* cursor↔handle offset so fine tuning never teleports it. Focus + arrow
|
|
240
|
+
* keys nudge: Left/Right = hue ±1° (wrapping), Up/Down = value ±1
|
|
241
|
+
* (role="slider"; saturation is pointer-only).
|
|
242
|
+
*
|
|
243
|
+
* @param {number} hue controlled hue, 0–360 — ring handle angle + triangle rotation
|
|
244
|
+
* @param {number} sat controlled saturation, 0–100
|
|
245
|
+
* @param {number} val controlled value/brightness, 0–100
|
|
246
|
+
* @param {Function} onChangeHue (hue: number) => void — ring drag / hue arrow keys
|
|
247
|
+
* @param {Function} onChangeSV (sat: number, val: number) => void — triangle drag / value arrow keys
|
|
248
|
+
*/
|
|
249
|
+
export function WheelTriangle({ hue, sat, val, onChangeHue, onChangeSV }) {
|
|
250
|
+
const svgRef = useRef(null)
|
|
251
|
+
const dragMode = useRef(null) /* { type: 'ring' } | { type: 'tri', offsetX, offsetY } | null */
|
|
252
|
+
|
|
253
|
+
/* [source-bug] fixed — the source hardcoded these three <defs> ids, so
|
|
254
|
+
* mounting two WheelTriangles collided (clip + gradients resolve to the
|
|
255
|
+
* first instance in the document). useId-derived per instance; sanitized
|
|
256
|
+
* because React wraps ids in ":"/"«»", which are unreliable inside
|
|
257
|
+
* url(#…) references. */
|
|
258
|
+
const uid = useId().replace(/[^a-zA-Z0-9_-]/g, '')
|
|
259
|
+
const ringClipId = `kol-wheel-ring-clip-${uid}`
|
|
260
|
+
const whiteFadeId = `kol-wheel-white-fade-${uid}`
|
|
261
|
+
const blackFadeId = `kol-wheel-black-fade-${uid}`
|
|
262
|
+
|
|
263
|
+
/* Convert client coords to viewBox coords (0..100). */
|
|
264
|
+
const toView = useCallback((clientX, clientY) => {
|
|
265
|
+
const node = svgRef.current
|
|
266
|
+
if (!node) return [50, 50]
|
|
267
|
+
const rect = node.getBoundingClientRect()
|
|
268
|
+
return [
|
|
269
|
+
((clientX - rect.left) / rect.width) * 100,
|
|
270
|
+
((clientY - rect.top) / rect.height) * 100,
|
|
271
|
+
]
|
|
272
|
+
}, [])
|
|
273
|
+
|
|
274
|
+
/* Compute hue vertex angle (radians). */
|
|
275
|
+
const hueAngle = hue * Math.PI / 180
|
|
276
|
+
const angleHue = hueAngle
|
|
277
|
+
const angleWhite = hueAngle - (2 * Math.PI / 3)
|
|
278
|
+
const angleBlack = hueAngle + (2 * Math.PI / 3)
|
|
279
|
+
const Vh = vertexAt(angleHue)
|
|
280
|
+
const Vw = vertexAt(angleWhite)
|
|
281
|
+
const Vb = vertexAt(angleBlack)
|
|
282
|
+
|
|
283
|
+
/* Barycentric weights for point P given the triangle vertices. */
|
|
284
|
+
const baryFor = (px, py) => {
|
|
285
|
+
const [x1, y1] = Vh
|
|
286
|
+
const [x2, y2] = Vw
|
|
287
|
+
const [x3, y3] = Vb
|
|
288
|
+
const denom = (y2 - y3) * (x1 - x3) + (x3 - x2) * (y1 - y3)
|
|
289
|
+
if (Math.abs(denom) < 1e-9) return [1, 0, 0]
|
|
290
|
+
const a = ((y2 - y3) * (px - x3) + (x3 - x2) * (py - y3)) / denom
|
|
291
|
+
const b = ((y3 - y1) * (px - x3) + (x1 - x3) * (py - y3)) / denom
|
|
292
|
+
const c = 1 - a - b
|
|
293
|
+
return [a, b, c]
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/* Clamp a point to the triangle interior using its barycentric coords. */
|
|
297
|
+
const clampToTriangle = (px, py) => {
|
|
298
|
+
let [a, b, c] = baryFor(px, py)
|
|
299
|
+
a = Math.max(0, a); b = Math.max(0, b); c = Math.max(0, c)
|
|
300
|
+
const sum = a + b + c
|
|
301
|
+
if (sum === 0) return [Vh[0], Vh[1], 1, 0, 0]
|
|
302
|
+
a /= sum; b /= sum; c /= sum
|
|
303
|
+
const x = a * Vh[0] + b * Vw[0] + c * Vb[0]
|
|
304
|
+
const y = a * Vh[1] + b * Vw[1] + c * Vb[1]
|
|
305
|
+
return [x, y, a, b, c]
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/* SV handle position from current (sat, val). */
|
|
309
|
+
const aH = (sat / 100) * (val / 100)
|
|
310
|
+
const bH = (1 - sat / 100) * (val / 100)
|
|
311
|
+
const cH = 1 - val / 100
|
|
312
|
+
const handleX = aH * Vh[0] + bH * Vw[0] + cH * Vb[0]
|
|
313
|
+
const handleY = aH * Vh[1] + bH * Vw[1] + cH * Vb[1]
|
|
314
|
+
|
|
315
|
+
/* Hue handle on ring (midline between outer and inner radius). */
|
|
316
|
+
const ringR = (RING_OUTER + RING_INNER) / 2
|
|
317
|
+
const ringHx = 50 + ringR * Math.cos(hueAngle)
|
|
318
|
+
const ringHy = 50 + ringR * Math.sin(hueAngle)
|
|
319
|
+
|
|
320
|
+
/* Pointer behavior:
|
|
321
|
+
* - Ring → snap-on-click; continued drag tracks cursor angle.
|
|
322
|
+
* - Tri → grab-with-offset so fine-tuning doesn't teleport the handle. */
|
|
323
|
+
const onSvgDown = (e) => {
|
|
324
|
+
const [vx, vy] = toView(e.clientX, e.clientY)
|
|
325
|
+
const dist = Math.hypot(vx - 50, vy - 50)
|
|
326
|
+
if (dist > RING_INNER) {
|
|
327
|
+
const cursorAngle = (Math.atan2(vy - 50, vx - 50) * 180 / Math.PI + 360) % 360
|
|
328
|
+
onChangeHue(cursorAngle)
|
|
329
|
+
dragMode.current = { type: 'ring' }
|
|
330
|
+
} else {
|
|
331
|
+
dragMode.current = {
|
|
332
|
+
type: 'tri',
|
|
333
|
+
offsetX: handleX - vx,
|
|
334
|
+
offsetY: handleY - vy,
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
e.preventDefault()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
const onMove = (e) => {
|
|
342
|
+
const d = dragMode.current
|
|
343
|
+
if (!d) return
|
|
344
|
+
const [vx, vy] = toView(e.clientX, e.clientY)
|
|
345
|
+
if (d.type === 'ring') {
|
|
346
|
+
const cursorAngle = (Math.atan2(vy - 50, vx - 50) * 180 / Math.PI + 360) % 360
|
|
347
|
+
onChangeHue(cursorAngle)
|
|
348
|
+
} else {
|
|
349
|
+
const tx = vx + d.offsetX
|
|
350
|
+
const ty = vy + d.offsetY
|
|
351
|
+
const [, , a, b] = clampToTriangle(tx, ty)
|
|
352
|
+
const value = a + b
|
|
353
|
+
const s = (a + b) > 0 ? (a / (a + b)) : 0
|
|
354
|
+
onChangeSV(s * 100, value * 100)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const onUp = () => { dragMode.current = null }
|
|
358
|
+
window.addEventListener('pointermove', onMove)
|
|
359
|
+
window.addEventListener('pointerup', onUp)
|
|
360
|
+
window.addEventListener('pointercancel', onUp)
|
|
361
|
+
return () => {
|
|
362
|
+
window.removeEventListener('pointermove', onMove)
|
|
363
|
+
window.removeEventListener('pointerup', onUp)
|
|
364
|
+
window.removeEventListener('pointercancel', onUp)
|
|
365
|
+
}
|
|
366
|
+
}, [toView, onChangeHue, onChangeSV, Vh, Vw, Vb])
|
|
367
|
+
|
|
368
|
+
const onKeyDown = (e) => {
|
|
369
|
+
if (e.key === 'ArrowRight') onChangeHue((hue + 1) % 360)
|
|
370
|
+
else if (e.key === 'ArrowLeft') onChangeHue((hue - 1 + 360) % 360)
|
|
371
|
+
else if (e.key === 'ArrowUp') onChangeSV(sat, Math.min(100, val + 1))
|
|
372
|
+
else if (e.key === 'ArrowDown') onChangeSV(sat, Math.max(0, val - 1))
|
|
373
|
+
else return
|
|
374
|
+
e.preventDefault()
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/* Gradient endpoints: white-fade goes from V_white (opaque) toward the
|
|
378
|
+
* midpoint of the V_hue—V_black edge (transparent). Same idea for black. */
|
|
379
|
+
const midHB = [(Vh[0] + Vb[0]) / 2, (Vh[1] + Vb[1]) / 2]
|
|
380
|
+
const midHW = [(Vh[0] + Vw[0]) / 2, (Vh[1] + Vw[1]) / 2]
|
|
381
|
+
|
|
382
|
+
const trianglePoints = `${Vh[0]},${Vh[1]} ${Vw[0]},${Vw[1]} ${Vb[0]},${Vb[1]}`
|
|
383
|
+
|
|
384
|
+
return (
|
|
385
|
+
<div
|
|
386
|
+
role="slider"
|
|
387
|
+
tabIndex={0}
|
|
388
|
+
aria-label="Hue wheel"
|
|
389
|
+
aria-valuemin={0}
|
|
390
|
+
aria-valuemax={360}
|
|
391
|
+
aria-valuenow={Math.round(hue)}
|
|
392
|
+
aria-valuetext={`Hue ${Math.round(hue)} degrees, saturation ${Math.round(sat)}%, brightness ${Math.round(val)}%`}
|
|
393
|
+
onKeyDown={onKeyDown}
|
|
394
|
+
className="aspect-square max-h-full max-w-full"
|
|
395
|
+
>
|
|
396
|
+
<svg
|
|
397
|
+
ref={svgRef}
|
|
398
|
+
viewBox="0 0 100 100"
|
|
399
|
+
onPointerDown={onSvgDown}
|
|
400
|
+
preserveAspectRatio="xMidYMid meet"
|
|
401
|
+
className="block cursor-crosshair select-none touch-none w-full h-full"
|
|
402
|
+
>
|
|
403
|
+
<defs>
|
|
404
|
+
<clipPath id={ringClipId} clipPathUnits="userSpaceOnUse">
|
|
405
|
+
<path
|
|
406
|
+
d={`M 50 ${50 - RING_OUTER} a ${RING_OUTER} ${RING_OUTER} 0 1 0 0.001 0 Z
|
|
407
|
+
M 50 ${50 - RING_INNER} a ${RING_INNER} ${RING_INNER} 0 1 1 -0.001 0 Z`}
|
|
408
|
+
fillRule="evenodd"
|
|
409
|
+
/>
|
|
410
|
+
</clipPath>
|
|
411
|
+
<linearGradient id={whiteFadeId} gradientUnits="userSpaceOnUse"
|
|
412
|
+
x1={midHB[0]} y1={midHB[1]} x2={Vw[0]} y2={Vw[1]}>
|
|
413
|
+
<stop offset="0" stopColor="#FFFFFF" stopOpacity="0" />
|
|
414
|
+
<stop offset="1" stopColor="#FFFFFF" stopOpacity="1" />
|
|
415
|
+
</linearGradient>
|
|
416
|
+
<linearGradient id={blackFadeId} gradientUnits="userSpaceOnUse"
|
|
417
|
+
x1={midHW[0]} y1={midHW[1]} x2={Vb[0]} y2={Vb[1]}>
|
|
418
|
+
<stop offset="0" stopColor="#000000" stopOpacity="0" />
|
|
419
|
+
<stop offset="1" stopColor="#000000" stopOpacity="1" />
|
|
420
|
+
</linearGradient>
|
|
421
|
+
</defs>
|
|
422
|
+
|
|
423
|
+
{/* Outer ring: foreignObject + CSS conic gradient, clipped to a torus
|
|
424
|
+
path so only the band is visible. */}
|
|
425
|
+
<foreignObject x="0" y="0" width="100" height="100" clipPath={`url(#${ringClipId})`}>
|
|
426
|
+
<div
|
|
427
|
+
xmlns="http://www.w3.org/1999/xhtml"
|
|
428
|
+
style={{
|
|
429
|
+
width: '100%', height: '100%',
|
|
430
|
+
background: `conic-gradient(from 0deg,
|
|
431
|
+
hsl(0,100%,50%), hsl(60,100%,50%), hsl(120,100%,50%),
|
|
432
|
+
hsl(180,100%,50%), hsl(240,100%,50%), hsl(300,100%,50%),
|
|
433
|
+
hsl(360,100%,50%))`,
|
|
434
|
+
}}
|
|
435
|
+
/>
|
|
436
|
+
</foreignObject>
|
|
437
|
+
|
|
438
|
+
{/* Triangle layers — three stacked polygons mix to a true HSV triangle */}
|
|
439
|
+
<polygon points={trianglePoints} fill={`hsl(${hue}, 100%, 50%)`} />
|
|
440
|
+
<polygon points={trianglePoints} fill={`url(#${whiteFadeId})`} />
|
|
441
|
+
<polygon points={trianglePoints} fill={`url(#${blackFadeId})`} />
|
|
442
|
+
|
|
443
|
+
{/* Handles. Ring handle is larger than the SV handle to match the Ref. */}
|
|
444
|
+
<SvgHandle cx={ringHx} cy={ringHy} r={3.2} />
|
|
445
|
+
<SvgHandle cx={handleX} cy={handleY} r={2.0} />
|
|
446
|
+
</svg>
|
|
447
|
+
</div>
|
|
448
|
+
)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/* ────────── Internal helpers (not exported) ────────── */
|
|
452
|
+
|
|
453
|
+
/* DOM handle for HueStrip / SBSquare. Solid white-stroked circle with thin
|
|
454
|
+
* black halo (matching the macOS-port look). */
|
|
455
|
+
function Handle({ left, top }) {
|
|
456
|
+
return (
|
|
457
|
+
<span
|
|
458
|
+
className="absolute pointer-events-none rounded-full bg-fg"
|
|
459
|
+
style={{
|
|
460
|
+
left, top,
|
|
461
|
+
width: 14, height: 14,
|
|
462
|
+
transform: 'translate(-50%, -50%)',
|
|
463
|
+
boxShadow: '0 0 0 1px #000, 0 0 0 2px #505050',
|
|
464
|
+
}}
|
|
465
|
+
/>
|
|
466
|
+
)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/* SVG handle for WheelTriangle. Two concentric circles to stay sharp at any
|
|
470
|
+
* zoom — outer black halo + white outline. */
|
|
471
|
+
function SvgHandle({ cx, cy, r = 2.4 }) {
|
|
472
|
+
return (
|
|
473
|
+
<g pointerEvents="none">
|
|
474
|
+
<circle cx={cx} cy={cy} r={r + 0.5} fill="none" stroke="#000" strokeOpacity="0.7" strokeWidth="0.5" />
|
|
475
|
+
<circle cx={cx} cy={cy} r={r} fill="none" stroke="#FFF" strokeWidth="1.4" />
|
|
476
|
+
</g>
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* SpectrumControls — the composed classic square picker: HueStrip stacked
|
|
482
|
+
* over a fill-height SBSquare, per the source color panel's Hue mode layout
|
|
483
|
+
* (SBSquare wrapped in `rounded-[2px] overflow-hidden`, see its comment).
|
|
484
|
+
* Fills its container — the consumer owns the outer size. For the ring
|
|
485
|
+
* picker, use WheelTriangle directly; the primitives are also exported for
|
|
486
|
+
* custom layouts.
|
|
487
|
+
*
|
|
488
|
+
* Controlled on a single HSV object; hex/RGB conversion stays at the call
|
|
489
|
+
* site (pairs with ColorSwatch + a hex Input there).
|
|
490
|
+
*
|
|
491
|
+
* @param {Object} value { hue: 0–360, sat: 0–100, val: 0–100 }
|
|
492
|
+
* @param {Function} onChange (next: { hue, sat, val }) => void
|
|
493
|
+
*/
|
|
494
|
+
export default function SpectrumControls({ value, onChange }) {
|
|
495
|
+
const { hue, sat, val } = value
|
|
496
|
+
return (
|
|
497
|
+
<div className="flex flex-col gap-3 w-full h-full min-h-0">
|
|
498
|
+
<HueStrip hue={hue} onChange={(h) => onChange({ hue: h, sat, val })} />
|
|
499
|
+
<div className="flex-1 min-h-0 rounded-[2px] overflow-hidden">
|
|
500
|
+
<SBSquare hue={hue} sat={sat} val={val} onChange={(s, v) => onChange({ hue, sat: s, val: v })} />
|
|
501
|
+
</div>
|
|
502
|
+
</div>
|
|
503
|
+
)
|
|
504
|
+
}
|