[eric] theme: knob turns relative with seam-safe delta + stuck-drag sealed; minimize parks in 250ms

This commit is contained in:
ciregenz
2026-07-27 20:46:42 -07:00
parent 998c452523
commit 276ca5a04a
2 changed files with 35 additions and 18 deletions
+33 -17
View File
@@ -5,7 +5,7 @@ import React, { useCallback, useRef } from 'react';
export const SquiggleSlider: React.FC<{ value: number; onChange: (v: number) => void; width?: number }> = ({ value, onChange, width = 170 }) => {
const ref = useRef<HTMLDivElement | null>(null);
const dragging = useRef(false);
const [dragging, setDragging] = React.useState(false);
const apply = useCallback((clientX: number) => {
const el = ref.current;
if (!el) return;
@@ -22,12 +22,14 @@ export const SquiggleSlider: React.FC<{ value: number; onChange: (v: number) =>
<div
ref={ref}
title="Grain"
onPointerDown={(e) => { dragging.current = true; (e.target as HTMLElement).setPointerCapture?.(e.pointerId); apply(e.clientX); }}
onPointerMove={(e) => { if (dragging.current) apply(e.clientX); }}
onPointerUp={() => { dragging.current = false; }}
style={{ position: 'relative', width, height: H, cursor: 'pointer', touchAction: 'none', flexShrink: 0 }}
onPointerDown={(e) => { setDragging(true); e.currentTarget.setPointerCapture?.(e.pointerId); apply(e.clientX); }}
onPointerMove={(e) => { if (dragging) apply(e.clientX); }}
onPointerUp={() => setDragging(false)}
onPointerCancel={() => setDragging(false)}
onLostPointerCapture={() => setDragging(false)}
style={{ position: 'relative', width, height: H, cursor: dragging ? 'grabbing' : 'grab', touchAction: 'none', flexShrink: 0 }}
>
<svg width={width} height={H} style={{ display: 'block' }}>
<svg width={width} height={H} style={{ display: 'block', pointerEvents: 'none' }}>
<path d={wavePath} stroke="currentColor" strokeOpacity={0.5} strokeWidth={2} fill="none" strokeLinecap="round" />
</svg>
<div style={{
@@ -42,29 +44,43 @@ export const SquiggleSlider: React.FC<{ value: number; onChange: (v: number) =>
export const Knob: React.FC<{ value: number; onChange: (v: number) => void; size?: number }> = ({ value, onChange, size = 34 }) => {
const ref = useRef<HTMLDivElement | null>(null);
const [grabbing, setGrabbing] = React.useState(false);
// A real knob turns RELATIVE to where you grabbed it; jumping to the grab angle feels broken.
const start = useRef<{ deg: number; value: number } | null>(null);
const angle = -135 + value * 270;
// Turn like a physical knob: the indicator chases the pointer's angle around the center.
const applyAngle = useCallback((clientX: number, clientY: number) => {
const pointerDeg = useCallback((clientX: number, clientY: number): number => {
const el = ref.current;
if (!el) return;
if (!el) return 0;
const r = el.getBoundingClientRect();
const deg = Math.atan2(clientX - (r.left + r.width / 2), (r.top + r.height / 2) - clientY) * (180 / Math.PI);
onChange(Math.min(1, Math.max(0, (Math.max(-135, Math.min(135, deg)) + 135) / 270)));
}, [onChange]);
return Math.atan2(clientX - (r.left + r.width / 2), (r.top + r.height / 2) - clientY) * (180 / Math.PI);
}, []);
const end = (): void => { setGrabbing(false); start.current = null; };
return (
<div
ref={ref}
title="Intensity"
onPointerDown={(e) => { setGrabbing(true); (e.target as HTMLElement).setPointerCapture?.(e.pointerId); applyAngle(e.clientX, e.clientY); }}
onPointerMove={(e) => { if (grabbing) applyAngle(e.clientX, e.clientY); }}
onPointerUp={() => setGrabbing(false)}
onPointerDown={(e) => {
setGrabbing(true);
e.currentTarget.setPointerCapture?.(e.pointerId);
start.current = { deg: pointerDeg(e.clientX, e.clientY), value };
}}
onPointerMove={(e) => {
if (!grabbing || !start.current) return;
let delta = pointerDeg(e.clientX, e.clientY) - start.current.deg;
// Crossing the +-180 seam must not spin the knob a full turn.
if (delta > 180) delta -= 360;
if (delta < -180) delta += 360;
onChange(Math.min(1, Math.max(0, start.current.value + delta / 270)));
}}
onPointerUp={end}
onPointerCancel={end}
onLostPointerCapture={end}
style={{
position: 'relative', width: size + 10, height: size + 10, display: 'flex', alignItems: 'center',
justifyContent: 'center', cursor: grabbing ? 'grabbing' : 'grab', touchAction: 'none', flexShrink: 0,
}}
>
<div style={{ position: 'absolute', inset: 0, borderRadius: 999, border: '2px dotted currentColor', opacity: 0.4 }} />
<div style={{ width: size - 8, height: size - 8, borderRadius: 999, background: '#3a3835', transform: `rotate(${angle}deg)`, display: 'flex', justifyContent: 'center' }}>
<div style={{ position: 'absolute', inset: 0, borderRadius: 999, border: '2px dotted currentColor', opacity: 0.4, pointerEvents: 'none' }} />
<div style={{ width: size - 8, height: size - 8, borderRadius: 999, background: '#3a3835', transform: `rotate(${angle}deg)`, display: 'flex', justifyContent: 'center', pointerEvents: 'none' }}>
<div style={{ width: 3, height: 9, borderRadius: 2, background: '#fff', marginTop: 2 }} />
</div>
</div>
@@ -559,7 +559,8 @@ const BrowserCard: React.FC<Props> = ({
const park = (): void => { if (parked) return; parked = true; dispatch(toggleMinimizeCard({ cardId: browserId })); };
if (capture && typeof (capture as Promise<unknown>).then === 'function') {
// capturePage can hang forever on off-screen guests (Electron 42); the timer guarantees the park.
window.setTimeout(park, 800);
// 250ms: captures land in ~100-200ms when visible, and a snappy minimize beats a perfect thumbnail.
window.setTimeout(park, 250);
(capture as Promise<{ toDataURL(): string }>)
.then((img) => { saveMinimizedShot(browserId, img.toDataURL()); })
.catch(() => undefined)