🆕 Brand new Floorplan

This commit is contained in:
duckietm
2026-05-26 16:38:01 +02:00
parent 4f0a8be2b0
commit bf0a73eaf8
24 changed files with 482 additions and 245 deletions
@@ -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 };
};