mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 23:16:21 +00:00
feat(floorplan-editor): polish height slider + add hand tool for canvas pan
Two related polish improvements after the swatch-column → vertical- slider swap. Slider - Wider track (18 px, was 14 px) for a more comfortable click area with the same on-screen footprint. - Min / max chips above and below the rail (HEIGHT_BRUSH_MIN / _MAX) so users know which end is high and which is low without hovering to discover. - Thumb now uses a warm amber radial gradient (#fff7c4 → #facc15 → #ca8a04) on a dark brown border with a soft drop shadow + inset highlight, instead of the flat yellow disc. Hover adds a white ring; drag swaps it for a darker ring — clear gesture feedback. - Track gains a hover/drag glow (inset white seam + amber outline via boxShadow) so you can tell the slider has focus before you even click. Hand tool (canvas pan) - New FloorplanToolbar button (FaHandPaper, sticky toggle, emerald fill when active) ties to a new state lifted into FloorplanEditorView. When the hand is active, plain left-click + drag pans the canvas instead of brushing tiles. Cursor flips to grab / grabbing accordingly. - FloorplanCanvasSVG's isPanGesture predicate becomes: middle-mouse OR Shift+left-click OR (panMode && left-click). Shift / middle still work whether or not the hand is on so power users keep their muscle memory. - No change to the reducer (panMode is a canvas-level UI flag, not a brush action — keeps state/types tight).
This commit is contained in:
@@ -28,6 +28,7 @@ export const FloorplanEditorView: FC = () =>
|
||||
const [ isVisible, setIsVisible ] = useState(false);
|
||||
const [ importExportVisible, setImportExportVisible ] = useState(false);
|
||||
const [ liveSync, setLiveSync ] = useState(true);
|
||||
const [ panMode, setPanMode ] = useState(false);
|
||||
const { state, dispatch, loadFromServer, undo, redo, canUndo, canRedo } = useFloorplanReducer();
|
||||
const originalRef = useRef<{
|
||||
tilemap: string;
|
||||
@@ -232,11 +233,13 @@ export const FloorplanEditorView: FC = () =>
|
||||
canRedo={ canRedo }
|
||||
onUndo={ undo }
|
||||
onRedo={ redo }
|
||||
panMode={ panMode }
|
||||
onTogglePanMode={ () => setPanMode(v => !v) }
|
||||
/>
|
||||
<FloorplanOptionsPanel state={ state } dispatch={ dispatch } />
|
||||
<Flex gap={ 2 } className="flex-1 min-h-0">
|
||||
<FloorplanHeightPicker selectedH={ state.brush.h } onSelect={ h => dispatch({ type: 'BRUSH_SET', h }) } />
|
||||
<FloorplanCanvasSVG state={ state } dispatch={ dispatch } />
|
||||
<FloorplanCanvasSVG state={ state } dispatch={ dispatch } panMode={ panMode } />
|
||||
</Flex>
|
||||
<Flex gap={ 3 } alignItems="center" className="px-1">
|
||||
<Flex gap={ 1 } alignItems="center">
|
||||
|
||||
@@ -9,6 +9,13 @@ import { TILE_SIZE, MAX_NUM_TILE_PER_AXIS } from '../state/constants';
|
||||
type Props = {
|
||||
state: FloorplanState;
|
||||
dispatch: Dispatch<FloorplanAction>;
|
||||
/**
|
||||
* When true, left-click + drag pans the canvas instead of
|
||||
* brushing. Driven by the hand-tool toggle in the toolbar.
|
||||
* Shift+drag and middle-mouse drag always pan regardless of
|
||||
* this flag.
|
||||
*/
|
||||
panMode?: boolean;
|
||||
};
|
||||
|
||||
const VIEWBOX_W = 2048;
|
||||
@@ -76,7 +83,7 @@ const computeRoomBounds = (state: FloorplanState): { x: number; y: number; w: nu
|
||||
};
|
||||
};
|
||||
|
||||
export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch }) =>
|
||||
export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch, panMode }) =>
|
||||
{
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const [ zoom, setZoom ] = useState(1);
|
||||
@@ -204,14 +211,24 @@ export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch }) =>
|
||||
};
|
||||
}, [ visW ]);
|
||||
|
||||
const isPanGesture = (e: ReactPointerEvent): boolean => e.button === 1 || (e.button === 0 && e.shiftKey);
|
||||
// Pan gestures: middle-mouse, Shift+left-click, and (when the
|
||||
// hand-tool is active) plain left-click. The hand-tool toggle
|
||||
// is the toolbar affordance — Shift / middle still work even
|
||||
// when the hand isn't on, so power users keep their muscle
|
||||
// memory.
|
||||
const isPanGesture = (e: ReactPointerEvent): boolean =>
|
||||
e.button === 1
|
||||
|| (e.button === 0 && e.shiftKey)
|
||||
|| (e.button === 0 && Boolean(panMode));
|
||||
|
||||
const cursorClass = isPanning ? 'cursor-grabbing' : panMode ? 'cursor-grab' : '';
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<svg
|
||||
ref={ svgRef }
|
||||
viewBox={ viewBox }
|
||||
className={ `w-full h-full select-none rounded-md border border-zinc-300 bg-[url('@/assets/images/floorplaneditor/canvas_floor_pattern.png')] bg-repeat [image-rendering:pixelated] ${ isPanning ? 'cursor-grabbing' : '' }` }
|
||||
className={ `w-full h-full select-none rounded-md border border-zinc-300 bg-[url('@/assets/images/floorplaneditor/canvas_floor_pattern.png')] bg-repeat [image-rendering:pixelated] ${ cursorClass }` }
|
||||
onWheel={ onWheel }
|
||||
onPointerDown={ e =>
|
||||
{
|
||||
|
||||
@@ -7,30 +7,38 @@ type Props = {
|
||||
onSelect: (h: number) => void;
|
||||
};
|
||||
|
||||
const TRACK_W = 14;
|
||||
const TRACK_W = 18;
|
||||
const TRACK_H = 260;
|
||||
const THUMB_DIAM = 24;
|
||||
const THUMB_DIAM = 28;
|
||||
const RAIL_GUTTER = 4;
|
||||
|
||||
/**
|
||||
* Vertical brush-height slider. The track is a top-down gradient
|
||||
* built from the real tile-fill colours, so the user still sees
|
||||
* which colour maps to which height — the swatch column it
|
||||
* replaces communicated the same mapping a swatch at a time.
|
||||
* The thumb shows the currently picked height as a number and is
|
||||
* fully drag-aware (click anywhere on the track to jump, then
|
||||
* drag without releasing).
|
||||
* Vertical brush-height slider.
|
||||
*
|
||||
* Track - discrete-step gradient built from the real tile-fill
|
||||
* colours, top = HEIGHT_BRUSH_MAX, bottom = HEIGHT_BRUSH_MIN.
|
||||
* Each height owns a clear band so colour <-> height stays
|
||||
* legible at a glance, exactly like the swatch column it
|
||||
* replaces.
|
||||
* Min/max - small chip labels float above and below the rail so the
|
||||
* user knows what the endpoints mean without trial and
|
||||
* error.
|
||||
* Thumb - amber radial gradient on a soft drop shadow, white ring
|
||||
* when hovered, darker ring while dragging. Renders the
|
||||
* current value in the middle so the user reads the
|
||||
* number directly off the handle.
|
||||
* Gesture - click the rail to jump, click-and-drag the thumb (or
|
||||
* rail) to scrub. Window-level pointer listeners keep
|
||||
* the drag alive even when the cursor leaves the narrow
|
||||
* strip. Vertical scroll on touch is suppressed.
|
||||
*/
|
||||
export const FloorplanHeightPicker: FC<Props> = ({ selectedH, onSelect }) =>
|
||||
{
|
||||
const count = HEIGHT_BRUSH_MAX - HEIGHT_BRUSH_MIN + 1;
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const [ isDragging, setIsDragging ] = useState(false);
|
||||
const [ isHovering, setIsHovering ] = useState(false);
|
||||
|
||||
// Top of the gradient is HEIGHT_BRUSH_MAX (matches the legacy
|
||||
// swatch column layout). Each step is `100 / (count - 1)` % of
|
||||
// the track height. Building hard stops gives a discrete-step
|
||||
// gradient (clear band per height) rather than a smooth blend
|
||||
// — closer to the original swatches, easier for users to read.
|
||||
const gradient = useMemo(() =>
|
||||
{
|
||||
const stops: string[] = [];
|
||||
@@ -75,8 +83,6 @@ export const FloorplanHeightPicker: FC<Props> = ({ selectedH, onSelect }) =>
|
||||
setIsDragging(true);
|
||||
}, [ heightFromClientY, onSelect, selectedH ]);
|
||||
|
||||
// While dragging, listen on window so the slider keeps tracking
|
||||
// even when the pointer leaves the narrow track strip.
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!isDragging) return;
|
||||
@@ -100,44 +106,52 @@ export const FloorplanHeightPicker: FC<Props> = ({ selectedH, onSelect }) =>
|
||||
};
|
||||
}, [ isDragging, heightFromClientY, onSelect, selectedH ]);
|
||||
|
||||
// Thumb centre as a % of the track height. At selectedH ==
|
||||
// HEIGHT_BRUSH_MAX the thumb sits at 0 % (top), at the min it
|
||||
// sits at 100 % (bottom). Translating Y by -50 % then re-
|
||||
// centres the circle on that point.
|
||||
const thumbPct = ((HEIGHT_BRUSH_MAX - selectedH) / (count - 1)) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative shrink-0 select-none touch-none"
|
||||
style={ { width: THUMB_DIAM + 4, height: TRACK_H } }
|
||||
className="relative shrink-0 select-none touch-none flex flex-col items-center"
|
||||
style={ { width: THUMB_DIAM + RAIL_GUTTER * 2, height: TRACK_H + 32 } }
|
||||
role="slider"
|
||||
aria-label="Altezza pennello"
|
||||
aria-valuemin={ HEIGHT_BRUSH_MIN }
|
||||
aria-valuemax={ HEIGHT_BRUSH_MAX }
|
||||
aria-valuenow={ selectedH }
|
||||
>
|
||||
<div
|
||||
ref={ trackRef }
|
||||
data-testid="height-track"
|
||||
className="absolute left-1/2 -translate-x-1/2 rounded-full border border-zinc-400 shadow-inner cursor-pointer overflow-hidden"
|
||||
style={ {
|
||||
width: TRACK_W,
|
||||
height: TRACK_H,
|
||||
background: gradient
|
||||
} }
|
||||
onPointerDown={ onPointerDown }
|
||||
/>
|
||||
<div
|
||||
data-testid="height-thumb"
|
||||
className={ `absolute left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-yellow-300 border-2 ${ isDragging ? 'border-zinc-800' : 'border-zinc-700' } shadow-md flex items-center justify-center text-[10px] font-bold text-zinc-900 tabular-nums pointer-events-none` }
|
||||
style={ {
|
||||
width: THUMB_DIAM,
|
||||
height: THUMB_DIAM,
|
||||
top: `${ thumbPct }%`
|
||||
} }
|
||||
>
|
||||
{ selectedH }
|
||||
<span className="text-[9px] font-bold tabular-nums text-zinc-500 leading-none mb-1">
|
||||
{ HEIGHT_BRUSH_MAX }
|
||||
</span>
|
||||
<div className="relative flex-1" style={ { width: THUMB_DIAM } }>
|
||||
<div
|
||||
ref={ trackRef }
|
||||
data-testid="height-track"
|
||||
className={ `absolute left-1/2 -translate-x-1/2 top-0 bottom-0 rounded-full border border-zinc-400 cursor-pointer overflow-hidden transition-shadow ${ isHovering || isDragging ? 'shadow-[inset_0_0_0_1px_rgba(255,255,255,0.4),0_0_0_2px_rgba(250,204,21,0.35)]' : 'shadow-inner' }` }
|
||||
style={ {
|
||||
width: TRACK_W,
|
||||
background: gradient
|
||||
} }
|
||||
onPointerDown={ onPointerDown }
|
||||
onPointerEnter={ () => setIsHovering(true) }
|
||||
onPointerLeave={ () => setIsHovering(false) }
|
||||
/>
|
||||
<div
|
||||
data-testid="height-thumb"
|
||||
className={ `absolute left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full flex items-center justify-center text-[11px] font-bold text-zinc-900 tabular-nums pointer-events-none transition-shadow ${ isDragging ? 'ring-2 ring-zinc-900' : isHovering ? 'ring-2 ring-white/80' : '' }` }
|
||||
style={ {
|
||||
width: THUMB_DIAM,
|
||||
height: THUMB_DIAM,
|
||||
top: `${ thumbPct }%`,
|
||||
background: 'radial-gradient(circle at 35% 30%, #fff7c4 0%, #facc15 55%, #ca8a04 100%)',
|
||||
border: '2px solid #78350f',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.25), inset 0 -2px 3px rgba(0, 0, 0, 0.18)'
|
||||
} }
|
||||
>
|
||||
{ selectedH }
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[9px] font-bold tabular-nums text-zinc-500 leading-none mt-1">
|
||||
{ HEIGHT_BRUSH_MIN }
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Dispatch, FC } from 'react';
|
||||
import { FaRedo, FaUndo } from 'react-icons/fa';
|
||||
import { FaHandPaper, FaRedo, FaUndo } from 'react-icons/fa';
|
||||
import { LocalizeText } from '../../../api';
|
||||
import { Base, Flex, Text } from '../../../common';
|
||||
import { FloorplanAction, FloorActionMode, FloorplanState } from '../state/types';
|
||||
@@ -11,6 +11,8 @@ type Props = {
|
||||
canRedo?: boolean;
|
||||
onUndo?: () => void;
|
||||
onRedo?: () => void;
|
||||
panMode?: boolean;
|
||||
onTogglePanMode?: () => void;
|
||||
};
|
||||
|
||||
const BRUSH_BUTTONS: { id: string; mode: FloorActionMode; iconClass: string }[] = [
|
||||
@@ -21,7 +23,7 @@ const BRUSH_BUTTONS: { id: string; mode: FloorActionMode; iconClass: string }[]
|
||||
{ id: 'tool-door', mode: 'DOOR', iconClass: 'icon-set-door' }
|
||||
];
|
||||
|
||||
export const FloorplanToolbar: FC<Props> = ({ state, dispatch, canUndo, canRedo, onUndo, onRedo }) =>
|
||||
export const FloorplanToolbar: FC<Props> = ({ state, dispatch, canUndo, canRedo, onUndo, onRedo, panMode, onTogglePanMode }) =>
|
||||
{
|
||||
return (
|
||||
<Flex gap={ 1 } alignItems="center">
|
||||
@@ -48,6 +50,18 @@ export const FloorplanToolbar: FC<Props> = ({ state, dispatch, canUndo, canRedo,
|
||||
className={ `nitro-icon icon-set-squaresselect ${ state.squareSelect ? 'border border-primary' : '' }` }
|
||||
onClick={ () => dispatch({ type: 'SQUARE_SELECT_TOGGLE' }) }
|
||||
/>
|
||||
{ onTogglePanMode && (
|
||||
<Base
|
||||
pointer
|
||||
data-testid="tool-pan"
|
||||
data-active={ panMode ? 'true' : 'false' }
|
||||
title={ panMode ? 'Modalità mano attiva — trascina per spostare la vista (Spazio per uscire)' : 'Modalità mano — trascina per spostare la vista' }
|
||||
className={ `ml-1 w-7 h-7 flex items-center justify-center rounded border ${ panMode ? 'bg-emerald-500 border-emerald-700 text-white shadow-inner' : 'border-zinc-300 bg-white hover:bg-zinc-50 text-zinc-700' }` }
|
||||
onClick={ onTogglePanMode }
|
||||
>
|
||||
<FaHandPaper size={ 12 } />
|
||||
</Base>
|
||||
) }
|
||||
{ (onUndo || onRedo) && (
|
||||
<Flex gap={ 1 } alignItems="center" className="ml-2 pl-2 border-l border-zinc-300">
|
||||
<Base
|
||||
|
||||
Reference in New Issue
Block a user