second commit
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { Group, Rect } from "react-konva";
|
||||
import type { FurnitureType } from "../types";
|
||||
|
||||
export type Outline = "normal" | "valid" | "invalid";
|
||||
export type Outline = "normal" | "selected" | "valid" | "invalid";
|
||||
|
||||
const STROKE: Record<Outline, string> = {
|
||||
normal: "#3b3b3b",
|
||||
selected: "#1c7ed6",
|
||||
valid: "#2f9e44",
|
||||
invalid: "#e03131",
|
||||
};
|
||||
@@ -25,6 +26,15 @@ export function FurnitureShape({ type, width, height, pxPerM, outline, opacity =
|
||||
const stroke = STROKE[outline];
|
||||
const strokeWidth = outline === "normal" ? 1.5 : 3;
|
||||
|
||||
if (type === "screen") {
|
||||
const inset = Math.min(3, h / 4);
|
||||
return (
|
||||
<Group opacity={opacity}>
|
||||
<Rect width={w} height={h} fill="#2d3748" stroke={stroke} strokeWidth={strokeWidth} cornerRadius={2} />
|
||||
<Rect x={inset} y={inset} width={Math.max(0, w - inset * 2)} height={Math.max(0, h - inset * 2)} fill="#63b3ed" listening={false} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
if (type === "table") {
|
||||
return (
|
||||
<Group opacity={opacity}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, type PointerEvent, type ReactNode } from "react";
|
||||
import type { FurnitureType } from "../types";
|
||||
import { FURNITURE_TYPES, TYPE_LABEL, type FurnitureType } from "../types";
|
||||
|
||||
const CLICK_THRESHOLD_PX = 5;
|
||||
|
||||
@@ -112,12 +112,30 @@ function Tile({ type, remaining, onStartDrag, onOpenMenu }: TileProps) {
|
||||
>
|
||||
{remaining}
|
||||
</span>
|
||||
{type === "chair" ? <ChairIcon /> : <TableIcon />}
|
||||
{type === "chair" ? "Chair" : "Table"}
|
||||
{(() => {
|
||||
const Icon = TYPE_ICON[type];
|
||||
return <Icon />;
|
||||
})()}
|
||||
{TYPE_LABEL[type]}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScreenIcon({ size = 36 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 36 36">
|
||||
<rect x="2" y="14" width="32" height="8" rx="2" fill="#2d3748" stroke="#3b3b3b" strokeWidth="1.5" />
|
||||
<rect x="4" y="16" width="28" height="4" fill="#63b3ed" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const TYPE_ICON: Record<FurnitureType, (p: { size?: number }) => React.JSX.Element> = {
|
||||
chair: ChairIcon,
|
||||
table: TableIcon,
|
||||
screen: ScreenIcon,
|
||||
};
|
||||
|
||||
export function Palette({ remaining, onStartDrag, onOpenMenu, panel }: Props) {
|
||||
const [width, setWidth] = useState(264);
|
||||
|
||||
@@ -151,7 +169,7 @@ export function Palette({ remaining, onStartDrag, onOpenMenu, panel }: Props) {
|
||||
background: "#ececec",
|
||||
}}
|
||||
>
|
||||
{panel ?? (["chair", "table"] as const).map((type) => (
|
||||
{panel ?? FURNITURE_TYPES.map((type) => (
|
||||
<Tile key={type} type={type} remaining={remaining[type]} onStartDrag={onStartDrag} onOpenMenu={onOpenMenu} />
|
||||
))}
|
||||
<div
|
||||
|
||||
+90
-11
@@ -1,8 +1,8 @@
|
||||
import { useState, type RefObject } from "react";
|
||||
import { Stage, Layer, Group } from "react-konva";
|
||||
import { useRef, useState, type RefObject } from "react";
|
||||
import { Stage, Layer, Group, Rect } from "react-konva";
|
||||
import type Konva from "konva";
|
||||
import { isValidPlacement, clampToRoom } from "../geometry";
|
||||
import type { FurnitureItem, FurnitureType, Room as RoomT } from "../types";
|
||||
import { MIN_SCREEN_SIZE, RESIZABLE, isValidPlacement, clampToRoom, resizeRect } from "../geometry";
|
||||
import type { FurnitureItem, FurnitureType, Rect as RectM, Room as RoomT } from "../types";
|
||||
import { FurnitureShape } from "./FurnitureShape";
|
||||
|
||||
export interface Preview {
|
||||
@@ -21,6 +21,7 @@ interface Props {
|
||||
pxPerM: number;
|
||||
floorRef: RefObject<HTMLDivElement | null>;
|
||||
onMove: (id: string, x: number, y: number) => void;
|
||||
onResize: (id: string, rect: RectM) => void;
|
||||
}
|
||||
|
||||
interface DragInfo {
|
||||
@@ -28,8 +29,21 @@ interface DragInfo {
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
export function Room({ room, items, preview, pxPerM, floorRef, onMove }: Props) {
|
||||
interface ResizeInfo {
|
||||
id: string;
|
||||
rect: RectM;
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
const HANDLE_PX = 10;
|
||||
// Top-down view: a screen only changes in length, its thickness is fixed.
|
||||
const EDGES: ("left" | "right")[] = ["left", "right"];
|
||||
|
||||
export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize }: Props) {
|
||||
const [drag, setDrag] = useState<DragInfo | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [resize, setResize] = useState<ResizeInfo | null>(null);
|
||||
const resizeStart = useRef<RectM | null>(null);
|
||||
const w = room.width * pxPerM;
|
||||
const h = room.height * pxPerM;
|
||||
|
||||
@@ -53,16 +67,28 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove }: Props)
|
||||
touchAction: "none",
|
||||
}}
|
||||
>
|
||||
<Stage width={w} height={h}>
|
||||
<Stage
|
||||
width={w}
|
||||
height={h}
|
||||
onMouseDown={(e) => e.target === e.target.getStage() && setSelectedId(null)}
|
||||
onTouchStart={(e) => e.target === e.target.getStage() && setSelectedId(null)}
|
||||
>
|
||||
<Layer>
|
||||
{items.map((item) => {
|
||||
const outline =
|
||||
drag?.id === item.id ? (drag.valid ? "valid" : "invalid") : "normal";
|
||||
const isResizing = resize?.id === item.id;
|
||||
const r: RectM = isResizing ? resize.rect : item;
|
||||
const outline = isResizing
|
||||
? resize.valid ? "valid" : "invalid"
|
||||
: drag?.id === item.id
|
||||
? drag.valid ? "valid" : "invalid"
|
||||
: selectedId === item.id ? "selected" : "normal";
|
||||
return (
|
||||
<Group
|
||||
key={item.id}
|
||||
x={item.x * pxPerM}
|
||||
y={item.y * pxPerM}
|
||||
x={r.x * pxPerM}
|
||||
y={r.y * pxPerM}
|
||||
onMouseDown={() => setSelectedId(RESIZABLE[item.type] ? item.id : null)}
|
||||
onTouchStart={() => setSelectedId(RESIZABLE[item.type] ? item.id : null)}
|
||||
draggable={!item.locked}
|
||||
// Hard-clamp to the walls while dragging (stage is unscaled, so absolute = local).
|
||||
dragBoundFunc={(pos) => ({
|
||||
@@ -94,10 +120,63 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove }: Props)
|
||||
if (c) c.style.cursor = "";
|
||||
}}
|
||||
>
|
||||
<FurnitureShape type={item.type} width={item.width} height={item.height} pxPerM={pxPerM} outline={outline} />
|
||||
<FurnitureShape type={item.type} width={r.width} height={r.height} pxPerM={pxPerM} outline={outline} />
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
{items.map((item) => {
|
||||
if (item.id !== selectedId || !RESIZABLE[item.type] || drag?.id === item.id) return null;
|
||||
const r: RectM = resize?.id === item.id ? resize.rect : item;
|
||||
const mid: Record<"left" | "right", readonly [number, number]> = {
|
||||
left: [r.x, r.y + r.height / 2],
|
||||
right: [r.x + r.width, r.y + r.height / 2],
|
||||
};
|
||||
return EDGES.map((edge) => (
|
||||
<Rect
|
||||
key={`${item.id}-${edge}`}
|
||||
x={mid[edge][0] * pxPerM}
|
||||
y={mid[edge][1] * pxPerM}
|
||||
offsetX={HANDLE_PX / 2}
|
||||
offsetY={HANDLE_PX / 2}
|
||||
width={HANDLE_PX}
|
||||
height={HANDLE_PX}
|
||||
fill="#fff"
|
||||
stroke="#1c7ed6"
|
||||
strokeWidth={2}
|
||||
cornerRadius={2}
|
||||
hitStrokeWidth={12}
|
||||
draggable
|
||||
// The handle is pinned in place; the pointer drives the new size, React moves the handle.
|
||||
dragBoundFunc={function (this: Konva.Node) {
|
||||
return this.absolutePosition();
|
||||
}}
|
||||
onDragStart={(e) => {
|
||||
e.cancelBubble = true;
|
||||
resizeStart.current = { x: item.x, y: item.y, width: item.width, height: item.height };
|
||||
}}
|
||||
onDragMove={(e) => {
|
||||
const p = e.target.getStage()?.getPointerPosition();
|
||||
const start = resizeStart.current;
|
||||
if (!p || !start) return;
|
||||
const next = resizeRect(start, edge, p.x / pxPerM, p.y / pxPerM, room, MIN_SCREEN_SIZE);
|
||||
setResize({ id: item.id, rect: next, valid: isValidPlacement(next, items, room, item.id) });
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
if (resize?.valid) onResize(item.id, resize.rect); // else snap back to the old size
|
||||
setResize(null);
|
||||
resizeStart.current = null;
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
const c = e.target.getStage()?.container();
|
||||
if (c) c.style.cursor = "ew-resize";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
const c = e.target.getStage()?.container();
|
||||
if (c) c.style.cursor = "";
|
||||
}}
|
||||
/>
|
||||
));
|
||||
})}
|
||||
{preview && (
|
||||
<Group x={preview.x * pxPerM} y={preview.y * pxPerM} listening={false}>
|
||||
<FurnitureShape
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
value: number;
|
||||
min: number;
|
||||
onChange: (v: number) => void;
|
||||
}
|
||||
|
||||
const btn: React.CSSProperties = {
|
||||
width: 28,
|
||||
height: 28,
|
||||
flexShrink: 0,
|
||||
border: "1px solid #c4c4c4",
|
||||
background: "#fff",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
font: "16px system-ui, sans-serif",
|
||||
lineHeight: 1,
|
||||
};
|
||||
|
||||
/** - / input / + . Typing is committed on blur or Enter so intermediate values aren't clamped. */
|
||||
export function Stepper({ value, min, onChange }: Props) {
|
||||
const [draft, setDraft] = useState(String(value));
|
||||
useEffect(() => setDraft(String(value)), [value]);
|
||||
|
||||
const commit = () => {
|
||||
const n = Math.round(Number(draft));
|
||||
if (draft.trim() === "" || Number.isNaN(n)) return setDraft(String(value));
|
||||
const v = Math.max(min, n);
|
||||
setDraft(String(v));
|
||||
if (v !== value) onChange(v);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 6, alignItems: "center", width: "100%" }}>
|
||||
<button style={btn} disabled={value <= min} onClick={() => onChange(Math.max(min, value - 1))} aria-label="Decrease">
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
value={draft}
|
||||
inputMode="numeric"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => e.key === "Enter" && e.currentTarget.blur()}
|
||||
style={{ flex: 1, minWidth: 0, height: 28, boxSizing: "border-box", textAlign: "center", border: "1px solid #c4c4c4", borderRadius: 6, font: "14px system-ui, sans-serif" }}
|
||||
/>
|
||||
<button style={btn} onClick={() => onChange(value + 1)} aria-label="Increase">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import type { FurnitureType, FurnitureTypeConfig } from "../types";
|
||||
import { Stepper } from "./Stepper";
|
||||
import { TYPE_LABEL, type FurnitureType, type FurnitureTypeConfig } from "../types";
|
||||
import { Wheel } from "./Wheel";
|
||||
|
||||
interface Props {
|
||||
type: FurnitureType;
|
||||
@@ -29,16 +29,19 @@ export function TypeMenu({ type, config, placed, onSetAvailable, onSetSeats, onC
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<div style={{ font: "600 15px system-ui, sans-serif", color: "#222" }}>{type === "chair" ? "Chair" : "Table"}</div>
|
||||
<div style={{ font: "600 15px system-ui, sans-serif", color: "#222" }}>{TYPE_LABEL[type]}</div>
|
||||
<div>
|
||||
<div style={label}>Available</div>
|
||||
<Stepper value={config.available} min={placed} onChange={onSetAvailable} />
|
||||
<Wheel value={config.available} min={placed} onChange={onSetAvailable} />
|
||||
<div style={hint}>{placed} placed</div>
|
||||
</div>
|
||||
{type === "screen" && (
|
||||
<div style={hint}>Click a placed screen, then drag an end handle to change its length.</div>
|
||||
)}
|
||||
{type === "table" && (
|
||||
<div>
|
||||
<div style={label}>Seats per table</div>
|
||||
<Stepper value={config.seatsPerUnit ?? 8} min={1} onChange={onSetSeats} />
|
||||
<Wheel value={config.seatsPerUnit ?? 8} min={1} onChange={onSetSeats} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
value: number;
|
||||
min: number;
|
||||
max?: number;
|
||||
onChange: (v: number) => void;
|
||||
}
|
||||
|
||||
const ROW_H = 34;
|
||||
const VISIBLE = 5; // odd, so one row sits in the middle
|
||||
const REACH = Math.floor(VISIBLE / 2) + 1;
|
||||
const CLICK_PX = 4; // total movement under this = a tap
|
||||
const SETTLE_K = 7; // 1/s; higher = snappier settle (fling distance = velocity / k)
|
||||
const SPRING_W = 12; // 1/s; stiffness of the critically damped settle spring
|
||||
const SLOW_VEL = 4; // rows/s; releases slower than this settle with the spring, faster ones fling
|
||||
const MAX_VEL = 40; // rows/s
|
||||
const NOTCH_DELTA = 40; // wheel deltas this big are mouse notches (one step), smaller are trackpad
|
||||
|
||||
/**
|
||||
* Scroll-wheel number picker (like the iOS timer).
|
||||
* Drag follows the pointer 1:1, release flings with momentum and settles on a value,
|
||||
* touching it mid-roll stops it instantly. Also: mouse wheel, tap a row, arrow keys.
|
||||
*/
|
||||
export function Wheel({ value, min, max = 999, onChange }: Props) {
|
||||
const [pos, setPos] = useState(value);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const posRef = useRef(value); // fractional position in "value" units
|
||||
const lastEmitted = useRef(value);
|
||||
const raf = useRef(0);
|
||||
const settleTimer = useRef(0);
|
||||
const targetRef = useRef(value); // where the running animation is heading
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
const drag = useRef<{ y0: number; pos0: number; moved: boolean; wasMoving: boolean } | null>(null);
|
||||
const samples = useRef<{ t: number; pos: number }[]>([]);
|
||||
|
||||
// Keep latest props visible to the animation loop.
|
||||
const cfg = useRef({ min, max, onChange });
|
||||
cfg.current = { min, max, onChange };
|
||||
|
||||
const clamp = (v: number) => Math.min(cfg.current.max, Math.max(cfg.current.min, v));
|
||||
|
||||
const setPosition = (p: number) => {
|
||||
const c = clamp(p);
|
||||
posRef.current = c;
|
||||
setPos(c);
|
||||
const v = Math.round(c);
|
||||
if (v !== lastEmitted.current) {
|
||||
lastEmitted.current = v;
|
||||
cfg.current.onChange(v);
|
||||
}
|
||||
return c;
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
cancelAnimationFrame(raf.current);
|
||||
raf.current = 0;
|
||||
window.clearTimeout(settleTimer.current);
|
||||
settleTimer.current = 0;
|
||||
};
|
||||
|
||||
/** Ease from the current position to `target` with exponential decay (iOS-style deceleration). */
|
||||
const animateTo = (rawTarget: number) => {
|
||||
stop();
|
||||
const target = clamp(Math.round(rawTarget));
|
||||
targetRef.current = target;
|
||||
let last = performance.now();
|
||||
const step = (now: number) => {
|
||||
const dt = Math.min(0.05, (now - last) / 1000);
|
||||
last = now;
|
||||
const next = posRef.current + (target - posRef.current) * (1 - Math.exp(-SETTLE_K * dt));
|
||||
if (Math.abs(target - next) < 0.003) {
|
||||
setPosition(target);
|
||||
raf.current = 0;
|
||||
return;
|
||||
}
|
||||
setPosition(next);
|
||||
raf.current = requestAnimationFrame(step);
|
||||
};
|
||||
raf.current = requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
/**
|
||||
* Settle onto `rawTarget` with a critically damped spring that starts at the current release
|
||||
* velocity `v0` (rows/s). Smooth start (no velocity jump) and no overshoot, so slow releases glide in.
|
||||
*/
|
||||
const settleTo = (rawTarget: number, v0: number) => {
|
||||
stop();
|
||||
const target = clamp(Math.round(rawTarget));
|
||||
targetRef.current = target;
|
||||
const x0 = posRef.current;
|
||||
const a = x0 - target;
|
||||
// Moving toward the target faster than the spring allows would overshoot: stiffen to avoid it.
|
||||
const dist = Math.abs(a);
|
||||
const toward = dist > 0 && Math.sign(v0) === -Math.sign(a);
|
||||
const w = toward ? Math.max(SPRING_W, Math.abs(v0) / dist) : SPRING_W;
|
||||
const b = v0 + w * a;
|
||||
const t0 = performance.now();
|
||||
const step = (now: number) => {
|
||||
const t = (now - t0) / 1000;
|
||||
const decay = Math.exp(-w * t);
|
||||
const x = target + (a + b * t) * decay;
|
||||
const vel = (b - w * (a + b * t)) * decay;
|
||||
if ((Math.abs(x - target) < 0.003 && Math.abs(vel) < 0.05) || t > 1.5) {
|
||||
setPosition(target);
|
||||
raf.current = 0;
|
||||
return;
|
||||
}
|
||||
setPosition(x);
|
||||
raf.current = requestAnimationFrame(step);
|
||||
};
|
||||
raf.current = requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
// Follow outside changes (e.g. min rising) when idle.
|
||||
useEffect(() => {
|
||||
if (drag.current || raf.current) return;
|
||||
posRef.current = value;
|
||||
lastEmitted.current = value;
|
||||
setPos(value);
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => stop, []);
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
const wasMoving = raf.current !== 0 || settleTimer.current !== 0;
|
||||
stop(); // stopping power: touching freezes the wheel exactly where it is
|
||||
drag.current = { y0: e.clientY, pos0: posRef.current, moved: false, wasMoving };
|
||||
samples.current = [{ t: performance.now(), pos: posRef.current }];
|
||||
setDragging(true);
|
||||
};
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
const d = drag.current;
|
||||
if (!d) return;
|
||||
const dy = e.clientY - d.y0;
|
||||
if (Math.abs(dy) > CLICK_PX) d.moved = true;
|
||||
const p = setPosition(d.pos0 - dy / ROW_H); // drag up = bigger number, 1:1 from the first pixel
|
||||
const now = performance.now();
|
||||
samples.current.push({ t: now, pos: p });
|
||||
samples.current = samples.current.filter((s) => now - s.t < 100);
|
||||
};
|
||||
|
||||
const onPointerUp = (e: React.PointerEvent) => {
|
||||
const d = drag.current;
|
||||
drag.current = null;
|
||||
setDragging(false);
|
||||
if (!d) return;
|
||||
|
||||
if (!d.moved) {
|
||||
if (d.wasMoving) {
|
||||
settleTo(posRef.current, 0); // tap that caught a rolling wheel just stops it on a value
|
||||
} else {
|
||||
// plain tap: jump to the tapped row
|
||||
const rect = box.current!.getBoundingClientRect();
|
||||
const rows = Math.round((e.clientY - rect.top - (VISIBLE * ROW_H) / 2) / ROW_H);
|
||||
posRef.current = d.pos0;
|
||||
animateTo(Math.round(d.pos0) + rows);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Release velocity from the last ~100ms of movement (zero if the finger rested before lifting).
|
||||
const now = performance.now();
|
||||
const s = samples.current;
|
||||
let v = 0;
|
||||
if (s.length > 1 && now - s[s.length - 1].t < 80) {
|
||||
const first = s[0];
|
||||
const dt = (s[s.length - 1].t - first.t) / 1000;
|
||||
if (dt > 0.005) v = (s[s.length - 1].pos - first.pos) / dt;
|
||||
}
|
||||
v = Math.max(-MAX_VEL, Math.min(MAX_VEL, v));
|
||||
if (Math.abs(v) < SLOW_VEL) settleTo(posRef.current + v * 0.1, v);
|
||||
else animateTo(posRef.current + v / SETTLE_K);
|
||||
};
|
||||
|
||||
const onWheel = (e: React.WheelEvent) => {
|
||||
if (Math.abs(e.deltaY) >= NOTCH_DELTA) {
|
||||
// mouse notch: one value per notch, repeated notches build on the pending target
|
||||
const base = raf.current ? targetRef.current : Math.round(posRef.current);
|
||||
animateTo(base + Math.sign(e.deltaY));
|
||||
return;
|
||||
}
|
||||
// trackpad: follow continuously, settle when it goes quiet
|
||||
stop();
|
||||
setPosition(posRef.current + e.deltaY / (ROW_H * 1.5));
|
||||
settleTimer.current = window.setTimeout(() => {
|
||||
settleTimer.current = 0;
|
||||
settleTo(posRef.current, 0);
|
||||
}, 120);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowUp") animateTo(Math.round(posRef.current) + 1);
|
||||
else if (e.key === "ArrowDown") animateTo(Math.round(posRef.current) - 1);
|
||||
else return;
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const centre = Math.round(pos);
|
||||
const rows = [];
|
||||
for (let v = Math.max(min, centre - REACH); v <= Math.min(max, centre + REACH); v++) {
|
||||
const off = v - pos;
|
||||
rows.push(
|
||||
<div
|
||||
key={v}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: ROW_H,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
font: "600 20px system-ui, sans-serif",
|
||||
color: "#222",
|
||||
opacity: Math.max(0, 1 - Math.abs(off) * 0.38),
|
||||
transform: `translateY(${((VISIBLE - 1) / 2 + off) * ROW_H}px) scale(${1 - Math.min(1, Math.abs(off)) * 0.12})`,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={box}
|
||||
tabIndex={0}
|
||||
role="spinbutton"
|
||||
aria-valuenow={value}
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
onWheel={onWheel}
|
||||
onKeyDown={onKeyDown}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: VISIBLE * ROW_H,
|
||||
overflow: "hidden",
|
||||
background: "#f4f4f4",
|
||||
borderRadius: 10,
|
||||
cursor: dragging ? "grabbing" : "ns-resize",
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 6,
|
||||
right: 6,
|
||||
top: ((VISIBLE - 1) / 2) * ROW_H,
|
||||
height: ROW_H,
|
||||
background: "rgba(0,0,0,0.08)",
|
||||
borderRadius: 8,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
{rows}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user