web: the mixer hangs a panel on the colour it read, and STRAIGHTEN becomes a scale on the photo

This commit is contained in:
2026-09-18 18:27:28 +07:00
parent 10466e122a
commit 27035c4acb
5 changed files with 401 additions and 41 deletions
+106 -35
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ToolRail, type TabId } from './ui/ToolRail';
import { ChipRow, SliderRow, type ChipDef, type SliderDef } from './ui/ChipColumn';
import { ChipRow, MiniSlider, SliderRow, type ChipDef, type SliderDef } from './ui/ChipColumn';
import { ImageStage } from './ui/ImageStage';
import { StraightenRuler } from './ui/StraightenRuler';
import { TopBar } from './ui/TopBar';
import { AuthModal } from './ui/AuthModal';
import { NameModal } from './ui/NameModal';
@@ -195,6 +196,8 @@ export function Workspace() {
const [hslBand, setHslBand] = useState<HslBandId>('red');
const [picking, setPicking] = useState(false);
const [sample, setSample] = useState<{ r: number; g: number; b: number } | null>(null);
// Where on the photo that colour was read: the mixer's panel hangs there.
const [pickedAt, setPickedAt] = useState<{ fx: number; fy: number } | null>(null);
const [openGroup, setOpenGroup] = useState<GroupKey | null>(null);
const [showRecipes, setShowRecipes] = useState(false);
const [tab, setTab] = useState<TabId>('presets');
@@ -592,6 +595,23 @@ export function Workspace() {
[remember]
);
// One band of the mixer, read and written through one door: the tab's ruler
// and the panel hung on the photo are the same three knobs. An all-zero band
// is dropped, so an untouched band never lands in the recipe and a band put
// back to zero leaves no trace.
const setBandKnob = useCallback(
(which: 0 | 1 | 2, v: number) => {
const band = recipe.adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand);
const next: HslBand = [band[0], band[1], band[2]];
next[which] = Math.round(v);
const bands = { ...recipe.adjustments.hslBands };
if (next[0] === 0 && next[1] === 0 && next[2] === 0) delete bands[hslBand];
else bands[hslBand] = next;
setAdjustmentOnce({ hslBands: bands });
},
[recipe.adjustments.hslBands, hslBand, setAdjustmentOnce]
);
// --- crop -----------------------------------------------------------------
// Picking a ratio only opens the frame; the crop is committed by APPLY.
const pickCrop = useCallback(
@@ -1077,10 +1097,28 @@ export function Workspace() {
setOpenParam(null);
setOpenGroup(null);
setShowRecipes(false);
// Leaving the mixer disarms the eyedropper: it is a mode of that tab.
// Leaving the mixer disarms the eyedropper and takes its panel off the
// photo: both are modes of that tab.
setPicking(false);
setPickedAt(null);
}, [tab]);
// STRAIGHTEN's scale is a mode of the photo, like the eyedropper: any click
// that lands outside it applies the angle and takes the scale off the photo —
// so reaching for another tab, chip or button levels the photo and puts the
// ruler away in the same gesture. Nothing is pending: the angle is already
// live in the render, this only ends the session.
useEffect(() => {
if (openParam !== 'straighten') return;
const dismiss = (e: PointerEvent) => {
const el = e.target as HTMLElement | null;
if (el?.closest('[data-key="straighten-ruler"], .chip[data-key="straighten"]')) return;
setOpenParam(null);
};
window.addEventListener('pointerdown', dismiss, true);
return () => window.removeEventListener('pointerdown', dismiss, true);
}, [openParam]);
// Continuous parameter chips: label + live value; amber text when edited,
// amber border while its ruler is open.
const paramChips = (defs: ContinuousParamDef[]): ChipDef[] =>
@@ -1100,42 +1138,18 @@ export function Workspace() {
const slider: SliderDef | null = useMemo(() => {
if (!openParam) return null;
// STRAIGHTEN is not a knob either: it is FRAME's own angle, and its ruler
// spans the full circle so a photo can be levelled either way round.
if (openParam === 'straighten') {
return {
key: 'straighten',
label: 'STRAIGHTEN',
value: straighten,
min: -180,
max: 180,
step: 1,
display: `${straighten > 0 ? '+' : ''}${straighten}°`,
edited: straighten !== 0,
onChange: setStraightenOnce,
onReset: () => setStraightenOnce(0),
onBack: () => {
setOpenParam(null);
setOpenGroup('rotate');
},
};
}
// STRAIGHTEN is not a knob either: it is FRAME's own angle, and it is not
// stacked in this column at all. Its ruler lies across the foot of the
// photo (StraightenRuler, drawn by ImageStage) so the angle is read off the
// picture it is levelling; nothing opens here.
if (openParam === 'straighten') return null;
// The mixer's three knobs are not in PARAM_DEFS: they read and write one
// band of hslBands rather than a flat field of adjustments.
if (openParam.startsWith('hsl.')) {
const which = openParam === 'hsl.h' ? 0 : openParam === 'hsl.s' ? 1 : 2;
const band = recipe.adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand);
const value = band[which];
const set = (v: number) => {
const next: HslBand = [band[0], band[1], band[2]];
next[which] = Math.round(v);
const bands = { ...recipe.adjustments.hslBands };
// An all-zero band is dropped, so an untouched band never lands in the
// recipe and a band put back to zero leaves no trace.
if (next[0] === 0 && next[1] === 0 && next[2] === 0) delete bands[hslBand];
else bands[hslBand] = next;
setAdjustmentOnce({ hslBands: bands });
};
const set = (v: number) => setBandKnob(which as 0 | 1 | 2, v);
const name = openParam === 'hsl.h' ? 'HUE' : openParam === 'hsl.s' ? 'SAT' : 'LUM';
return {
key: openParam,
@@ -1175,16 +1189,64 @@ export function Workspace() {
},
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openParam, recipe.adjustments, setAdjustmentOnce, hslBand, straighten, setStraightenOnce]);
}, [openParam, recipe.adjustments, setAdjustmentOnce, setBandKnob, hslBand, straighten, setStraightenOnce]);
// The eyedropper's report: print what was read, point the ruler at that
// colour's band, and put the tool away — one pick is one colour.
const pickColor = useCallback((rgb: { r: number; g: number; b: number }) => {
// colour's band, hang the mixer's panel on the spot it came from, and put the
// tool away — one pick is one colour.
const pickColor = useCallback((rgb: { r: number; g: number; b: number }, at: { fx: number; fy: number }) => {
setSample(rgb);
setHslBand(nearestHslBand(rgbToHsl(rgb.r, rgb.g, rgb.b).h));
setPickedAt(at);
setPicking(false);
}, []);
const picked = sample ? rgbToHsl(sample.r, sample.g, sample.b) : null;
const bandName = HSL_BANDS.find((b) => b.id === hslBand)?.label ?? '';
// The colour as this band's knobs leave it: the panel's swatch, and what the
// mixer is doing to every pixel the band owns.
const mixed = (() => {
if (!picked) return null;
const band = recipe.adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand);
const h = picked.h + band[0];
const s = Math.max(0, Math.min(100, picked.s * 100 + band[1]));
const l = Math.max(0, Math.min(100, picked.l * 100 + band[2]));
return { hex: hslToHex(h, s, l) };
})();
const bandKnobs = recipe.adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand);
// The panel the stage hangs on the picked point: the colour on top (it moves
// with the knobs), then HUE/SAT/LUM in one row under it. Each takes the wheel
// for one step and a double-click back to 0.
const pickPanel =
sample && picked && mixed && pickedAt ? (
<>
<div className="hsl-card-head">
<span
className="hsl-card-swatch"
data-key="hsl-panel-swatch"
data-color={mixed.hex}
style={{ background: mixed.hex }}
/>
<span className="hsl-card-name" data-key="hsl-panel-name">
{bandName}
<b>{mixed.hex}</b>
</span>
<button
type="button"
className="hsl-card-close"
data-key="hsl-panel-close"
aria-label="close"
onClick={() => setPickedAt(null)}
>
✕
</button>
</div>
<div className="hsl-card-knobs">
<MiniSlider label="HUE" value={bandKnobs[0]} onChange={(v) => setBandKnob(0, v)} onReset={() => setBandKnob(0, 0)} />
<MiniSlider label="SAT" value={bandKnobs[1]} onChange={(v) => setBandKnob(1, v)} onReset={() => setBandKnob(1, 0)} />
<MiniSlider label="LUM" value={bandKnobs[2]} onChange={(v) => setBandKnob(2, v)} onReset={() => setBandKnob(2, 0)} />
</div>
</>
) : null;
// Option strips: a chip opens one, each option picks a value. `off` is the
// neutral value the chip glows amber against.
@@ -1543,6 +1605,8 @@ export function Workspace() {
return {
key: `hsl-band-${b.id}`,
label: b.label,
// The chip names a colour, so it shows the colour it names.
color: hslToHex(b.hue, 70, 50),
active: hslBand === b.id,
amberValue: hslBand !== b.id && moved,
onClick: () => setHslBand(b.id),
@@ -1900,6 +1964,13 @@ export function Workspace() {
canCompare={crop === 'none'}
picking={picking}
onPickColor={pickColor}
pickPanel={pickPanel}
pickPanelAt={pickedAt}
straightenRuler={
openParam === 'straighten' ? (
<StraightenRuler value={straighten} onChange={setStraightenOnce} onReset={() => setStraightenOnce(0)} />
) : null
}
/>
</div>
+62
View File
@@ -314,6 +314,68 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
}
.hsl-swatch { display: block; height: 34px; border: 1px solid var(--border); border-radius: var(--radius-sm); }
.hsl-line { font-size: 12px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
/* A chip that names a colour shows it: the dot rides the leading edge. */
.chip-dot { width: 10px; height: 10px; border-radius: 999px; border: 1px solid var(--border); flex: 0 0 auto; }
/* The eyedropper's own icon, riding the cursor inside the pick layer. Its hot
spot is the tip, so it is nudged up and left of the pointer. */
.pick-icon { position: absolute; pointer-events: none; transform: translate(-2px, -21px); filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.65)); }
.pick-icon-body { fill: #fff; stroke: #111; stroke-width: 1.2; }
.pick-icon-tip { fill: var(--accent); stroke: #111; stroke-width: 1.2; }
/* The mixer's panel for the colour just sampled: it hangs on the photo at the
point the eyedropper read, its swatch on top and HUE/SAT/LUM in one row
under it. The layer is transparent to the pointer, the card is not. */
.hsl-panel-layer { position: absolute; pointer-events: none; }
.hsl-panel {
position: absolute;
pointer-events: auto;
width: 248px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
}
.hsl-card-head { display: flex; align-items: center; gap: 8px; }
.hsl-card-swatch { width: 28px; height: 28px; flex: 0 0 auto; border: 1px solid var(--border); border-radius: var(--radius-sm); }
.hsl-card-name { display: flex; flex-direction: column; flex: 1 1 auto; min-width: 0; font-size: 11px; letter-spacing: 0.08em; color: var(--text-dim); }
.hsl-card-name b { font-family: var(--mono); font-size: 12px; letter-spacing: 0; color: var(--text); }
.hsl-card-close { border: 1px solid var(--border); background: var(--bg); color: var(--text-dim); border-radius: 999px; padding: 2px 7px; font-size: 11px; cursor: pointer; }
.hsl-card-knobs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.mini-slider { min-width: 0; }
.mini-head { display: flex; justify-content: space-between; font-size: 10px; letter-spacing: 0.08em; color: var(--text-dim); }
.mini-head b { font-family: var(--mono); letter-spacing: 0; color: var(--text); }
.mini-head b.amber { color: var(--accent); }
/* FRAME's STRAIGHTEN scale: a graduated ruler lying along the foot of the
photo. The layer is transparent to the pointer so the photo still answers a
drag outside the card; the card itself takes the drag. */
.straighten-layer { position: absolute; z-index: 3; pointer-events: none; }
.straighten-ruler {
position: absolute;
left: 10px;
right: 10px;
bottom: 10px;
padding: 6px 14px 8px;
pointer-events: auto;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--bg-elev) 90%, transparent);
backdrop-filter: blur(6px);
box-shadow: var(--shadow);
user-select: none;
touch-action: none;
}
.straighten-head { display: flex; align-items: baseline; justify-content: space-between; font-size: 10px; letter-spacing: 0.08em; color: var(--text-dim); }
.straighten-head b { font-family: var(--mono); font-size: 12px; letter-spacing: 0; color: var(--text); font-variant-numeric: tabular-nums; }
.straighten-head b.amber { color: var(--accent); }
.straighten-scale { position: relative; height: 30px; cursor: ew-resize; }
.straighten-tick { position: absolute; bottom: 12px; width: 1px; height: 7px; background: var(--border); transform: translateX(-50%); }
.straighten-tick.major { height: 12px; background: var(--text-dim); }
.straighten-tick.zero { height: 16px; background: var(--text); }
.straighten-tick i { position: absolute; top: 13px; left: 0; transform: translateX(-50%); font-style: normal; font-size: 9px; color: var(--text-dim); }
.straighten-needle { position: absolute; bottom: 4px; width: 2px; height: 20px; background: var(--accent); transform: translateX(-50%); pointer-events: none; }
/* Histogram overlay: a small translucent frame on the photo, dragged by its
header. The luma curve is a grey fill behind the three channel curves, which
screen over each other the way a Lightroom histogram reads. */
+58
View File
@@ -14,6 +14,9 @@ export interface ChipDef {
amberValue?: boolean;
pro?: boolean;
disabled?: boolean;
// CSS colour drawn as a dot on the chip's leading edge — the HSL mixer's band
// chips name a colour, so they show it too.
color?: string;
// Present when the chip itself can be dragged somewhere: the payload the drop
// target reads back (the recipe's id, for the FAVORITED rail button).
drag?: string;
@@ -62,6 +65,7 @@ export function ChipRow({ chips }: { chips: ChipDef[] }) {
}
onClick={chip.onClick}
>
{chip.color ? <span className="chip-dot" data-color={chip.color} style={{ background: chip.color }} /> : null}
{chip.label}
{chip.value ? <span className="val">{chip.value}</span> : null}
{chip.pro ? <span className="pro">PRO</span> : null}
@@ -123,3 +127,57 @@ export function SliderRow({ slider }: { slider: SliderDef }) {
</div>
);
}
// The same ruler without its own column: one knob of the mixer's inline panel,
// where HUE/SAT/LUM sit side by side under the colour they are moving. Wheel
// steps it, double-click puts it back to 0.
export function MiniSlider({
label,
value,
onChange,
onReset,
}: {
label: string;
value: number;
onChange: (value: number) => void;
onReset: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const live = useRef({ value, onChange });
live.current = { value, onChange };
useEffect(() => {
const el = ref.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
// The panel lives on the stage, whose own wheel zooms the photo: the
// knob takes the notch and keeps it.
e.preventDefault();
e.stopPropagation();
const s = live.current;
const next = Math.max(-10, Math.min(10, s.value + (e.deltaY < 0 ? 1 : -1)));
if (next !== s.value) s.onChange(next);
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, []);
return (
<div className="mini-slider" ref={ref} data-key={`hsl-knob-${label}`}>
<div className="mini-head">
<span>{label}</span>
<b className={value !== 0 ? 'amber' : ''} data-key={`hsl-knob-${label}-value`}>
{value > 0 ? `+${value}` : String(value)}
</b>
</div>
<input
type="range"
min={-10}
max={10}
step={1}
value={value}
aria-label={label}
onChange={(e) => onChange(Number(e.target.value))}
onDoubleClick={onReset}
/>
</div>
);
}
+96 -6
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { useI18n } from '../i18n/I18nProvider';
import { MIN_CROP_FRAC, type CropRect } from '../../shared/types';
import { samplePixel } from '../engine/imageOps';
@@ -29,6 +29,9 @@ export function ImageStage({
canCompare = false,
picking = false,
onPickColor,
pickPanel = null,
pickPanelAt = null,
straightenRuler = null,
}: {
previewUrl: string | null;
originalUrl: string | null;
@@ -53,8 +56,17 @@ export function ImageStage({
// Eyedropper mode: clicks on the photo report the colour underneath instead of
// panning it. The HSL mixer's PICK chip owns this flag.
picking?: boolean;
onPickColor?: (rgb: { r: number; g: number; b: number }) => void;
onPickColor?: (rgb: { r: number; g: number; b: number }, at: { fx: number; fy: number }) => void;
// The mixer's panel for the colour just sampled: hung on the photo at the
// point it was read from, so the swatch and its three knobs sit on the colour
// they are moving.
pickPanel?: ReactNode;
pickPanelAt?: { fx: number; fy: number } | null;
// FRAME's STRAIGHTEN scale, laid along the foot of the photo. Drawn here
// rather than in the panel column because the angle is read off the picture.
straightenRuler?: ReactNode;
}) {
const showStraighten = !!straightenRuler;
const { t } = useI18n();
const inputRef = useRef<HTMLInputElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
@@ -78,6 +90,9 @@ export function ImageStage({
// The histogram overlay is shown by default and dismissed from its own frame
// or from the toolbar button; it steps aside while the crop frame is up.
const [showHist, setShowHist] = useState(true);
// Where the eyedropper's own icon is drawing itself inside the photo: the
// layer is the photo's box, so plain pixels are enough.
const [pickPos, setPickPos] = useState<{ x: number; y: number } | null>(null);
// \ toggles PEEK; the key is the app's own shortcut, kept identical on web.
useEffect(() => {
@@ -121,14 +136,14 @@ export function ImageStage({
}, []);
useEffect(() => {
if (!cropEditing && !compare && !picking) {
if (!cropEditing && !compare && !picking && !pickPanelAt && !showStraighten) {
setBox(null);
return;
}
measure();
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, [cropEditing, compare, picking, measure, previewUrl]);
}, [cropEditing, compare, picking, pickPanelAt, showStraighten, measure, previewUrl]);
// Compare is a property of the un-cropped photo, so a crop taking over (or a
// new photo arriving) puts it away.
@@ -167,7 +182,7 @@ export function ImageStage({
pickCache.current?.bmp.close();
pickCache.current = { url: previewUrl, bmp: await createImageBitmap(blob) };
}
onPickColor(samplePixel(pickCache.current.bmp, fx, fy));
onPickColor(samplePixel(pickCache.current.bmp, fx, fy), { fx, fy });
},
[previewUrl, onPickColor]
);
@@ -480,8 +495,83 @@ export function ImageStage({
view.s === 1 ? undefined : `translate(${view.x}px, ${view.y}px) scale(${view.s})`,
}}
onPointerDown={(e) => e.stopPropagation()}
onPointerMove={(e) => {
const r = e.currentTarget.getBoundingClientRect();
setPickPos({ x: e.clientX - r.left, y: e.clientY - r.top });
}}
onPointerLeave={() => setPickPos(null)}
onClick={pickAt}
/>
>
{/* The picker's own icon rides the cursor, so the tool is visible
on the photo instead of only in the chip row. */}
<span
className="pick-icon"
data-key="pick-icon"
style={{
left: pickPos?.x ?? box.width / 2,
top: pickPos?.y ?? box.height / 2,
}}
>
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path
className="pick-icon-body"
d="m14.5 5.5 4 4-8.4 8.4-3 1-1-1 1-3z"
/>
<path className="pick-icon-tip" d="m15.6 4.4 1.6-1.6a1.6 1.6 0 0 1 2.3 0l1.7 1.7a1.6 1.6 0 0 1 0 2.3l-1.6 1.6z" />
</svg>
</span>
</div>
) : null}
{/* The mixer's panel for the colour the eyedropper just read: it hangs
on the photo at the point it was read from, and its swatch follows
the three knobs under it. */}
{pickPanel && pickPanelAt && box ? (
<div
className="hsl-panel-layer"
data-key="hsl-panel-layer"
style={{
left: box.left,
top: box.top,
width: box.width,
height: box.height,
transform:
view.s === 1 ? undefined : `translate(${view.x}px, ${view.y}px) scale(${view.s})`,
}}
>
<div
className="hsl-panel"
data-key="hsl-panel"
onPointerDown={(e) => e.stopPropagation()}
style={{
left: `${pickPanelAt.fx * 100}%`,
top: `${pickPanelAt.fy * 100}%`,
// Below the point, or above it when the point sits low enough
// that the panel would fall off the photo.
transform: pickPanelAt.fy > 0.55 ? 'translate(-50%, calc(-100% - 14px))' : 'translate(-50%, 14px)',
}}
>
{pickPanel}
</div>
</div>
) : null}
{/* FRAME's STRAIGHTEN scale: a graduated ruler along the foot of the
photo, so the angle is turned while the picture that answers it is
in front of the eye. */}
{straightenRuler && box && !cropEditing ? (
<div
className="straighten-layer"
data-key="straighten-layer"
style={{
left: box.left,
top: box.top,
width: box.width,
height: box.height,
transform:
view.s === 1 ? undefined : `translate(${view.x}px, ${view.y}px) scale(${view.s})`,
}}
>
{straightenRuler}
</div>
) : null}
{compare && canCompare && originalUrl && box ? (
<div
@@ -0,0 +1,79 @@
import { useRef } from 'react';
// FRAME > ROTATE > STRAIGHTEN's ruler is not one of the chip row's stacked
// knobs: it is a graduated scale lying across the foot of the photo, so the
// angle is read off the picture it is levelling. Dragging anywhere on the scale
// turns the photo by whole degrees; leaving the tool (another tab or button)
// keeps the angle and takes the scale away — see App's dismiss effect.
const RANGE = 180; // degrees either side of 0, the whole circle across the width
const TICK = 5; // minor tick spacing
const MAJOR = 45; // labelled tick spacing
export function StraightenRuler({
value,
onChange,
onReset,
}: {
value: number;
onChange: (v: number) => void;
onReset: () => void;
}) {
const scaleRef = useRef<HTMLDivElement>(null);
const dragging = useRef(false);
// A pixel of travel is well under a degree at any sane width, so the reading
// snaps to whole degrees however far the pointer has been dragged.
const angleAt = (clientX: number) => {
const r = scaleRef.current?.getBoundingClientRect();
if (!r || !(r.width > 0)) return value;
const pct = (clientX - r.left) / r.width;
return Math.max(-RANGE, Math.min(RANGE, Math.round(pct * 2 * RANGE - RANGE)));
};
const ticks: number[] = [];
for (let d = -RANGE; d <= RANGE; d += TICK) ticks.push(d);
const pctOf = (d: number) => ((d + RANGE) / (2 * RANGE)) * 100;
return (
<div className="straighten-ruler" data-key="straighten-ruler" onPointerDown={(e) => e.stopPropagation()}>
<div className="straighten-head">
<span className="straighten-label">STRAIGHTEN</span>
<b className={value !== 0 ? 'amber' : ''} data-key="straighten-value">
{value > 0 ? `+${value}` : value}°
</b>
</div>
<div
ref={scaleRef}
className="straighten-scale"
data-key="straighten-scale"
onPointerDown={(e) => {
dragging.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
onChange(angleAt(e.clientX));
}}
onPointerMove={(e) => {
if (dragging.current) onChange(angleAt(e.clientX));
}}
onPointerUp={(e) => {
dragging.current = false;
e.currentTarget.releasePointerCapture(e.pointerId);
}}
onPointerCancel={() => {
dragging.current = false;
}}
onDoubleClick={onReset}
>
{ticks.map((d) => (
<span
key={d}
className={`straighten-tick${d === 0 ? ' zero' : d % MAJOR === 0 ? ' major' : ''}`}
style={{ left: `${pctOf(d)}%` }}
>
{d % MAJOR === 0 ? <i>{d > 0 ? `+${d}` : d}</i> : null}
</span>
))}
<span className="straighten-needle" data-key="straighten-needle" style={{ left: `${pctOf(value)}%` }} />
</div>
</div>
);
}