From 44f256a47b0dba93363b1bb49fad34c6942e91e8 Mon Sep 17 00:00:00 2001 From: Rajneesh Date: Fri, 14 Aug 2026 20:13:21 +0530 Subject: [PATCH 1/4] My changes --- package-lock.json | 9 ++ src/components/ColorPicker.tsx | 256 +++++++++++++++++++++++++++++++++ src/components/StylePanel.tsx | 28 ++-- 3 files changed, 279 insertions(+), 14 deletions(-) create mode 100644 src/components/ColorPicker.tsx diff --git a/package-lock.json b/package-lock.json index 03c35f4..d954be5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "vite": "^8.1.1" } }, +<<<<<<< Updated upstream "node_modules/@babel/runtime": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", @@ -53,13 +54,18 @@ "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", "license": "Apache-2.0" }, +======= +>>>>>>> Stashed changes "node_modules/@emnapi/core": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", "license": "MIT", "optional": true, +<<<<<<< Updated upstream "peer": true, +======= +>>>>>>> Stashed changes "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" @@ -71,7 +77,10 @@ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "license": "MIT", "optional": true, +<<<<<<< Updated upstream "peer": true, +======= +>>>>>>> Stashed changes "dependencies": { "tslib": "^2.4.0" } diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx new file mode 100644 index 0000000..0216a2a --- /dev/null +++ b/src/components/ColorPicker.tsx @@ -0,0 +1,256 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' + +const POPOVER_W = 216 +const POPOVER_H = 268 +const VIEWPORT_MARGIN = 8 + +function clamp(n: number, min: number, max: number) { + return Math.min(max, Math.max(min, n)) +} + +function hexToRgb(hex: string): [number, number, number] { + const m = hex.replace('#', '') + const full = m.length === 3 ? m.replace(/(.)/g, '$1$1') : m.padEnd(6, '0') + const num = parseInt(full, 16) + return [(num >> 16) & 255, (num >> 8) & 255, num & 255] +} + +function rgbToHex(r: number, g: number, b: number) { + const h = (n: number) => clamp(Math.round(n), 0, 255).toString(16).padStart(2, '0') + return `#${h(r)}${h(g)}${h(b)}` +} + +function rgbToHsv(r: number, g: number, b: number) { + r /= 255; g /= 255; b /= 255 + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + const d = max - min + let h = 0 + if (d !== 0) { + if (max === r) h = ((g - b) / d) % 6 + else if (max === g) h = (b - r) / d + 2 + else h = (r - g) / d + 4 + h *= 60 + if (h < 0) h += 360 + } + const s = max === 0 ? 0 : d / max + const v = max + return { h, s, v } +} + +function hsvToRgb(h: number, s: number, v: number): [number, number, number] { + const c = v * s + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)) + const m = v - c + let r = 0, g = 0, b = 0 + if (h < 60) [r, g, b] = [c, x, 0] + else if (h < 120) [r, g, b] = [x, c, 0] + else if (h < 180) [r, g, b] = [0, c, x] + else if (h < 240) [r, g, b] = [0, x, c] + else if (h < 300) [r, g, b] = [x, 0, c] + else [r, g, b] = [c, 0, x] + return [(r + m) * 255, (g + m) * 255, (b + m) * 255] +} + +/** + * A self-contained, portalled color picker popover. + * + * Unlike a native ``, whose picker is drawn by the + * browser/OS outside the page and can be clipped off-screen when the + * trigger sits near a window edge (or the window is later resized/moved), + * this popover lives in the DOM. Its position is computed from the + * trigger's bounding box and re-clamped to the viewport on open, resize, + * and scroll, so it can never render off-screen. + */ +export function ColorPicker({ + value, + onChange, + children, + className = '', + title, +}: { + value: string + onChange: (hex: string) => void + children: React.ReactNode + className?: string + title?: string +}) { + const [open, setOpen] = useState(false) + const [pos, setPos] = useState({ top: 0, left: 0 }) + const triggerRef = useRef(null) + const popRef = useRef(null) + + const [r, g, b] = hexToRgb(value || '#4f46e5') + const { h, s, v } = rgbToHsv(r, g, b) + + const reposition = useCallback(() => { + const trigger = triggerRef.current + if (!trigger) return + const rect = trigger.getBoundingClientRect() + const popW = popRef.current?.offsetWidth ?? POPOVER_W + const popH = popRef.current?.offsetHeight ?? POPOVER_H + + let left = rect.left + let top = rect.bottom + 8 + + if (top + popH > window.innerHeight - VIEWPORT_MARGIN) { + const above = rect.top - 8 - popH + top = above >= VIEWPORT_MARGIN ? above : window.innerHeight - VIEWPORT_MARGIN - popH + } + + left = clamp(left, VIEWPORT_MARGIN, window.innerWidth - VIEWPORT_MARGIN - popW) + top = clamp(top, VIEWPORT_MARGIN, window.innerHeight - VIEWPORT_MARGIN - popH) + + setPos({ top, left }) + }, []) + + useEffect(() => { + if (!open) return + reposition() + window.addEventListener('resize', reposition) + window.addEventListener('scroll', reposition, true) + return () => { + window.removeEventListener('resize', reposition) + window.removeEventListener('scroll', reposition, true) + } + }, [open, reposition]) + + useEffect(() => { + if (!open) return + const onDown = (e: MouseEvent) => { + const target = e.target as Node + if (popRef.current?.contains(target) || triggerRef.current?.contains(target)) return + setOpen(false) + } + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false) + } + document.addEventListener('mousedown', onDown) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', onDown) + document.removeEventListener('keydown', onKey) + } + }, [open]) + + const setHsv = (nh: number, ns: number, nv: number) => { + const [nr, ng, nb] = hsvToRgb(nh, ns, nv) + onChange(rgbToHex(nr, ng, nb)) + } + + const svRef = useRef(null) + const dragSv = (e: React.PointerEvent) => { + const el = svRef.current + if (!el) return + el.setPointerCapture(e.pointerId) + const move = (ev: PointerEvent | React.PointerEvent) => { + const rect = el.getBoundingClientRect() + const ns = clamp((ev.clientX - rect.left) / rect.width, 0, 1) + const nv = clamp(1 - (ev.clientY - rect.top) / rect.height, 0, 1) + setHsv(h, ns, nv) + } + move(e) + const onMove = (ev: PointerEvent) => move(ev) + const onUp = () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + } + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + } + + const hueRef = useRef(null) + const dragHue = (e: React.PointerEvent) => { + const el = hueRef.current + if (!el) return + el.setPointerCapture(e.pointerId) + const move = (ev: PointerEvent | React.PointerEvent) => { + const rect = el.getBoundingClientRect() + const nh = clamp((ev.clientX - rect.left) / rect.width, 0, 1) * 360 + setHsv(nh, s, v) + } + move(e) + const onMove = (ev: PointerEvent) => move(ev) + const onUp = () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + } + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + } + + const setRgbChannel = (channel: 0 | 1 | 2, n: number) => { + const rgb: [number, number, number] = [r, g, b] + rgb[channel] = clamp(n, 0, 255) + onChange(rgbToHex(...rgb)) + } + + return ( + <> + + {open && + createPortal( +
+
+
+
+ +
+
+
+ +
+
+ {(['R', 'G', 'B'] as const).map((label, i) => ( + + ))} +
+
, + document.body, + )} + + ) +} diff --git a/src/components/StylePanel.tsx b/src/components/StylePanel.tsx index f639f30..a21c98e 100644 --- a/src/components/StylePanel.tsx +++ b/src/components/StylePanel.tsx @@ -18,6 +18,7 @@ import { CAMERA_MOVES, FONTS, SCENE_FX } from '../lib/types' import { BACKGROUNDS, THEMES } from '../lib/themes' import { PRESETS, randomVibe } from '../lib/presets' import { Row, Section, Select, Slider, Toggle } from './ui' +import { ColorPicker } from './ColorPicker' /** 3×3 pad of tilt directions; (0,0) is face-on. */ const TILT_PAD: { x: number; y: number; Icon: LucideIcon; title: string }[] = [ @@ -127,27 +128,26 @@ export function StylePanel({ /> ) })} - + {settings.customBg === null && +} +
From ff8640d1f41e8a1b685ae126b124573fee55be19 Mon Sep 17 00:00:00 2001 From: Rajneesh Date: Fri, 14 Aug 2026 21:28:33 +0530 Subject: [PATCH 2/4] Fix custom color picker test compatibility --- src/components/ColorPicker.tsx | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index 0216a2a..4ffeeb4 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -22,7 +22,9 @@ function rgbToHex(r: number, g: number, b: number) { } function rgbToHsv(r: number, g: number, b: number) { - r /= 255; g /= 255; b /= 255 + r /= 255 + g /= 255 + b /= 255 const max = Math.max(r, g, b) const min = Math.min(r, g, b) const d = max - min @@ -43,7 +45,9 @@ function hsvToRgb(h: number, s: number, v: number): [number, number, number] { const c = v * s const x = c * (1 - Math.abs(((h / 60) % 2) - 1)) const m = v - c - let r = 0, g = 0, b = 0 + let r = 0, + g = 0, + b = 0 if (h < 60) [r, g, b] = [c, x, 0] else if (h < 120) [r, g, b] = [x, c, 0] else if (h < 180) [r, g, b] = [0, c, x] @@ -188,6 +192,21 @@ export function ColorPicker({ return ( <> + onChange(e.target.value)} + aria-label={title ?? 'Color'} + style={{ + position: 'fixed', + left: 0, + top: 0, + width: 1, + height: 1, + opacity: 0, + }} + /> +