mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
🆕 Brand new Floorplan
This commit is contained in:
@@ -41,13 +41,6 @@ export const FloorplanEditorView: FC = () =>
|
||||
|
||||
const area = useMemo(() => areaCount(state.tiles), [ state.tiles ]);
|
||||
|
||||
// Live in-room preview: while the editor is open every tile /
|
||||
// door / thickness / wallHeight change is applied immediately
|
||||
// to the 3D room behind the editor card, CLIENT-SIDE ONLY (no
|
||||
// server packet). The wire UpdateFloorPropertiesMessageComposer
|
||||
// is only sent when the user clicks Save. `setBaseline` is
|
||||
// called by the message handlers below so the hook knows what
|
||||
// state to roll back to if the user closes without saving.
|
||||
const { setBaseline, revert: revertLivePreview } = useFloorplanLiveSync({ enabled: liveSync && isVisible, state });
|
||||
|
||||
useNitroEvent<RoomEngineEvent>(RoomEngineEvent.DISPOSED, () => setIsVisible(false));
|
||||
@@ -110,9 +103,6 @@ export const FloorplanEditorView: FC = () =>
|
||||
thicknessFloor: originalRef.current.thicknessFloor,
|
||||
wallHeight: parser.wallHeight + 1
|
||||
});
|
||||
// Anchor the live-sync baseline at the server's authoritative
|
||||
// snapshot so the first re-render after this load doesn't
|
||||
// bounce the same model back as an "edit".
|
||||
setBaseline({
|
||||
tilemap: parser.model,
|
||||
doorX: originalRef.current.entryPoint[0],
|
||||
@@ -140,10 +130,6 @@ export const FloorplanEditorView: FC = () =>
|
||||
dispatch({ type: 'SET_THICKNESS', wall, floor, source: 'remote' });
|
||||
});
|
||||
|
||||
// Keyboard shortcuts: Ctrl+Z = undo, Ctrl+Shift+Z / Ctrl+Y = redo.
|
||||
// Scoped to when the editor is visible; ignored when focus is in
|
||||
// a text-entry field (Import/Export modal textarea, wall height
|
||||
// input) so we don't fight the OS-native undo.
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!isVisible) return;
|
||||
@@ -214,9 +200,6 @@ export const FloorplanEditorView: FC = () =>
|
||||
const o = originalRef.current;
|
||||
if(!o) return;
|
||||
loadFromServer(o);
|
||||
// Roll the live in-room preview back to the server-known
|
||||
// baseline. No-op if live sync is off (nothing was changed
|
||||
// in the room).
|
||||
if(liveSync) revertLivePreview();
|
||||
};
|
||||
|
||||
@@ -254,14 +237,14 @@ export const FloorplanEditorView: FC = () =>
|
||||
<FaCaretRight className="cursor-pointer fa-icon text-zinc-600" onClick={ () => onWallHeightChange(state.wallHeight + 1) } />
|
||||
</Flex>
|
||||
<Text bold small className="text-zinc-700">
|
||||
Area: <span className="tabular-nums">{ area.total }</span> ({ area.walkable } caselle)
|
||||
Area: <span className="tabular-nums">{ area.total }</span> ({ area.walkable } tiles)
|
||||
</Text>
|
||||
<Flex
|
||||
alignItems="center"
|
||||
gap={ 1 }
|
||||
className={ `ml-auto border rounded px-2 py-1 cursor-pointer select-none ${ liveSync ? 'bg-emerald-500/15 border-emerald-500 text-emerald-700' : 'border-zinc-400 text-zinc-600' }` }
|
||||
onClick={ () => setLiveSync(v => !v) }
|
||||
title="Anteprima locale nella stanza mentre disegni (non salva al server)"
|
||||
title="Local in-room preview while drawing (does not save to server)"
|
||||
>
|
||||
<FaBolt className={ liveSync ? 'text-emerald-600' : 'text-zinc-500' } />
|
||||
<Text bold small>{ liveSync ? 'Live preview ON' : 'Live preview OFF' }</Text>
|
||||
|
||||
@@ -21,10 +21,6 @@ type Api = {
|
||||
canRedo: boolean;
|
||||
};
|
||||
|
||||
// Actions that DON'T change the room model — they only affect the
|
||||
// editor's UI state (brush selection, drag-select rectangle, …) and
|
||||
// should NOT push a new history snapshot. Brushing a tile, moving a
|
||||
// door, changing thickness, etc. all DO push history.
|
||||
const isNonHistoryAction = (action: FloorplanAction): boolean =>
|
||||
{
|
||||
switch(action.type)
|
||||
@@ -40,10 +36,6 @@ const isNonHistoryAction = (action: FloorplanAction): boolean =>
|
||||
}
|
||||
};
|
||||
|
||||
// Remote-driven actions also bypass history — they represent the
|
||||
// "true" server state, not a user edit. Treating a server push as
|
||||
// a history step would let the user "undo" a server snapshot, which
|
||||
// makes no sense.
|
||||
const isRemoteAction = (action: FloorplanAction): boolean =>
|
||||
{
|
||||
if(action.type === 'APPLY_REMOTE_DIFF' || action.type === 'APPLY_REMOTE_SNAPSHOT') return true;
|
||||
@@ -56,23 +48,12 @@ export const useFloorplanReducer = (): Api =>
|
||||
{
|
||||
const [ state, dispatch ] = useReducer(reducer, initialState);
|
||||
|
||||
// Past / future stacks — paired with `state` to form a linear
|
||||
// timeline (`past` ++ [state] ++ `future`). Refs because the
|
||||
// wrappedDispatch closure needs the latest value but we don't
|
||||
// want every push to trigger a re-render. canUndo / canRedo are
|
||||
// separately tracked as React state so the UI buttons disable
|
||||
// correctly.
|
||||
const pastRef = useRef<FloorplanState[]>([]);
|
||||
const futureRef = useRef<FloorplanState[]>([]);
|
||||
const [ canUndo, setCanUndo ] = useState(false);
|
||||
const [ canRedo, setCanRedo ] = useState(false);
|
||||
const stateRef = useRef<FloorplanState>(state);
|
||||
|
||||
// Keep stateRef in sync with the latest committed render so the
|
||||
// history pushers (which run inside callbacks, not during
|
||||
// render) always see the up-to-date state. Writing the ref
|
||||
// inside an effect — not directly in the render body — is what
|
||||
// React's `refs-during-render` rule enforces.
|
||||
useEffect(() =>
|
||||
{
|
||||
stateRef.current = state;
|
||||
@@ -92,8 +73,6 @@ export const useFloorplanReducer = (): Api =>
|
||||
return;
|
||||
}
|
||||
|
||||
// Local edit: push current state onto past, drop future
|
||||
// (any redo branch is invalidated by a new edit).
|
||||
pastRef.current.push(stateRef.current);
|
||||
|
||||
if(pastRef.current.length > HISTORY_LIMIT) pastRef.current.shift();
|
||||
@@ -106,7 +85,6 @@ export const useFloorplanReducer = (): Api =>
|
||||
|
||||
const loadFromServer = useCallback((s: ServerFloorSettings) =>
|
||||
{
|
||||
// Server load wipes history — the document is fresh.
|
||||
pastRef.current = [];
|
||||
futureRef.current = [];
|
||||
dispatch({
|
||||
@@ -133,15 +111,6 @@ export const useFloorplanReducer = (): Api =>
|
||||
thickness: previous.thickness,
|
||||
wallHeight: previous.wallHeight,
|
||||
seq: previous.seq });
|
||||
// The APPLY_REMOTE_SNAPSHOT action re-parses the tilemap;
|
||||
// but we also want to restore brush/selection state. Wrap
|
||||
// the dispatch in an effect-like immediate sync by writing
|
||||
// through stateRef AFTER React commits — handled by the
|
||||
// next render setting stateRef. The selection/brush carried
|
||||
// by `previous` is recovered on the next mutating dispatch
|
||||
// since the reducer's APPLY_REMOTE_SNAPSHOT path resets
|
||||
// selection (acceptable: undoing a paint clears the
|
||||
// selection rectangle, which matches user intuition).
|
||||
refreshCanFlags();
|
||||
}, [ refreshCanFlags ]);
|
||||
|
||||
@@ -166,12 +135,6 @@ export const useFloorplanReducer = (): Api =>
|
||||
}), [ state, wrappedDispatch, loadFromServer, undo, redo, canUndo, canRedo ]);
|
||||
};
|
||||
|
||||
// Local serializer mirror — the reducer's APPLY_REMOTE_SNAPSHOT
|
||||
// path takes a raw tilemap string, but our history entries are
|
||||
// the live Tile[][] arrays. Re-emit `\r`-joined rows in the same
|
||||
// shape the encoding module uses for SAVES (we keep this here to
|
||||
// avoid a circular import: state/reducer already imports
|
||||
// state/encoding).
|
||||
const serializeTilesForSnapshot = (tiles: { h: number; blocked: boolean }[][]): string =>
|
||||
{
|
||||
if(!tiles || tiles.length === 0) return '';
|
||||
|
||||
@@ -22,8 +22,6 @@ describe('tileToScreen / screenToTile round-trip', () =>
|
||||
|
||||
it('rounds to the containing diamond for jittered points', () =>
|
||||
{
|
||||
// The diamond for tile (R, C) is centered at tileToScreen(R, C).
|
||||
// Small jitter inside the diamond should still resolve to the same tile under round-to-nearest.
|
||||
const [ sx, sy ] = tileToScreen(7, 2);
|
||||
const [ r, c ] = screenToTile(sx + 2, sy + 1);
|
||||
expect(Math.round(r)).toBe(7);
|
||||
|
||||
@@ -29,24 +29,39 @@ export const usePointerToTile = (
|
||||
viewBox: ViewBox
|
||||
): PointerProjection =>
|
||||
{
|
||||
const { width, height, x: viewX = 0, y: viewY = 0 } = viewBox;
|
||||
void viewBox;
|
||||
|
||||
const fromClient = useCallback((clientX: number, clientY: number) =>
|
||||
{
|
||||
const svg = svgRef.current;
|
||||
if(!svg) return null;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
if(rect.width === 0 || rect.height === 0) return null;
|
||||
// Map screen-space pointer onto the viewBox interior, then
|
||||
// shift by the viewBox origin — when zoomed in the viewBox
|
||||
// starts at (viewX, viewY) instead of (0, 0), so a pointer
|
||||
// at the left edge of the SVG corresponds to viewX in
|
||||
// local SVG units, not 0.
|
||||
const localX = viewX + ((clientX - rect.left) / rect.width) * width;
|
||||
const localY = viewY + ((clientY - rect.top) / rect.height) * height;
|
||||
const [ row, col ] = screenToTile(localX, localY);
|
||||
|
||||
if(typeof document !== 'undefined' && typeof document.elementFromPoint === 'function')
|
||||
{
|
||||
const hit = document.elementFromPoint(clientX, clientY) as SVGElement | null;
|
||||
if(hit)
|
||||
{
|
||||
const r = hit.getAttribute('data-row');
|
||||
const c = hit.getAttribute('data-col');
|
||||
if(r !== null && c !== null)
|
||||
{
|
||||
const row = parseInt(r, 10);
|
||||
const col = parseInt(c, 10);
|
||||
if(Number.isFinite(row) && Number.isFinite(col)) return { row, col };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ctm = svg.getScreenCTM();
|
||||
if(!ctm) return null;
|
||||
const pt = svg.createSVGPoint();
|
||||
pt.x = clientX;
|
||||
pt.y = clientY;
|
||||
const local = pt.matrixTransform(ctm.inverse());
|
||||
|
||||
const [ row, col ] = screenToTile(local.x, local.y);
|
||||
return { row: Math.round(row), col: Math.round(col) };
|
||||
}, [ svgRef, width, height, viewX, viewY ]);
|
||||
}, [ svgRef ]);
|
||||
|
||||
return useMemo(() => ({ fromClient }), [ fromClient ]);
|
||||
};
|
||||
|
||||
@@ -84,4 +84,37 @@ describe('useTool', () =>
|
||||
act(() => result.current.onPointerMove({ clientX: 1, clientY: 1, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never));
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('square-select drag dispatches SELECT_RECT (down + move) then APPLY_BRUSH_TO_SELECTION + SQUARE_SELECT_TOGGLE on release', () =>
|
||||
{
|
||||
const dispatch = vi.fn();
|
||||
let projTile: { row: number; col: number } = { row: 2, col: 3 };
|
||||
const projection = { fromClient: () => projTile };
|
||||
const state: FloorplanState = { ...withBrush('SET'), squareSelect: true };
|
||||
const { result } = renderHook(() => useTool(state, dispatch as React.Dispatch<FloorplanAction>, projection));
|
||||
|
||||
act(() => result.current.onPointerDown({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never));
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'SELECT_RECT', from: [ 2, 3 ], to: [ 2, 3 ] });
|
||||
|
||||
projTile = { row: 5, col: 7 };
|
||||
dispatch.mockClear();
|
||||
act(() => result.current.onPointerMove({ clientX: 10, clientY: 10, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never));
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'SELECT_RECT', from: [ 2, 3 ], to: [ 5, 7 ] });
|
||||
expect(dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'PAINT_TILE' }));
|
||||
|
||||
dispatch.mockClear();
|
||||
act(() => result.current.onPointerUp({ clientX: 10, clientY: 10, pointerId: 1, currentTarget: { releasePointerCapture: () => {} } } as never));
|
||||
expect(dispatch).toHaveBeenNthCalledWith(1, { type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
expect(dispatch).toHaveBeenNthCalledWith(2, { type: 'SQUARE_SELECT_TOGGLE' });
|
||||
});
|
||||
|
||||
it('square-select pointer up without a prior down is a no-op', () =>
|
||||
{
|
||||
const dispatch = vi.fn();
|
||||
const projection = { fromClient: () => ({ row: 0, col: 0 }) };
|
||||
const state: FloorplanState = { ...withBrush('SET'), squareSelect: true };
|
||||
const { result } = renderHook(() => useTool(state, dispatch as React.Dispatch<FloorplanAction>, projection));
|
||||
act(() => result.current.onPointerUp({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { releasePointerCapture: () => {} } } as never));
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ export const useTool = (
|
||||
{
|
||||
const isDownRef = useRef(false);
|
||||
const lastTileRef = useRef<string | null>(null);
|
||||
const squareStartRef = useRef<{ row: number; col: number } | null>(null);
|
||||
|
||||
const apply = useCallback((e: PointerEvent<SVGSVGElement>) =>
|
||||
{
|
||||
@@ -52,22 +53,49 @@ export const useTool = (
|
||||
isDownRef.current = true;
|
||||
lastTileRef.current = null;
|
||||
try { e.currentTarget.setPointerCapture?.(e.pointerId); } catch {}
|
||||
|
||||
if(state.squareSelect)
|
||||
{
|
||||
const hit = projection.fromClient(e.clientX, e.clientY);
|
||||
if(!hit) return;
|
||||
squareStartRef.current = hit;
|
||||
dispatch({ type: 'SELECT_RECT', from: [ hit.row, hit.col ], to: [ hit.row, hit.col ] });
|
||||
return;
|
||||
}
|
||||
|
||||
apply(e);
|
||||
}, [ apply ]);
|
||||
}, [ apply, state.squareSelect, projection, dispatch ]);
|
||||
|
||||
const onPointerMove = useCallback((e: PointerEvent<SVGSVGElement>) =>
|
||||
{
|
||||
if(!isDownRef.current) return;
|
||||
if(state.brush.action === 'DOOR') return; // door is a single-click placement
|
||||
|
||||
if(state.squareSelect && squareStartRef.current)
|
||||
{
|
||||
const hit = projection.fromClient(e.clientX, e.clientY);
|
||||
if(!hit) return;
|
||||
const start = squareStartRef.current;
|
||||
dispatch({ type: 'SELECT_RECT', from: [ start.row, start.col ], to: [ hit.row, hit.col ] });
|
||||
return;
|
||||
}
|
||||
|
||||
if(state.brush.action === 'DOOR') return;
|
||||
apply(e);
|
||||
}, [ apply, state.brush.action ]);
|
||||
}, [ apply, state.brush.action, state.squareSelect, projection, dispatch ]);
|
||||
|
||||
const onPointerUp = useCallback((e: PointerEvent<SVGSVGElement>) =>
|
||||
{
|
||||
isDownRef.current = false;
|
||||
lastTileRef.current = null;
|
||||
try { e.currentTarget.releasePointerCapture?.(e.pointerId); } catch {}
|
||||
}, []);
|
||||
|
||||
if(state.squareSelect && squareStartRef.current)
|
||||
{
|
||||
squareStartRef.current = null;
|
||||
dispatch({ type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
dispatch({ type: 'SQUARE_SELECT_TOGGLE' });
|
||||
}
|
||||
}, [ state.squareSelect, dispatch ]);
|
||||
|
||||
return { onPointerDown, onPointerMove, onPointerUp };
|
||||
};
|
||||
|
||||
@@ -11,5 +11,4 @@ export const MAX_WALL_HEIGHT = 16;
|
||||
export const HEIGHT_BRUSH_MIN = 0;
|
||||
export const HEIGHT_BRUSH_MAX = 26;
|
||||
|
||||
// Empty (uninitialized) door used as initial state until a server event arrives.
|
||||
export const EMPTY_DOOR = { x: 0, y: 0, dir: 2 as const };
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Tile } from './types';
|
||||
import { HEIGHT_SCHEME } from './constants';
|
||||
|
||||
// 'x0123456789abcdefghijklmnopq' (28 chars total)
|
||||
const VALID_CHARS = HEIGHT_SCHEME;
|
||||
const HMIN = 0;
|
||||
const HMAX = VALID_CHARS.length - 2; // 26
|
||||
const HMAX = VALID_CHARS.length - 2;
|
||||
|
||||
export const charToTile = (ch: string): Tile =>
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
|
||||
import { reducer, initialState } from './reducer';
|
||||
import { FloorplanState } from './types';
|
||||
import { defaultEmptyTilemap } from './selectors';
|
||||
import { MAX_NUM_TILE_PER_AXIS } from './constants';
|
||||
|
||||
const stateWith = (tiles: FloorplanState['tiles']): FloorplanState => ({
|
||||
...initialState,
|
||||
@@ -180,17 +181,32 @@ describe('reducer — BRUSH_SET', () =>
|
||||
|
||||
describe('reducer — selection', () =>
|
||||
{
|
||||
it('SELECT_ALL marks every non-blocked tile', () =>
|
||||
it('SELECT_ALL marks every cell in the full editor grid (MAX × MAX)', () =>
|
||||
{
|
||||
const start = stateWith([
|
||||
[{ h: 0, blocked: false }, { h: 0, blocked: true }],
|
||||
[{ h: 0, blocked: false }, { h: 0, blocked: false }]
|
||||
]);
|
||||
const next = reducer(start, { type: 'SELECT_ALL' });
|
||||
expect(next.selection.size).toBe(3);
|
||||
expect(next.selection.size).toBe(MAX_NUM_TILE_PER_AXIS * MAX_NUM_TILE_PER_AXIS);
|
||||
expect(next.selection.has('0,0')).toBe(true);
|
||||
expect(next.selection.has('0,1')).toBe(false);
|
||||
expect(next.selection.has('1,1')).toBe(true);
|
||||
expect(next.selection.has('0,1')).toBe(true);
|
||||
expect(next.selection.has(`${MAX_NUM_TILE_PER_AXIS - 1},${MAX_NUM_TILE_PER_AXIS - 1}`)).toBe(true);
|
||||
});
|
||||
|
||||
it('SELECT_ALL + APPLY_BRUSH_TO_SELECTION SET fills empty cells inside the room shape', () =>
|
||||
{
|
||||
const start = stateWith([
|
||||
[{ h: 0, blocked: false }, { h: 0, blocked: true }],
|
||||
[{ h: 0, blocked: true }, { h: 0, blocked: false }]
|
||||
]);
|
||||
const armed = reducer(start, { type: 'BRUSH_SET', action: 'SET', h: 2 });
|
||||
const selected = reducer(armed, { type: 'SELECT_ALL' });
|
||||
const next = reducer(selected, { type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
expect(next.tiles[0][0]).toEqual({ h: 2, blocked: false });
|
||||
expect(next.tiles[0][1]).toEqual({ h: 2, blocked: false });
|
||||
expect(next.tiles[1][0]).toEqual({ h: 2, blocked: false });
|
||||
expect(next.tiles[1][1]).toEqual({ h: 2, blocked: false });
|
||||
});
|
||||
|
||||
it('CLEAR_SELECTION empties it', () =>
|
||||
@@ -203,7 +219,6 @@ describe('reducer — selection', () =>
|
||||
it('SELECT_RECT marks the rectangle inclusive', () =>
|
||||
{
|
||||
const start = stateWith(defaultEmptyTilemap(4, 4));
|
||||
// First populate non-blocked tiles so SELECT_RECT picks them up
|
||||
const populated = {
|
||||
...start,
|
||||
tiles: start.tiles.map(row => row.map(() => ({ h: 0, blocked: false })))
|
||||
@@ -213,6 +228,27 @@ describe('reducer — selection', () =>
|
||||
expect(keys).toEqual([ '1,1', '1,2', '1,3', '2,1', '2,2', '2,3' ].sort());
|
||||
});
|
||||
|
||||
it('SELECT_RECT includes blocked / empty cells so the SET brush can paint into them', () =>
|
||||
{
|
||||
const start = stateWith([
|
||||
[{ h: 0, blocked: true }, { h: 0, blocked: true }],
|
||||
[{ h: 0, blocked: true }, { h: 0, blocked: false }]
|
||||
]);
|
||||
const next = reducer(start, { type: 'SELECT_RECT', from: [ 0, 0 ], to: [ 1, 1 ] });
|
||||
const keys = Array.from(next.selection).sort();
|
||||
expect(keys).toEqual([ '0,0', '0,1', '1,0', '1,1' ].sort());
|
||||
});
|
||||
|
||||
it('SELECT_RECT clamps to grid bounds when the drag goes negative or past MAX', () =>
|
||||
{
|
||||
const start = stateWith([[{ h: 0, blocked: false }]]);
|
||||
const next = reducer(start, { type: 'SELECT_RECT', from: [ -5, -5 ], to: [ 999, 999 ] });
|
||||
expect(next.selection.has('0,0')).toBe(true);
|
||||
expect(next.selection.has('63,63')).toBe(true);
|
||||
expect(next.selection.has('64,0')).toBe(false);
|
||||
expect(next.selection.has('-1,0')).toBe(false);
|
||||
});
|
||||
|
||||
it('SQUARE_SELECT_TOGGLE flips the flag', () =>
|
||||
{
|
||||
const a = reducer(initialState, { type: 'SQUARE_SELECT_TOGGLE' });
|
||||
@@ -220,6 +256,107 @@ describe('reducer — selection', () =>
|
||||
const b = reducer(a, { type: 'SQUARE_SELECT_TOGGLE' });
|
||||
expect(b.squareSelect).toBe(false);
|
||||
});
|
||||
|
||||
it('APPLY_BRUSH_TO_SELECTION with SET fills selected tiles at brush height (including blocked ones) and clears selection', () =>
|
||||
{
|
||||
const populated = stateWith([
|
||||
[{ h: 0, blocked: false }, { h: 0, blocked: false }],
|
||||
[{ h: 0, blocked: false }, { h: 0, blocked: true }]
|
||||
]);
|
||||
const withSel = reducer(populated, { type: 'SELECT_ALL' });
|
||||
const armed = reducer(withSel, { type: 'BRUSH_SET', action: 'SET', h: 4 });
|
||||
const next = reducer(armed, { type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
expect(next.tiles[0][0]).toEqual({ h: 4, blocked: false });
|
||||
expect(next.tiles[0][1]).toEqual({ h: 4, blocked: false });
|
||||
expect(next.tiles[1][0]).toEqual({ h: 4, blocked: false });
|
||||
expect(next.tiles[1][1]).toEqual({ h: 4, blocked: false });
|
||||
expect(next.selection.size).toBe(0);
|
||||
});
|
||||
|
||||
it('APPLY_BRUSH_TO_SELECTION with UNSET erases selected tiles', () =>
|
||||
{
|
||||
const populated = stateWith([
|
||||
[{ h: 3, blocked: false }, { h: 3, blocked: false }]
|
||||
]);
|
||||
const withSel = reducer(populated, { type: 'SELECT_ALL' });
|
||||
const armed = reducer(withSel, { type: 'BRUSH_SET', action: 'UNSET' });
|
||||
const next = reducer(armed, { type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
expect(next.tiles[0][0].blocked).toBe(true);
|
||||
expect(next.tiles[0][1].blocked).toBe(true);
|
||||
expect(next.selection.size).toBe(0);
|
||||
});
|
||||
|
||||
it('APPLY_BRUSH_TO_SELECTION with UP/DOWN adjusts heights', () =>
|
||||
{
|
||||
const populated = stateWith([[{ h: 2, blocked: false }, { h: 0, blocked: false }]]);
|
||||
const withSel = reducer(populated, { type: 'SELECT_ALL' });
|
||||
const armedUp = reducer(withSel, { type: 'BRUSH_SET', action: 'UP' });
|
||||
const up = reducer(armedUp, { type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
expect(up.tiles[0][0].h).toBe(3);
|
||||
expect(up.tiles[0][1].h).toBe(1);
|
||||
|
||||
const withSel2 = reducer(up, { type: 'SELECT_ALL' });
|
||||
const armedDown = reducer(withSel2, { type: 'BRUSH_SET', action: 'DOWN' });
|
||||
const down = reducer(armedDown, { type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
expect(down.tiles[0][0].h).toBe(2);
|
||||
expect(down.tiles[0][1].h).toBe(0);
|
||||
});
|
||||
|
||||
it('APPLY_BRUSH_TO_SELECTION SET paints into blocked / empty cells (build-from-scratch UX)', () =>
|
||||
{
|
||||
const start = stateWith([
|
||||
[{ h: 0, blocked: true }, { h: 0, blocked: true }],
|
||||
[{ h: 0, blocked: true }, { h: 0, blocked: true }]
|
||||
]);
|
||||
const armed = reducer(start, { type: 'BRUSH_SET', action: 'SET', h: 2 });
|
||||
const selected = reducer(armed, { type: 'SELECT_RECT', from: [ 0, 0 ], to: [ 1, 1 ] });
|
||||
const next = reducer(selected, { type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
expect(next.tiles[0][0]).toEqual({ h: 2, blocked: false });
|
||||
expect(next.tiles[0][1]).toEqual({ h: 2, blocked: false });
|
||||
expect(next.tiles[1][0]).toEqual({ h: 2, blocked: false });
|
||||
expect(next.tiles[1][1]).toEqual({ h: 2, blocked: false });
|
||||
expect(next.selection.size).toBe(0);
|
||||
});
|
||||
|
||||
it('APPLY_BRUSH_TO_SELECTION SET grows the tiles array when the rect extends beyond current bounds', () =>
|
||||
{
|
||||
const start = stateWith([[{ h: 0, blocked: false }]]); // 1x1 room
|
||||
const armed = reducer(start, { type: 'BRUSH_SET', action: 'SET', h: 1 });
|
||||
const selected = reducer(armed, { type: 'SELECT_RECT', from: [ 0, 0 ], to: [ 2, 2 ] });
|
||||
const next = reducer(selected, { type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
expect(next.tiles.length).toBeGreaterThanOrEqual(3);
|
||||
expect(next.tiles[0].length).toBeGreaterThanOrEqual(3);
|
||||
expect(next.tiles[2][2]).toEqual({ h: 1, blocked: false });
|
||||
});
|
||||
|
||||
it('APPLY_BRUSH_TO_SELECTION UNSET still skips blocked cells (only erases painted ones)', () =>
|
||||
{
|
||||
const start = stateWith([
|
||||
[{ h: 3, blocked: false }, { h: 0, blocked: true }]
|
||||
]);
|
||||
const armed = reducer(start, { type: 'BRUSH_SET', action: 'UNSET' });
|
||||
const selected = reducer(armed, { type: 'SELECT_RECT', from: [ 0, 0 ], to: [ 0, 1 ] });
|
||||
const next = reducer(selected, { type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
expect(next.tiles[0][0].blocked).toBe(true);
|
||||
expect(next.tiles[0][1]).toEqual({ h: 0, blocked: true });
|
||||
});
|
||||
|
||||
it('APPLY_BRUSH_TO_SELECTION on empty selection is a no-op', () =>
|
||||
{
|
||||
const start = stateWith([[{ h: 5, blocked: false }]]);
|
||||
const next = reducer(start, { type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
expect(next).toBe(start);
|
||||
});
|
||||
|
||||
it('APPLY_BRUSH_TO_SELECTION with DOOR clears selection without touching tiles', () =>
|
||||
{
|
||||
const populated = stateWith([[{ h: 1, blocked: false }, { h: 2, blocked: false }]]);
|
||||
const withSel = reducer(populated, { type: 'SELECT_ALL' });
|
||||
const armed = reducer(withSel, { type: 'BRUSH_SET', action: 'DOOR' });
|
||||
const next = reducer(armed, { type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
expect(next.tiles).toEqual(armed.tiles);
|
||||
expect(next.selection.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reducer — IMPORT_STRING', () =>
|
||||
|
||||
@@ -116,11 +116,11 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl
|
||||
case 'SELECT_ALL':
|
||||
{
|
||||
const sel = new Set<`${number},${number}`>();
|
||||
for(let r = 0; r < state.tiles.length; r++)
|
||||
for(let r = 0; r < MAX_NUM_TILE_PER_AXIS; r++)
|
||||
{
|
||||
for(let c = 0; c < (state.tiles[r]?.length ?? 0); c++)
|
||||
for(let c = 0; c < MAX_NUM_TILE_PER_AXIS; c++)
|
||||
{
|
||||
if(!state.tiles[r][c].blocked) sel.add(`${r},${c}`);
|
||||
sel.add(`${r},${c}`);
|
||||
}
|
||||
}
|
||||
return { ...state, selection: sel };
|
||||
@@ -131,18 +131,72 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl
|
||||
{
|
||||
const [ r0, c0 ] = action.from;
|
||||
const [ r1, c1 ] = action.to;
|
||||
const rMin = Math.min(r0, r1), rMax = Math.max(r0, r1);
|
||||
const cMin = Math.min(c0, c1), cMax = Math.max(c0, c1);
|
||||
const rMin = Math.max(0, Math.min(r0, r1));
|
||||
const rMax = Math.min(MAX_NUM_TILE_PER_AXIS - 1, Math.max(r0, r1));
|
||||
const cMin = Math.max(0, Math.min(c0, c1));
|
||||
const cMax = Math.min(MAX_NUM_TILE_PER_AXIS - 1, Math.max(c0, c1));
|
||||
const sel = new Set<`${number},${number}`>();
|
||||
for(let r = rMin; r <= rMax; r++)
|
||||
{
|
||||
for(let c = cMin; c <= cMax; c++)
|
||||
{
|
||||
if(state.tiles[r]?.[c] && !state.tiles[r][c].blocked) sel.add(`${r},${c}`);
|
||||
sel.add(`${r},${c}`);
|
||||
}
|
||||
}
|
||||
return { ...state, selection: sel };
|
||||
}
|
||||
case 'APPLY_BRUSH_TO_SELECTION':
|
||||
{
|
||||
if(state.selection.size === 0) return state;
|
||||
if(state.brush.action === 'DOOR') return { ...state, selection: new Set() };
|
||||
|
||||
let maxR = -1;
|
||||
let maxC = -1;
|
||||
for(const key of state.selection)
|
||||
{
|
||||
const [ rStr, cStr ] = key.split(',');
|
||||
const r = parseInt(rStr, 10);
|
||||
const c = parseInt(cStr, 10);
|
||||
if(r > maxR) maxR = r;
|
||||
if(c > maxC) maxC = c;
|
||||
}
|
||||
|
||||
let tiles = state.tiles;
|
||||
if(state.brush.action === 'SET' && maxR >= 0 && maxC >= 0)
|
||||
{
|
||||
tiles = ensureRect(tiles, maxR + 1, maxC + 1);
|
||||
}
|
||||
|
||||
for(const key of state.selection)
|
||||
{
|
||||
const [ rStr, cStr ] = key.split(',');
|
||||
const row = parseInt(rStr, 10);
|
||||
const col = parseInt(cStr, 10);
|
||||
const current = tiles[row]?.[col];
|
||||
if(!current) continue;
|
||||
|
||||
switch(state.brush.action)
|
||||
{
|
||||
case 'SET':
|
||||
tiles = setTile(tiles, row, col, { h: clampHeight(state.brush.h), blocked: false });
|
||||
break;
|
||||
case 'UNSET':
|
||||
if(current.blocked) continue;
|
||||
tiles = setTile(tiles, row, col, { h: current.h, blocked: true });
|
||||
break;
|
||||
case 'UP':
|
||||
if(current.blocked) continue;
|
||||
tiles = setTile(tiles, row, col, { h: clampHeight(current.h + 1), blocked: false });
|
||||
break;
|
||||
case 'DOWN':
|
||||
if(current.blocked) continue;
|
||||
tiles = setTile(tiles, row, col, { h: clampHeight(current.h - 1), blocked: false });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...state, tiles, selection: new Set() };
|
||||
}
|
||||
case 'SQUARE_SELECT_TOGGLE':
|
||||
return { ...state, squareSelect: !state.squareSelect };
|
||||
case 'IMPORT_STRING':
|
||||
|
||||
@@ -34,9 +34,6 @@ describe('areaCount', () =>
|
||||
|
||||
it('treats blocked tiles as non-tiles (per existing UI semantics)', () =>
|
||||
{
|
||||
// In the original implementation, height === 'x' was the marker for "not a tile".
|
||||
// total counts placed tiles only (i.e. !blocked), walkable equals total since blocked are excluded.
|
||||
// This matches the legacy calculateArea() behaviour where blocked tiles were skipped entirely.
|
||||
const grid = [
|
||||
[{ h: 0, blocked: true }, { h: 0, blocked: true }, { h: 0, blocked: true }],
|
||||
[{ h: 0, blocked: false }, { h: 1, blocked: false }, { h: 0, blocked: true }]
|
||||
|
||||
@@ -43,6 +43,7 @@ export type FloorplanAction =
|
||||
| { type: 'SELECT_RECT'; from: [number, number]; to: [number, number] }
|
||||
| { type: 'SELECT_ALL' }
|
||||
| { type: 'CLEAR_SELECTION' }
|
||||
| { type: 'APPLY_BRUSH_TO_SELECTION'; source: LocalSource }
|
||||
| { type: 'SQUARE_SELECT_TOGGLE' }
|
||||
| { type: 'IMPORT_STRING'; raw: string; door?: Door; thickness?: { wall: ThicknessLevel; floor: ThicknessLevel }; wallHeight?: number; source: LocalSource }
|
||||
| { type: 'APPLY_REMOTE_DIFF'; diff: { tiles?: Array<{ row: number; col: number; h: number; blocked: boolean }>; door?: Door; thickness?: { wall: ThicknessLevel; floor: ThicknessLevel }; wallHeight?: number }; seq: number; editorUserId: number }
|
||||
|
||||
@@ -15,7 +15,6 @@ describe('FloorplanCanvasSVG', () =>
|
||||
]
|
||||
};
|
||||
const { container } = render(<FloorplanCanvasSVG state={ state } dispatch={ () => {} } />);
|
||||
// 3 non-blocked tiles → 3 base polygons (plus possibly selection/door extras)
|
||||
const polys = container.querySelectorAll('polygon');
|
||||
expect(polys.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
@@ -41,7 +40,6 @@ describe('FloorplanCanvasSVG', () =>
|
||||
const dispatch = vi.fn();
|
||||
const { container } = render(<FloorplanCanvasSVG state={ state } dispatch={ dispatch } />);
|
||||
const svg = container.querySelector('svg') as SVGSVGElement;
|
||||
// jsdom getBoundingClientRect returns zeros; we need to stub it so projection works.
|
||||
svg.getBoundingClientRect = () => ({ left: 0, top: 0, right: 2048, bottom: 1024, width: 2048, height: 1024, x: 0, y: 0, toJSON: () => ({}) });
|
||||
fireEvent.pointerDown(svg, { clientX: 1024, clientY: 0, pointerId: 1 });
|
||||
expect(dispatch).toHaveBeenCalled();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Dispatch, FC, PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState, WheelEvent } from 'react';
|
||||
import { FaCrosshairs, FaSearchMinus, FaSearchPlus } from 'react-icons/fa';
|
||||
import { FaCrosshairs, FaSearchMinus, FaSearchPlus, FaSyncAlt } from 'react-icons/fa';
|
||||
import { FloorplanAction, FloorplanState } from '../state/types';
|
||||
import { FloorplanTile } from './FloorplanTile';
|
||||
import { tileToScreen, usePointerToTile } from '../hooks/usePointerToTile';
|
||||
@@ -9,12 +9,6 @@ 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;
|
||||
};
|
||||
|
||||
@@ -24,21 +18,10 @@ const VIEWBOX_H = (MAX_NUM_TILE_PER_AXIS * TILE_SIZE) / 2;
|
||||
const ZOOM_MIN = 0.4;
|
||||
const ZOOM_MAX = 6;
|
||||
const ZOOM_STEP = 0.2;
|
||||
// Slack around the room bounding box when auto-fitting, so the tiles
|
||||
// don't sit flush against the canvas edge.
|
||||
const FIT_PADDING = TILE_SIZE * 2;
|
||||
|
||||
const clampZoom = (z: number): number => Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z));
|
||||
|
||||
/**
|
||||
* Compute the screen-space bounding box of the painted (= non-
|
||||
* blocked) tiles. Returns `null` if the room is fully blocked /
|
||||
* empty — caller can fall back to the centered default view.
|
||||
*
|
||||
* tileToScreen returns the TOP corner of the iso diamond; we
|
||||
* inflate by half a tile in every direction so the diamond's
|
||||
* extremities (left/right/bottom points) are included.
|
||||
*/
|
||||
const computeRoomBounds = (state: FloorplanState): { x: number; y: number; w: number; h: number } | null =>
|
||||
{
|
||||
let minX = Infinity;
|
||||
@@ -89,19 +72,12 @@ export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch, panMode }) =>
|
||||
const [ zoom, setZoom ] = useState(1);
|
||||
const [ pan, setPan ] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const [ isPanning, setIsPanning ] = useState(false);
|
||||
const [ flipped, setFlipped ] = useState(false);
|
||||
const panStartRef = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null);
|
||||
// First-paint flag: once we've seen a non-empty room we
|
||||
// auto-fit (zoom in/out until the room fills the canvas with
|
||||
// a small margin) exactly once. Manual zoom/pan afterwards is
|
||||
// preserved.
|
||||
const centeredRef = useRef(false);
|
||||
|
||||
const roomBounds = useMemo(() => computeRoomBounds(state), [ state.tiles ]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Pan a given zoom level so the room centre sits in the
|
||||
// viewport centre. With zoom kept, the formula reduces to
|
||||
// `roomCenter - VIEWBOX_center` because the (VIEWBOX - visible)
|
||||
// / 2 base offset terms cancel.
|
||||
const centerPanForRoom = useCallback((): { x: number; y: number } | null =>
|
||||
{
|
||||
if(!roomBounds) return null;
|
||||
@@ -111,10 +87,6 @@ export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch, panMode }) =>
|
||||
};
|
||||
}, [ roomBounds ]);
|
||||
|
||||
// Fit-to-room: zooms IN/OUT until the whole room is visible
|
||||
// (with a 5 % margin), then centres the pan. This is the
|
||||
// default view — running on first paint and on every click of
|
||||
// the %% / 'reset' label.
|
||||
const fitToRoom = useCallback(() =>
|
||||
{
|
||||
if(!roomBounds) return;
|
||||
@@ -127,12 +99,6 @@ export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch, panMode }) =>
|
||||
setPan(next);
|
||||
}, [ roomBounds, centerPanForRoom ]);
|
||||
|
||||
// Auto-fit the FIRST time we see a non-empty room (typically
|
||||
// right after the server-driven load). The literal 100 % zoom
|
||||
// leaves too much empty space around small rooms, so the
|
||||
// 'default view' is fit-to-room (~95 % of the smaller axis
|
||||
// so tiles don't sit flush against the edge). The user's
|
||||
// subsequent manual zoom / pan adjustments are preserved.
|
||||
useEffect(() =>
|
||||
{
|
||||
if(centeredRef.current) return;
|
||||
@@ -158,17 +124,47 @@ export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch, panMode }) =>
|
||||
{
|
||||
const isDoor = state.door.x === c && state.door.y === r && !tile.blocked;
|
||||
const selected = state.selection.has(`${ r },${ c }`);
|
||||
return <FloorplanTile key={ `${ r }-${ c }` } row={ r } col={ c } tile={ tile } selected={ selected } isDoor={ isDoor } />;
|
||||
const south = state.tiles[r]?.[c + 1];
|
||||
const west = state.tiles[r + 1]?.[c];
|
||||
const southH = (south && !south.blocked) ? south.h : 0;
|
||||
const westH = (west && !west.blocked) ? west.h : 0;
|
||||
return <FloorplanTile key={ `${ r }-${ c }` } row={ r } col={ c } tile={ tile } selected={ selected } isDoor={ isDoor } southH={ southH } westH={ westH } />;
|
||||
});
|
||||
return <g key={ `row-${ r }` }>{ cells }</g>;
|
||||
}), [ state.tiles, state.door.x, state.door.y, state.selection ]);
|
||||
|
||||
const outOfBoundsOverlay = useMemo(() =>
|
||||
{
|
||||
if(state.selection.size === 0) return null;
|
||||
const half = TILE_SIZE / 2;
|
||||
const quarter = TILE_SIZE / 4;
|
||||
const tilesRows = state.tiles.length;
|
||||
const tilesCols = state.tiles[0]?.length ?? 0;
|
||||
const out: JSX.Element[] = [];
|
||||
for(const key of state.selection)
|
||||
{
|
||||
const [ rStr, cStr ] = key.split(',');
|
||||
const row = parseInt(rStr, 10);
|
||||
const col = parseInt(cStr, 10);
|
||||
if(row < tilesRows && col < tilesCols) continue;
|
||||
const [ cx, cy ] = tileToScreen(row, col);
|
||||
const points = `${ cx },${ cy - quarter } ${ cx + half },${ cy } ${ cx },${ cy + quarter } ${ cx - half },${ cy }`;
|
||||
out.push(
|
||||
<polygon
|
||||
key={ `oob-${ key }` }
|
||||
points={ points }
|
||||
fill="rgba(250, 204, 21, 0.45)"
|
||||
stroke="#facc15"
|
||||
strokeWidth={ 1.5 }
|
||||
strokeDasharray="3 2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return out.length ? <g data-testid="selection-overlay">{ out }</g> : null;
|
||||
}, [ state.selection, state.tiles ]);
|
||||
|
||||
const zoomIn = useCallback(() => setZoom(z => clampZoom(z + ZOOM_STEP)), []);
|
||||
const zoomOut = useCallback(() => setZoom(z => clampZoom(z - ZOOM_STEP)), []);
|
||||
// The %% label button restores the default view: fit-to-room
|
||||
// (the same auto-fit that runs on first paint). Clicking it
|
||||
// after manual zoom always gets you back to "room fills the
|
||||
// canvas, room centred".
|
||||
const resetView = useCallback(() =>
|
||||
{
|
||||
fitToRoom();
|
||||
@@ -211,11 +207,6 @@ export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch, panMode }) =>
|
||||
};
|
||||
}, [ visW ]);
|
||||
|
||||
// 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)
|
||||
@@ -228,7 +219,8 @@ export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch, panMode }) =>
|
||||
<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] ${ cursorClass }` }
|
||||
style={ flipped ? { transform: 'scaleX(-1)' } : undefined }
|
||||
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] transition-transform ${ cursorClass }` }
|
||||
onWheel={ onWheel }
|
||||
onPointerDown={ e =>
|
||||
{
|
||||
@@ -253,12 +245,13 @@ export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch, panMode }) =>
|
||||
} }
|
||||
>
|
||||
{ rows }
|
||||
{ outOfBoundsOverlay }
|
||||
</svg>
|
||||
<div className="absolute bottom-2 left-2 flex items-center gap-1 rounded-md bg-white/95 border border-zinc-300 shadow-sm px-1 py-1 text-zinc-700">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="zoom-out"
|
||||
title="Riduci (Ctrl+rotellina)"
|
||||
title="Zoom out (Ctrl+wheel)"
|
||||
className="w-7 h-7 flex items-center justify-center rounded hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
disabled={ zoom <= ZOOM_MIN + 1e-3 }
|
||||
onClick={ zoomOut }
|
||||
@@ -268,7 +261,7 @@ export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch, panMode }) =>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="zoom-reset"
|
||||
title="Inquadra la stanza"
|
||||
title="Fit room to view"
|
||||
className="px-2 h-7 min-w-[3rem] flex items-center justify-center rounded hover:bg-zinc-100 text-xs font-bold tabular-nums"
|
||||
onClick={ resetView }
|
||||
>
|
||||
@@ -277,7 +270,7 @@ export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch, panMode }) =>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="zoom-in"
|
||||
title="Ingrandisci (Ctrl+rotellina)"
|
||||
title="Zoom in (Ctrl+wheel)"
|
||||
className="w-7 h-7 flex items-center justify-center rounded hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
disabled={ zoom >= ZOOM_MAX - 1e-3 }
|
||||
onClick={ zoomIn }
|
||||
@@ -288,7 +281,7 @@ export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch, panMode }) =>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="zoom-recenter"
|
||||
title="Centra sulla stanza (mantiene lo zoom)"
|
||||
title="Recenter on room (keep zoom)"
|
||||
className="w-7 h-7 flex items-center justify-center rounded hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
disabled={ !roomBounds }
|
||||
onClick={ () =>
|
||||
@@ -299,6 +292,16 @@ export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch, panMode }) =>
|
||||
>
|
||||
<FaCrosshairs size={ 12 } />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="zoom-flip"
|
||||
data-active={ flipped ? 'true' : 'false' }
|
||||
title={ flipped ? 'Original view' : 'View from the other side' }
|
||||
className={ `w-7 h-7 flex items-center justify-center rounded transition-colors ${ flipped ? 'bg-amber-400 text-zinc-900 hover:bg-amber-500' : 'hover:bg-zinc-100' }` }
|
||||
onClick={ () => setFlipped(v => !v) }
|
||||
>
|
||||
<FaSyncAlt size={ 12 } />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,9 +4,6 @@ import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { FloorplanHeightPicker } from './FloorplanHeightPicker';
|
||||
|
||||
// Force a fixed track size into getBoundingClientRect so the
|
||||
// pointer-y -> height math is reproducible regardless of jsdom's
|
||||
// layout (which would otherwise hand back zeroes).
|
||||
const TRACK_HEIGHT = 260;
|
||||
|
||||
const stubTrackGeometry = (top = 0) =>
|
||||
@@ -99,8 +96,6 @@ describe('FloorplanHeightPicker', () =>
|
||||
|
||||
fireEvent.pointerDown(track, { clientY: TRACK_HEIGHT / 2, button: 0 });
|
||||
|
||||
// (1 - 0.5) * 26 = 13. The exact value depends on Math.round,
|
||||
// which here lands on 13 for a half-track click.
|
||||
expect(onSelect).toHaveBeenCalledWith(13);
|
||||
|
||||
restore();
|
||||
@@ -124,9 +119,6 @@ describe('FloorplanHeightPicker', () =>
|
||||
|
||||
it('thumb fill matches the tile colour at the picked height', () =>
|
||||
{
|
||||
// h=0 is solid blue (#0065ff in COLORMAP). Re-render at a
|
||||
// different height and assert the recorded thumb colour
|
||||
// changes — i.e., the thumb tracks the band underneath.
|
||||
const { rerender } = render(<FloorplanHeightPicker selectedH={ 0 } onSelect={ () => undefined } />);
|
||||
|
||||
const colourAtZero = screen.getByTestId('height-thumb').getAttribute('data-thumb-color');
|
||||
|
||||
@@ -12,12 +12,6 @@ const TRACK_H = 260;
|
||||
const THUMB_DIAM = 28;
|
||||
const RAIL_GUTTER = 4;
|
||||
|
||||
/**
|
||||
* Perceptual-luminance heuristic. Returns true if a hex colour is
|
||||
* 'light enough' that black text reads better than white. Uses the
|
||||
* Rec. 601 luma coefficients — good enough for a UI affordance,
|
||||
* cheap to compute, no dep on a colour lib.
|
||||
*/
|
||||
const isLightColor = (hex: string): boolean =>
|
||||
{
|
||||
const c = hex.replace('#', '');
|
||||
@@ -32,26 +26,6 @@ const isLightColor = (hex: string): boolean =>
|
||||
return luma > 160;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -135,7 +109,7 @@ export const FloorplanHeightPicker: FC<Props> = ({ selectedH, onSelect }) =>
|
||||
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-label="Brush height"
|
||||
aria-valuemin={ HEIGHT_BRUSH_MIN }
|
||||
aria-valuemax={ HEIGHT_BRUSH_MAX }
|
||||
aria-valuenow={ selectedH }
|
||||
@@ -164,13 +138,6 @@ export const FloorplanHeightPicker: FC<Props> = ({ selectedH, onSelect }) =>
|
||||
width: THUMB_DIAM,
|
||||
height: THUMB_DIAM,
|
||||
top: `${ thumbPct }%`,
|
||||
// Thumb fill picks up the colour of the band
|
||||
// under it — visual continuity with the
|
||||
// gradient so users see the colour of the
|
||||
// height they're picking, not a generic
|
||||
// amber chip. Radial highlight + bottom
|
||||
// shadow give it a beaded look without
|
||||
// hiding the underlying colour.
|
||||
background: `radial-gradient(circle at 32% 28%, ${ thumbTextDark ? 'rgba(255, 255, 255, 0.85)' : 'rgba(255, 255, 255, 0.55)' } 0%, ${ thumbColor } 45%, ${ thumbColor } 78%, rgba(0, 0, 0, 0.25) 100%)`,
|
||||
border: '2px solid rgba(0, 0, 0, 0.55)',
|
||||
boxShadow: '0 2px 5px rgba(0, 0, 0, 0.35), inset 0 -2px 3px rgba(0, 0, 0, 0.25), inset 0 1px 2px rgba(255, 255, 255, 0.4)',
|
||||
|
||||
@@ -18,7 +18,6 @@ describe('FloorplanImportExport', () =>
|
||||
render(<FloorplanImportExport state={ state } dispatch={ () => {} } onClose={ () => {} } onSaveFromText={ () => {} } onRevertText={ () => '' } />);
|
||||
const ta = document.querySelector('textarea') as HTMLTextAreaElement;
|
||||
expect(ta).toBeTruthy();
|
||||
// Textarea normalizes \r to \n
|
||||
expect(ta.value).toBe('01\nx2');
|
||||
});
|
||||
|
||||
@@ -28,7 +27,6 @@ describe('FloorplanImportExport', () =>
|
||||
render(<FloorplanImportExport state={ initialState } dispatch={ dispatch } onClose={ () => {} } onSaveFromText={ () => {} } onRevertText={ () => '' } />);
|
||||
const ta = document.querySelector('textarea') as HTMLTextAreaElement;
|
||||
expect(ta).toBeTruthy();
|
||||
// Textarea normalizes \r to \n
|
||||
fireEvent.change(ta, { target: { value: 'xq\n00' } });
|
||||
const button = document.querySelector('[data-testid="import-load"]') as HTMLButtonElement;
|
||||
expect(button).toBeTruthy();
|
||||
|
||||
@@ -43,8 +43,8 @@ export const FloorplanImportExport: FC<Props> = ({ state, dispatch, onClose, onS
|
||||
onChange={ e => setRaw(e.target.value) }
|
||||
/>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button data-testid="import-revert" onClick={ revert }>Annulla</Button>
|
||||
<Button data-testid="import-load" onClick={ load }>Carica</Button>
|
||||
<Button data-testid="import-revert" onClick={ revert }>Revert</Button>
|
||||
<Button data-testid="import-load" onClick={ load }>Load</Button>
|
||||
<Button data-testid="import-save" onClick={ save }>{ LocalizeText('floor.plan.editor.save') }</Button>
|
||||
</div>
|
||||
</NitroCardContentView>
|
||||
|
||||
@@ -29,7 +29,7 @@ export const FloorplanOptionsPanel: FC<Props> = ({ state, dispatch }) =>
|
||||
<Base
|
||||
data-testid="entry-dir-prev"
|
||||
pointer
|
||||
title="Ruota a sinistra"
|
||||
title="Rotate left"
|
||||
className="w-7 h-9 flex items-center justify-center text-zinc-600 hover:bg-zinc-100"
|
||||
onClick={ () => setDir(rotateDir(state.door.dir, -1)) }
|
||||
>
|
||||
@@ -38,14 +38,14 @@ export const FloorplanOptionsPanel: FC<Props> = ({ state, dispatch }) =>
|
||||
<Base
|
||||
data-testid="entry-dir"
|
||||
pointer
|
||||
title={ `Direzione ${ state.door.dir }/7 (click per ruotare)` }
|
||||
title={ `Direction ${ state.door.dir }/7 (click to rotate)` }
|
||||
className={ `nitro-icon icon-door-direction-${ state.door.dir } mx-1` }
|
||||
onClick={ () => setDir(rotateDir(state.door.dir, 1)) }
|
||||
/>
|
||||
<Base
|
||||
data-testid="entry-dir-next"
|
||||
pointer
|
||||
title="Ruota a destra"
|
||||
title="Rotate right"
|
||||
className="w-7 h-9 flex items-center justify-center text-zinc-600 hover:bg-zinc-100"
|
||||
onClick={ () => setDir(rotateDir(state.door.dir, 1)) }
|
||||
>
|
||||
@@ -55,7 +55,7 @@ export const FloorplanOptionsPanel: FC<Props> = ({ state, dispatch }) =>
|
||||
</Flex>
|
||||
|
||||
<ThicknessSegmented
|
||||
label="Pareti"
|
||||
label="Walls"
|
||||
value={ state.thickness.wall }
|
||||
onChange={ setWall }
|
||||
testIdPrefix="wall-thickness"
|
||||
@@ -63,7 +63,7 @@ export const FloorplanOptionsPanel: FC<Props> = ({ state, dispatch }) =>
|
||||
/>
|
||||
|
||||
<ThicknessSegmented
|
||||
label="Pavimenti"
|
||||
label="Floors"
|
||||
value={ state.thickness.floor }
|
||||
onChange={ setFloor }
|
||||
testIdPrefix="floor-thickness"
|
||||
|
||||
@@ -6,30 +6,9 @@ import { FloorplanState } from '../state/types';
|
||||
|
||||
type Props = {
|
||||
state: FloorplanState;
|
||||
/** Outer container height; the previewer fills the parent width. */
|
||||
height?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Textured isometric room preview driven by the renderer's
|
||||
* RoomPreviewer (the same engine the catalog uses for furniture
|
||||
* thumbnails). Whenever the editor's tilemap / wallHeight changes,
|
||||
* `RoomPreviewer.updatePreviewModel` re-renders the floor with
|
||||
* actual sand/plaster textures — far closer to what the room will
|
||||
* look like in-game than the previous SVG-on-black preview.
|
||||
*
|
||||
* IMPORTANT — construction lives INSIDE the lifecycle effect, not
|
||||
* in a lazy `useState` initializer. RoomPreviewer.dispose() nulls
|
||||
* out internal fields (`_planeParser`, `_backgroundSprite`, …), so
|
||||
* once we've disposed an instance any subsequent
|
||||
* `updatePreviewModel` call on it crashes with "this._planeParser
|
||||
* is null". React 19 StrictMode runs each effect setup → cleanup →
|
||||
* setup again on first mount in dev: a lazy useState would hand
|
||||
* the same disposed instance to the second setup. By creating the
|
||||
* previewer inside the effect and writing it to state, the
|
||||
* StrictMode re-run gets a fresh instance — matching the pattern
|
||||
* useCatalog already uses for the same renderer object.
|
||||
*/
|
||||
export const FloorplanRoomPreview: FC<Props> = ({ state, height = 320 }) =>
|
||||
{
|
||||
const [ previewer, setPreviewer ] = useState<RoomPreviewer | null>(null);
|
||||
@@ -49,16 +28,10 @@ export const FloorplanRoomPreview: FC<Props> = ({ state, height = 320 }) =>
|
||||
|
||||
const tilemap = useMemo(() => serializeTilemap(state.tiles), [ state.tiles ]);
|
||||
|
||||
// Push the current editor model into the previewer whenever it
|
||||
// changes. updatePreviewModel re-runs the same plane-parser +
|
||||
// ObjectRoomMapUpdateMessage pipeline as the in-room
|
||||
// applyFloorModelLocally, so the textured preview matches the
|
||||
// live in-room preview pixel-for-pixel.
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!previewer) return;
|
||||
if(!tilemap) return;
|
||||
// server-space wall height: editor stores 1+, wire is 0-based
|
||||
previewer.updatePreviewModel(tilemap, Math.max(0, state.wallHeight - 1), true);
|
||||
}, [ previewer, tilemap, state.wallHeight ]);
|
||||
|
||||
|
||||
@@ -32,4 +32,25 @@ describe('FloorplanTile', () =>
|
||||
const { container } = render(svg(<FloorplanTile row={ 0 } col={ 0 } tile={ { h: 0, blocked: false } } selected={ true } isDoor={ false } />));
|
||||
expect(container.querySelector('[data-testid="selection-ring"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders south + west side walls when h > neighbour heights', () =>
|
||||
{
|
||||
const { container } = render(svg(<FloorplanTile row={ 0 } col={ 0 } tile={ { h: 4, blocked: false } } selected={ false } isDoor={ false } southH={ 0 } westH={ 0 } />));
|
||||
expect(container.querySelector('[data-testid="tile-south-wall"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="tile-west-wall"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('omits south wall when south neighbour is at or above the tile height', () =>
|
||||
{
|
||||
const { container } = render(svg(<FloorplanTile row={ 0 } col={ 0 } tile={ { h: 3, blocked: false } } selected={ false } isDoor={ false } southH={ 3 } westH={ 0 } />));
|
||||
expect(container.querySelector('[data-testid="tile-south-wall"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="tile-west-wall"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('omits all walls for ground-level tiles', () =>
|
||||
{
|
||||
const { container } = render(svg(<FloorplanTile row={ 0 } col={ 0 } tile={ { h: 0, blocked: false } } selected={ false } isDoor={ false } southH={ 0 } westH={ 0 } />));
|
||||
expect(container.querySelector('[data-testid="tile-south-wall"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="tile-west-wall"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,29 +10,105 @@ type Props = {
|
||||
tile: Tile;
|
||||
selected: boolean;
|
||||
isDoor: boolean;
|
||||
southH?: number;
|
||||
westH?: number;
|
||||
};
|
||||
|
||||
const HEIGHT_LIFT = TILE_SIZE / 8;
|
||||
|
||||
const diamondPoints = (row: number, col: number, h: number): string =>
|
||||
{
|
||||
const [ cx, cyBase ] = tileToScreen(row, col);
|
||||
const cy = cyBase - h * (TILE_SIZE / 8);
|
||||
const cy = cyBase - h * HEIGHT_LIFT;
|
||||
const half = TILE_SIZE / 2;
|
||||
const quarter = TILE_SIZE / 4;
|
||||
// Diamond corners: top, right, bottom, left
|
||||
return `${ cx },${ cy - quarter } ${ cx + half },${ cy } ${ cx },${ cy + quarter } ${ cx - half },${ cy }`;
|
||||
};
|
||||
|
||||
const FloorplanTileImpl: FC<Props> = ({ row, col, tile, selected, isDoor }) =>
|
||||
const darkenHex = (hex: string, factor: number): string =>
|
||||
{
|
||||
if(tile.blocked) return null;
|
||||
const h = hex.replace('#', '');
|
||||
if(h.length !== 6) return hex;
|
||||
const r = Math.max(0, Math.floor(parseInt(h.slice(0, 2), 16) * factor));
|
||||
const g = Math.max(0, Math.floor(parseInt(h.slice(2, 4), 16) * factor));
|
||||
const b = Math.max(0, Math.floor(parseInt(h.slice(4, 6), 16) * factor));
|
||||
return `#${ [ r, g, b ].map(v => v.toString(16).padStart(2, '0')).join('') }`;
|
||||
};
|
||||
|
||||
const southWallPoints = (cx: number, cy: number, drop: number): string =>
|
||||
{
|
||||
const half = TILE_SIZE / 2;
|
||||
const quarter = TILE_SIZE / 4;
|
||||
return `${ cx + half },${ cy } ${ cx + half },${ cy + drop } ${ cx },${ cy + quarter + drop } ${ cx },${ cy + quarter }`;
|
||||
};
|
||||
|
||||
const westWallPoints = (cx: number, cy: number, drop: number): string =>
|
||||
{
|
||||
const half = TILE_SIZE / 2;
|
||||
const quarter = TILE_SIZE / 4;
|
||||
return `${ cx - half },${ cy } ${ cx },${ cy + quarter } ${ cx },${ cy + quarter + drop } ${ cx - half },${ cy + drop }`;
|
||||
};
|
||||
|
||||
const FloorplanTileImpl: FC<Props> = ({ row, col, tile, selected, isDoor, southH = 0, westH = 0 }) =>
|
||||
{
|
||||
if(tile.blocked)
|
||||
{
|
||||
if(!selected) return null;
|
||||
const points = diamondPoints(row, col, tile.h);
|
||||
return (
|
||||
<polygon
|
||||
data-testid="selection-ring"
|
||||
points={ points }
|
||||
fill="rgba(250, 204, 21, 0.45)"
|
||||
stroke="#facc15"
|
||||
strokeWidth={ 1.5 }
|
||||
strokeDasharray="3 2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const points = diamondPoints(row, col, tile.h);
|
||||
const fill = tileFill(tile);
|
||||
|
||||
const [ cx, cyBase ] = tileToScreen(row, col);
|
||||
const cy = cyBase - tile.h * HEIGHT_LIFT;
|
||||
const southDrop = Math.max(0, tile.h - southH) * HEIGHT_LIFT;
|
||||
const westDrop = Math.max(0, tile.h - westH) * HEIGHT_LIFT;
|
||||
const southFill = (southDrop > 0) ? darkenHex(fill, 0.70) : null;
|
||||
const westFill = (westDrop > 0) ? darkenHex(fill, 0.55) : null;
|
||||
|
||||
return (
|
||||
<g>
|
||||
<polygon points={ points } fill={ fill } stroke="#222" strokeWidth={ 0.5 } />
|
||||
{ southFill && (
|
||||
<polygon
|
||||
data-testid="tile-south-wall"
|
||||
points={ southWallPoints(cx, cy, southDrop) }
|
||||
fill={ southFill }
|
||||
stroke="#222"
|
||||
strokeWidth={ 0.5 }
|
||||
/>
|
||||
) }
|
||||
{ westFill && (
|
||||
<polygon
|
||||
data-testid="tile-west-wall"
|
||||
points={ westWallPoints(cx, cy, westDrop) }
|
||||
fill={ westFill }
|
||||
stroke="#222"
|
||||
strokeWidth={ 0.5 }
|
||||
/>
|
||||
) }
|
||||
<polygon
|
||||
data-row={ row }
|
||||
data-col={ col }
|
||||
points={ points }
|
||||
fill={ fill }
|
||||
stroke="#222"
|
||||
strokeWidth={ 0.5 }
|
||||
/>
|
||||
{ selected && (
|
||||
<polygon
|
||||
data-testid="selection-ring"
|
||||
data-row={ row }
|
||||
data-col={ col }
|
||||
points={ points }
|
||||
fill="none"
|
||||
stroke="#fff"
|
||||
@@ -43,6 +119,8 @@ const FloorplanTileImpl: FC<Props> = ({ row, col, tile, selected, isDoor }) =>
|
||||
{ isDoor && (
|
||||
<polygon
|
||||
data-testid="door-marker"
|
||||
data-row={ row }
|
||||
data-col={ col }
|
||||
points={ points }
|
||||
fill="rgba(255,255,255,0.85)"
|
||||
stroke="#000"
|
||||
|
||||
@@ -27,14 +27,21 @@ describe('FloorplanToolbar', () =>
|
||||
expect(types).toEqual([ 'UNSET', 'UP', 'DOWN', 'DOOR' ]);
|
||||
});
|
||||
|
||||
it('select-all and square-select dispatch their actions', () =>
|
||||
it('select-all dispatches SELECT_ALL + APPLY_BRUSH_TO_SELECTION (bulk-apply UX)', () =>
|
||||
{
|
||||
const dispatch = vi.fn();
|
||||
const { getByTestId } = render(<FloorplanToolbar state={ initialState } dispatch={ dispatch } />);
|
||||
fireEvent.click(getByTestId('tool-select-all'));
|
||||
fireEvent.click(getByTestId('tool-square-select'));
|
||||
expect(dispatch).toHaveBeenNthCalledWith(1, { type: 'SELECT_ALL' });
|
||||
expect(dispatch).toHaveBeenNthCalledWith(2, { type: 'SQUARE_SELECT_TOGGLE' });
|
||||
expect(dispatch).toHaveBeenNthCalledWith(2, { type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
});
|
||||
|
||||
it('square-select dispatches SQUARE_SELECT_TOGGLE', () =>
|
||||
{
|
||||
const dispatch = vi.fn();
|
||||
const { getByTestId } = render(<FloorplanToolbar state={ initialState } dispatch={ dispatch } />);
|
||||
fireEvent.click(getByTestId('tool-square-select'));
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'SQUARE_SELECT_TOGGLE' });
|
||||
});
|
||||
|
||||
it('marks active brush button with data-active', () =>
|
||||
|
||||
@@ -12,13 +12,6 @@ type Props = {
|
||||
onUndo?: () => void;
|
||||
onRedo?: () => void;
|
||||
panMode?: boolean;
|
||||
/**
|
||||
* Imperative setter for pan mode. Receiving the explicit
|
||||
* value (not a toggle) lets every tool button switch the
|
||||
* hand off on click without needing to know its current
|
||||
* state — the hand is part of the same exclusive tool group
|
||||
* as the brushes, so picking any brush has to clear it.
|
||||
*/
|
||||
setPanMode?: (next: boolean) => void;
|
||||
};
|
||||
|
||||
@@ -32,9 +25,6 @@ const BRUSH_BUTTONS: { id: string; mode: FloorActionMode; iconClass: string }[]
|
||||
|
||||
export const FloorplanToolbar: FC<Props> = ({ state, dispatch, canUndo, canRedo, onUndo, onRedo, panMode, setPanMode }) =>
|
||||
{
|
||||
// The hand and the brush buttons form a single exclusive tool
|
||||
// group. Picking ANY other tool clears pan mode so the user
|
||||
// never ends up in 'I clicked SET but the canvas still pans'.
|
||||
const exitPan = () =>
|
||||
{
|
||||
if(panMode && setPanMode) setPanMode(false);
|
||||
@@ -48,7 +38,7 @@ export const FloorplanToolbar: FC<Props> = ({ state, dispatch, canUndo, canRedo,
|
||||
pointer
|
||||
data-testid="tool-pan"
|
||||
data-active={ panMode ? 'true' : 'false' }
|
||||
title={ panMode ? 'Modalità mano attiva — trascina per spostare la vista' : 'Modalità mano — trascina per spostare la vista' }
|
||||
title={ panMode ? 'Hand mode active — drag to pan the view' : 'Hand mode — drag to pan the view' }
|
||||
className={ `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={ () => setPanMode(!panMode) }
|
||||
>
|
||||
@@ -77,18 +67,21 @@ export const FloorplanToolbar: FC<Props> = ({ state, dispatch, canUndo, canRedo,
|
||||
<Base
|
||||
pointer
|
||||
data-testid="tool-select-all"
|
||||
className={ `nitro-icon ${ state.selection.size > 0 ? 'icon-set-deselect' : 'icon-set-select' }` }
|
||||
className={ `nitro-icon ${ state.brush.action === 'UNSET' ? 'icon-set-deselect' : 'icon-set-select' }` }
|
||||
title={ state.brush.action === 'UNSET' ? 'Erase all tiles' : 'Apply brush to all tiles' }
|
||||
onClick={ () =>
|
||||
{
|
||||
exitPan();
|
||||
dispatch({ type: 'SELECT_ALL' });
|
||||
dispatch({ type: 'APPLY_BRUSH_TO_SELECTION', source: 'local' });
|
||||
} }
|
||||
/>
|
||||
<Base
|
||||
pointer
|
||||
data-testid="tool-square-select"
|
||||
data-active={ state.squareSelect && !panMode ? 'true' : 'false' }
|
||||
className={ `nitro-icon icon-set-squaresselect ${ state.squareSelect && !panMode ? 'border border-primary' : '' }` }
|
||||
title={ state.squareSelect && !panMode ? 'Rectangular selection mode active — drag on the canvas to apply the brush' : 'Rectangular selection — apply the brush to all tiles in an area' }
|
||||
className={ `nitro-icon icon-set-squaresselect transition-shadow ${ state.squareSelect && !panMode ? 'border-2 border-amber-500 bg-amber-400 shadow-[0_0_0_2px_rgba(245,158,11,0.45)]' : '' }` }
|
||||
onClick={ () =>
|
||||
{
|
||||
exitPan();
|
||||
@@ -100,7 +93,7 @@ export const FloorplanToolbar: FC<Props> = ({ state, dispatch, canUndo, canRedo,
|
||||
<Base
|
||||
pointer={ Boolean(canUndo) }
|
||||
data-testid="tool-undo"
|
||||
title="Annulla (Ctrl+Z)"
|
||||
title="Undo (Ctrl+Z)"
|
||||
className={ `w-7 h-7 flex items-center justify-center rounded border ${ canUndo ? 'border-zinc-300 bg-white hover:bg-zinc-50 text-zinc-700' : 'border-zinc-200 bg-zinc-100 text-zinc-300 cursor-not-allowed' }` }
|
||||
onClick={ canUndo && onUndo ? onUndo : undefined }
|
||||
>
|
||||
@@ -109,7 +102,7 @@ export const FloorplanToolbar: FC<Props> = ({ state, dispatch, canUndo, canRedo,
|
||||
<Base
|
||||
pointer={ Boolean(canRedo) }
|
||||
data-testid="tool-redo"
|
||||
title="Ripeti (Ctrl+Shift+Z)"
|
||||
title="Redo (Ctrl+Shift+Z)"
|
||||
className={ `w-7 h-7 flex items-center justify-center rounded border ${ canRedo ? 'border-zinc-300 bg-white hover:bg-zinc-50 text-zinc-700' : 'border-zinc-200 bg-zinc-100 text-zinc-300 cursor-not-allowed' }` }
|
||||
onClick={ canRedo && onRedo ? onRedo : undefined }
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user