v0.9.20 created new colour picker on mobile
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jama-frontend",
|
||||
"version": "0.9.18",
|
||||
"version": "0.9.20",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { api } from '../utils/api.js';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
|
||||
@@ -10,18 +10,216 @@ const COLOUR_SUGGESTIONS = [
|
||||
'#1a73e8', '#a142f4', '#e53935', '#fa7b17', '#fdd835', '#34a853',
|
||||
];
|
||||
|
||||
// A single colour picker card: shows suggestions by default, Custom button opens native picker
|
||||
// ── Colour math helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function hexToHsv(hex) {
|
||||
const r = parseInt(hex.slice(1,3),16)/255;
|
||||
const g = parseInt(hex.slice(3,5),16)/255;
|
||||
const b = parseInt(hex.slice(5,7),16)/255;
|
||||
const max = Math.max(r,g,b), min = Math.min(r,g,b), d = max - min;
|
||||
let h = 0;
|
||||
if (d !== 0) {
|
||||
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
else if (max === g) h = ((b - r) / d + 2) / 6;
|
||||
else h = ((r - g) / d + 4) / 6;
|
||||
}
|
||||
return { h: h * 360, s: max === 0 ? 0 : d / max, v: max };
|
||||
}
|
||||
|
||||
function hsvToHex(h, s, v) {
|
||||
h = h / 360;
|
||||
const i = Math.floor(h * 6);
|
||||
const f = h * 6 - i;
|
||||
const p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s);
|
||||
let r, g, b;
|
||||
switch (i % 6) {
|
||||
case 0: r=v; g=t; b=p; break; case 1: r=q; g=v; b=p; break;
|
||||
case 2: r=p; g=v; b=t; break; case 3: r=p; g=q; b=v; break;
|
||||
case 4: r=t; g=p; b=v; break; default: r=v; g=p; b=q;
|
||||
}
|
||||
return '#' + [r,g,b].map(x => Math.round(x*255).toString(16).padStart(2,'0')).join('');
|
||||
}
|
||||
|
||||
function isValidHex(h) { return /^#[0-9a-fA-F]{6}$/.test(h); }
|
||||
|
||||
// ── SV (saturation/value) square ─────────────────────────────────────────────
|
||||
|
||||
function SvSquare({ hue, s, v, onChange }) {
|
||||
const canvasRef = useRef(null);
|
||||
const dragging = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const W = canvas.width, H = canvas.height;
|
||||
// White → hue gradient (left→right)
|
||||
const hGrad = ctx.createLinearGradient(0, 0, W, 0);
|
||||
hGrad.addColorStop(0, '#fff');
|
||||
hGrad.addColorStop(1, `hsl(${hue},100%,50%)`);
|
||||
ctx.fillStyle = hGrad; ctx.fillRect(0, 0, W, H);
|
||||
// Transparent → black gradient (top→bottom)
|
||||
const vGrad = ctx.createLinearGradient(0, 0, 0, H);
|
||||
vGrad.addColorStop(0, 'transparent');
|
||||
vGrad.addColorStop(1, '#000');
|
||||
ctx.fillStyle = vGrad; ctx.fillRect(0, 0, W, H);
|
||||
}, [hue]);
|
||||
|
||||
const getPos = (e, canvas) => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const cx = (e.touches ? e.touches[0].clientX : e.clientX) - r.left;
|
||||
const cy = (e.touches ? e.touches[0].clientY : e.clientY) - r.top;
|
||||
return {
|
||||
s: Math.max(0, Math.min(1, cx / r.width)),
|
||||
v: Math.max(0, Math.min(1, 1 - cy / r.height)),
|
||||
};
|
||||
};
|
||||
|
||||
const handle = (e) => {
|
||||
e.preventDefault();
|
||||
const p = getPos(e, canvasRef.current);
|
||||
onChange(p.s, p.v);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', userSelect: 'none', touchAction: 'none' }}>
|
||||
<canvas
|
||||
ref={canvasRef} width={260} height={160}
|
||||
style={{ display: 'block', width: '100%', height: 160, borderRadius: 8, cursor: 'crosshair', border: '1px solid var(--border)' }}
|
||||
onMouseDown={e => { dragging.current = true; handle(e); }}
|
||||
onMouseMove={e => { if (dragging.current) handle(e); }}
|
||||
onMouseUp={() => { dragging.current = false; }}
|
||||
onMouseLeave={() => { dragging.current = false; }}
|
||||
onTouchStart={handle} onTouchMove={handle}
|
||||
/>
|
||||
{/* Cursor circle */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: `calc(${s * 100}% - 7px)`,
|
||||
top: `calc(${(1 - v) * 100}% - 7px)`,
|
||||
width: 14, height: 14, borderRadius: '50%',
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 0 0 1.5px rgba(0,0,0,0.4)',
|
||||
pointerEvents: 'none',
|
||||
background: 'transparent',
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hue bar ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function HueBar({ hue, onChange }) {
|
||||
const barRef = useRef(null);
|
||||
const dragging = useRef(false);
|
||||
|
||||
const handle = (e) => {
|
||||
e.preventDefault();
|
||||
const r = barRef.current.getBoundingClientRect();
|
||||
const cx = (e.touches ? e.touches[0].clientX : e.clientX) - r.left;
|
||||
onChange(Math.max(0, Math.min(360, (cx / r.width) * 360)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', userSelect: 'none', touchAction: 'none', marginTop: 10 }}>
|
||||
<div
|
||||
ref={barRef}
|
||||
style={{
|
||||
height: 20, borderRadius: 10,
|
||||
background: 'linear-gradient(to right,#f00,#ff0,#0f0,#0ff,#00f,#f0f,#f00)',
|
||||
border: '1px solid var(--border)', cursor: 'pointer',
|
||||
}}
|
||||
onMouseDown={e => { dragging.current = true; handle(e); }}
|
||||
onMouseMove={e => { if (dragging.current) handle(e); }}
|
||||
onMouseUp={() => { dragging.current = false; }}
|
||||
onMouseLeave={() => { dragging.current = false; }}
|
||||
onTouchStart={handle} onTouchMove={handle}
|
||||
/>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: `calc(${(hue / 360) * 100}% - 9px)`,
|
||||
top: -2, width: 18, height: 24, borderRadius: 4,
|
||||
background: `hsl(${hue},100%,50%)`,
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 0 0 1.5px rgba(0,0,0,0.3)',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Custom HSV picker ─────────────────────────────────────────────────────────
|
||||
|
||||
function CustomPicker({ initial, onSet, onBack }) {
|
||||
const { h: ih, s: is, v: iv } = hexToHsv(initial);
|
||||
const [hue, setHue] = useState(ih);
|
||||
const [sat, setSat] = useState(is);
|
||||
const [val, setVal] = useState(iv);
|
||||
const [hexInput, setHexInput] = useState(initial);
|
||||
const [hexError, setHexError] = useState(false);
|
||||
|
||||
const current = hsvToHex(hue, sat, val);
|
||||
|
||||
// Sync hex input when sliders change
|
||||
useEffect(() => { setHexInput(current); setHexError(false); }, [current]);
|
||||
|
||||
const handleHexInput = (e) => {
|
||||
const v = e.target.value;
|
||||
setHexInput(v);
|
||||
if (isValidHex(v)) {
|
||||
const { h, s, v: bv } = hexToHsv(v);
|
||||
setHue(h); setSat(s); setVal(bv);
|
||||
setHexError(false);
|
||||
} else {
|
||||
setHexError(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<SvSquare hue={hue} s={sat} v={val} onChange={(s, v) => { setSat(s); setVal(v); }} />
|
||||
<HueBar hue={hue} onChange={setHue} />
|
||||
|
||||
{/* Preview + hex input */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 2 }}>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 8, background: current,
|
||||
border: '2px solid var(--border)', flexShrink: 0,
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.15)',
|
||||
}} />
|
||||
<input
|
||||
value={hexInput}
|
||||
onChange={handleHexInput}
|
||||
maxLength={7}
|
||||
style={{
|
||||
fontFamily: 'monospace', fontSize: 14,
|
||||
padding: '6px 10px', borderRadius: 8,
|
||||
border: `1px solid ${hexError ? '#e53935' : 'var(--border)'}`,
|
||||
width: 110, background: 'var(--surface)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
placeholder="#000000"
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>Chosen colour</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 2 }}>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => onSet(current)} disabled={hexError}>
|
||||
Set
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={onBack}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ColourPicker card ─────────────────────────────────────────────────────────
|
||||
|
||||
function ColourPicker({ label, value, onChange, preview }) {
|
||||
const [mode, setMode] = useState('suggestions'); // 'suggestions' | 'custom'
|
||||
const [draft, setDraft] = useState(value);
|
||||
|
||||
// Keep draft in sync if parent value changes (e.g. reset)
|
||||
useEffect(() => { setDraft(value); }, [value]);
|
||||
|
||||
const handleSet = () => {
|
||||
onChange(draft);
|
||||
setMode('suggestions');
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -54,41 +252,18 @@ function ColourPicker({ label, value, onChange, preview }) {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => { setDraft(value); setMode('custom'); }}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setMode('custom')}>
|
||||
Custom
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'custom' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{/* Large label-wrapped swatch triggers the colour input — reliable on mobile and desktop */}
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer' }}>
|
||||
<span style={{
|
||||
display: 'inline-block', width: 56, height: 44,
|
||||
borderRadius: 8, background: draft,
|
||||
border: '2px solid var(--border)',
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.15)',
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
<input
|
||||
type="color"
|
||||
value={draft}
|
||||
onInput={e => setDraft(e.target.value)}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
style={{ position: 'absolute', opacity: 0, width: 0, height: 0, pointerEvents: 'none' }}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', fontFamily: 'monospace' }}>{draft}</span>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleSet}>
|
||||
Set
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setMode('suggestions')}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<CustomPicker
|
||||
initial={value}
|
||||
onSet={(hex) => { onChange(hex); setMode('suggestions'); }}
|
||||
onBack={() => setMode('suggestions')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user