third commit
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Wheel } from "./Wheel";
|
||||
import { backBtn, btn, hint, label, panelRoot, title, twoCol } from "./panelStyles";
|
||||
|
||||
export interface CustomDraft {
|
||||
name: string;
|
||||
isTable: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
seats: number;
|
||||
available: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSave: (draft: CustomDraft) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const fmt = (v: number) => v.toFixed(1);
|
||||
const SEATING = { width: 0.5, height: 0.5, seats: 1 };
|
||||
const TABLE = { width: 1.8, height: 0.8, seats: 8 };
|
||||
|
||||
export function CustomForm({ onSave, onCancel }: Props) {
|
||||
const [name, setName] = useState("");
|
||||
const [isTable, setIsTable] = useState(false);
|
||||
const [width, setWidth] = useState(SEATING.width);
|
||||
const [height, setHeight] = useState(SEATING.height);
|
||||
const [seats, setSeats] = useState(SEATING.seats);
|
||||
const [available, setAvailable] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
const key = (e: KeyboardEvent) => e.key === "Escape" && onCancel();
|
||||
window.addEventListener("keydown", key);
|
||||
return () => window.removeEventListener("keydown", key);
|
||||
}, [onCancel]);
|
||||
|
||||
const pickKind = (table: boolean) => {
|
||||
setIsTable(table);
|
||||
const d = table ? TABLE : SEATING;
|
||||
setWidth(d.width);
|
||||
setHeight(d.height);
|
||||
setSeats(d.seats);
|
||||
};
|
||||
|
||||
const canSave = name.trim().length > 0;
|
||||
const seg = (active: boolean): React.CSSProperties => ({
|
||||
...btn,
|
||||
flex: 1,
|
||||
background: active ? "#2b2b2b" : "#fff",
|
||||
color: active ? "#fff" : "#222",
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={panelRoot}>
|
||||
<button onClick={onCancel} style={backBtn}>
|
||||
← Cancel
|
||||
</button>
|
||||
<div style={title}>New furniture</div>
|
||||
|
||||
<div>
|
||||
<div style={label}>Name</div>
|
||||
<input
|
||||
value={name}
|
||||
autoFocus
|
||||
maxLength={24}
|
||||
placeholder="e.g. Couch"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
style={{ width: "100%", boxSizing: "border-box", height: 32, padding: "0 10px", border: "1px solid #c4c4c4", borderRadius: 8, font: "14px system-ui, sans-serif" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={label}>Type</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button style={seg(!isTable)} onClick={() => pickKind(false)}>
|
||||
Seating
|
||||
</button>
|
||||
<button style={seg(isTable)} onClick={() => pickKind(true)}>
|
||||
Table
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={twoCol}>
|
||||
<div>
|
||||
<div style={label}>Width (m)</div>
|
||||
<Wheel value={width} min={0.3} max={6} step={0.1} rows={3} format={fmt} onChange={setWidth} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={label}>Length (m)</div>
|
||||
<Wheel value={height} min={0.3} max={6} step={0.1} rows={3} format={fmt} onChange={setHeight} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={label}>Seats</div>
|
||||
<Wheel value={seats} min={1} max={99} rows={3} onChange={setSeats} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={label}>Available</div>
|
||||
<Wheel value={available} min={1} max={999} rows={3} onChange={setAvailable} />
|
||||
</div>
|
||||
</div>
|
||||
{!canSave && <div style={hint}>Enter a name to save.</div>}
|
||||
|
||||
<button
|
||||
style={{ ...btn, background: canSave ? "#2b2b2b" : "#ddd", color: canSave ? "#fff" : "#999", cursor: canSave ? "pointer" : "not-allowed" }}
|
||||
disabled={!canSave}
|
||||
onClick={() => onSave({ name: name.trim(), isTable, width, height, seats, available })}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Group, Rect } from "react-konva";
|
||||
import type { FurnitureType } from "../types";
|
||||
import type { Shape } from "../types";
|
||||
|
||||
export type Outline = "normal" | "selected" | "valid" | "invalid";
|
||||
|
||||
@@ -11,7 +11,7 @@ const STROKE: Record<Outline, string> = {
|
||||
};
|
||||
|
||||
interface Props {
|
||||
type: FurnitureType;
|
||||
shape: Shape;
|
||||
width: number; // meters
|
||||
height: number;
|
||||
pxPerM: number;
|
||||
@@ -20,13 +20,13 @@ interface Props {
|
||||
}
|
||||
|
||||
/** Top-down shape drawn at local (0,0). Position is set by the parent Group. */
|
||||
export function FurnitureShape({ type, width, height, pxPerM, outline, opacity = 1 }: Props) {
|
||||
export function FurnitureShape({ shape, width, height, pxPerM, outline, opacity = 1 }: Props) {
|
||||
const w = width * pxPerM;
|
||||
const h = height * pxPerM;
|
||||
const stroke = STROKE[outline];
|
||||
const strokeWidth = outline === "normal" ? 1.5 : 3;
|
||||
|
||||
if (type === "screen") {
|
||||
if (shape === "screen") {
|
||||
const inset = Math.min(3, h / 4);
|
||||
return (
|
||||
<Group opacity={opacity}>
|
||||
@@ -35,7 +35,7 @@ export function FurnitureShape({ type, width, height, pxPerM, outline, opacity =
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
if (type === "table") {
|
||||
if (shape === "table") {
|
||||
return (
|
||||
<Group opacity={opacity}>
|
||||
<Rect width={w} height={h} fill="#d9b382" stroke={stroke} strokeWidth={strokeWidth} cornerRadius={3} />
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Shape } from "../types";
|
||||
|
||||
export function ChairIcon({ size = 36 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 36 36">
|
||||
<rect x="4" y="4" width="28" height="28" rx="3" fill="#8fb4d9" stroke="#3b3b3b" strokeWidth="1.5" />
|
||||
<rect x="4" y="4" width="28" height="8" rx="3" fill="#4a6f96" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableIcon({ size = 36 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 36 36">
|
||||
<rect x="2" y="9" width="32" height="18" rx="3" fill="#d9b382" stroke="#3b3b3b" strokeWidth="1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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 function ShapeIcon({ shape, size }: { shape: Shape; size?: number }) {
|
||||
if (shape === "table") return <TableIcon size={size} />;
|
||||
if (shape === "screen") return <ScreenIcon size={size} />;
|
||||
return <ChairIcon size={size} />;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { FurnitureItem, FurnitureTypeConfig } from "../types";
|
||||
import { effectiveSeats } from "../inventory";
|
||||
import { Wheel } from "./Wheel";
|
||||
import { backBtn, btn, hint, label, panelRoot, title, twoCol } from "./panelStyles";
|
||||
|
||||
interface Props {
|
||||
item: FurnitureItem;
|
||||
config: FurnitureTypeConfig;
|
||||
/** Try to apply a change. Returns false if it was rejected (overlap / no room). */
|
||||
onPatch: (patch: Partial<FurnitureItem>) => boolean;
|
||||
onDelete: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const MIN_SIZE = 0.3;
|
||||
const MAX_SIZE = 6;
|
||||
const fmt = (v: number) => v.toFixed(1);
|
||||
|
||||
export function ItemPanel({ item, config, onPatch, onDelete, onClose }: Props) {
|
||||
const [notice, setNotice] = useState("");
|
||||
useEffect(() => {
|
||||
const key = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
window.addEventListener("keydown", key);
|
||||
return () => window.removeEventListener("keydown", key);
|
||||
}, [onClose]);
|
||||
|
||||
const patch = (p: Partial<FurnitureItem>) => {
|
||||
const ok = onPatch(p);
|
||||
setNotice(ok ? "" : "No room for that change.");
|
||||
};
|
||||
|
||||
const isScreen = config.shape === "screen";
|
||||
const seats = effectiveSeats(item, config) ?? 1;
|
||||
const overridden = item.seatsOverride !== undefined;
|
||||
// Never let the wheel's range exclude the current value (e.g. a 0.2 m default).
|
||||
const minOf = (v: number, floor: number) => Math.min(floor, v);
|
||||
|
||||
return (
|
||||
<div style={panelRoot}>
|
||||
<button onClick={onClose} style={backBtn}>
|
||||
← Done
|
||||
</button>
|
||||
<div style={title}>
|
||||
{config.name}
|
||||
{item.locked ? " 🔒" : ""}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={label}>Rotation</div>
|
||||
<Wheel
|
||||
value={item.rotation}
|
||||
min={0}
|
||||
max={359}
|
||||
wrap
|
||||
rows={3}
|
||||
disabled={item.locked}
|
||||
format={(v) => `${v}°`}
|
||||
onChange={(v) => patch({ rotation: v })}
|
||||
/>
|
||||
<button
|
||||
style={{ ...btn, marginTop: 8, width: "100%", opacity: item.locked ? 0.45 : 1 }}
|
||||
disabled={item.locked}
|
||||
onClick={() => patch({ rotation: (item.rotation + 90) % 360 })}
|
||||
>
|
||||
Rotate 90°
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{config.isTable && (
|
||||
<div>
|
||||
<div style={label}>Seats at this table</div>
|
||||
<Wheel value={seats} min={1} max={99} rows={3} format={(v) => String(v)} onChange={(v) => patch({ seatsOverride: v })} />
|
||||
{overridden && (
|
||||
<button
|
||||
onClick={() => patch({ seatsOverride: undefined })}
|
||||
style={{ ...hint, border: "none", background: "none", padding: 0, cursor: "pointer", color: "#1c7ed6", textDecoration: "underline" }}
|
||||
>
|
||||
Reset to default ({config.seatsPerUnit ?? 1})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div style={label}>Size (m)</div>
|
||||
<div style={twoCol}>
|
||||
{isScreen ? (
|
||||
<div>
|
||||
<div style={hint}>Length</div>
|
||||
<Wheel value={item.width} min={0.5} max={MAX_SIZE} step={0.1} rows={3} format={fmt} onChange={(v) => patch({ width: v })} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<div style={hint}>Width</div>
|
||||
<Wheel value={item.width} min={minOf(item.width, MIN_SIZE)} max={MAX_SIZE} step={0.1} rows={3} format={fmt} onChange={(v) => patch({ width: v })} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={hint}>Length</div>
|
||||
<Wheel value={item.height} min={minOf(item.height, MIN_SIZE)} max={MAX_SIZE} step={0.1} rows={3} format={fmt} onChange={(v) => patch({ height: v })} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notice && <div style={{ ...hint, color: "#e03131", marginTop: 0 }}>{notice}</div>}
|
||||
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button style={{ ...btn, flex: 1 }} onClick={() => patch({ locked: !item.locked })}>
|
||||
{item.locked ? "Unlock" : "Lock"}
|
||||
</button>
|
||||
<button style={{ ...btn, flex: 1, color: "#e03131", borderColor: "#e8a8a8" }} onClick={onDelete}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+43
-48
@@ -1,13 +1,16 @@
|
||||
import { useState, type PointerEvent, type ReactNode } from "react";
|
||||
import { FURNITURE_TYPES, TYPE_LABEL, type FurnitureType } from "../types";
|
||||
import type { FurnitureType, FurnitureTypeConfig } from "../types";
|
||||
import { ShapeIcon } from "./Icons";
|
||||
|
||||
const CLICK_THRESHOLD_PX = 5;
|
||||
|
||||
interface Props {
|
||||
types: FurnitureTypeConfig[]; // in palette order
|
||||
remaining: Record<FurnitureType, number>;
|
||||
onStartDrag: (type: FurnitureType, clientX: number, clientY: number) => void;
|
||||
onOpenMenu: (type: FurnitureType) => void;
|
||||
panel: ReactNode; // when set, replaces the tiles (type settings menu)
|
||||
onNewCustom: () => void;
|
||||
panel: ReactNode; // when set, replaces the tiles (type menu, item panel, custom form)
|
||||
}
|
||||
|
||||
const tile: React.CSSProperties = {
|
||||
@@ -29,35 +32,19 @@ const tile: React.CSSProperties = {
|
||||
color: "#444",
|
||||
};
|
||||
|
||||
export function ChairIcon({ size = 36 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 36 36">
|
||||
<rect x="4" y="4" width="28" height="28" rx="3" fill="#8fb4d9" stroke="#3b3b3b" strokeWidth="1.5" />
|
||||
<rect x="4" y="4" width="28" height="8" rx="3" fill="#4a6f96" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableIcon({ size = 36 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 36 36">
|
||||
<rect x="2" y="9" width="32" height="18" rx="3" fill="#d9b382" stroke="#3b3b3b" strokeWidth="1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const MIN_W = 152;
|
||||
const MAX_W = 600;
|
||||
|
||||
interface TileProps {
|
||||
type: FurnitureType;
|
||||
config: FurnitureTypeConfig;
|
||||
remaining: number;
|
||||
onStartDrag: Props["onStartDrag"];
|
||||
onOpenMenu: Props["onOpenMenu"];
|
||||
}
|
||||
|
||||
/** Press + move > threshold = drag a new item out. Press + release without moving = open menu. */
|
||||
function Tile({ type, remaining, onStartDrag, onOpenMenu }: TileProps) {
|
||||
function Tile({ config, remaining, onStartDrag, onOpenMenu }: TileProps) {
|
||||
const type = config.id;
|
||||
const empty = remaining <= 0;
|
||||
|
||||
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
|
||||
@@ -90,7 +77,7 @@ function Tile({ type, remaining, onStartDrag, onOpenMenu }: TileProps) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
aria-label={`${type} (${remaining} remaining)`}
|
||||
aria-label={`${config.name} (${remaining} remaining)`}
|
||||
style={{ ...tile, position: "relative", opacity: empty ? 0.45 : 1, filter: empty ? "grayscale(1)" : undefined, cursor: empty ? "pointer" : "grab" }}
|
||||
onPointerDown={onPointerDown}
|
||||
>
|
||||
@@ -112,31 +99,27 @@ function Tile({ type, remaining, onStartDrag, onOpenMenu }: TileProps) {
|
||||
>
|
||||
{remaining}
|
||||
</span>
|
||||
{(() => {
|
||||
const Icon = TYPE_ICON[type];
|
||||
return <Icon />;
|
||||
})()}
|
||||
{TYPE_LABEL[type]}
|
||||
<ShapeIcon shape={config.shape} />
|
||||
{config.name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScreenIcon({ size = 36 }: { size?: number }) {
|
||||
function NewCustomTile({ onClick }: { onClick: () => void }) {
|
||||
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>
|
||||
<div
|
||||
role="button"
|
||||
aria-label="New custom furniture"
|
||||
onClick={onClick}
|
||||
style={{ ...tile, border: "1.5px dashed #9a9a9a", background: "transparent", cursor: "pointer" }}
|
||||
>
|
||||
<span style={{ font: "300 30px/30px system-ui, sans-serif", color: "#666" }}>+</span>
|
||||
Custom
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
export function Palette({ types, remaining, onStartDrag, onOpenMenu, onNewCustom, panel }: Props) {
|
||||
const [width, setWidth] = useState(264);
|
||||
|
||||
const startResize = (e: PointerEvent) => {
|
||||
@@ -158,20 +141,32 @@ export function Palette({ remaining, onStartDrag, onOpenMenu, panel }: Props) {
|
||||
style={{
|
||||
position: "relative",
|
||||
width,
|
||||
boxSizing: "border-box",
|
||||
flexShrink: 0,
|
||||
padding: 16,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(84px, 1fr))",
|
||||
alignContent: "start",
|
||||
gap: 12,
|
||||
borderRight: "1px solid #d4d4d4",
|
||||
background: "#ececec",
|
||||
}}
|
||||
>
|
||||
{panel ?? FURNITURE_TYPES.map((type) => (
|
||||
<Tile key={type} type={type} remaining={remaining[type]} onStartDrag={onStartDrag} onOpenMenu={onOpenMenu} />
|
||||
))}
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
boxSizing: "border-box",
|
||||
overflowY: "auto",
|
||||
padding: 16,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(84px, 1fr))",
|
||||
alignContent: "start",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{panel ?? (
|
||||
<>
|
||||
{types.map((cfg) => (
|
||||
<Tile key={cfg.id} config={cfg} remaining={remaining[cfg.id]} onStartDrag={onStartDrag} onOpenMenu={onOpenMenu} />
|
||||
))}
|
||||
<NewCustomTile onClick={onNewCustom} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onPointerDown={startResize}
|
||||
style={{ position: "absolute", top: 0, right: -3, width: 6, height: "100%", cursor: "col-resize", touchAction: "none" }}
|
||||
|
||||
+64
-35
@@ -1,12 +1,12 @@
|
||||
import { useRef, useState, type RefObject } from "react";
|
||||
import { Stage, Layer, Group, Rect } from "react-konva";
|
||||
import { Stage, Layer, Group, Rect, Path } from "react-konva";
|
||||
import type Konva from "konva";
|
||||
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";
|
||||
import { boundsOf, isValidPlacement, clampToRoom, resizeLength } from "../geometry";
|
||||
import type { FurnitureItem, FurnitureTypeConfig, Rect as RectM, Room as RoomT, Shape } from "../types";
|
||||
import { FurnitureShape, type Outline } from "./FurnitureShape";
|
||||
|
||||
export interface Preview {
|
||||
type: FurnitureType;
|
||||
shape: Shape;
|
||||
x: number; // meters
|
||||
y: number;
|
||||
width: number;
|
||||
@@ -17,9 +17,12 @@ export interface Preview {
|
||||
interface Props {
|
||||
room: RoomT;
|
||||
items: FurnitureItem[];
|
||||
configs: Record<string, FurnitureTypeConfig>;
|
||||
preview: Preview | null; // new item being dragged in from the palette
|
||||
pxPerM: number;
|
||||
floorRef: RefObject<HTMLDivElement | null>;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
onMove: (id: string, x: number, y: number) => void;
|
||||
onResize: (id: string, rect: RectM) => void;
|
||||
}
|
||||
@@ -36,21 +39,34 @@ interface ResizeInfo {
|
||||
}
|
||||
|
||||
const HANDLE_PX = 10;
|
||||
// Top-down view: a screen only changes in length, its thickness is fixed.
|
||||
const EDGES: ("left" | "right")[] = ["left", "right"];
|
||||
const ENDS: ("left" | "right")[] = ["left", "right"];
|
||||
|
||||
export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize }: Props) {
|
||||
function LockIcon({ w, h, rotation }: { w: number; h: number; rotation: number }) {
|
||||
// 24x24 padlock centered in the item, counter-rotated so it stays upright.
|
||||
const s = Math.min(1, Math.max(0.35, (Math.min(w, h) * 0.7) / 24));
|
||||
return (
|
||||
<Group x={w / 2} y={h / 2} offsetX={12} offsetY={12} scaleX={s} scaleY={s} rotation={-rotation} listening={false}>
|
||||
<Path data="M8 11V8a4 4 0 0 1 8 0v3" stroke="#1a1a1a" strokeWidth={2.5} />
|
||||
<Rect x={5} y={11} width={14} height={10} cornerRadius={2} fill="#fff" stroke="#1a1a1a" strokeWidth={2} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function Room({ room, items, configs, preview, pxPerM, floorRef, selectedId, onSelect, 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;
|
||||
|
||||
// Where the item would land if dropped now: clamped to walls.
|
||||
const candidate = (item: FurnitureItem, node: Konva.Node) =>
|
||||
// The group's origin is the item's CENTER (offset puts the rect's center there), so rotation pivots on it.
|
||||
const candidate = (item: FurnitureItem, node: Konva.Node): FurnitureItem =>
|
||||
clampToRoom(
|
||||
{ x: node.x() / pxPerM, y: node.y() / pxPerM, width: item.width, height: item.height },
|
||||
{
|
||||
...item,
|
||||
x: node.x() / pxPerM - item.width / 2,
|
||||
y: node.y() / pxPerM - item.height / 2,
|
||||
},
|
||||
room,
|
||||
);
|
||||
|
||||
@@ -70,31 +86,42 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
|
||||
<Stage
|
||||
width={w}
|
||||
height={h}
|
||||
onMouseDown={(e) => e.target === e.target.getStage() && setSelectedId(null)}
|
||||
onTouchStart={(e) => e.target === e.target.getStage() && setSelectedId(null)}
|
||||
onMouseDown={(e) => e.target === e.target.getStage() && onSelect(null)}
|
||||
onTouchStart={(e) => e.target === e.target.getStage() && onSelect(null)}
|
||||
>
|
||||
<Layer>
|
||||
{items.map((item) => {
|
||||
const shape = configs[item.type]?.shape ?? "chair";
|
||||
const isResizing = resize?.id === item.id;
|
||||
const r: RectM = isResizing ? resize.rect : item;
|
||||
const outline = isResizing
|
||||
const outline: Outline = isResizing
|
||||
? resize.valid ? "valid" : "invalid"
|
||||
: drag?.id === item.id
|
||||
? drag.valid ? "valid" : "invalid"
|
||||
: selectedId === item.id ? "selected" : "normal";
|
||||
const half = boundsOf({ ...r, rotation: item.rotation });
|
||||
const wpx = r.width * pxPerM;
|
||||
const hpx = r.height * pxPerM;
|
||||
return (
|
||||
<Group
|
||||
key={item.id}
|
||||
x={r.x * pxPerM}
|
||||
y={r.y * pxPerM}
|
||||
onMouseDown={() => setSelectedId(RESIZABLE[item.type] ? item.id : null)}
|
||||
onTouchStart={() => setSelectedId(RESIZABLE[item.type] ? item.id : null)}
|
||||
x={(r.x + r.width / 2) * pxPerM}
|
||||
y={(r.y + r.height / 2) * pxPerM}
|
||||
offsetX={wpx / 2}
|
||||
offsetY={hpx / 2}
|
||||
rotation={item.rotation}
|
||||
draggable={!item.locked}
|
||||
// Hard-clamp to the walls while dragging (stage is unscaled, so absolute = local).
|
||||
dragBoundFunc={(pos) => ({
|
||||
x: Math.min(Math.max(pos.x, 0), w - item.width * pxPerM),
|
||||
y: Math.min(Math.max(pos.y, 0), h - item.height * pxPerM),
|
||||
})}
|
||||
onMouseDown={() => onSelect(item.id)}
|
||||
onTouchStart={() => onSelect(item.id)}
|
||||
// Keep the rotated bounds inside the walls (stage is unscaled, so absolute = local).
|
||||
dragBoundFunc={(pos) => {
|
||||
const hx = (half.width / 2) * pxPerM;
|
||||
const hy = (half.height / 2) * pxPerM;
|
||||
return {
|
||||
x: Math.min(Math.max(pos.x, hx), w - hx),
|
||||
y: Math.min(Math.max(pos.y, hy), h - hy),
|
||||
};
|
||||
}}
|
||||
onDragStart={(e) => {
|
||||
e.target.moveToTop();
|
||||
}}
|
||||
@@ -107,31 +134,34 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
|
||||
const c = candidate(item, node);
|
||||
const ok = isValidPlacement(c, items, room, item.id);
|
||||
const final = ok ? c : item; // snap back to last valid position
|
||||
node.position({ x: final.x * pxPerM, y: final.y * pxPerM });
|
||||
node.position({ x: (final.x + item.width / 2) * pxPerM, y: (final.y + item.height / 2) * pxPerM });
|
||||
if (ok) onMove(item.id, c.x, c.y);
|
||||
setDrag(null);
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
const c = e.target.getStage()?.container();
|
||||
if (c) c.style.cursor = "grab";
|
||||
if (c) c.style.cursor = item.locked ? "not-allowed" : "grab";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
const c = e.target.getStage()?.container();
|
||||
if (c) c.style.cursor = "";
|
||||
}}
|
||||
>
|
||||
<FurnitureShape type={item.type} width={r.width} height={r.height} pxPerM={pxPerM} outline={outline} />
|
||||
<FurnitureShape shape={shape} width={r.width} height={r.height} pxPerM={pxPerM} outline={outline} />
|
||||
{item.locked && <LockIcon w={wpx} h={hpx} rotation={item.rotation} />}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
{items.map((item) => {
|
||||
if (item.id !== selectedId || !RESIZABLE[item.type] || drag?.id === item.id) return null;
|
||||
// Screens get length handles on their ends while selected, unlocked and unrotated.
|
||||
const isScreen = configs[item.type]?.shape === "screen";
|
||||
if (item.id !== selectedId || !isScreen || item.locked || item.rotation !== 0 || drag?.id === item.id) return null;
|
||||
const r: RectM = resize?.id === item.id ? resize.rect : item;
|
||||
const mid: Record<"left" | "right", readonly [number, number]> = {
|
||||
const mid = {
|
||||
left: [r.x, r.y + r.height / 2],
|
||||
right: [r.x + r.width, r.y + r.height / 2],
|
||||
};
|
||||
return EDGES.map((edge) => (
|
||||
} as const;
|
||||
return ENDS.map((edge) => (
|
||||
<Rect
|
||||
key={`${item.id}-${edge}`}
|
||||
x={mid[edge][0] * pxPerM}
|
||||
@@ -158,8 +188,8 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
|
||||
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) });
|
||||
const next = resizeLength(start, edge, p.x / pxPerM, room);
|
||||
setResize({ id: item.id, rect: next, valid: isValidPlacement({ ...item, ...next }, items, room, item.id) });
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
if (resize?.valid) onResize(item.id, resize.rect); // else snap back to the old size
|
||||
@@ -180,7 +210,7 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
|
||||
{preview && (
|
||||
<Group x={preview.x * pxPerM} y={preview.y * pxPerM} listening={false}>
|
||||
<FurnitureShape
|
||||
type={preview.type}
|
||||
shape={preview.shape}
|
||||
width={preview.width}
|
||||
height={preview.height}
|
||||
pxPerM={pxPerM}
|
||||
@@ -194,4 +224,3 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+11
-17
@@ -1,9 +1,9 @@
|
||||
import { useEffect } from "react";
|
||||
import { TYPE_LABEL, type FurnitureType, type FurnitureTypeConfig } from "../types";
|
||||
import type { FurnitureTypeConfig } from "../types";
|
||||
import { Wheel } from "./Wheel";
|
||||
import { backBtn, hint, label, panelRoot, title } from "./panelStyles";
|
||||
|
||||
interface Props {
|
||||
type: FurnitureType;
|
||||
config: FurnitureTypeConfig;
|
||||
placed: number;
|
||||
onSetAvailable: (v: number) => void;
|
||||
@@ -11,10 +11,7 @@ interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const label: React.CSSProperties = { font: "600 12px system-ui, sans-serif", color: "#555", marginBottom: 4 };
|
||||
const hint: React.CSSProperties = { font: "12px system-ui, sans-serif", color: "#888", marginTop: 4 };
|
||||
|
||||
export function TypeMenu({ type, config, placed, onSetAvailable, onSetSeats, onClose }: Props) {
|
||||
export function TypeMenu({ config, placed, onSetAvailable, onSetSeats, onClose }: Props) {
|
||||
useEffect(() => {
|
||||
const key = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
window.addEventListener("keydown", key);
|
||||
@@ -22,26 +19,23 @@ export function TypeMenu({ type, config, placed, onSetAvailable, onSetSeats, onC
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div style={{ gridColumn: "1 / -1", display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{ alignSelf: "flex-start", border: "none", background: "none", padding: 0, cursor: "pointer", font: "13px system-ui, sans-serif", color: "#555" }}
|
||||
>
|
||||
<div style={panelRoot}>
|
||||
<button onClick={onClose} style={backBtn}>
|
||||
← Back
|
||||
</button>
|
||||
<div style={{ font: "600 15px system-ui, sans-serif", color: "#222" }}>{TYPE_LABEL[type]}</div>
|
||||
<div style={title}>{config.name}</div>
|
||||
<div>
|
||||
<div style={label}>Available</div>
|
||||
<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>
|
||||
{config.shape === "screen" && (
|
||||
<div style={hint}>Click a placed screen, then drag an end handle (or use the Length wheel) to change its length.</div>
|
||||
)}
|
||||
{type === "table" && (
|
||||
{config.seatsPerUnit !== undefined && (
|
||||
<div>
|
||||
<div style={label}>Seats per table</div>
|
||||
<Wheel value={config.seatsPerUnit ?? 8} min={1} onChange={onSetSeats} />
|
||||
<div style={label}>{config.isTable ? "Seats per table" : "Seats each"}</div>
|
||||
<Wheel value={config.seatsPerUnit} min={1} onChange={onSetSeats} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+85
-39
@@ -4,12 +4,15 @@ interface Props {
|
||||
value: number;
|
||||
min: number;
|
||||
max?: number;
|
||||
step?: number; // value units per wheel notch (default 1)
|
||||
wrap?: boolean; // loop from max back to min (angles)
|
||||
rows?: number; // visible rows, odd (default 5)
|
||||
disabled?: boolean;
|
||||
format?: (v: number) => string;
|
||||
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
|
||||
@@ -21,34 +24,44 @@ const NOTCH_DELTA = 40; // wheel deltas this big are mouse notches (one step), s
|
||||
* 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.
|
||||
* Internally works in integer "notch" space; `step` maps notches to real values.
|
||||
* If the parent doesn't accept a change (value prop stays put) the wheel settles back to the real value.
|
||||
*/
|
||||
export function Wheel({ value, min, max = 999, onChange }: Props) {
|
||||
const [pos, setPos] = useState(value);
|
||||
export function Wheel({ value, min, max = 999, step = 1, wrap = false, rows: visible = 5, disabled = false, format, onChange }: Props) {
|
||||
const idx = (v: number) => Math.round(v / step);
|
||||
const decimals = (String(step).split(".")[1] ?? "").length;
|
||||
|
||||
const [pos, setPos] = useState(idx(value));
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const posRef = useRef(value); // fractional position in "value" units
|
||||
const lastEmitted = useRef(value);
|
||||
const posRef = useRef(idx(value)); // fractional position in notches
|
||||
const lastEmitted = useRef(idx(value));
|
||||
const raf = useRef(0);
|
||||
const settleTimer = useRef(0);
|
||||
const targetRef = useRef(value); // where the running animation is heading
|
||||
const targetRef = useRef(idx(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 imin = idx(min);
|
||||
const imax = idx(max);
|
||||
const n = imax - imin + 1;
|
||||
const cfg = useRef({ imin, imax, n, wrap, step, onChange, valueIdx: idx(value) });
|
||||
cfg.current = { imin, imax, n, wrap, step, onChange, valueIdx: idx(value) };
|
||||
|
||||
const clamp = (v: number) => Math.min(cfg.current.max, Math.max(cfg.current.min, v));
|
||||
const wrapIdx = (i: number) => cfg.current.imin + ((((i - cfg.current.imin) % cfg.current.n) + cfg.current.n) % cfg.current.n);
|
||||
const keyOf = (i: number) => (cfg.current.wrap ? wrapIdx(i) : i);
|
||||
const clamp = (v: number) => (cfg.current.wrap ? v : Math.min(cfg.current.imax, Math.max(cfg.current.imin, 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);
|
||||
const k = keyOf(Math.round(c));
|
||||
if (k !== lastEmitted.current) {
|
||||
lastEmitted.current = k;
|
||||
cfg.current.onChange(Math.round(k * cfg.current.step * 1000) / 1000);
|
||||
}
|
||||
return c;
|
||||
};
|
||||
@@ -60,32 +73,53 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
|
||||
settleTimer.current = 0;
|
||||
};
|
||||
|
||||
/** Where the real value sits relative to the current position (shortest way round when wrapping). */
|
||||
const valueTarget = () => {
|
||||
const have = Math.round(posRef.current);
|
||||
if (!cfg.current.wrap) return cfg.current.valueIdx;
|
||||
const d = wrapIdx(cfg.current.valueIdx - have + cfg.current.imin) - cfg.current.imin; // 0..n-1
|
||||
return have + (d > cfg.current.n / 2 ? d - cfg.current.n : d);
|
||||
};
|
||||
|
||||
/** One frame after a settle ends, check the parent actually took our value; if not, glide back. */
|
||||
const reconcileSoon = () => {
|
||||
raf.current = requestAnimationFrame(() => {
|
||||
raf.current = 0;
|
||||
if (drag.current) return;
|
||||
if (keyOf(Math.round(posRef.current)) !== cfg.current.valueIdx) {
|
||||
lastEmitted.current = cfg.current.valueIdx;
|
||||
settleTo(valueTarget(), 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 frame = (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;
|
||||
reconcileSoon();
|
||||
return;
|
||||
}
|
||||
setPosition(next);
|
||||
raf.current = requestAnimationFrame(step);
|
||||
raf.current = requestAnimationFrame(frame);
|
||||
};
|
||||
raf.current = requestAnimationFrame(step);
|
||||
raf.current = requestAnimationFrame(frame);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
function settleTo(rawTarget: number, v0: number) {
|
||||
stop();
|
||||
const target = clamp(Math.round(rawTarget));
|
||||
targetRef.current = target;
|
||||
@@ -97,7 +131,7 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
|
||||
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 frame = (now: number) => {
|
||||
const t = (now - t0) / 1000;
|
||||
const decay = Math.exp(-w * t);
|
||||
const x = target + (a + b * t) * decay;
|
||||
@@ -105,25 +139,29 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
|
||||
if ((Math.abs(x - target) < 0.003 && Math.abs(vel) < 0.05) || t > 1.5) {
|
||||
setPosition(target);
|
||||
raf.current = 0;
|
||||
reconcileSoon();
|
||||
return;
|
||||
}
|
||||
setPosition(x);
|
||||
raf.current = requestAnimationFrame(step);
|
||||
raf.current = requestAnimationFrame(frame);
|
||||
};
|
||||
raf.current = requestAnimationFrame(step);
|
||||
};
|
||||
raf.current = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
// Follow outside changes (e.g. min rising) when idle.
|
||||
// Follow outside changes (rotate button, min rising, selection change) when idle.
|
||||
useEffect(() => {
|
||||
if (drag.current || raf.current) return;
|
||||
posRef.current = value;
|
||||
lastEmitted.current = value;
|
||||
setPos(value);
|
||||
const p = valueTarget();
|
||||
posRef.current = p;
|
||||
lastEmitted.current = keyOf(Math.round(p));
|
||||
setPos(p);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => stop, []);
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
if (disabled) return;
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
const wasMoving = raf.current !== 0 || settleTimer.current !== 0;
|
||||
stop(); // stopping power: touching freezes the wheel exactly where it is
|
||||
@@ -155,9 +193,9 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
|
||||
} 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);
|
||||
const r = Math.round((e.clientY - rect.top - (visible * ROW_H) / 2) / ROW_H);
|
||||
posRef.current = d.pos0;
|
||||
animateTo(Math.round(d.pos0) + rows);
|
||||
animateTo(Math.round(d.pos0) + r);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -177,6 +215,7 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
|
||||
};
|
||||
|
||||
const onWheel = (e: React.WheelEvent) => {
|
||||
if (disabled) return;
|
||||
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);
|
||||
@@ -193,17 +232,22 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (disabled) return;
|
||||
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 reach = Math.floor(visible / 2) + 1;
|
||||
const centre = Math.round(pos);
|
||||
const rows = [];
|
||||
for (let v = Math.max(min, centre - REACH); v <= Math.min(max, centre + REACH); v++) {
|
||||
const lo = wrap ? centre - reach : Math.max(imin, centre - reach);
|
||||
const hi = wrap ? centre + reach : Math.min(imax, centre + reach);
|
||||
const items = [];
|
||||
for (let v = lo; v <= hi; v++) {
|
||||
const off = v - pos;
|
||||
rows.push(
|
||||
const real = (wrap ? wrapIdx(v) : v) * step;
|
||||
items.push(
|
||||
<div
|
||||
key={v}
|
||||
style={{
|
||||
@@ -216,12 +260,12 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
|
||||
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})`,
|
||||
opacity: Math.max(0, 1 - Math.abs(off) * (visible <= 3 ? 0.55 : 0.38)),
|
||||
transform: `translateY(${((visible - 1) / 2 + off) * ROW_H}px) scale(${1 - Math.min(1, Math.abs(off)) * 0.12})`,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
{format ? format(real) : real.toFixed(decimals)}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
@@ -229,11 +273,12 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
|
||||
return (
|
||||
<div
|
||||
ref={box}
|
||||
tabIndex={0}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
role="spinbutton"
|
||||
aria-valuenow={value}
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-disabled={disabled}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
@@ -243,11 +288,12 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: VISIBLE * ROW_H,
|
||||
height: visible * ROW_H,
|
||||
overflow: "hidden",
|
||||
background: "#f4f4f4",
|
||||
borderRadius: 10,
|
||||
cursor: dragging ? "grabbing" : "ns-resize",
|
||||
cursor: disabled ? "not-allowed" : dragging ? "grabbing" : "ns-resize",
|
||||
opacity: disabled ? 0.45 : 1,
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
outline: "none",
|
||||
@@ -258,14 +304,14 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
|
||||
position: "absolute",
|
||||
left: 6,
|
||||
right: 6,
|
||||
top: ((VISIBLE - 1) / 2) * ROW_H,
|
||||
top: ((visible - 1) / 2) * ROW_H,
|
||||
height: ROW_H,
|
||||
background: "rgba(0,0,0,0.08)",
|
||||
borderRadius: 8,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
{rows}
|
||||
{items}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export const panelRoot: React.CSSProperties = { gridColumn: "1 / -1", display: "flex", flexDirection: "column", gap: 14 };
|
||||
export const label: React.CSSProperties = { font: "600 12px system-ui, sans-serif", color: "#555", marginBottom: 4 };
|
||||
export const hint: React.CSSProperties = { font: "12px system-ui, sans-serif", color: "#888", marginTop: 4 };
|
||||
export const title: React.CSSProperties = { font: "600 15px system-ui, sans-serif", color: "#222" };
|
||||
export const backBtn: React.CSSProperties = {
|
||||
alignSelf: "flex-start",
|
||||
border: "none",
|
||||
background: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
font: "13px system-ui, sans-serif",
|
||||
color: "#555",
|
||||
};
|
||||
export const btn: React.CSSProperties = {
|
||||
height: 32,
|
||||
padding: "0 12px",
|
||||
border: "1px solid #c4c4c4",
|
||||
background: "#fff",
|
||||
borderRadius: 8,
|
||||
cursor: "pointer",
|
||||
font: "13px system-ui, sans-serif",
|
||||
color: "#222",
|
||||
};
|
||||
export const twoCol: React.CSSProperties = { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(96px, 1fr))", gap: 12 };
|
||||
Reference in New Issue
Block a user