fix: knob & text

This commit is contained in:
2026-06-05 15:11:02 +03:00
parent ef69f7bb65
commit 5cd33a2b9d
3 changed files with 39 additions and 8 deletions
+14 -6
View File
@@ -97,8 +97,11 @@ export const Knob = ({
const [dragging, setDragging] = useState(false);
const elRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
+1 -1
View File
@@ -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}
</span>
+24 -1
View File
@@ -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;