Files
Nitro-V3/src/components/floorplan-editor/hooks/useFloorplanReducer.ts
T
simoleo89 b540b163c6 feat(floorplan-editor): React rewrite + live in-room preview + UX polish
Complete modernization of the floor-plan editor. Three layered
changes shipped together since they share state shapes and the
test infrastructure stubs.

1) React rewrite (state + hooks + views + tests)

   Drops the FloorplanEditorContext singleton + legacy view
   components and replaces them with a pure-React reducer
   architecture:

   - state/ — typed FloorplanState + FloorplanAction union,
     pure reducer covering PAINT_TILE / ERASE_TILE /
     ADJUST_HEIGHT / SET_DOOR / SET_DOOR_DIR / SET_THICKNESS /
     SET_WALL_HEIGHT / BRUSH_SET / SELECT_RECT / SELECT_ALL /
     CLEAR_SELECTION / SQUARE_SELECT_TOGGLE / IMPORT_STRING /
     APPLY_REMOTE_DIFF / APPLY_REMOTE_SNAPSHOT. Source-tagged
     ('local' | 'remote') so the editor can distinguish user
     edits from server pushes. Co-located encoding helpers
     (parseTilemap / serializeTilemap) and area-counter
     selectors.
   - hooks/ — useFloorplanReducer (wraps useReducer with a
     history stack + loadFromServer + undo/redo), useTool
     (pointer events -> dispatch), usePointerToTile (screen
     -> tile projection that respects the viewBox origin so
     pan/zoom stays accurate).
   - views/ — FloorplanCanvasSVG, FloorplanHeightPicker,
     FloorplanToolbar, FloorplanOptionsPanel,
     FloorplanImportExport, FloorplanTile,
     FloorplanPreviewSVG (alternative iso preview kept as a
     fallback view, not wired into the main layout).
   - Co-located Vitest suites for every module above (encoding,
     reducer, selectors, hooks, views, integration). 100+ new
     test cases.

2) Live in-room preview (NEW capability)

   useFloorplanLiveSync drives client-side preview of the edit
   directly into the active room — every tile / door / wall
   height / thickness change is applied through
   GetRoomMessageHandler().applyFloorModelLocally (new public
   method on the renderer, see paired renderer PR) with
   zero server traffic during editing. The wire
   UpdateFloorPropertiesMessageComposer is only sent when the
   user explicitly clicks Save. Thickness slider additionally
   calls RoomEngine.updateRoomInstancePlaneThickness for
   zero-latency wall/floor-depth feedback while dragging.

   Toggle 'Live preview ON / OFF' in the bottom strip (default
   ON) lets the user opt out if they want to keep changes
   contained to the editor's own preview until Save.
   Revert button re-applies the original snapshot locally so
   the room snaps back to where it was when the editor opened.

3) UX polish

   - Undo / Redo (Ctrl+Z, Ctrl+Shift+Z / Ctrl+Y) backed by a
     100-step history stack inside useFloorplanReducer. Local
     mutating actions push history; brush/selection UI bumps
     and remote dispatches bypass it; loadFromServer wipes the
     stack.
   - Zoom 40-600 % with Ctrl+wheel, +/- buttons, % label.
     Shift+drag or middle-mouse drag pans the canvas.
   - Auto-fit on first paint: computes the screen-space
     bounding box of the painted (non-blocked) tiles, picks the
     zoom that just contains them with a 5 % margin, pans so
     the room sits in the viewport centre. Default view is now
     'room fills the canvas' instead of 'room is a dot at the
     top-centre of a huge empty canvas'. Clicking the % label
     re-runs the fit; crosshair button keeps zoom and recentres
     the pan only.
   - Door direction control: arrows + door icon triplet
     (8-way rotate by single click on prev/next, full cycle
     forward on the icon itself). Wall and floor thickness
     collapse from two 4-button rows into two compact
     segmented selectors (active state in emerald). Saves
     significant horizontal space.
   - Habbo floor pattern tile (~186 B PNG, vendored from
     habbofurni.com/images/furni_floor.png) tiled as the
     canvas background with image-rendering: pixelated so the
     texture stays crisp at every zoom level. Replaces the
     solid black background.

