From 5cd33a2b9d38898ed767ca3081a989dcb4fd669e Mon Sep 17 00:00:00 2001 From: ollyhearn Date: Fri, 5 Jun 2026 15:11:02 +0300 Subject: [PATCH] fix: knob & text --- src/components/knob/index.tsx | 20 ++++++++++++++------ src/components/text-field/index.tsx | 2 +- src/styles/components.css | 25 ++++++++++++++++++++++++- 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/components/knob/index.tsx b/src/components/knob/index.tsx index 48f86d3..3842805 100644 --- a/src/components/knob/index.tsx +++ b/src/components/knob/index.tsx @@ -97,8 +97,11 @@ export const Knob = ({ const [dragging, setDragging] = useState(false); const elRef = useRef(null); - const a0 = useRef(0); - const v0 = useRef(0); + // Drag tracking: previous pointer angle + a continuous value accumulator. + // Accumulating per-move deltas (each < 180°) instead of measuring from the + // grab angle lets the dial wrap past ±180° without the value snapping. + const lastAngle = useRef(0); + const acc = useRef(0); const reactId = useId(); const knobId = id ?? reactId; @@ -129,16 +132,21 @@ export const Knob = ({ elRef.current?.focus(); elRef.current?.setPointerCapture(e.pointerId); setDragging(true); - a0.current = angleAt(e.clientX, e.clientY); - v0.current = committed; + lastAngle.current = angleAt(e.clientX, e.clientY); + acc.current = clamp(committed); }; const onPointerMove = (e: PointerEvent) => { if (!dragging) return; - let d = angleAt(e.clientX, e.clientY) - a0.current; + const a = angleAt(e.clientX, e.clientY); + let d = a - lastAngle.current; if (d > 180) d -= 360; if (d < -180) d += 360; - commit(v0.current + (d / SWEEP) * span, true); + lastAngle.current = a; + // Clamp the accumulator (not just the committed value) so overshoot past + // an end doesn't build a dead zone you must unwind before reversing. + acc.current = clamp(acc.current + (d / SWEEP) * span); + commit(acc.current, true); }; const endDrag = (e: PointerEvent) => { diff --git a/src/components/text-field/index.tsx b/src/components/text-field/index.tsx index 2b10ebe..d40da22 100644 --- a/src/components/text-field/index.tsx +++ b/src/components/text-field/index.tsx @@ -165,7 +165,7 @@ function useFieldAnimation( if (node) spanRefs.current.set(e.id, node); else spanRefs.current.delete(e.id); }} - className="modern-sk-field-char" + className={cx('modern-sk-field-char', !multiline && 'modern-sk-field-char--composited')} > {e.char} diff --git a/src/styles/components.css b/src/styles/components.css index 3288188..13c749f 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -269,11 +269,23 @@ textarea.modern-sk-field { .modern-sk-field-char { /* inline (not inline-block) so the overlay wraps exactly like the field; inherit white-space so multiline (pre-wrap) wraps while input (pre) won't. - 'pre' on the span itself would suppress wrapping at its boundaries. */ + 'pre' on the span itself would suppress wrapping at its boundaries. + Multiline keeps this inline path: inline boxes ignore transform, so the + rise eases `top` (a layout property — acceptable here, textarea wraps + instead of scrolling so there's no per-keystroke scroll-sync thrash). */ position: relative; white-space: inherit; animation: modern-sk-char-in var(--dur-base) var(--ease-snap) both; } +/* Single-line never wraps, so chars can be inline-block and animate + `transform` (compositor-only) instead of `top` (relayout every frame). + The single-line field scrolls horizontally and syncs the overlay each + keystroke, so the layout-triggering `top` path janked there — this doesn't. */ +.modern-sk-field-char--composited { + display: inline-block; + will-change: transform, opacity; + animation-name: modern-sk-char-in-rise; +} .modern-sk-field-char--leaving { display: inline-block; position: absolute; @@ -292,6 +304,17 @@ textarea.modern-sk-field { top: 0; } } +/* inline-block (single-line) variant — composited transform, no relayout */ +@keyframes modern-sk-char-in-rise { + from { + opacity: 0; + transform: translateY(-0.32em); + } + to { + opacity: 1; + transform: none; + } +} @keyframes modern-sk-char-out { from { opacity: 1;