mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-20 23:46:19 +00:00
🆕 Brand new Floorplan
This commit is contained in:
@@ -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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user