Test infrastructure

   nitro-renderer.mock grows constructors / proxies / functions
   for everything the new floor-editor tests transitively
   import (floor composers + events, RoomEngineEvent,
   ILinkEventTracker, convertNumbersForSaving /
   convertSettingToNumber, GetRoomMessageHandler,
   GetTicker, GetRenderer, NitroTicker, RoomPreviewer with a
   sufficiently real .updatePreviewModel / dispose surface,
   and a TextureUtils.createRenderTexture that returns an
   object with a no-op .destroy). test-setup adds a no-op
   ResizeObserver polyfill (jsdom doesn't ship one and the
   optional FloorplanRoomPreview observes its container) and
   a draggable-windows-container portal root for tests that
   mount NitroCardView.

Files: 44 changed (mostly new). yarn typecheck 0 errors,
yarn test 341/341 green.
2026-05-24 21:19:10 +02:00

186 lines
6.6 KiB
TypeScript

import { Dispatch, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { FloorplanAction, FloorplanState, EntryDir, ThicknessLevel } from '../state/types';
import { initialState, reducer } from '../state/reducer';
export type ServerFloorSettings = {
tilemap: string;
entryPoint: [number, number];
entryPointDir: number;
thicknessWall: ThicknessLevel;
thicknessFloor: ThicknessLevel;
wallHeight: number;
};
type Api = {
state: FloorplanState;
dispatch: Dispatch<FloorplanAction>;
loadFromServer: (s: ServerFloorSettings) => void;
undo: () => void;
redo: () => void;
canUndo: boolean;
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)
{
case 'BRUSH_SET':
case 'SELECT_ALL':
case 'CLEAR_SELECTION':
case 'SELECT_RECT':
case 'SQUARE_SELECT_TOGGLE':
return true;
default:
return false;
}
};
// 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;
return 'source' in action && action.source === 'remote';
};
const HISTORY_LIMIT = 100;
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;
}, [ state ]);
const refreshCanFlags = useCallback(() =>
{
setCanUndo(pastRef.current.length > 0);
setCanRedo(futureRef.current.length > 0);
}, []);
const wrappedDispatch = useCallback<Dispatch<FloorplanAction>>((action) =>
{
if(isNonHistoryAction(action) || isRemoteAction(action))
{
dispatch(action);
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();
futureRef.current = [];
dispatch(action);
refreshCanFlags();
}, [ refreshCanFlags ]);
const loadFromServer = useCallback((s: ServerFloorSettings) =>
{
// Server load wipes history — the document is fresh.
pastRef.current = [];
futureRef.current = [];
dispatch({
type: 'IMPORT_STRING',
raw: s.tilemap,
door: { x: s.entryPoint[0], y: s.entryPoint[1], dir: ((s.entryPointDir | 0) & 7) as EntryDir },
thickness: { wall: s.thicknessWall, floor: s.thicknessFloor },
wallHeight: s.wallHeight,
source: 'remote'
});
refreshCanFlags();
}, [ refreshCanFlags ]);
const undo = useCallback(() =>
{
const previous = pastRef.current.pop();
if(!previous) return;
futureRef.current.push(stateRef.current);
dispatch({ type: 'APPLY_REMOTE_SNAPSHOT',
raw: serializeTilesForSnapshot(previous.tiles),
door: previous.door,
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 ]);
const redo = useCallback(() =>
{
const next = futureRef.current.pop();
if(!next) return;
pastRef.current.push(stateRef.current);
dispatch({ type: 'APPLY_REMOTE_SNAPSHOT',
raw: serializeTilesForSnapshot(next.tiles),
door: next.door,
thickness: next.thickness,
wallHeight: next.wallHeight,
seq: next.seq });
refreshCanFlags();
}, [ refreshCanFlags ]);
return useMemo(() => ({
state, dispatch: wrappedDispatch, loadFromServer, undo, redo, canUndo, canRedo
}), [ 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 '';
const scheme = 'x0123456789abcdefghijklmnopq';
return tiles.map(row => row.map(tile =>
{
if(tile.blocked) return 'x';
const h = Number.isFinite(tile.h) ? Math.max(0, Math.min(scheme.length - 2, tile.h)) : 0;
return scheme.charAt(h + 1);
}).join('')).join('\r');
};