Merge pull request #158 from simoleo89/pr/floor-editor-modernization

feat(floorplan-editor): React rewrite + live in-room preview + UX polish
This commit is contained in:
DuckieTM
2026-05-26 13:21:29 +02:00
committed by GitHub
42 changed files with 3812 additions and 996 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 186 B

@@ -1,34 +0,0 @@
import { createContext, Dispatch, FC, SetStateAction, useContext } from 'react';
import { IFloorplanSettings } from '@nitrots/nitro-renderer';
import { IVisualizationSettings } from '@nitrots/nitro-renderer';
interface IFloorplanEditorContext
{
originalFloorplanSettings: IFloorplanSettings;
setOriginalFloorplanSettings: Dispatch<SetStateAction<IFloorplanSettings>>;
visualizationSettings: IVisualizationSettings;
setVisualizationSettings: Dispatch<SetStateAction<IVisualizationSettings>>;
floorHeight: number;
setFloorHeight: Dispatch<SetStateAction<number>>;
floorAction: number;
setFloorAction: Dispatch<SetStateAction<number>>;
tilemapVersion: number;
areaInfo: { total: number; walkable: number };
}
const FloorplanEditorContext = createContext<IFloorplanEditorContext>({
originalFloorplanSettings: null,
setOriginalFloorplanSettings: null,
visualizationSettings: null,
setVisualizationSettings: null,
floorHeight: 0,
setFloorHeight: null,
floorAction: 3,
setFloorAction: null,
tilemapVersion: 0,
areaInfo: { total: 0, walkable: 0 }
});
export const FloorplanEditorContextProvider: FC<{ value: IFloorplanEditorContext; children?: React.ReactNode }> = props => <FloorplanEditorContext { ...props } />;
export const useFloorplanEditorContext = () => useContext(FloorplanEditorContext);
@@ -0,0 +1,205 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act, render, cleanup, fireEvent } from '@testing-library/react';
// Capture handlers registered by useMessageEvent / useNitroEvent so we can fire fake events.
const messageHandlers = new Map<unknown, (event: unknown) => void>();
const nitroHandlers = new Map<unknown, (event: unknown) => void>();
vi.mock('../../hooks', async () =>
{
return {
useMessageEvent: (eventClass: unknown, handler: (event: unknown) => void) =>
{
messageHandlers.set(eventClass, handler);
},
useNitroEvent: (eventType: unknown, handler: (event: unknown) => void) =>
{
nitroHandlers.set(eventType, handler);
}
};
});
// Spy SendMessageComposer — use importOriginal to keep all other api exports intact
// (DraggableWindow et al. rely on GetLocalStorage and others at mount time).
const sendMessageComposer = vi.fn();
vi.mock('../../api', async (importOriginal) =>
{
const actual = await importOriginal<typeof import('../../api')>();
return {
...actual,
SendMessageComposer: (...args: unknown[]) => sendMessageComposer(...args),
LocalizeText: (key: string) => key
};
});
import {
FloorHeightMapEvent,
RoomVisualizationSettingsEvent,
RoomEntryTileMessageEvent,
RoomOccupiedTilesMessageEvent,
RoomEngineEvent,
GetRoomEntryTileMessageComposer,
GetOccupiedTilesMessageComposer,
UpdateFloorPropertiesMessageComposer,
AddLinkEventTracker,
RemoveLinkEventTracker
} from '@nitrots/nitro-renderer';
import { FloorplanEditorView } from './FloorplanEditorView';
// The Button component in this codebase renders as a <div> (via Base), not <button>.
// NitroCardView portals everything into #draggable-windows-container.
// Find a clickable element by its exact trimmed text content in the portal.
const findByExactText = (text: string): Element | undefined =>
{
const container = document.getElementById('draggable-windows-container') ?? document.body;
return Array.from(container.querySelectorAll('div')).find(
(el: Element) => el.textContent?.trim() === text
);
};
describe('FloorplanEditorView container', () =>
{
beforeEach(() =>
{
messageHandlers.clear();
nitroHandlers.clear();
sendMessageComposer.mockClear();
(AddLinkEventTracker as ReturnType<typeof vi.fn>).mockClear();
(RemoveLinkEventTracker as ReturnType<typeof vi.fn>).mockClear();
});
afterEach(() => cleanup());
const openEditor = () =>
{
render(<FloorplanEditorView />);
// Trigger link tracker: 'floor-editor/show' to make editor visible
const tracker = (AddLinkEventTracker as ReturnType<typeof vi.fn>).mock.calls[0][0];
act(() => tracker.linkReceived('floor-editor/show'));
};
it('registers a link tracker on mount with floor-editor/ prefix', () =>
{
render(<FloorplanEditorView />);
expect(AddLinkEventTracker).toHaveBeenCalledTimes(1);
const tracker = (AddLinkEventTracker as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(tracker.eventUrlPrefix).toBe('floor-editor/');
});
it('dispatches GetRoomEntryTileMessageComposer when editor becomes visible', () =>
{
openEditor();
const composers = sendMessageComposer.mock.calls.map((c: unknown[]) => c[0]);
const entryComposer = composers.find((c: unknown) => c instanceof GetRoomEntryTileMessageComposer);
expect(entryComposer).toBeTruthy();
});
it('dispatches GetOccupiedTilesMessageComposer when editor becomes visible', () =>
{
openEditor();
const composers = sendMessageComposer.mock.calls.map((c: unknown[]) => c[0]);
const occupiedComposer = composers.find((c: unknown) => c instanceof GetOccupiedTilesMessageComposer);
expect(occupiedComposer).toBeTruthy();
});
it('seeds door from RoomEntryTileMessageEvent', () =>
{
openEditor();
const handler = messageHandlers.get(RoomEntryTileMessageEvent);
expect(handler).toBeTruthy();
act(() => handler!({ getParser: () => ({ x: 3, y: 4, direction: 6 }) }));
// Seed tilemap + thickness so Save is callable
const fhmHandler = messageHandlers.get(FloorHeightMapEvent);
act(() => fhmHandler!({ getParser: () => ({ model: '00\rxq', wallHeight: 5 }) }));
const rvsHandler = messageHandlers.get(RoomVisualizationSettingsEvent);
act(() => rvsHandler!({ getParser: () => ({ thicknessWall: 1, thicknessFloor: 1 }) }));
// With LocalizeText mocked to identity, the button text is literally the i18n key.
// Button renders as <div> (not <button>) — use findByExactText.
const saveBtn = findByExactText('floor.plan.editor.save');
expect(saveBtn).toBeTruthy();
sendMessageComposer.mockClear();
fireEvent.click(saveBtn!);
expect(sendMessageComposer).toHaveBeenCalledTimes(1);
const composer = sendMessageComposer.mock.calls[0][0];
expect(composer).toBeInstanceOf(UpdateFloorPropertiesMessageComposer);
expect(composer.doorX).toBe(3);
expect(composer.doorY).toBe(4);
expect(composer.dir).toBe(6);
});
it('Save composer carries wallHeight - 1 from the reducer state', () =>
{
openEditor();
const fhmHandler = messageHandlers.get(FloorHeightMapEvent);
// parser.wallHeight = 4 → state.wallHeight = 4 + 1 = 5 → Save sends 5 - 1 = 4
act(() => fhmHandler!({ getParser: () => ({ model: '0', wallHeight: 4 }) }));
const saveBtn = findByExactText('floor.plan.editor.save');
expect(saveBtn).toBeTruthy();
sendMessageComposer.mockClear();
fireEvent.click(saveBtn!);
const composer = sendMessageComposer.mock.calls[0][0];
expect(composer).toBeInstanceOf(UpdateFloorPropertiesMessageComposer);
expect(composer.wallHeight).toBe(4);
});
it('Save composer thickness goes through convertNumbersForSaving', () =>
{
openEditor();
const fhmHandler = messageHandlers.get(FloorHeightMapEvent);
act(() => fhmHandler!({ getParser: () => ({ model: '0', wallHeight: 0 }) }));
const rvsHandler = messageHandlers.get(RoomVisualizationSettingsEvent);
// server sends 2 for both; convertSettingToNumber(2) = 3; reducer stores thickness=3
// Save applies convertNumbersForSaving(3) = 1
act(() => rvsHandler!({ getParser: () => ({ thicknessWall: 2, thicknessFloor: 2 }) }));
const saveBtn = findByExactText('floor.plan.editor.save');
expect(saveBtn).toBeTruthy();
sendMessageComposer.mockClear();
fireEvent.click(saveBtn!);
const composer = sendMessageComposer.mock.calls[0][0];
expect(composer).toBeInstanceOf(UpdateFloorPropertiesMessageComposer);
expect(composer.thicknessWall).toBe(1);
expect(composer.thicknessFloor).toBe(1);
});
it('RoomOccupiedTilesMessageEvent marks blockedTilesMap entries as blocked in state', () =>
{
openEditor();
const fhmHandler = messageHandlers.get(FloorHeightMapEvent);
// 2x2 grid: '00\r00' → rows 0 and 1, each with 2 walkable tiles
act(() => fhmHandler!({ getParser: () => ({ model: '00\r00', wallHeight: 0 }) }));
const occHandler = messageHandlers.get(RoomOccupiedTilesMessageEvent);
expect(occHandler).toBeTruthy();
// Mark col 1 of row 0 as occupied; blockedTilesMap[row][col]
const blockedTilesMap = [[false, true], [false, false]];
act(() => occHandler!({ getParser: () => ({ blockedTilesMap }) }));
const saveBtn = findByExactText('floor.plan.editor.save');
expect(saveBtn).toBeTruthy();
sendMessageComposer.mockClear();
fireEvent.click(saveBtn!);
const composer = sendMessageComposer.mock.calls[0][0];
expect(composer).toBeInstanceOf(UpdateFloorPropertiesMessageComposer);
// Row separator is \r per serializeTilemap; row 0 was '00', col 1 blocked → '0x'
expect(composer.tilemap.split(/\r/)[0]).toBe('0x');
});
it('RoomEngineEvent.DISPOSED hides the editor', () =>
{
render(<FloorplanEditorView />);
const tracker = (AddLinkEventTracker as ReturnType<typeof vi.fn>).mock.calls[0][0];
act(() => tracker.linkReceived('floor-editor/show'));
// Editor should be visible — NitroCardHeaderView renders the title
expect(document.body.textContent).toContain('floor.plan.editor.title');
const disposeHandler = nitroHandlers.get(RoomEngineEvent.DISPOSED);
expect(disposeHandler).toBeTruthy();
act(() => disposeHandler!({}));
expect(document.body.textContent).not.toContain('floor.plan.editor.title');
});
it('cleans up the link tracker on unmount', () =>
{
const { unmount } = render(<FloorplanEditorView />);
expect(RemoveLinkEventTracker).not.toHaveBeenCalled();
unmount();
expect(RemoveLinkEventTracker).toHaveBeenCalledTimes(1);
});
});
@@ -1,284 +1,302 @@
import { AddLinkEventTracker, FloorHeightMapEvent, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomVisualizationSettingsEvent, UpdateFloorPropertiesMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useState } from 'react';
import { FaCaretLeft, FaCaretRight } from 'react-icons/fa';
import { AddLinkEventTracker, convertNumbersForSaving, convertSettingToNumber, FloorHeightMapEvent, GetOccupiedTilesMessageComposer, GetRoomEntryTileMessageComposer, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomEntryTileMessageEvent, RoomOccupiedTilesMessageEvent, RoomVisualizationSettingsEvent, UpdateFloorPropertiesMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useRef, useState } from 'react';
import { FaBolt, FaCaretLeft, FaCaretRight } from 'react-icons/fa';
import { LocalizeText, SendMessageComposer } from '../../api';
import { Button, ButtonGroup, Column, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
import { Button, ButtonGroup, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
import { useMessageEvent, useNitroEvent } from '../../hooks';
import { FloorplanEditorContextProvider } from './FloorplanEditorContext';
import { FloorplanEditor } from '@nitrots/nitro-renderer';
import { IFloorplanSettings } from '@nitrots/nitro-renderer';
import { IVisualizationSettings } from '@nitrots/nitro-renderer';
import { convertNumbersForSaving, convertSettingToNumber, FloorAction, HEIGHT_SCHEME } from '@nitrots/nitro-renderer';
import { FloorplanCanvasView } from './views/FloorplanCanvasView';
import { FloorplanImportExportView } from './views/FloorplanImportExportView';
import { FloorplanOptionsView } from './views/FloorplanOptionsView';
import { FloorplanHeightSelector } from './views/FloorplanHeightSelector';
import { FloorplanPreviewView } from './views/FloorplanPreviewView';
import { useFloorplanLiveSync } from '../../hooks/rooms/widgets/useFloorplanLiveSync';
import { MAX_WALL_HEIGHT, MIN_WALL_HEIGHT } from './state/constants';
import { EntryDir, ThicknessLevel } from './state/types';
import { areaCount } from './state/selectors';
import { serializeTilemap } from './state/encoding';
import { useFloorplanReducer } from './hooks/useFloorplanReducer';
import { FloorplanCanvasSVG } from './views/FloorplanCanvasSVG';
import { FloorplanHeightPicker } from './views/FloorplanHeightPicker';
import { FloorplanToolbar } from './views/FloorplanToolbar';
import { FloorplanOptionsPanel } from './views/FloorplanOptionsPanel';
import { FloorplanImportExport } from './views/FloorplanImportExport';
const MIN_WALL_HEIGHT = 0;
const MAX_WALL_HEIGHT = 16;
const clampThickness = (v: number): ThicknessLevel =>
{
if(v <= 0) return 0;
if(v >= 3) return 3;
return (v | 0) as ThicknessLevel;
};
export const FloorplanEditorView: FC<{}> = props =>
export const FloorplanEditorView: FC = () =>
{
const [ isVisible, setIsVisible ] = useState(false);
const [ importExportVisible, setImportExportVisible ] = useState(false);
const [ originalFloorplanSettings, setOriginalFloorplanSettings ] = useState<IFloorplanSettings>({
tilemap: '',
reservedTiles: [],
entryPoint: [ 0, 0 ],
entryPointDir: 2,
wallHeight: -1,
thicknessWall: 1,
thicknessFloor: 1
});
const [ visualizationSettings, setVisualizationSettings ] = useState<IVisualizationSettings>({
entryPointDir: 2,
wallHeight: -1,
thicknessWall: 1,
thicknessFloor: 1
});
const [ floorHeight, setFloorHeight ] = useState(0);
const [ floorAction, setFloorAction ] = useState(FloorAction.SET);
const [ tilemapVersion, setTilemapVersion ] = useState(0);
const [ areaInfo, setAreaInfo ] = useState({ total: 0, walkable: 0 });
const [ liveSync, setLiveSync ] = useState(true);
const [ panMode, setPanMode ] = useState(false);
const { state, dispatch, loadFromServer, undo, redo, canUndo, canRedo } = useFloorplanReducer();
const originalRef = useRef<{
tilemap: string;
entryPoint: [number, number];
entryPointDir: number;
thicknessWall: ThicknessLevel;
thicknessFloor: ThicknessLevel;
wallHeight: number;
} | null>(null);
const calculateArea = useCallback(() =>
{
const tilemap = FloorplanEditor.instance.tilemap;
const area = useMemo(() => areaCount(state.tiles), [ state.tiles ]);
if(!tilemap || tilemap.length === 0)
{
setAreaInfo({ total: 0, walkable: 0 });
// 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 });
return;
}
useNitroEvent<RoomEngineEvent>(RoomEngineEvent.DISPOSED, () => setIsVisible(false));
let total = 0;
let walkable = 0;
for(let y = 0; y < tilemap.length; y++)
{
if(!tilemap[y]) continue;
for(let x = 0; x < tilemap[y].length; x++)
{
if(!tilemap[y][x] || tilemap[y][x].height === 'x') continue;
total++;
if(!tilemap[y][x].isBlocked) walkable++;
}
}
setAreaInfo({ total, walkable });
}, []);
// sync floorHeight/floorAction changes to the FloorplanEditor instance
useEffect(() =>
{
FloorplanEditor.instance.actionSettings.currentAction = floorAction;
FloorplanEditor.instance.actionSettings.currentHeight = floorHeight.toString(36);
}, [ floorHeight, floorAction ]);
// register onTilemapChange callback
useEffect(() =>
{
if(!isVisible) return;
SendMessageComposer(new GetRoomEntryTileMessageComposer());
SendMessageComposer(new GetOccupiedTilesMessageComposer());
}, [ isVisible ]);
FloorplanEditor.instance.onTilemapChange = () =>
{
setTilemapVersion(prev => prev + 1);
calculateArea();
useMessageEvent<RoomEntryTileMessageEvent>(RoomEntryTileMessageEvent, event =>
{
const parser = event.getParser();
originalRef.current = {
tilemap: originalRef.current?.tilemap ?? '',
entryPoint: [ parser.x, parser.y ],
entryPointDir: parser.direction,
thicknessWall: originalRef.current?.thicknessWall ?? 1,
thicknessFloor: originalRef.current?.thicknessFloor ?? 1,
wallHeight: originalRef.current?.wallHeight ?? -1
};
dispatch({ type: 'SET_DOOR', x: parser.x, y: parser.y, source: 'remote' });
dispatch({ type: 'SET_DOOR_DIR', dir: ((parser.direction | 0) & 7) as EntryDir, source: 'remote' });
});
return () =>
useMessageEvent<RoomOccupiedTilesMessageEvent>(RoomOccupiedTilesMessageEvent, event =>
{
const parser = event.getParser();
const blockedTilesMap = parser.blockedTilesMap;
const diffTiles: Array<{ row: number; col: number; h: number; blocked: boolean }> = [];
for(let row = 0; row < blockedTilesMap.length; row++)
{
FloorplanEditor.instance.onTilemapChange = null;
};
}, [ isVisible, calculateArea ]);
const saveFloorChanges = () =>
{
SendMessageComposer(new UpdateFloorPropertiesMessageComposer(
FloorplanEditor.instance.getCurrentTilemapString(),
FloorplanEditor.instance.doorLocation.x,
FloorplanEditor.instance.doorLocation.y,
visualizationSettings.entryPointDir,
convertNumbersForSaving(visualizationSettings.thicknessWall),
convertNumbersForSaving(visualizationSettings.thicknessFloor),
(visualizationSettings.wallHeight - 1)
));
};
const revertChanges = () =>
{
setVisualizationSettings({ wallHeight: originalFloorplanSettings.wallHeight, thicknessWall: originalFloorplanSettings.thicknessWall, thicknessFloor: originalFloorplanSettings.thicknessFloor, entryPointDir: originalFloorplanSettings.entryPointDir });
FloorplanEditor.instance.doorLocation = { x: originalFloorplanSettings.entryPoint[0], y: originalFloorplanSettings.entryPoint[1] };
FloorplanEditor.instance.setTilemap(originalFloorplanSettings.tilemap, originalFloorplanSettings.reservedTiles);
FloorplanEditor.instance.renderTiles();
};
const onWallHeightChange = (value: number) =>
{
if(isNaN(value) || (value <= 0)) value = MIN_WALL_HEIGHT;
if(value > MAX_WALL_HEIGHT) value = MAX_WALL_HEIGHT;
setVisualizationSettings(prevValue =>
{
const newValue = { ...prevValue };
newValue.wallHeight = value;
return newValue;
});
};
const increaseWallHeight = () =>
{
let height = (visualizationSettings.wallHeight + 1);
if(height > MAX_WALL_HEIGHT) height = MAX_WALL_HEIGHT;
onWallHeightChange(height);
};
const decreaseWallHeight = () =>
{
let height = (visualizationSettings.wallHeight - 1);
if(height <= 0) height = MIN_WALL_HEIGHT;
onWallHeightChange(height);
};
useNitroEvent<RoomEngineEvent>(RoomEngineEvent.DISPOSED, event => setIsVisible(false));
const rowArr = blockedTilesMap[row];
if(!rowArr) continue;
for(let col = 0; col < rowArr.length; col++)
{
if(rowArr[col]) diffTiles.push({ row, col, h: 0, blocked: true });
}
}
dispatch({ type: 'APPLY_REMOTE_DIFF', diff: { tiles: diffTiles }, seq: 0, editorUserId: 0 });
});
useMessageEvent<FloorHeightMapEvent>(FloorHeightMapEvent, event =>
{
const parser = event.getParser();
setOriginalFloorplanSettings(prevValue =>
{
const newValue = { ...prevValue };
newValue.tilemap = parser.model;
newValue.wallHeight = (parser.wallHeight + 1);
return newValue;
originalRef.current = {
tilemap: parser.model,
entryPoint: originalRef.current?.entryPoint ?? [ 0, 0 ],
entryPointDir: originalRef.current?.entryPointDir ?? 2,
thicknessWall: originalRef.current?.thicknessWall ?? 1,
thicknessFloor: originalRef.current?.thicknessFloor ?? 1,
wallHeight: parser.wallHeight + 1
};
loadFromServer({
tilemap: parser.model,
entryPoint: originalRef.current.entryPoint,
entryPointDir: originalRef.current.entryPointDir,
thicknessWall: originalRef.current.thicknessWall,
thicknessFloor: originalRef.current.thicknessFloor,
wallHeight: parser.wallHeight + 1
});
setVisualizationSettings(prevValue =>
{
const newValue = { ...prevValue };
newValue.wallHeight = (parser.wallHeight + 1);
return newValue;
// 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],
doorY: originalRef.current.entryPoint[1],
doorDir: originalRef.current.entryPointDir,
thicknessWall: originalRef.current.thicknessWall,
thicknessFloor: originalRef.current.thicknessFloor,
wallHeight: parser.wallHeight + 1
});
});
useMessageEvent<RoomVisualizationSettingsEvent>(RoomVisualizationSettingsEvent, event =>
{
const parser = event.getParser();
setOriginalFloorplanSettings(prevValue =>
{
const newValue = { ...prevValue };
newValue.thicknessFloor = convertSettingToNumber(parser.thicknessFloor);
newValue.thicknessWall = convertSettingToNumber(parser.thicknessWall);
return newValue;
});
setVisualizationSettings(prevValue =>
{
const newValue = { ...prevValue };
newValue.thicknessFloor = convertSettingToNumber(parser.thicknessFloor);
newValue.thicknessWall = convertSettingToNumber(parser.thicknessWall);
return newValue;
});
const wall = clampThickness(convertSettingToNumber(parser.thicknessWall));
const floor = clampThickness(convertSettingToNumber(parser.thicknessFloor));
originalRef.current = {
tilemap: originalRef.current?.tilemap ?? '',
entryPoint: originalRef.current?.entryPoint ?? [ 0, 0 ],
entryPointDir: originalRef.current?.entryPointDir ?? 2,
thicknessWall: wall,
thicknessFloor: floor,
wallHeight: originalRef.current?.wallHeight ?? -1
};
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;
const handler = (e: KeyboardEvent) =>
{
if(!(e.ctrlKey || e.metaKey)) return;
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
if(tag === 'INPUT' || tag === 'TEXTAREA' || target?.isContentEditable) return;
const key = e.key.toLowerCase();
if(key === 'z' && !e.shiftKey)
{
e.preventDefault();
undo();
}
else if((key === 'z' && e.shiftKey) || key === 'y')
{
e.preventDefault();
redo();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [ isVisible, undo, redo ]);
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
const parts = url.split('/');
if(parts.length < 2) return;
switch(parts[1])
{
case 'show':
setIsVisible(true);
return;
case 'hide':
setIsVisible(false);
return;
case 'toggle':
setIsVisible(prevValue => !prevValue);
return;
case 'show': setIsVisible(true); return;
case 'hide': setIsVisible(false); return;
case 'toggle': setIsVisible(v => !v); return;
}
},
eventUrlPrefix: 'floor-editor/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
const onWallHeightChange = (value: number) =>
{
if(isNaN(value) || value <= 0) value = MIN_WALL_HEIGHT;
if(value > MAX_WALL_HEIGHT) value = MAX_WALL_HEIGHT;
dispatch({ type: 'SET_WALL_HEIGHT', value, source: 'local' });
};
const saveFloorChanges = () =>
{
SendMessageComposer(new UpdateFloorPropertiesMessageComposer(
serializeTilemap(state.tiles),
state.door.x,
state.door.y,
state.door.dir,
convertNumbersForSaving(state.thickness.wall),
convertNumbersForSaving(state.thickness.floor),
state.wallHeight - 1
));
};
const revertChanges = () =>
{
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();
};
return (
<FloorplanEditorContextProvider value={ {
originalFloorplanSettings,
setOriginalFloorplanSettings,
visualizationSettings,
setVisualizationSettings,
floorHeight,
setFloorHeight,
floorAction,
setFloorAction,
tilemapVersion,
areaInfo
} }>
{ isVisible &&
<NitroCardView uniqueKey="floorpan-editor" className="w-[1100px] h-[600px]" theme="primary-slim">
<>
{ isVisible && (
<NitroCardView uniqueKey="floorpan-editor" className="w-[820px] h-[620px]" theme="primary-slim">
<NitroCardHeaderView headerText={ LocalizeText('floor.plan.editor.title') } onCloseClick={ () => setIsVisible(false) } />
<NitroCardContentView overflow="hidden" className="flex flex-col">
<FloorplanOptionsView />
<NitroCardContentView overflow="hidden" className="flex flex-col gap-2">
<FloorplanToolbar
state={ state }
dispatch={ dispatch }
canUndo={ canUndo }
canRedo={ canRedo }
onUndo={ undo }
onRedo={ redo }
panMode={ panMode }
setPanMode={ setPanMode }
/>
<FloorplanOptionsPanel state={ state } dispatch={ dispatch } />
<Flex gap={ 2 } className="flex-1 min-h-0">
<FloorplanHeightSelector />
<FloorplanCanvasView overflow="hidden" />
<Column gap={ 2 } className="w-[380px] min-w-[380px]">
<FloorplanPreviewView />
<Flex gap={ 1 } alignItems="center">
<Text bold small>{ LocalizeText('floor.editor.wall.height') }</Text>
<FaCaretLeft className="cursor-pointer fa-icon" onClick={ decreaseWallHeight } />
<input type="number" className="form-control form-control-sm w-[49px]" value={ visualizationSettings.wallHeight } onChange={ event => onWallHeightChange(event.target.valueAsNumber) } />
<FaCaretRight className="cursor-pointer fa-icon" onClick={ increaseWallHeight } />
</Flex>
<Text bold small className="text-center">
Area: { areaInfo.total } ({ areaInfo.walkable } caselle)
</Text>
</Column>
<FloorplanHeightPicker selectedH={ state.brush.h } onSelect={ h => dispatch({ type: 'BRUSH_SET', h }) } />
<FloorplanCanvasSVG state={ state } dispatch={ dispatch } panMode={ panMode } />
</Flex>
<Flex gap={ 3 } alignItems="center" className="px-1">
<Flex gap={ 1 } alignItems="center">
<Text bold small className="text-zinc-700">{ LocalizeText('floor.editor.wall.height') }</Text>
<FaCaretLeft className="cursor-pointer fa-icon text-zinc-600" onClick={ () => onWallHeightChange(state.wallHeight - 1) } />
<input
type="number"
className="form-control form-control-sm w-[49px] text-center"
value={ state.wallHeight }
onChange={ e => onWallHeightChange(e.target.valueAsNumber) }
/>
<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)
</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)"
>
<FaBolt className={ liveSync ? 'text-emerald-600' : 'text-zinc-500' } />
<Text bold small>{ liveSync ? 'Live preview ON' : 'Live preview OFF' }</Text>
</Flex>
</Flex>
<Flex justifyContent="between">
<Button variant="danger" onClick={ revertChanges }>{ LocalizeText('floor.plan.editor.reload') }</Button>
<ButtonGroup>
<Button onClick={ event => setImportExportVisible(true) }>{ LocalizeText('floor.plan.editor.import.export') }</Button>
<Button onClick={ () => setImportExportVisible(true) }>{ LocalizeText('floor.plan.editor.import.export') }</Button>
<Button onClick={ saveFloorChanges }>{ LocalizeText('floor.plan.editor.save') }</Button>
</ButtonGroup>
</Flex>
</NitroCardContentView>
</NitroCardView> }
{ importExportVisible &&
<FloorplanImportExportView onCloseClick={ () => setImportExportVisible(false) } /> }
</FloorplanEditorContextProvider>
</NitroCardView>
) }
{ importExportVisible && (
<FloorplanImportExport
state={ state }
dispatch={ dispatch }
onClose={ () => setImportExportVisible(false) }
onSaveFromText={ raw =>
{
SendMessageComposer(new UpdateFloorPropertiesMessageComposer(
raw,
state.door.x,
state.door.y,
state.door.dir,
convertNumbersForSaving(state.thickness.wall),
convertNumbersForSaving(state.thickness.floor),
state.wallHeight - 1
));
} }
onRevertText={ () => originalRef.current?.tilemap ?? serializeTilemap(state.tiles) }
/>
) }
</>
);
};
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { act, renderHook } from '@testing-library/react';
import { useFloorplanReducer } from './useFloorplanReducer';
describe('useFloorplanReducer', () =>
{
it('starts with initialState', () =>
{
const { result } = renderHook(() => useFloorplanReducer());
expect(result.current.state.tiles).toEqual([]);
expect(result.current.state.brush.action).toBe('SET');
});
it('loadFromServer seeds tiles + door + wallHeight', () =>
{
const { result } = renderHook(() => useFloorplanReducer());
act(() =>
{
result.current.loadFromServer({
tilemap: '00\rxq',
entryPoint: [ 1, 0 ],
entryPointDir: 4,
thicknessWall: 1,
thicknessFloor: 0,
wallHeight: 5
});
});
expect(result.current.state.tiles).toHaveLength(2);
expect(result.current.state.door).toEqual({ x: 1, y: 0, dir: 4 });
expect(result.current.state.thickness).toEqual({ wall: 1, floor: 0 });
expect(result.current.state.wallHeight).toBe(5);
});
it('dispatch updates state synchronously', () =>
{
const { result } = renderHook(() => useFloorplanReducer());
act(() =>
{
result.current.dispatch({ type: 'BRUSH_SET', action: 'DOOR' });
});
expect(result.current.state.brush.action).toBe('DOOR');
});
});
@@ -0,0 +1,185 @@
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');
};
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { renderHook } from '@testing-library/react';
import { usePointerToTile, screenToTile, tileToScreen } from './usePointerToTile';
describe('tileToScreen / screenToTile round-trip', () =>
{
it('origin tile (0,0) projects to (1024, 0) and back', () =>
{
const [ sx, sy ] = tileToScreen(0, 0);
expect(sx).toBe(1024);
expect(sy).toBe(0);
expect(screenToTile(sx, sy)).toEqual([ 0, 0 ]);
});
it('tile (3, 5) round-trips', () =>
{
const [ sx, sy ] = tileToScreen(3, 5);
const [ r, c ] = screenToTile(sx, sy);
expect(r).toBeCloseTo(3, 5);
expect(c).toBeCloseTo(5, 5);
});
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);
expect(Math.round(c)).toBe(2);
});
});
describe('usePointerToTile', () =>
{
it('returns null when no SVG ref is attached', () =>
{
const ref = { current: null } as React.RefObject<SVGSVGElement | null>;
const { result } = renderHook(() => usePointerToTile(ref, { width: 2048, height: 1024 }));
expect(result.current.fromClient(100, 100)).toBeNull();
});
});
@@ -0,0 +1,52 @@
import { RefObject, useCallback, useMemo } from 'react';
import { TILE_SIZE } from '../state/constants';
const X_OFFSET = 1024;
export const tileToScreen = (row: number, col: number): [number, number] =>
{
const x = (col * TILE_SIZE / 2) - (row * TILE_SIZE / 2) + X_OFFSET;
const y = (col * TILE_SIZE / 4) + (row * TILE_SIZE / 4);
return [ x, y ];
};
export const screenToTile = (x: number, y: number): [number, number] =>
{
const tx = x - X_OFFSET;
const col = ((tx / (TILE_SIZE / 2)) + (y / (TILE_SIZE / 4))) / 2;
const row = ((y / (TILE_SIZE / 4)) - (tx / (TILE_SIZE / 2))) / 2;
return [ row, col ];
};
type ViewBox = { width: number; height: number; x?: number; y?: number };
export type PointerProjection = {
fromClient: (clientX: number, clientY: number) => { row: number; col: number } | null;
};
export const usePointerToTile = (
svgRef: RefObject<SVGSVGElement | null>,
viewBox: ViewBox
): PointerProjection =>
{
const { width, height, x: viewX = 0, y: viewY = 0 } = 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);
return { row: Math.round(row), col: Math.round(col) };
}, [ svgRef, width, height, viewX, viewY ]);
return useMemo(() => ({ fromClient }), [ fromClient ]);
};
@@ -0,0 +1,87 @@
import { describe, it, expect, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useTool } from './useTool';
import { FloorplanState, FloorplanAction } from '../state/types';
import { initialState } from '../state/reducer';
const withBrush = (action: FloorplanState['brush']['action'], h = 0): FloorplanState =>
({ ...initialState, brush: { h, action } });
const mockProjection = (tile: { row: number; col: number } | null) => ({
fromClient: () => tile
});
describe('useTool', () =>
{
it('SET dispatches PAINT_TILE on pointer down at hit tile', () =>
{
const dispatch = vi.fn();
const { result } = renderHook(() => useTool(withBrush('SET', 3), dispatch as React.Dispatch<FloorplanAction>, mockProjection({ row: 1, col: 2 })));
act(() => result.current.onPointerDown({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never));
expect(dispatch).toHaveBeenCalledWith({ type: 'PAINT_TILE', row: 1, col: 2, h: 3, source: 'local' });
});
it('UNSET dispatches ERASE_TILE', () =>
{
const dispatch = vi.fn();
const { result } = renderHook(() => useTool(withBrush('UNSET'), dispatch as React.Dispatch<FloorplanAction>, mockProjection({ row: 0, col: 0 })));
act(() => result.current.onPointerDown({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never));
expect(dispatch).toHaveBeenCalledWith({ type: 'ERASE_TILE', row: 0, col: 0, source: 'local' });
});
it('UP dispatches ADJUST_HEIGHT delta=+1', () =>
{
const dispatch = vi.fn();
const { result } = renderHook(() => useTool(withBrush('UP'), dispatch as React.Dispatch<FloorplanAction>, mockProjection({ row: 5, col: 6 })));
act(() => result.current.onPointerDown({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never));
expect(dispatch).toHaveBeenCalledWith({ type: 'ADJUST_HEIGHT', row: 5, col: 6, delta: 1, source: 'local' });
});
it('DOWN dispatches ADJUST_HEIGHT delta=-1', () =>
{
const dispatch = vi.fn();
const { result } = renderHook(() => useTool(withBrush('DOWN'), dispatch as React.Dispatch<FloorplanAction>, mockProjection({ row: 1, col: 1 })));
act(() => result.current.onPointerDown({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never));
expect(dispatch).toHaveBeenCalledWith({ type: 'ADJUST_HEIGHT', row: 1, col: 1, delta: -1, source: 'local' });
});
it('DOOR dispatches SET_DOOR with row→y, col→x', () =>
{
const dispatch = vi.fn();
const { result } = renderHook(() => useTool(withBrush('DOOR'), dispatch as React.Dispatch<FloorplanAction>, mockProjection({ row: 4, col: 7 })));
act(() => result.current.onPointerDown({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never));
expect(dispatch).toHaveBeenCalledWith({ type: 'SET_DOOR', x: 7, y: 4, source: 'local' });
});
it('does nothing when projection returns null', () =>
{
const dispatch = vi.fn();
const { result } = renderHook(() => useTool(withBrush('SET'), dispatch as React.Dispatch<FloorplanAction>, mockProjection(null)));
act(() => result.current.onPointerDown({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never));
expect(dispatch).not.toHaveBeenCalled();
});
it('PAINT continues on pointer move when dragging', () =>
{
const dispatch = vi.fn();
let projTile: { row: number; col: number } = { row: 0, col: 0 };
const projection = { fromClient: () => projTile };
const { result } = renderHook(() => useTool(withBrush('SET', 0), dispatch as React.Dispatch<FloorplanAction>, projection));
act(() => result.current.onPointerDown({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never));
dispatch.mockClear();
projTile = { row: 0, col: 1 };
act(() => result.current.onPointerMove({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never));
expect(dispatch).toHaveBeenCalledWith({ type: 'PAINT_TILE', row: 0, col: 1, h: 0, source: 'local' });
});
it('PAINT does not re-dispatch on move within same tile', () =>
{
const dispatch = vi.fn();
const projection = { fromClient: () => ({ row: 0, col: 0 }) };
const { result } = renderHook(() => useTool(withBrush('SET'), dispatch as React.Dispatch<FloorplanAction>, projection));
act(() => result.current.onPointerDown({ clientX: 0, clientY: 0, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never));
dispatch.mockClear();
act(() => result.current.onPointerMove({ clientX: 1, clientY: 1, pointerId: 1, currentTarget: { setPointerCapture: () => {} } } as never));
expect(dispatch).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,73 @@
import { Dispatch, PointerEvent, useCallback, useRef } from 'react';
import { FloorplanAction, FloorplanState } from '../state/types';
import { PointerProjection } from './usePointerToTile';
type Handlers = {
onPointerDown: (e: PointerEvent<SVGSVGElement>) => void;
onPointerMove: (e: PointerEvent<SVGSVGElement>) => void;
onPointerUp: (e: PointerEvent<SVGSVGElement>) => void;
};
const tileKey = (row: number, col: number) => `${ row },${ col }` as const;
const dispatchForBrush = (
action: FloorplanState['brush']['action'],
h: number,
row: number,
col: number,
dispatch: Dispatch<FloorplanAction>
): void =>
{
switch(action)
{
case 'SET': dispatch({ type: 'PAINT_TILE', row, col, h, source: 'local' }); return;
case 'UNSET': dispatch({ type: 'ERASE_TILE', row, col, source: 'local' }); return;
case 'UP': dispatch({ type: 'ADJUST_HEIGHT', row, col, delta: 1, source: 'local' }); return;
case 'DOWN': dispatch({ type: 'ADJUST_HEIGHT', row, col, delta: -1, source: 'local' }); return;
case 'DOOR': dispatch({ type: 'SET_DOOR', x: col, y: row, source: 'local' }); return;
}
};
export const useTool = (
state: FloorplanState,
dispatch: Dispatch<FloorplanAction>,
projection: PointerProjection
): Handlers =>
{
const isDownRef = useRef(false);
const lastTileRef = useRef<string | null>(null);
const apply = useCallback((e: PointerEvent<SVGSVGElement>) =>
{
const hit = projection.fromClient(e.clientX, e.clientY);
if(!hit) return;
const key = tileKey(hit.row, hit.col);
if(key === lastTileRef.current) return;
lastTileRef.current = key;
dispatchForBrush(state.brush.action, state.brush.h, hit.row, hit.col, dispatch);
}, [ projection, state.brush.action, state.brush.h, dispatch ]);
const onPointerDown = useCallback((e: PointerEvent<SVGSVGElement>) =>
{
isDownRef.current = true;
lastTileRef.current = null;
try { e.currentTarget.setPointerCapture?.(e.pointerId); } catch {}
apply(e);
}, [ apply ]);
const onPointerMove = useCallback((e: PointerEvent<SVGSVGElement>) =>
{
if(!isDownRef.current) return;
if(state.brush.action === 'DOOR') return; // door is a single-click placement
apply(e);
}, [ apply, state.brush.action ]);
const onPointerUp = useCallback((e: PointerEvent<SVGSVGElement>) =>
{
isDownRef.current = false;
lastTileRef.current = null;
try { e.currentTarget.releasePointerCapture?.(e.pointerId); } catch {}
}, []);
return { onPointerDown, onPointerMove, onPointerUp };
};
@@ -0,0 +1,15 @@
export {
FloorAction,
HEIGHT_SCHEME,
COLORMAP,
TILE_SIZE,
MAX_NUM_TILE_PER_AXIS
} from '@nitrots/nitro-renderer';
export const MIN_WALL_HEIGHT = 0;
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 };
@@ -0,0 +1,169 @@
import { describe, it, expect } from 'vitest';
import {
parseTilemap,
serializeTilemap,
tileToChar,
charToTile
} from './encoding';
describe('charToTile', () =>
{
it('returns blocked for x', () =>
{
expect(charToTile('x')).toEqual({ h: 0, blocked: true });
});
it('returns h=0 for "0"', () =>
{
expect(charToTile('0')).toEqual({ h: 0, blocked: false });
});
it('returns h=9 for "9"', () =>
{
expect(charToTile('9')).toEqual({ h: 9, blocked: false });
});
it('returns h=10 for "a"', () =>
{
expect(charToTile('a')).toEqual({ h: 10, blocked: false });
});
it('returns h=26 for "q"', () =>
{
expect(charToTile('q')).toEqual({ h: 26, blocked: false });
});
it('treats uppercase X as blocked', () =>
{
expect(charToTile('X')).toEqual({ h: 0, blocked: true });
});
it('returns blocked for any unknown char (defensive)', () =>
{
expect(charToTile('?')).toEqual({ h: 0, blocked: true });
});
});
describe('tileToChar', () =>
{
it('returns x for blocked tile', () =>
{
expect(tileToChar({ h: 5, blocked: true })).toBe('x');
});
it('returns "0" for h=0 non-blocked', () =>
{
expect(tileToChar({ h: 0, blocked: false })).toBe('0');
});
it('returns "q" for h=26 non-blocked', () =>
{
expect(tileToChar({ h: 26, blocked: false })).toBe('q');
});
it('clamps out-of-range h to nearest valid', () =>
{
expect(tileToChar({ h: -1, blocked: false })).toBe('0');
expect(tileToChar({ h: 99, blocked: false })).toBe('q');
});
it('treats NaN h as h=0 on non-blocked tile (does not collapse to blocked)', () =>
{
expect(tileToChar({ h: NaN, blocked: false })).toBe('0');
});
});
describe('parseTilemap', () =>
{
it('returns empty grid for empty string', () =>
{
expect(parseTilemap('')).toEqual([]);
});
it('parses a single row', () =>
{
expect(parseTilemap('00x0')).toEqual([
[
{ h: 0, blocked: false },
{ h: 0, blocked: false },
{ h: 0, blocked: true },
{ h: 0, blocked: false }
]
]);
});
it('parses multiple rows separated by \\r', () =>
{
const raw = '00\rxx\r12';
const grid = parseTilemap(raw);
expect(grid).toHaveLength(3);
expect(grid[0]).toHaveLength(2);
expect(grid[1][0].blocked).toBe(true);
expect(grid[2][1]).toEqual({ h: 2, blocked: false });
});
it('also accepts \\r\\n as row separator', () =>
{
const raw = '00\r\nxx';
const grid = parseTilemap(raw);
expect(grid).toHaveLength(2);
expect(grid[1][1].blocked).toBe(true);
});
it('also accepts \\n alone as row separator (textarea normalization)', () =>
{
const raw = '00\nxq';
const grid = parseTilemap(raw);
expect(grid).toHaveLength(2);
expect(grid[0]).toHaveLength(2);
expect(grid[1][1]).toEqual({ h: 26, blocked: false });
});
it('pads short rows with blocked tiles so the grid is rectangular', () =>
{
const raw = '000\rx';
const grid = parseTilemap(raw);
expect(grid[1]).toHaveLength(3);
expect(grid[1][1]).toEqual({ h: 0, blocked: true });
expect(grid[1][2]).toEqual({ h: 0, blocked: true });
});
});
describe('serializeTilemap', () =>
{
it('returns empty string for empty grid', () =>
{
expect(serializeTilemap([])).toBe('');
});
it('serializes a single row with no separator', () =>
{
const grid = [[
{ h: 0, blocked: false },
{ h: 1, blocked: false },
{ h: 0, blocked: true }
]];
expect(serializeTilemap(grid)).toBe('01x');
});
it('separates rows with \\r', () =>
{
const grid = [
[{ h: 0, blocked: false }, { h: 0, blocked: false }],
[{ h: 0, blocked: true }, { h: 26, blocked: false }]
];
expect(serializeTilemap(grid)).toBe('00\rxq');
});
it('round-trips parse → serialize', () =>
{
const raw = '0123\rxxqq\r1234';
expect(serializeTilemap(parseTilemap(raw))).toBe(raw);
});
it('jagged-row round-trip normalizes short rows with x-padding', () =>
{
const raw = '000\rx';
expect(serializeTilemap(parseTilemap(raw))).toBe('000\rxxx');
});
});
@@ -0,0 +1,47 @@
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
export const charToTile = (ch: string): Tile =>
{
const lower = ch.toLowerCase();
const idx = VALID_CHARS.indexOf(lower);
if(idx <= 0) return { h: 0, blocked: true };
return { h: idx - 1, blocked: false };
};
export const tileToChar = (tile: Tile): string =>
{
if(tile.blocked) return 'x';
const h = Number.isFinite(tile.h)
? Math.max(HMIN, Math.min(HMAX, tile.h))
: HMIN;
return VALID_CHARS.charAt(h + 1);
};
export const parseTilemap = (raw: string): Tile[][] =>
{
if(!raw) return [];
const cleaned = raw.split(/\r\n|\r|\n/).filter(r => r.length > 0);
if(cleaned.length === 0) return [];
const width = cleaned.reduce((m, r) => Math.max(m, r.length), 0);
return cleaned.map(rowStr =>
{
const cells: Tile[] = [];
for(let i = 0; i < width; i++)
{
cells.push(i < rowStr.length ? charToTile(rowStr.charAt(i)) : { h: 0, blocked: true });
}
return cells;
});
};
export const serializeTilemap = (tiles: Tile[][]): string =>
{
if(!tiles || tiles.length === 0) return '';
return tiles.map(row => row.map(tileToChar).join('')).join('\r');
};
@@ -0,0 +1,326 @@
import { describe, it, expect } from 'vitest';
import { reducer, initialState } from './reducer';
import { FloorplanState } from './types';
import { defaultEmptyTilemap } from './selectors';
const stateWith = (tiles: FloorplanState['tiles']): FloorplanState => ({
...initialState,
tiles
});
describe('reducer — PAINT_TILE', () =>
{
it('sets tile to {h, blocked: false}', () =>
{
const start = stateWith(defaultEmptyTilemap(2, 2));
const next = reducer(start, { type: 'PAINT_TILE', row: 0, col: 1, h: 5, source: 'local' });
expect(next.tiles[0][1]).toEqual({ h: 5, blocked: false });
expect(next.tiles[0][0]).toEqual({ h: 0, blocked: true });
});
it('clamps h to 0..26', () =>
{
const start = stateWith(defaultEmptyTilemap(1, 1));
const next = reducer(start, { type: 'PAINT_TILE', row: 0, col: 0, h: 99, source: 'local' });
expect(next.tiles[0][0].h).toBe(26);
});
it('grows the grid to fit out-of-bounds rows/cols', () =>
{
const start = stateWith(defaultEmptyTilemap(1, 1));
const next = reducer(start, { type: 'PAINT_TILE', row: 2, col: 3, h: 0, source: 'local' });
expect(next.tiles).toHaveLength(3);
expect(next.tiles[2]).toHaveLength(4);
expect(next.tiles[2][3]).toEqual({ h: 0, blocked: false });
expect(next.tiles[0][0]).toEqual({ h: 0, blocked: true });
});
it('caps growth at MAX_NUM_TILE_PER_AXIS', () =>
{
const start = stateWith(defaultEmptyTilemap(1, 1));
const next = reducer(start, { type: 'PAINT_TILE', row: 99, col: 99, h: 0, source: 'local' });
expect(next.tiles).toHaveLength(64);
expect(next.tiles[0]).toHaveLength(64);
});
it('returns the same reference if no change (idempotent painting)', () =>
{
const tile = { h: 5, blocked: false };
const start = stateWith([[tile]]);
const next = reducer(start, { type: 'PAINT_TILE', row: 0, col: 0, h: 5, source: 'local' });
expect(next).toBe(start);
});
});
describe('reducer — ERASE_TILE', () =>
{
it('marks tile as blocked', () =>
{
const start = stateWith([[{ h: 5, blocked: false }]]);
const next = reducer(start, { type: 'ERASE_TILE', row: 0, col: 0, source: 'local' });
expect(next.tiles[0][0]).toEqual({ h: 5, blocked: true });
});
it('is a no-op outside the grid', () =>
{
const start = stateWith(defaultEmptyTilemap(1, 1));
const next = reducer(start, { type: 'ERASE_TILE', row: 5, col: 5, source: 'local' });
expect(next).toBe(start);
});
});
describe('reducer — ADJUST_HEIGHT', () =>
{
it('increments height by 1', () =>
{
const start = stateWith([[{ h: 5, blocked: false }]]);
const next = reducer(start, { type: 'ADJUST_HEIGHT', row: 0, col: 0, delta: 1, source: 'local' });
expect(next.tiles[0][0]).toEqual({ h: 6, blocked: false });
});
it('decrements height by 1', () =>
{
const start = stateWith([[{ h: 5, blocked: false }]]);
const next = reducer(start, { type: 'ADJUST_HEIGHT', row: 0, col: 0, delta: -1, source: 'local' });
expect(next.tiles[0][0]).toEqual({ h: 4, blocked: false });
});
it('clamps at 26 going up', () =>
{
const start = stateWith([[{ h: 26, blocked: false }]]);
const next = reducer(start, { type: 'ADJUST_HEIGHT', row: 0, col: 0, delta: 1, source: 'local' });
expect(next.tiles[0][0].h).toBe(26);
});
it('clamps at 0 going down', () =>
{
const start = stateWith([[{ h: 0, blocked: false }]]);
const next = reducer(start, { type: 'ADJUST_HEIGHT', row: 0, col: 0, delta: -1, source: 'local' });
expect(next.tiles[0][0].h).toBe(0);
});
it('is a no-op on blocked tiles', () =>
{
const start = stateWith([[{ h: 5, blocked: true }]]);
const next = reducer(start, { type: 'ADJUST_HEIGHT', row: 0, col: 0, delta: 1, source: 'local' });
expect(next).toBe(start);
});
});
describe('reducer — SET_DOOR', () =>
{
it('updates door position', () =>
{
const next = reducer(initialState, { type: 'SET_DOOR', x: 3, y: 4, source: 'local' });
expect(next.door).toEqual({ x: 3, y: 4, dir: 2 });
});
it('preserves door direction', () =>
{
const start = { ...initialState, door: { x: 0, y: 0, dir: 5 as const } };
const next = reducer(start, { type: 'SET_DOOR', x: 1, y: 1, source: 'local' });
expect(next.door).toEqual({ x: 1, y: 1, dir: 5 });
});
});
describe('reducer — SET_DOOR_DIR', () =>
{
it('updates direction', () =>
{
const next = reducer(initialState, { type: 'SET_DOOR_DIR', dir: 7, source: 'local' });
expect(next.door.dir).toBe(7);
});
});
describe('reducer — SET_THICKNESS', () =>
{
it('updates wall only', () =>
{
const next = reducer(initialState, { type: 'SET_THICKNESS', wall: 3, source: 'local' });
expect(next.thickness).toEqual({ wall: 3, floor: 1 });
});
it('updates floor only', () =>
{
const next = reducer(initialState, { type: 'SET_THICKNESS', floor: 0, source: 'local' });
expect(next.thickness).toEqual({ wall: 1, floor: 0 });
});
it('updates both', () =>
{
const next = reducer(initialState, { type: 'SET_THICKNESS', wall: 2, floor: 3, source: 'local' });
expect(next.thickness).toEqual({ wall: 2, floor: 3 });
});
});
describe('reducer — SET_WALL_HEIGHT', () =>
{
it('updates wallHeight clamped to 0..16', () =>
{
expect(reducer(initialState, { type: 'SET_WALL_HEIGHT', value: 5, source: 'local' }).wallHeight).toBe(5);
expect(reducer(initialState, { type: 'SET_WALL_HEIGHT', value: 99, source: 'local' }).wallHeight).toBe(16);
expect(reducer(initialState, { type: 'SET_WALL_HEIGHT', value: -3, source: 'local' }).wallHeight).toBe(0);
});
});
describe('reducer — BRUSH_SET', () =>
{
it('updates h only', () =>
{
const next = reducer(initialState, { type: 'BRUSH_SET', h: 10 });
expect(next.brush).toEqual({ h: 10, action: 'SET' });
});
it('updates action only', () =>
{
const next = reducer(initialState, { type: 'BRUSH_SET', action: 'DOOR' });
expect(next.brush).toEqual({ h: 0, action: 'DOOR' });
});
});
describe('reducer — selection', () =>
{
it('SELECT_ALL marks every non-blocked tile', () =>
{
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.has('0,0')).toBe(true);
expect(next.selection.has('0,1')).toBe(false);
expect(next.selection.has('1,1')).toBe(true);
});
it('CLEAR_SELECTION empties it', () =>
{
const start = { ...initialState, selection: new Set(['0,0', '1,1']) as ReadonlySet<`${number},${number}`> };
const next = reducer(start, { type: 'CLEAR_SELECTION' });
expect(next.selection.size).toBe(0);
});
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 })))
} as FloorplanState;
const next = reducer(populated, { type: 'SELECT_RECT', from: [ 1, 1 ], to: [ 2, 3 ] });
const keys = Array.from(next.selection).sort();
expect(keys).toEqual([ '1,1', '1,2', '1,3', '2,1', '2,2', '2,3' ].sort());
});
it('SQUARE_SELECT_TOGGLE flips the flag', () =>
{
const a = reducer(initialState, { type: 'SQUARE_SELECT_TOGGLE' });
expect(a.squareSelect).toBe(true);
const b = reducer(a, { type: 'SQUARE_SELECT_TOGGLE' });
expect(b.squareSelect).toBe(false);
});
});
describe('reducer — IMPORT_STRING', () =>
{
it('replaces tilemap with parsed string', () =>
{
const start = stateWith(defaultEmptyTilemap(1, 1));
const next = reducer(start, { type: 'IMPORT_STRING', raw: '01\rxq', source: 'local' });
expect(next.tiles).toHaveLength(2);
expect(next.tiles[0]).toEqual([
{ h: 0, blocked: false },
{ h: 1, blocked: false }
]);
expect(next.tiles[1]).toEqual([
{ h: 0, blocked: true },
{ h: 26, blocked: false }
]);
});
it('optionally updates door, thickness, wallHeight', () =>
{
const next = reducer(initialState, {
type: 'IMPORT_STRING',
raw: '00',
door: { x: 5, y: 6, dir: 4 },
thickness: { wall: 3, floor: 2 },
wallHeight: 8,
source: 'local'
});
expect(next.door).toEqual({ x: 5, y: 6, dir: 4 });
expect(next.thickness).toEqual({ wall: 3, floor: 2 });
expect(next.wallHeight).toBe(8);
});
});
describe('reducer — APPLY_REMOTE_DIFF', () =>
{
it('applies tile edits without re-broadcasting (source agnostic)', () =>
{
const start = stateWith([[{ h: 0, blocked: false }]]);
const next = reducer(start, {
type: 'APPLY_REMOTE_DIFF',
diff: { tiles: [{ row: 0, col: 0, h: 7, blocked: false }] },
seq: 1,
editorUserId: 42
});
expect(next.tiles[0][0]).toEqual({ h: 7, blocked: false });
expect(next.seq).toBe(1);
});
it('records last seq', () =>
{
const start = stateWith([[{ h: 0, blocked: false }]]);
const a = reducer(start, { type: 'APPLY_REMOTE_DIFF', diff: { tiles: [{ row: 0, col: 0, h: 1, blocked: false }] }, seq: 5, editorUserId: 1 });
expect(a.seq).toBe(5);
});
it('applies door/thickness/wallHeight from diff', () =>
{
const next = reducer(initialState, {
type: 'APPLY_REMOTE_DIFF',
diff: { door: { x: 2, y: 3, dir: 0 }, thickness: { wall: 0, floor: 0 }, wallHeight: 4 },
seq: 1,
editorUserId: 99
});
expect(next.door).toEqual({ x: 2, y: 3, dir: 0 });
expect(next.thickness).toEqual({ wall: 0, floor: 0 });
expect(next.wallHeight).toBe(4);
});
});
describe('reducer — APPLY_REMOTE_SNAPSHOT', () =>
{
it('replaces full state from snapshot', () =>
{
const next = reducer(initialState, {
type: 'APPLY_REMOTE_SNAPSHOT',
raw: '01\rxq',
door: { x: 1, y: 1, dir: 3 },
thickness: { wall: 2, floor: 3 },
wallHeight: 9,
seq: 100
});
expect(next.tiles).toHaveLength(2);
expect(next.door).toEqual({ x: 1, y: 1, dir: 3 });
expect(next.thickness).toEqual({ wall: 2, floor: 3 });
expect(next.wallHeight).toBe(9);
expect(next.seq).toBe(100);
});
it('clears selection on snapshot apply', () =>
{
const start = { ...initialState, selection: new Set([ '0,0' ]) as ReadonlySet<`${number},${number}`> };
const next = reducer(start, {
type: 'APPLY_REMOTE_SNAPSHOT',
raw: '0',
door: initialState.door,
thickness: initialState.thickness,
wallHeight: 0,
seq: 1
});
expect(next.selection.size).toBe(0);
});
});
@@ -0,0 +1,190 @@
import { FloorplanAction, FloorplanState, Tile } from './types';
import { MAX_NUM_TILE_PER_AXIS, EMPTY_DOOR, MIN_WALL_HEIGHT, MAX_WALL_HEIGHT } from './constants';
import { parseTilemap } from './encoding';
export const initialState: FloorplanState = {
tiles: [],
door: { ...EMPTY_DOOR },
thickness: { wall: 1, floor: 1 },
wallHeight: -1,
brush: { h: 0, action: 'SET' },
selection: new Set<`${number},${number}`>(),
squareSelect: false,
lease: { holder: null, me: false, expiresAt: null },
seq: 0
};
const clampHeight = (h: number): number => Math.max(0, Math.min(26, h | 0));
const clamp64 = (n: number): number => Math.max(0, Math.min(MAX_NUM_TILE_PER_AXIS - 1, n | 0));
const ensureRect = (tiles: Tile[][], rows: number, cols: number): Tile[][] =>
{
const tRows = Math.min(MAX_NUM_TILE_PER_AXIS, Math.max(rows, tiles.length));
const tCols = Math.min(MAX_NUM_TILE_PER_AXIS, Math.max(cols, tiles[0]?.length ?? 0));
if(tRows === tiles.length && (tiles[0]?.length ?? 0) === tCols) return tiles;
const next: Tile[][] = [];
for(let r = 0; r < tRows; r++)
{
const src = tiles[r] ?? [];
const row: Tile[] = [];
for(let c = 0; c < tCols; c++)
{
row.push(src[c] ?? { h: 0, blocked: true });
}
next.push(row);
}
return next;
};
const setTile = (tiles: Tile[][], row: number, col: number, tile: Tile): Tile[][] =>
{
const current = tiles[row]?.[col];
if(current && current.h === tile.h && current.blocked === tile.blocked) return tiles;
const next = tiles.map((r, ri) => ri === row ? r.map((t, ci) => ci === col ? tile : t) : r);
return next;
};
export const reducer = (state: FloorplanState, action: FloorplanAction): FloorplanState =>
{
switch(action.type)
{
case 'PAINT_TILE':
{
const row = clamp64(action.row);
const col = clamp64(action.col);
const tiles = ensureRect(state.tiles, row + 1, col + 1);
const target = { h: clampHeight(action.h), blocked: false };
const next = setTile(tiles, row, col, target);
if(next === tiles && tiles === state.tiles) return state;
return { ...state, tiles: next };
}
case 'ERASE_TILE':
{
const row = action.row | 0;
const col = action.col | 0;
if(row < 0 || col < 0 || row >= state.tiles.length || col >= (state.tiles[0]?.length ?? 0)) return state;
const current = state.tiles[row][col];
const target = { h: current.h, blocked: true };
const next = setTile(state.tiles, row, col, target);
if(next === state.tiles) return state;
return { ...state, tiles: next };
}
case 'ADJUST_HEIGHT':
{
const row = action.row | 0;
const col = action.col | 0;
if(row < 0 || col < 0 || row >= state.tiles.length || col >= (state.tiles[0]?.length ?? 0)) return state;
const current = state.tiles[row][col];
if(current.blocked) return state;
const newH = clampHeight(current.h + action.delta);
if(newH === current.h) return state;
const next = setTile(state.tiles, row, col, { h: newH, blocked: false });
return { ...state, tiles: next };
}
case 'SET_DOOR':
{
const x = clamp64(action.x);
const y = clamp64(action.y);
if(state.door.x === x && state.door.y === y) return state;
return { ...state, door: { ...state.door, x, y } };
}
case 'SET_DOOR_DIR':
{
if(state.door.dir === action.dir) return state;
return { ...state, door: { ...state.door, dir: action.dir } };
}
case 'SET_THICKNESS':
{
const wall = action.wall ?? state.thickness.wall;
const floor = action.floor ?? state.thickness.floor;
if(wall === state.thickness.wall && floor === state.thickness.floor) return state;
return { ...state, thickness: { wall, floor } };
}
case 'SET_WALL_HEIGHT':
{
const value = Math.max(MIN_WALL_HEIGHT, Math.min(MAX_WALL_HEIGHT, action.value | 0));
if(value === state.wallHeight) return state;
return { ...state, wallHeight: value };
}
case 'BRUSH_SET':
{
const h = action.h ?? state.brush.h;
const act = action.action ?? state.brush.action;
if(h === state.brush.h && act === state.brush.action) return state;
return { ...state, brush: { h: clampHeight(h), action: act } };
}
case 'SELECT_ALL':
{
const sel = new Set<`${number},${number}`>();
for(let r = 0; r < state.tiles.length; r++)
{
for(let c = 0; c < (state.tiles[r]?.length ?? 0); c++)
{
if(!state.tiles[r][c].blocked) sel.add(`${r},${c}`);
}
}
return { ...state, selection: sel };
}
case 'CLEAR_SELECTION':
return state.selection.size === 0 ? state : { ...state, selection: new Set() };
case 'SELECT_RECT':
{
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 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}`);
}
}
return { ...state, selection: sel };
}
case 'SQUARE_SELECT_TOGGLE':
return { ...state, squareSelect: !state.squareSelect };
case 'IMPORT_STRING':
{
const tiles = parseTilemap(action.raw);
const next: FloorplanState = { ...state, tiles };
if(action.door) next.door = action.door;
if(action.thickness) next.thickness = action.thickness;
if(action.wallHeight !== undefined) next.wallHeight = Math.max(MIN_WALL_HEIGHT, Math.min(MAX_WALL_HEIGHT, action.wallHeight | 0));
return next;
}
case 'APPLY_REMOTE_DIFF':
{
let next: FloorplanState = { ...state, seq: action.seq };
if(action.diff.tiles)
{
let tiles = next.tiles;
for(const e of action.diff.tiles)
{
tiles = ensureRect(tiles, e.row + 1, e.col + 1);
tiles = setTile(tiles, e.row, e.col, { h: clampHeight(e.h), blocked: e.blocked });
}
next.tiles = tiles;
}
if(action.diff.door) next.door = action.diff.door;
if(action.diff.thickness) next.thickness = action.diff.thickness;
if(action.diff.wallHeight !== undefined) next.wallHeight = Math.max(MIN_WALL_HEIGHT, Math.min(MAX_WALL_HEIGHT, action.diff.wallHeight | 0));
return next;
}
case 'APPLY_REMOTE_SNAPSHOT':
{
return {
...state,
tiles: parseTilemap(action.raw),
door: action.door,
thickness: action.thickness,
wallHeight: Math.max(MIN_WALL_HEIGHT, Math.min(MAX_WALL_HEIGHT, action.wallHeight | 0)),
selection: new Set(),
seq: action.seq
};
}
default:
return state;
}
};
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest';
import {
areaCount,
brushChar,
tileFill,
defaultEmptyTilemap
} from './selectors';
import { HEIGHT_SCHEME } from './constants';
describe('areaCount', () =>
{
it('returns zeros for empty grid', () =>
{
expect(areaCount([])).toEqual({ total: 0, walkable: 0 });
});
it('counts total = walkable when no blocked tiles', () =>
{
const grid = [
[{ h: 0, blocked: false }, { h: 1, blocked: false }],
[{ h: 2, blocked: false }, { h: 3, blocked: false }]
];
expect(areaCount(grid)).toEqual({ total: 4, walkable: 4 });
});
it('excludes blocked from walkable but counts in total', () =>
{
const grid = [
[{ h: 0, blocked: false }, { h: 0, blocked: true }],
[{ h: 2, blocked: false }, { h: 3, blocked: false }]
];
expect(areaCount(grid)).toEqual({ total: 3, walkable: 3 });
});
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 }]
];
expect(areaCount(grid)).toEqual({ total: 2, walkable: 2 });
});
});
describe('brushChar', () =>
{
it('h=0 → "0"', () => expect(brushChar(0)).toBe('0'));
it('h=26 → "q"', () => expect(brushChar(26)).toBe('q'));
it('clamps below to "0"', () => expect(brushChar(-5)).toBe('0'));
it('clamps above to "q"', () => expect(brushChar(99)).toBe('q'));
});
describe('tileFill', () =>
{
it('returns COLORMAP entry for non-blocked tile', () =>
{
const fill = tileFill({ h: 0, blocked: false });
expect(fill).toBe('#0065ff');
});
it('returns COLORMAP entry for blocked tile', () =>
{
expect(tileFill({ h: 5, blocked: true })).toBe('#101010');
});
});
describe('defaultEmptyTilemap', () =>
{
it('returns a rows×cols grid of blocked tiles', () =>
{
const grid = defaultEmptyTilemap(3, 4);
expect(grid).toHaveLength(3);
expect(grid[0]).toHaveLength(4);
expect(grid[0][0]).toEqual({ h: 0, blocked: true });
expect(grid[2][3]).toEqual({ h: 0, blocked: true });
});
});
@@ -0,0 +1,43 @@
import { Tile } from './types';
import { HEIGHT_SCHEME, COLORMAP, HEIGHT_BRUSH_MIN, HEIGHT_BRUSH_MAX } from './constants';
export const areaCount = (tiles: Tile[][]): { total: number; walkable: number } =>
{
let total = 0;
let walkable = 0;
for(const row of tiles)
{
for(const tile of row)
{
if(tile.blocked) continue;
total++;
walkable++;
}
}
return { total, walkable };
};
export const brushChar = (h: number): string =>
{
const clamped = Math.max(HEIGHT_BRUSH_MIN, Math.min(HEIGHT_BRUSH_MAX, h));
return HEIGHT_SCHEME.charAt(clamped + 1);
};
export const tileFill = (tile: Tile): string =>
{
const ch = tile.blocked ? 'x' : HEIGHT_SCHEME.charAt(Math.max(0, Math.min(26, tile.h)) + 1);
const hex = (COLORMAP as Record<string, string>)[ch] ?? '101010';
return `#${ hex }`;
};
export const defaultEmptyTilemap = (rows: number, cols: number): Tile[][] =>
{
const grid: Tile[][] = [];
for(let r = 0; r < rows; r++)
{
const row: Tile[] = [];
for(let c = 0; c < cols; c++) row.push({ h: 0, blocked: true });
grid.push(row);
}
return grid;
};
@@ -0,0 +1,49 @@
export type Tile = { h: number; blocked: boolean };
export type EntryDir = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
export type ThicknessLevel = 0 | 1 | 2 | 3;
export type Door = { x: number; y: number; dir: EntryDir };
export type FloorActionMode = 'SET' | 'UNSET' | 'UP' | 'DOWN' | 'DOOR';
export type Brush = { h: number; action: FloorActionMode };
export type Selection = ReadonlySet<`${number},${number}`>;
export type Lease = {
holder: number | null;
me: boolean;
expiresAt: number | null;
};
export type FloorplanState = {
tiles: Tile[][];
door: Door;
thickness: { wall: ThicknessLevel; floor: ThicknessLevel };
wallHeight: number;
brush: Brush;
selection: Selection;
squareSelect: boolean;
lease: Lease;
seq: number;
};
export type LocalSource = 'local' | 'remote';
export type FloorplanAction =
| { type: 'PAINT_TILE'; row: number; col: number; h: number; source: LocalSource }
| { type: 'ERASE_TILE'; row: number; col: number; source: LocalSource }
| { type: 'ADJUST_HEIGHT'; row: number; col: number; delta: 1 | -1; source: LocalSource }
| { type: 'SET_DOOR'; x: number; y: number; source: LocalSource }
| { type: 'SET_DOOR_DIR'; dir: EntryDir; source: LocalSource }
| { type: 'SET_THICKNESS'; wall?: ThicknessLevel; floor?: ThicknessLevel; source: LocalSource }
| { type: 'SET_WALL_HEIGHT'; value: number; source: LocalSource }
| { type: 'BRUSH_SET'; h?: number; action?: FloorActionMode }
| { type: 'SELECT_RECT'; from: [number, number]; to: [number, number] }
| { type: 'SELECT_ALL' }
| { type: 'CLEAR_SELECTION' }
| { 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 }
| { type: 'APPLY_REMOTE_SNAPSHOT'; raw: string; door: Door; thickness: { wall: ThicknessLevel; floor: ThicknessLevel }; wallHeight: number; seq: number };
@@ -0,0 +1,60 @@
import { describe, it, expect, vi } from 'vitest';
import { fireEvent, render } from '@testing-library/react';
import { FloorplanCanvasSVG } from './FloorplanCanvasSVG';
import { initialState } from '../state/reducer';
describe('FloorplanCanvasSVG', () =>
{
it('renders one polygon per non-blocked tile', () =>
{
const state = {
...initialState,
tiles: [
[{ h: 0, blocked: false }, { h: 1, blocked: true }],
[{ h: 2, blocked: false }, { h: 3, blocked: false }]
]
};
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);
});
it('renders door marker on the door tile', () =>
{
const state = {
...initialState,
tiles: [[{ h: 0, blocked: false }, { h: 0, blocked: false }]],
door: { x: 1, y: 0, dir: 2 as const }
};
const { container } = render(<FloorplanCanvasSVG state={ state } dispatch={ () => {} } />);
expect(container.querySelector('[data-testid="door-marker"]')).toBeTruthy();
});
it('forwards pointer events to a tool dispatch (PAINT_TILE with brush)', () =>
{
const state = {
...initialState,
tiles: [[{ h: 0, blocked: false }]],
brush: { h: 0, action: 'SET' as const }
};
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();
const call = dispatch.mock.calls[0][0];
expect(call.type).toBe('PAINT_TILE');
});
it('zoom in/out buttons adjust the viewBox', () =>
{
const { container } = render(<FloorplanCanvasSVG state={ initialState } dispatch={ () => {} } />);
const svg = container.querySelector('svg') as SVGSVGElement;
const initialVB = svg.getAttribute('viewBox');
fireEvent.click(container.querySelector('[data-testid="zoom-in"]') as Element);
expect(svg.getAttribute('viewBox')).not.toBe(initialVB);
});
});
@@ -0,0 +1,305 @@
import { Dispatch, FC, PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState, WheelEvent } from 'react';
import { FaCrosshairs, FaSearchMinus, FaSearchPlus } from 'react-icons/fa';
import { FloorplanAction, FloorplanState } from '../state/types';
import { FloorplanTile } from './FloorplanTile';
import { tileToScreen, usePointerToTile } from '../hooks/usePointerToTile';
import { useTool } from '../hooks/useTool';
import { TILE_SIZE, MAX_NUM_TILE_PER_AXIS } from '../state/constants';
type Props = {
state: FloorplanState;
dispatch: Dispatch<FloorplanAction>;
/**
* When true, left-click + drag pans the canvas instead of
* brushing. Driven by the hand-tool toggle in the toolbar.
* Shift+drag and middle-mouse drag always pan regardless of
* this flag.
*/
panMode?: boolean;
};
const VIEWBOX_W = 2048;
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;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
let found = false;
for(let r = 0; r < state.tiles.length; r++)
{
const row = state.tiles[r];
if(!row) continue;
for(let c = 0; c < row.length; c++)
{
const tile = row[c];
if(!tile || tile.blocked) continue;
const [ x, y ] = tileToScreen(r, c);
const tileLeft = x - TILE_SIZE / 2;
const tileRight = x + TILE_SIZE / 2;
const tileTop = y;
const tileBottom = y + TILE_SIZE / 2;
if(tileLeft < minX) minX = tileLeft;
if(tileRight > maxX) maxX = tileRight;
if(tileTop < minY) minY = tileTop;
if(tileBottom > maxY) maxY = tileBottom;
found = true;
}
}
if(!found) return null;
return {
x: minX - FIT_PADDING,
y: minY - FIT_PADDING,
w: (maxX - minX) + FIT_PADDING * 2,
h: (maxY - minY) + FIT_PADDING * 2
};
};
export const FloorplanCanvasSVG: FC<Props> = ({ state, dispatch, panMode }) =>
{
const svgRef = useRef<SVGSVGElement | null>(null);
const [ zoom, setZoom ] = useState(1);
const [ pan, setPan ] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
const [ isPanning, setIsPanning ] = 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;
return {
x: roomBounds.x + roomBounds.w / 2 - VIEWBOX_W / 2,
y: roomBounds.y + roomBounds.h / 2 - VIEWBOX_H / 2
};
}, [ 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;
const zoomFitX = VIEWBOX_W / roomBounds.w;
const zoomFitY = VIEWBOX_H / roomBounds.h;
const targetZoom = clampZoom(Math.min(zoomFitX, zoomFitY) * 0.95);
const next = centerPanForRoom();
if(!next) return;
setZoom(targetZoom);
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;
if(!roomBounds) return;
centeredRef.current = true;
fitToRoom();
}, [ roomBounds, fitToRoom ]);
const visW = VIEWBOX_W / zoom;
const visH = VIEWBOX_H / zoom;
const baseX = (VIEWBOX_W - visW) / 2;
const baseY = (VIEWBOX_H - visH) / 2;
const viewX = baseX + pan.x;
const viewY = baseY + pan.y;
const viewBox = `${ viewX } ${ viewY } ${ visW } ${ visH }`;
const projection = usePointerToTile(svgRef, { width: visW, height: visH, x: viewX, y: viewY });
const tool = useTool(state, dispatch, projection);
const rows = useMemo(() => state.tiles.map((row, r) =>
{
const cells = row.map((tile, c) =>
{
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 } />;
});
return <g key={ `row-${ r }` }>{ cells }</g>;
}), [ state.tiles, state.door.x, state.door.y, state.selection ]);
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();
}, [ fitToRoom ]);
const onWheel = useCallback((e: WheelEvent<SVGSVGElement>) =>
{
if(!(e.ctrlKey || e.metaKey)) return;
e.preventDefault();
setZoom(z => clampZoom(z + (e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP)));
}, []);
useEffect(() =>
{
const onMove = (e: PointerEvent) =>
{
const start = panStartRef.current;
if(!start) return;
const dx = e.clientX - start.x;
const dy = e.clientY - start.y;
const rect = svgRef.current?.getBoundingClientRect();
if(!rect) return;
const scale = visW / rect.width;
setPan({
x: start.panX - dx * scale,
y: start.panY - dy * scale
});
};
const onUp = () =>
{
panStartRef.current = null;
setIsPanning(false);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
return () =>
{
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
}, [ 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)
|| (e.button === 0 && Boolean(panMode));
const cursorClass = isPanning ? 'cursor-grabbing' : panMode ? 'cursor-grab' : '';
return (
<div className="relative w-full h-full">
<svg
ref={ svgRef }
viewBox={ viewBox }
className={ `w-full h-full select-none rounded-md border border-zinc-300 bg-[url('@/assets/images/floorplaneditor/canvas_floor_pattern.png')] bg-repeat [image-rendering:pixelated] ${ cursorClass }` }
onWheel={ onWheel }
onPointerDown={ e =>
{
if(isPanGesture(e))
{
e.preventDefault();
panStartRef.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y };
setIsPanning(true);
return;
}
tool.onPointerDown(e);
} }
onPointerMove={ e =>
{
if(panStartRef.current) return;
tool.onPointerMove(e);
} }
onPointerUp={ e =>
{
if(panStartRef.current) return;
tool.onPointerUp(e);
} }
>
{ rows }
</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)"
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 }
>
<FaSearchMinus size={ 12 } />
</button>
<button
type="button"
data-testid="zoom-reset"
title="Inquadra la stanza"
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 }
>
{ Math.round(zoom * 100) }%
</button>
<button
type="button"
data-testid="zoom-in"
title="Ingrandisci (Ctrl+rotellina)"
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 }
>
<FaSearchPlus size={ 12 } />
</button>
<span className="w-px h-5 bg-zinc-300 mx-1" />
<button
type="button"
data-testid="zoom-recenter"
title="Centra sulla stanza (mantiene lo 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={ () =>
{
const next = centerPanForRoom();
if(next) setPan(next);
} }
>
<FaCrosshairs size={ 12 } />
</button>
</div>
</div>
);
};
@@ -1,179 +0,0 @@
import { GetOccupiedTilesMessageComposer, GetRoomEntryTileMessageComposer, RoomEntryTileMessageEvent, RoomOccupiedTilesMessageEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useRef, useState } from 'react';
import { FaPlus, FaMinus } from 'react-icons/fa';
import { SendMessageComposer } from '../../../api';
import { Base, Column, ColumnProps } from '../../../common';
import { useMessageEvent } from '../../../hooks';
import { useFloorplanEditorContext } from '../FloorplanEditorContext';
import { FloorplanEditor } from '@nitrots/nitro-renderer';
interface FloorplanCanvasViewProps extends ColumnProps
{
}
export const FloorplanCanvasView: FC<FloorplanCanvasViewProps> = props =>
{
const { gap = 1, children = null, ...rest } = props;
const [ occupiedTilesReceived, setOccupiedTilesReceived ] = useState(false);
const [ entryTileReceived, setEntryTileReceived ] = useState(false);
const [ zoomLevel, setZoomLevel ] = useState(1.0);
const { originalFloorplanSettings = null, setOriginalFloorplanSettings = null, setVisualizationSettings = null } = useFloorplanEditorContext();
const elementRef = useRef<HTMLDivElement>(null);
const canvasWrapperRef = useRef<HTMLDivElement>(null);
useMessageEvent<RoomOccupiedTilesMessageEvent>(RoomOccupiedTilesMessageEvent, event =>
{
const parser = event.getParser();
setOriginalFloorplanSettings(prevValue =>
{
const newValue = { ...prevValue };
newValue.reservedTiles = parser.blockedTilesMap;
FloorplanEditor.instance.setTilemap(newValue.tilemap, newValue.reservedTiles);
return newValue;
});
setOccupiedTilesReceived(true);
elementRef.current.scrollTo((FloorplanEditor.instance.renderer.canvas.width / 3), 0);
});
useMessageEvent<RoomEntryTileMessageEvent>(RoomEntryTileMessageEvent, event =>
{
const parser = event.getParser();
setOriginalFloorplanSettings(prevValue =>
{
const newValue = { ...prevValue };
newValue.entryPoint = [ parser.x, parser.y ];
newValue.entryPointDir = parser.direction;
return newValue;
});
setVisualizationSettings(prevValue =>
{
const newValue = { ...prevValue };
newValue.entryPointDir = parser.direction;
return newValue;
});
FloorplanEditor.instance.doorLocation = { x: parser.x, y: parser.y };
setEntryTileReceived(true);
});
const onPointerEvent = (event: PointerEvent) =>
{
event.preventDefault();
switch(event.type)
{
case 'pointerout':
case 'pointerup':
FloorplanEditor.instance.onPointerRelease(event);
break;
case 'pointerdown':
FloorplanEditor.instance.onPointerDown(event);
break;
case 'pointermove':
FloorplanEditor.instance.onPointerMove(event);
break;
}
};
const zoomIn = () => setZoomLevel(prev => Math.min(prev + 0.25, 2.0));
const zoomOut = () => setZoomLevel(prev => Math.max(prev - 0.25, 0.5));
useEffect(() =>
{
return () =>
{
FloorplanEditor.instance.clear();
setVisualizationSettings(prevValue =>
{
return {
wallHeight: originalFloorplanSettings.wallHeight,
thicknessWall: originalFloorplanSettings.thicknessWall,
thicknessFloor: originalFloorplanSettings.thicknessFloor,
entryPointDir: prevValue.entryPointDir
};
});
};
}, [ originalFloorplanSettings.thicknessFloor, originalFloorplanSettings.thicknessWall, originalFloorplanSettings.wallHeight, setVisualizationSettings ]);
useEffect(() =>
{
if(!entryTileReceived || !occupiedTilesReceived) return;
FloorplanEditor.instance.renderTiles();
}, [ entryTileReceived, occupiedTilesReceived ]);
useEffect(() =>
{
SendMessageComposer(new GetRoomEntryTileMessageComposer());
SendMessageComposer(new GetOccupiedTilesMessageComposer());
const currentElement = elementRef.current;
if(!currentElement) return;
const wrapper = canvasWrapperRef.current;
if(wrapper) wrapper.appendChild(FloorplanEditor.instance.renderer.canvas);
currentElement.addEventListener('pointerup', onPointerEvent);
currentElement.addEventListener('pointerout', onPointerEvent);
currentElement.addEventListener('pointerdown', onPointerEvent);
currentElement.addEventListener('pointermove', onPointerEvent);
return () =>
{
if(currentElement)
{
currentElement.removeEventListener('pointerup', onPointerEvent);
currentElement.removeEventListener('pointerout', onPointerEvent);
currentElement.removeEventListener('pointerdown', onPointerEvent);
currentElement.removeEventListener('pointermove', onPointerEvent);
}
};
}, []);
return (
<Column gap={ gap } { ...rest } className="relative flex-1">
<Base overflow="auto" innerRef={ elementRef } className="flex-1">
<div
ref={ canvasWrapperRef }
style={ {
transform: `scale(${ zoomLevel })`,
transformOrigin: '0 0'
} }
/>
</Base>
<div className="absolute top-2 right-2 flex flex-col gap-1 z-10">
<button
className="w-[28px] h-[28px] flex items-center justify-center rounded bg-[#1e7295] text-white border border-transparent shadow cursor-pointer hover:brightness-110"
onClick={ zoomIn }
title="Zoom in"
>
<FaPlus size={ 10 } />
</button>
<button
className="w-[28px] h-[28px] flex items-center justify-center rounded bg-[#1e7295] text-white border border-transparent shadow cursor-pointer hover:brightness-110"
onClick={ zoomOut }
title="Zoom out"
>
<FaMinus size={ 10 } />
</button>
</div>
{ children }
</Column>
);
};
@@ -0,0 +1,142 @@
/* @vitest-environment jsdom */
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) =>
{
const original = HTMLDivElement.prototype.getBoundingClientRect;
HTMLDivElement.prototype.getBoundingClientRect = function ()
{
if(this.getAttribute('data-testid') === 'height-track')
{
return {
top,
left: 0,
right: 14,
bottom: top + TRACK_HEIGHT,
width: 14,
height: TRACK_HEIGHT,
x: 0,
y: top,
toJSON: () => ''
} as DOMRect;
}
return original.call(this);
};
return () =>
{
HTMLDivElement.prototype.getBoundingClientRect = original;
};
};
describe('FloorplanHeightPicker', () =>
{
afterEach(() =>
{
cleanup();
});
it('renders the track + thumb with the current value', () =>
{
render(<FloorplanHeightPicker selectedH={ 12 } onSelect={ () => undefined } />);
const thumb = screen.getByTestId('height-thumb');
expect(thumb).toBeInTheDocument();
expect(thumb.textContent).toBe('12');
});
it('clicking near the top of the track picks HEIGHT_BRUSH_MAX', () =>
{
const restore = stubTrackGeometry();
const onSelect = vi.fn();
render(<FloorplanHeightPicker selectedH={ 0 } onSelect={ onSelect } />);
const track = screen.getByTestId('height-track');
fireEvent.pointerDown(track, { clientY: 0, button: 0 });
expect(onSelect).toHaveBeenCalledWith(26);
restore();
});
it('clicking near the bottom of the track picks HEIGHT_BRUSH_MIN', () =>
{
const restore = stubTrackGeometry();
const onSelect = vi.fn();
render(<FloorplanHeightPicker selectedH={ 26 } onSelect={ onSelect } />);
const track = screen.getByTestId('height-track');
fireEvent.pointerDown(track, { clientY: TRACK_HEIGHT, button: 0 });
expect(onSelect).toHaveBeenCalledWith(0);
restore();
});
it('clicking at the middle picks roughly the middle height', () =>
{
const restore = stubTrackGeometry();
const onSelect = vi.fn();
render(<FloorplanHeightPicker selectedH={ 0 } onSelect={ onSelect } />);
const track = screen.getByTestId('height-track');
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();
});
it('does not fire onSelect when the picked height equals the current selection', () =>
{
const restore = stubTrackGeometry();
const onSelect = vi.fn();
render(<FloorplanHeightPicker selectedH={ 26 } onSelect={ onSelect } />);
const track = screen.getByTestId('height-track');
fireEvent.pointerDown(track, { clientY: 0, button: 0 });
expect(onSelect).not.toHaveBeenCalled();
restore();
});
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');
rerender(<FloorplanHeightPicker selectedH={ 13 } onSelect={ () => undefined } />);
const colourAtThirteen = screen.getByTestId('height-thumb').getAttribute('data-thumb-color');
expect(colourAtZero).toBeTruthy();
expect(colourAtThirteen).toBeTruthy();
expect(colourAtZero).not.toBe(colourAtThirteen);
});
});
@@ -0,0 +1,188 @@
import { FC, PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { HEIGHT_BRUSH_MAX, HEIGHT_BRUSH_MIN } from '../state/constants';
import { tileFill } from '../state/selectors';
type Props = {
selectedH: number;
onSelect: (h: number) => void;
};
const TRACK_W = 18;
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('#', '');
if(c.length !== 6) return true;
const r = parseInt(c.slice(0, 2), 16);
const g = parseInt(c.slice(2, 4), 16);
const b = parseInt(c.slice(4, 6), 16);
const luma = (0.299 * r) + (0.587 * g) + (0.114 * b);
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;
const trackRef = useRef<HTMLDivElement>(null);
const [ isDragging, setIsDragging ] = useState(false);
const [ isHovering, setIsHovering ] = useState(false);
const gradient = useMemo(() =>
{
const stops: string[] = [];
for(let i = 0; i < count; i++)
{
const h = HEIGHT_BRUSH_MAX - i;
const fill = tileFill({ h, blocked: false });
const startPct = (i / count) * 100;
const endPct = ((i + 1) / count) * 100;
stops.push(`${ fill } ${ startPct.toFixed(2) }%`);
stops.push(`${ fill } ${ endPct.toFixed(2) }%`);
}
return `linear-gradient(to bottom, ${ stops.join(', ') })`;
}, [ count ]);
const heightFromClientY = useCallback((clientY: number): number | null =>
{
const track = trackRef.current;
if(!track) return null;
const rect = track.getBoundingClientRect();
if(rect.height === 0) return null;
const local = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
const idx = Math.round(local * (count - 1));
return HEIGHT_BRUSH_MAX - idx;
}, [ count ]);
const onPointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) =>
{
if(e.button !== 0) return;
const next = heightFromClientY(e.clientY);
if(next !== null && next !== selectedH) onSelect(next);
setIsDragging(true);
}, [ heightFromClientY, onSelect, selectedH ]);
useEffect(() =>
{
if(!isDragging) return;
const onMove = (e: PointerEvent) =>
{
const next = heightFromClientY(e.clientY);
if(next !== null && next !== selectedH) onSelect(next);
};
const onUp = () => setIsDragging(false);
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
return () =>
{
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
};
}, [ isDragging, heightFromClientY, onSelect, selectedH ]);
const thumbPct = ((HEIGHT_BRUSH_MAX - selectedH) / (count - 1)) * 100;
const thumbColor = tileFill({ h: selectedH, blocked: false });
const thumbTextDark = isLightColor(thumbColor);
return (
<div
className="relative shrink-0 select-none touch-none flex flex-col items-center"
style={ { width: THUMB_DIAM + RAIL_GUTTER * 2, height: TRACK_H + 32 } }
role="slider"
aria-label="Altezza pennello"
aria-valuemin={ HEIGHT_BRUSH_MIN }
aria-valuemax={ HEIGHT_BRUSH_MAX }
aria-valuenow={ selectedH }
>
<span className="text-[9px] font-bold tabular-nums text-zinc-500 leading-none mb-1">
{ HEIGHT_BRUSH_MAX }
</span>
<div className="relative flex-1" style={ { width: THUMB_DIAM } }>
<div
ref={ trackRef }
data-testid="height-track"
className={ `absolute left-1/2 -translate-x-1/2 top-0 bottom-0 rounded-full border border-zinc-400 cursor-pointer overflow-hidden transition-shadow ${ isHovering || isDragging ? 'shadow-[inset_0_0_0_1px_rgba(255,255,255,0.4),0_0_0_2px_rgba(250,204,21,0.35)]' : 'shadow-inner' }` }
style={ {
width: TRACK_W,
background: gradient
} }
onPointerDown={ onPointerDown }
onPointerEnter={ () => setIsHovering(true) }
onPointerLeave={ () => setIsHovering(false) }
/>
<div
data-testid="height-thumb"
data-thumb-color={ thumbColor }
className={ `absolute left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full flex items-center justify-center text-[11px] font-bold tabular-nums pointer-events-none transition-[box-shadow,transform] ${ thumbTextDark ? 'text-zinc-900' : 'text-white' } ${ isDragging ? 'ring-2 ring-zinc-900 scale-110' : isHovering ? 'ring-2 ring-white' : '' }` }
style={ {
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)',
textShadow: thumbTextDark ? '0 1px 0 rgba(255, 255, 255, 0.6)' : '0 1px 1px rgba(0, 0, 0, 0.55)'
} }
>
{ selectedH }
</div>
</div>
<span className="text-[9px] font-bold tabular-nums text-zinc-500 leading-none mt-1">
{ HEIGHT_BRUSH_MIN }
</span>
</div>
);
};
@@ -1,54 +0,0 @@
import { FC } from 'react';
import { COLORMAP, FloorAction, HEIGHT_SCHEME } from '@nitrots/nitro-renderer';
import { FloorplanEditor } from '@nitrots/nitro-renderer';
import { Column, Text } from '../../../common';
import { useFloorplanEditorContext } from '../FloorplanEditorContext';
const colormap = COLORMAP as Record<string, string>;
export const FloorplanHeightSelector: FC<{}> = () =>
{
const { floorHeight, setFloorHeight, setFloorAction } = useFloorplanEditorContext();
const onSelectHeight = (height: number) =>
{
setFloorHeight(height);
setFloorAction(FloorAction.SET);
FloorplanEditor.instance.actionSettings.currentAction = FloorAction.SET;
FloorplanEditor.instance.actionSettings.currentHeight = height.toString(36);
};
const heights: number[] = [];
for(let i = 26; i >= 0; i--) heights.push(i);
return (
<Column className="h-full w-[30px] min-w-[30px] select-none">
<Text bold small center>{ floorHeight }</Text>
<div className="flex flex-col flex-1 rounded overflow-hidden border-2 border-muted">
{ heights.map(h =>
{
const char = HEIGHT_SCHEME[h + 1];
const color = colormap[char] || '101010';
const isActive = (floorHeight === h);
return (
<div
key={ h }
className="flex-1 cursor-pointer relative flex items-center justify-center"
style={ {
backgroundColor: `#${ color }`,
outline: isActive ? '2px solid #fff' : 'none',
outlineOffset: '-2px',
zIndex: isActive ? 1 : 0
} }
onClick={ () => onSelectHeight(h) }
title={ `${ h }` }
/>
);
}) }
</div>
</Column>
);
};
@@ -0,0 +1,49 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { fireEvent, render, cleanup } from '@testing-library/react';
import { FloorplanImportExport } from './FloorplanImportExport';
import { initialState } from '../state/reducer';
describe('FloorplanImportExport', () =>
{
afterEach(() => cleanup());
it('shows serialized tilemap of current state in textarea', () =>
{
const state = {
...initialState,
tiles: [
[{ h: 0, blocked: false }, { h: 1, blocked: false }],
[{ h: 0, blocked: true }, { h: 2, blocked: false }]
]
};
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');
});
it('clicking Load dispatches IMPORT_STRING with textarea content', () =>
{
const dispatch = vi.fn();
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();
fireEvent.click(button);
expect(dispatch).toHaveBeenCalledWith({ type: 'IMPORT_STRING', raw: 'xq\n00', source: 'local' });
});
it('clicking Save invokes onSaveFromText with textarea content', () =>
{
const onSaveFromText = vi.fn();
render(<FloorplanImportExport state={ initialState } dispatch={ () => {} } onClose={ () => {} } onSaveFromText={ onSaveFromText } onRevertText={ () => '' } />);
const ta = document.querySelector('textarea') as HTMLTextAreaElement;
fireEvent.change(ta, { target: { value: '00\n01' } });
const saveBtn = document.querySelector('[data-testid="import-save"]') as HTMLButtonElement;
fireEvent.click(saveBtn);
expect(onSaveFromText).toHaveBeenCalledWith('00\n01');
});
});
@@ -0,0 +1,53 @@
import { Dispatch, FC, useState } from 'react';
import { LocalizeText } from '../../../api';
import { Button, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../common';
import { FloorplanAction, FloorplanState } from '../state/types';
import { serializeTilemap } from '../state/encoding';
type Props = {
state: FloorplanState;
dispatch: Dispatch<FloorplanAction>;
onClose: () => void;
onSaveFromText: (raw: string) => void;
onRevertText: () => string;
};
export const FloorplanImportExport: FC<Props> = ({ state, dispatch, onClose, onSaveFromText, onRevertText }) =>
{
const [ raw, setRaw ] = useState(() => serializeTilemap(state.tiles));
const load = () =>
{
dispatch({ type: 'IMPORT_STRING', raw, source: 'local' });
onClose();
};
const save = () =>
{
onSaveFromText(raw);
onClose();
};
const revert = () =>
{
setRaw(onRevertText());
};
return (
<NitroCardView uniqueKey="floorplan-import-export" theme="primary-slim" className="w-[630px] h-[475px]">
<NitroCardHeaderView headerText={ LocalizeText('floor.plan.editor.import.export') } onCloseClick={ onClose } />
<NitroCardContentView className="flex flex-col gap-2">
<textarea
className="form-control w-full flex-1 font-mono"
value={ raw }
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-save" onClick={ save }>{ LocalizeText('floor.plan.editor.save') }</Button>
</div>
</NitroCardContentView>
</NitroCardView>
);
};
@@ -1,55 +0,0 @@
import { UpdateFloorPropertiesMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { LocalizeText, SendMessageComposer } from '../../../api';
import { Button, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../common';
import { ConvertTileMapToString } from '@nitrots/nitro-renderer';
import { convertNumbersForSaving } from '@nitrots/nitro-renderer';
import { useFloorplanEditorContext } from '../FloorplanEditorContext';
interface FloorplanImportExportViewProps
{
onCloseClick(): void;
}
export const FloorplanImportExportView: FC<FloorplanImportExportViewProps> = props =>
{
const { onCloseClick = null } = props;
const [ map, setMap ] = useState<string>('');
const { originalFloorplanSettings = null } = useFloorplanEditorContext();
const saveFloorChanges = () =>
{
SendMessageComposer(new UpdateFloorPropertiesMessageComposer(
map.split('\n').join('\r'),
originalFloorplanSettings.entryPoint[0],
originalFloorplanSettings.entryPoint[1],
originalFloorplanSettings.entryPointDir,
convertNumbersForSaving(originalFloorplanSettings.thicknessWall),
convertNumbersForSaving(originalFloorplanSettings.thicknessFloor),
originalFloorplanSettings.wallHeight - 1
));
};
useEffect(() =>
{
// changed from UseMountEffect
setMap(ConvertTileMapToString(originalFloorplanSettings.tilemap));
}, []);
return (
<NitroCardView theme="primary-slim" className="w-[630px] h-[475px]">
<NitroCardHeaderView headerText={ LocalizeText('floor.plan.editor.import.export') } onCloseClick={ onCloseClick } />
<NitroCardContentView>
<textarea className="h-100" value={ map } onChange={ event => setMap(event.target.value) } />
<Flex justifyContent="between">
<Button onClick={ event => setMap(ConvertTileMapToString(originalFloorplanSettings.tilemap)) }>
{ LocalizeText('floor.plan.editor.revert.to.last.received.map') }
</Button>
<Button onClick={ saveFloorChanges }>
{ LocalizeText('floor.plan.editor.save') }
</Button>
</Flex>
</NitroCardContentView>
</NitroCardView>
);
};
@@ -0,0 +1,42 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { fireEvent, render, cleanup } from '@testing-library/react';
import { FloorplanOptionsPanel } from './FloorplanOptionsPanel';
import { initialState } from '../state/reducer';
describe('FloorplanOptionsPanel', () =>
{
afterEach(() => cleanup());
it('clicking entry direction cycles 0..7', () =>
{
const dispatch = vi.fn();
const state = { ...initialState, door: { x: 0, y: 0, dir: 2 as const } };
const { getByTestId } = render(<FloorplanOptionsPanel state={ state } dispatch={ dispatch } />);
fireEvent.click(getByTestId('entry-dir'));
expect(dispatch).toHaveBeenCalledWith({ type: 'SET_DOOR_DIR', dir: 3, source: 'local' });
});
it('wraps from 7 back to 0', () =>
{
const dispatch = vi.fn();
const state = { ...initialState, door: { x: 0, y: 0, dir: 7 as const } };
const { getByTestId } = render(<FloorplanOptionsPanel state={ state } dispatch={ dispatch } />);
fireEvent.click(getByTestId('entry-dir'));
expect(dispatch).toHaveBeenCalledWith({ type: 'SET_DOOR_DIR', dir: 0, source: 'local' });
});
it('wall thickness button dispatches SET_THICKNESS', () =>
{
const dispatch = vi.fn();
const { getByTestId } = render(<FloorplanOptionsPanel state={ initialState } dispatch={ dispatch } />);
fireEvent.click(getByTestId('wall-thickness-3'));
expect(dispatch).toHaveBeenCalledWith({ type: 'SET_THICKNESS', wall: 3, source: 'local' });
});
it('floor thickness button dispatches SET_THICKNESS', () =>
{
const dispatch = vi.fn();
const { getByTestId } = render(<FloorplanOptionsPanel state={ initialState } dispatch={ dispatch } />);
fireEvent.click(getByTestId('floor-thickness-0'));
expect(dispatch).toHaveBeenCalledWith({ type: 'SET_THICKNESS', floor: 0, source: 'local' });
});
});
@@ -0,0 +1,110 @@
import { Dispatch, FC } from 'react';
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
import { LocalizeText } from '../../../api';
import { Base, Flex, Text } from '../../../common';
import { EntryDir, FloorplanAction, FloorplanState, ThicknessLevel } from '../state/types';
type Props = {
state: FloorplanState;
dispatch: Dispatch<FloorplanAction>;
};
const THICKNESS_LEVELS: ThicknessLevel[] = [ 0, 1, 2, 3 ];
const THICKNESS_NAMES = [ 'thinnest', 'thin', 'normal', 'thick' ] as const;
const rotateDir = (dir: EntryDir, step: 1 | -1): EntryDir =>
(((dir + step + 8) & 7)) as EntryDir;
export const FloorplanOptionsPanel: FC<Props> = ({ state, dispatch }) =>
{
const setDir = (next: EntryDir) => dispatch({ type: 'SET_DOOR_DIR', dir: next, source: 'local' });
const setWall = (t: ThicknessLevel) => dispatch({ type: 'SET_THICKNESS', wall: t, source: 'local' });
const setFloor = (t: ThicknessLevel) => dispatch({ type: 'SET_THICKNESS', floor: t, source: 'local' });
return (
<Flex gap={ 3 } alignItems="center" className="py-1">
<Flex gap={ 1 } alignItems="center">
<Text bold small className="text-zinc-700">{ LocalizeText('floor.plan.editor.enter.direction') }</Text>
<Flex alignItems="center" gap={ 0 } className="rounded border border-zinc-300 bg-white overflow-hidden">
<Base
data-testid="entry-dir-prev"
pointer
title="Ruota a sinistra"
className="w-7 h-9 flex items-center justify-center text-zinc-600 hover:bg-zinc-100"
onClick={ () => setDir(rotateDir(state.door.dir, -1)) }
>
<FaChevronLeft size={ 12 } />
</Base>
<Base
data-testid="entry-dir"
pointer
title={ `Direzione ${ state.door.dir }/7 (click per ruotare)` }
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"
className="w-7 h-9 flex items-center justify-center text-zinc-600 hover:bg-zinc-100"
onClick={ () => setDir(rotateDir(state.door.dir, 1)) }
>
<FaChevronRight size={ 12 } />
</Base>
</Flex>
</Flex>
<ThicknessSegmented
label="Pareti"
value={ state.thickness.wall }
onChange={ setWall }
testIdPrefix="wall-thickness"
labelKeyPrefix="navigator.roomsettings.wall_thickness"
/>
<ThicknessSegmented
label="Pavimenti"
value={ state.thickness.floor }
onChange={ setFloor }
testIdPrefix="floor-thickness"
labelKeyPrefix="navigator.roomsettings.floor_thickness"
/>
</Flex>
);
};
type SegmentedProps = {
label: string;
value: ThicknessLevel;
onChange: (next: ThicknessLevel) => void;
testIdPrefix: string;
labelKeyPrefix: string;
};
const ThicknessSegmented: FC<SegmentedProps> = ({ label, value, onChange, testIdPrefix, labelKeyPrefix }) =>
{
return (
<Flex gap={ 1 } alignItems="center">
<Text bold small className="text-zinc-700">{ label }</Text>
<Flex className="rounded border border-zinc-300 bg-white overflow-hidden">
{ THICKNESS_LEVELS.map(t =>
{
const active = value === t;
return (
<Base
key={ `${ testIdPrefix }-${ t }` }
data-testid={ `${ testIdPrefix }-${ t }` }
pointer
title={ LocalizeText(`${ labelKeyPrefix }.${ THICKNESS_NAMES[t] }`) }
className={ `px-2 h-9 flex items-center justify-center text-xs ${ active ? 'bg-emerald-500 text-white font-bold' : 'text-zinc-700 hover:bg-zinc-100' } ${ t < THICKNESS_LEVELS.length - 1 ? 'border-r border-zinc-300' : '' }` }
onClick={ () => onChange(t) }
>
{ LocalizeText(`${ labelKeyPrefix }.${ THICKNESS_NAMES[t] }`) }
</Base>
);
}) }
</Flex>
</Flex>
);
};
@@ -1,122 +0,0 @@
import { FC } from 'react';
import { LocalizeText } from '../../../api';
import { Flex, LayoutGridItem, Text } from '../../../common';
import { FloorAction } from '@nitrots/nitro-renderer';
import { FloorplanEditor } from '@nitrots/nitro-renderer';
import { useFloorplanEditorContext } from '../FloorplanEditorContext';
interface FloorplanOptionsViewProps
{
}
export const FloorplanOptionsView: FC<FloorplanOptionsViewProps> = props =>
{
const { visualizationSettings = null, setVisualizationSettings = null, floorAction, setFloorAction } = useFloorplanEditorContext();
const isSquareSelectMode = FloorplanEditor.instance.isSquareSelectMode;
const selectAction = (action: number) =>
{
setFloorAction(action);
FloorplanEditor.instance.actionSettings.currentAction = action;
};
const toggleSquareSelectMode = () =>
{
FloorplanEditor.instance.toggleSquareSelectMode();
// force re-render by toggling action to same value
setFloorAction(prev => prev);
};
const changeDoorDirection = () =>
{
setVisualizationSettings(prevValue =>
{
const newValue = { ...prevValue };
if(newValue.entryPointDir < 7)
{
++newValue.entryPointDir;
}
else
{
newValue.entryPointDir = 0;
}
return newValue;
});
};
const onWallThicknessChange = (value: number) =>
{
setVisualizationSettings(prevValue =>
{
const newValue = { ...prevValue };
newValue.thicknessWall = value;
return newValue;
});
};
const onFloorThicknessChange = (value: number) =>
{
setVisualizationSettings(prevValue =>
{
const newValue = { ...prevValue };
newValue.thicknessFloor = value;
return newValue;
});
};
return (
<Flex gap={ 2 } alignItems="center">
<Flex gap={ 1 } alignItems="center">
<Text bold small>{ LocalizeText('floor.plan.editor.draw.mode') }</Text>
<Flex gap={ 1 }>
<LayoutGridItem itemActive={ (floorAction === FloorAction.SET) } onClick={ () => selectAction(FloorAction.SET) }>
<i className="nitro-icon icon-set-tile" />
</LayoutGridItem>
<LayoutGridItem itemActive={ (floorAction === FloorAction.UNSET) } onClick={ () => selectAction(FloorAction.UNSET) }>
<i className="nitro-icon icon-unset-tile" />
</LayoutGridItem>
<LayoutGridItem itemActive={ (floorAction === FloorAction.UP) } onClick={ () => selectAction(FloorAction.UP) }>
<i className="nitro-icon icon-increase-height" />
</LayoutGridItem>
<LayoutGridItem itemActive={ (floorAction === FloorAction.DOWN) } onClick={ () => selectAction(FloorAction.DOWN) }>
<i className="nitro-icon icon-decrease-height" />
</LayoutGridItem>
<LayoutGridItem itemActive={ (floorAction === FloorAction.DOOR) } onClick={ () => selectAction(FloorAction.DOOR) }>
<i className="nitro-icon icon-set-door" />
</LayoutGridItem>
<LayoutGridItem onClick={ () => FloorplanEditor.instance.toggleSelectAll() }>
<i className={ `nitro-icon ${ floorAction === FloorAction.UNSET ? 'icon-set-deselect' : 'icon-set-select' }` } />
</LayoutGridItem>
<LayoutGridItem itemActive={ isSquareSelectMode } onClick={ toggleSquareSelectMode }>
<i className={ `nitro-icon ${ isSquareSelectMode ? 'icon-set-active-squaresselect' : 'icon-set-squaresselect' }` } />
</LayoutGridItem>
</Flex>
</Flex>
<Flex gap={ 1 } alignItems="center">
<Text bold small>{ LocalizeText('floor.plan.editor.enter.direction') }</Text>
<i className={ `nitro-icon icon-door-direction-${ visualizationSettings.entryPointDir } cursor-pointer` } onClick={ changeDoorDirection } />
</Flex>
<Flex gap={ 1 } alignItems="center" className="ml-auto">
<select className="form-control form-control-sm" value={ visualizationSettings.thicknessWall } onChange={ event => onWallThicknessChange(parseInt(event.target.value)) }>
<option value={ 0 }>{ LocalizeText('navigator.roomsettings.wall_thickness.thinnest') }</option>
<option value={ 1 }>{ LocalizeText('navigator.roomsettings.wall_thickness.thin') }</option>
<option value={ 2 }>{ LocalizeText('navigator.roomsettings.wall_thickness.normal') }</option>
<option value={ 3 }>{ LocalizeText('navigator.roomsettings.wall_thickness.thick') }</option>
</select>
<select className="form-control form-control-sm" value={ visualizationSettings.thicknessFloor } onChange={ event => onFloorThicknessChange(parseInt(event.target.value)) }>
<option value={ 0 }>{ LocalizeText('navigator.roomsettings.floor_thickness.thinnest') }</option>
<option value={ 1 }>{ LocalizeText('navigator.roomsettings.floor_thickness.thin') }</option>
<option value={ 2 }>{ LocalizeText('navigator.roomsettings.floor_thickness.normal') }</option>
<option value={ 3 }>{ LocalizeText('navigator.roomsettings.floor_thickness.thick') }</option>
</select>
</Flex>
</Flex>
);
};
@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { FloorplanPreviewSVG } from './FloorplanPreviewSVG';
import { initialState } from '../state/reducer';
describe('FloorplanPreviewSVG', () =>
{
it('renders nothing for empty tilemap', () =>
{
const { container } = render(<FloorplanPreviewSVG state={ initialState } />);
expect(container.querySelector('polygon')).toBeNull();
});
it('renders a floor polygon per non-blocked tile', () =>
{
const state = {
...initialState,
tiles: [
[{ h: 0, blocked: false }, { h: 0, blocked: true }],
[{ h: 0, blocked: false }, { h: 0, blocked: false }]
]
};
const { container } = render(<FloorplanPreviewSVG state={ state } />);
expect(container.querySelectorAll('[data-role="floor"]')).toHaveLength(3);
});
it('renders wall polygons when wallHeight > 0', () =>
{
const state = {
...initialState,
wallHeight: 4,
tiles: [
[{ h: 0, blocked: false }, { h: 0, blocked: false }],
[{ h: 0, blocked: false }, { h: 0, blocked: false }]
]
};
const { container } = render(<FloorplanPreviewSVG state={ state } />);
expect(container.querySelectorAll('[data-role="wall"]').length).toBeGreaterThan(0);
});
it('does NOT render walls when wallHeight is 0 or negative', () =>
{
const state = {
...initialState,
wallHeight: 0,
tiles: [[{ h: 0, blocked: false }]]
};
const { container } = render(<FloorplanPreviewSVG state={ state } />);
expect(container.querySelectorAll('[data-role="wall"]')).toHaveLength(0);
});
});
@@ -0,0 +1,77 @@
import { FC, useMemo } from 'react';
import { FloorplanState, Tile } from '../state/types';
import { tileFill } from '../state/selectors';
import { TILE_SIZE } from '../state/constants';
import { tileToScreen } from '../hooks/usePointerToTile';
const WALL_FILL_LEFT = '#8a8a8a';
const WALL_FILL_BACK = '#cfcfcf';
const diamond = (row: number, col: number, h: number): string =>
{
const [ cx, cyBase ] = tileToScreen(row, col);
const cy = cyBase - h * (TILE_SIZE / 8);
const half = TILE_SIZE / 2;
const quarter = TILE_SIZE / 4;
return `${ cx },${ cy - quarter } ${ cx + half },${ cy } ${ cx },${ cy + quarter } ${ cx - half },${ cy }`;
};
const wallBack = (row: number, col: number, wallH: number): string =>
{
const [ cx, cyBase ] = tileToScreen(row, col);
const half = TILE_SIZE / 2;
const quarter = TILE_SIZE / 4;
const top = cyBase - quarter;
const wallPx = wallH * TILE_SIZE;
return `${ cx },${ top } ${ cx + half },${ top + quarter } ${ cx + half },${ top + quarter - wallPx } ${ cx },${ top - wallPx }`;
};
const wallLeft = (row: number, col: number, wallH: number): string =>
{
const [ cx, cyBase ] = tileToScreen(row, col);
const half = TILE_SIZE / 2;
const quarter = TILE_SIZE / 4;
const top = cyBase - quarter;
const wallPx = wallH * TILE_SIZE;
return `${ cx - half },${ top + quarter } ${ cx },${ top } ${ cx },${ top - wallPx } ${ cx - half },${ top + quarter - wallPx }`;
};
const isPlaced = (t: Tile | undefined): boolean => !!t && !t.blocked;
export const FloorplanPreviewSVG: FC<{ state: FloorplanState }> = ({ state }) =>
{
const elements = useMemo(() =>
{
const out: React.ReactNode[] = [];
for(let r = 0; r < state.tiles.length; r++)
{
const row = state.tiles[r];
for(let c = 0; c < row.length; c++)
{
const t = row[c];
if(!isPlaced(t)) continue;
out.push(<polygon key={ `f-${ r }-${ c }` } data-role="floor" points={ diamond(r, c, t.h) } fill={ tileFill(t) } stroke="#222" strokeWidth={ 0.4 } />);
if(state.wallHeight > 0)
{
const above = state.tiles[r - 1]?.[c];
const left = state.tiles[r]?.[c - 1];
if(!isPlaced(above))
{
out.push(<polygon key={ `wb-${ r }-${ c }` } data-role="wall" points={ wallBack(r, c, state.wallHeight) } fill={ WALL_FILL_BACK } stroke="#333" strokeWidth={ 0.4 } />);
}
if(!isPlaced(left))
{
out.push(<polygon key={ `wl-${ r }-${ c }` } data-role="wall" points={ wallLeft(r, c, state.wallHeight) } fill={ WALL_FILL_LEFT } stroke="#333" strokeWidth={ 0.4 } />);
}
}
}
}
return out;
}, [ state.tiles, state.wallHeight ]);
return (
<svg viewBox="0 0 2048 1024" className="w-full h-full bg-black">
{ elements }
</svg>
);
};
@@ -1,328 +0,0 @@
import { FC, useEffect, useRef } from 'react';
import { COLORMAP, HEIGHT_SCHEME, FloorplanEditor } from '@nitrots/nitro-renderer';
import { useFloorplanEditorContext } from '../FloorplanEditorContext';
const colormap = COLORMAP as Record<string, string>;
const PREVIEW_TILE_W = 16;
const PREVIEW_TILE_H = 8;
const PREVIEW_BLOCK_H = 5;
const WALL_HEIGHT_PX = 40;
const WALL_COLOR = '#6B7B5E';
const WALL_SIDE_COLOR = '#5A6A4F';
const WALL_TOP_COLOR = '#7D8E6F';
function hexToRgb(hex: string): [number, number, number]
{
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return [ r, g, b ];
}
function rgbToHex(r: number, g: number, b: number): string
{
return `#${ ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1) }`;
}
function darken(hex: string, factor: number): string
{
const [ r, g, b ] = hexToRgb(hex);
return rgbToHex(
Math.floor(r * factor),
Math.floor(g * factor),
Math.floor(b * factor)
);
}
function getTilemapBounds(tilemap: any[][]): { minX: number; minY: number; maxX: number; maxY: number }
{
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for(let y = 0; y < tilemap.length; y++)
{
if(!tilemap[y]) continue;
for(let x = 0; x < tilemap[y].length; x++)
{
if(!tilemap[y][x] || tilemap[y][x].height === 'x') continue;
if(x < minX) minX = x;
if(x > maxX) maxX = x;
if(y < minY) minY = y;
if(y > maxY) maxY = y;
}
}
if(minX === Infinity) return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
return { minX, minY, maxX, maxY };
}
function renderPreview(canvas: HTMLCanvasElement, wallHeight: number): void
{
const ctx = canvas.getContext('2d');
const tilemap = FloorplanEditor.instance.tilemap;
if(!ctx || !tilemap || tilemap.length === 0)
{
if(ctx)
{
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
return;
}
const bounds = getTilemapBounds(tilemap);
const tilesW = bounds.maxX - bounds.minX + 1;
const tilesH = bounds.maxY - bounds.minY + 1;
// find max height for offset calculation
let maxTileHeight = 0;
for(let y = bounds.minY; y <= bounds.maxY; y++)
{
for(let x = bounds.minX; x <= bounds.maxX; x++)
{
if(!tilemap[y] || !tilemap[y][x] || tilemap[y][x].height === 'x') continue;
const hi = HEIGHT_SCHEME.indexOf(tilemap[y][x].height) - 1;
if(hi > maxTileHeight) maxTileHeight = hi;
}
}
// calculate isometric bounds
const isoW = (tilesW + tilesH) * PREVIEW_TILE_W;
const isoH = (tilesW + tilesH) * PREVIEW_TILE_H + maxTileHeight * PREVIEW_BLOCK_H + WALL_HEIGHT_PX;
// scale to fit canvas
const scaleX = (canvas.width - 20) / isoW;
const scaleY = (canvas.height - 20) / isoH;
const scale = Math.min(scaleX, scaleY, 3);
const offsetX = (canvas.width - isoW * scale) / 2;
const offsetY = (canvas.height - isoH * scale) / 2 + WALL_HEIGHT_PX * scale * 0.5;
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.translate(offsetX, offsetY);
ctx.scale(scale, scale);
const tw = PREVIEW_TILE_W;
const th = PREVIEW_TILE_H;
function isoX(gx: number, gy: number): number
{
return (gx - bounds.minX - gy + bounds.minY) * tw + (tilesH - 1) * tw;
}
function isoY(gx: number, gy: number): number
{
return (gx - bounds.minX + gy - bounds.minY) * th;
}
function hasActiveTile(gx: number, gy: number): boolean
{
return tilemap[gy] && tilemap[gy][gx] && tilemap[gy][gx].height !== 'x';
}
function getTileHeight(gx: number, gy: number): number
{
if(!hasActiveTile(gx, gy)) return 0;
return Math.max(0, HEIGHT_SCHEME.indexOf(tilemap[gy][gx].height) - 1);
}
// draw walls on north and west edges
const wallH = wallHeight > 0 ? wallHeight * PREVIEW_BLOCK_H + WALL_HEIGHT_PX * 0.3 : WALL_HEIGHT_PX * 0.6;
for(let y = bounds.minY; y <= bounds.maxY; y++)
{
for(let x = bounds.minX; x <= bounds.maxX; x++)
{
if(!hasActiveTile(x, y)) continue;
const tileH = getTileHeight(x, y) * PREVIEW_BLOCK_H;
const cx = isoX(x, y);
const cy = isoY(x, y) - tileH;
// west wall (no tile to the left)
if(!hasActiveTile(x - 1, y))
{
ctx.beginPath();
ctx.moveTo(cx, cy + th);
ctx.lineTo(cx, cy + th - wallH);
ctx.lineTo(cx + tw, cy - wallH);
ctx.lineTo(cx + tw, cy);
ctx.closePath();
ctx.fillStyle = WALL_SIDE_COLOR;
ctx.fill();
ctx.strokeStyle = '#4A5A3F';
ctx.lineWidth = 0.5;
ctx.stroke();
}
// north wall (no tile above)
if(!hasActiveTile(x, y - 1))
{
ctx.beginPath();
ctx.moveTo(cx + tw, cy);
ctx.lineTo(cx + tw, cy - wallH);
ctx.lineTo(cx + tw * 2, cy + th - wallH);
ctx.lineTo(cx + tw * 2, cy + th);
ctx.closePath();
ctx.fillStyle = WALL_COLOR;
ctx.fill();
ctx.strokeStyle = '#4A5A3F';
ctx.lineWidth = 0.5;
ctx.stroke();
}
// wall top cap - corner
if(!hasActiveTile(x - 1, y) && !hasActiveTile(x, y - 1))
{
ctx.beginPath();
ctx.moveTo(cx + tw, cy - wallH);
ctx.lineTo(cx + tw + tw * 0.3, cy - wallH - th * 0.3);
ctx.lineTo(cx + tw, cy - wallH - th * 0.6);
ctx.lineTo(cx + tw - tw * 0.3, cy - wallH - th * 0.3);
ctx.closePath();
ctx.fillStyle = WALL_TOP_COLOR;
ctx.fill();
}
}
}
// draw tiles back-to-front
for(let y = bounds.minY; y <= bounds.maxY; y++)
{
for(let x = bounds.minX; x <= bounds.maxX; x++)
{
if(!hasActiveTile(x, y)) continue;
const tile = tilemap[y][x];
const heightIndex = HEIGHT_SCHEME.indexOf(tile.height) - 1;
const tileH = Math.max(0, heightIndex) * PREVIEW_BLOCK_H;
const cx = isoX(x, y);
const cy = isoY(x, y) - tileH;
const heightChar = tile.height;
const baseColor = colormap[heightChar] || 'aaaaaa';
const topColor = `#${ baseColor }`;
const leftColor = darken(baseColor, 0.65);
const rightColor = darken(baseColor, 0.80);
// draw side faces if tile has height
const blockH = Math.max(0, heightIndex) * PREVIEW_BLOCK_H;
// left face (visible when no neighbor to south or neighbor is shorter)
const southH = getTileHeight(x, y + 1);
const leftExpose = hasActiveTile(x, y + 1) ? Math.max(0, heightIndex - southH) * PREVIEW_BLOCK_H : blockH + PREVIEW_BLOCK_H;
if(leftExpose > 0)
{
ctx.beginPath();
ctx.moveTo(cx, cy + th);
ctx.lineTo(cx + tw, cy + th * 2);
ctx.lineTo(cx + tw, cy + th * 2 + leftExpose);
ctx.lineTo(cx, cy + th + leftExpose);
ctx.closePath();
ctx.fillStyle = leftColor;
ctx.fill();
}
// right face
const eastH = getTileHeight(x + 1, y);
const rightExpose = hasActiveTile(x + 1, y) ? Math.max(0, heightIndex - eastH) * PREVIEW_BLOCK_H : blockH + PREVIEW_BLOCK_H;
if(rightExpose > 0)
{
ctx.beginPath();
ctx.moveTo(cx + tw * 2, cy + th);
ctx.lineTo(cx + tw, cy + th * 2);
ctx.lineTo(cx + tw, cy + th * 2 + rightExpose);
ctx.lineTo(cx + tw * 2, cy + th + rightExpose);
ctx.closePath();
ctx.fillStyle = rightColor;
ctx.fill();
}
// top face
ctx.beginPath();
ctx.moveTo(cx + tw, cy);
ctx.lineTo(cx + tw * 2, cy + th);
ctx.lineTo(cx + tw, cy + th * 2);
ctx.lineTo(cx, cy + th);
ctx.closePath();
ctx.fillStyle = topColor;
ctx.fill();
// door indicator
const door = FloorplanEditor.instance.doorLocation;
if(door.x === x && door.y === y)
{
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.fill();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
ctx.stroke();
}
}
}
ctx.restore();
}
export const FloorplanPreviewView: FC<{}> = () =>
{
const { tilemapVersion, visualizationSettings } = useFloorplanEditorContext();
const canvasRef = useRef<HTMLCanvasElement>(null);
const rafRef = useRef<number>(0);
useEffect(() =>
{
if(!canvasRef.current) return;
if(rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() =>
{
const canvas = canvasRef.current;
if(!canvas) return;
const parent = canvas.parentElement;
if(parent)
{
canvas.width = parent.clientWidth;
canvas.height = parent.clientHeight;
}
renderPreview(canvas, visualizationSettings?.wallHeight ?? 0);
});
return () =>
{
if(rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [ tilemapVersion, visualizationSettings?.wallHeight ]);
return (
<div className="flex-1 relative rounded overflow-hidden border-2 border-muted" style={ { minHeight: 200, backgroundColor: '#1a1a1a' } }>
<canvas
ref={ canvasRef }
className="w-full h-full"
/>
</div>
);
};
@@ -0,0 +1,68 @@
import { GetRoomEngine, RoomPreviewer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useState } from 'react';
import { LayoutRoomPreviewerView } from '../../../common/layout/LayoutRoomPreviewerView';
import { serializeTilemap } from '../state/encoding';
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);
useEffect(() =>
{
const instance = new RoomPreviewer(GetRoomEngine(), ++RoomPreviewer.PREVIEW_COUNTER);
setPreviewer(instance);
return () =>
{
instance.dispose();
setPreviewer(prev => (prev === instance ? null : prev));
};
}, []);
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 ]);
if(!previewer) return <div className="w-full" style={ { height } } />;
return <LayoutRoomPreviewerView roomPreviewer={ previewer } height={ height } />;
};
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { FloorplanTile } from './FloorplanTile';
const svg = (children: React.ReactNode) => <svg>{ children }</svg>;
describe('FloorplanTile', () =>
{
it('renders nothing for blocked tile by default', () =>
{
const { container } = render(svg(<FloorplanTile row={ 0 } col={ 0 } tile={ { h: 0, blocked: true } } selected={ false } isDoor={ false } />));
expect(container.querySelector('polygon')).toBeNull();
});
it('renders a polygon for non-blocked tile', () =>
{
const { container } = render(svg(<FloorplanTile row={ 0 } col={ 0 } tile={ { h: 3, blocked: false } } selected={ false } isDoor={ false } />));
const poly = container.querySelector('polygon');
expect(poly).toBeTruthy();
expect(poly?.getAttribute('fill')).toMatch(/^#/);
expect(poly?.getAttribute('points')?.split(' ')).toHaveLength(4);
});
it('renders a door marker when isDoor=true', () =>
{
const { container } = render(svg(<FloorplanTile row={ 1 } col={ 1 } tile={ { h: 0, blocked: false } } selected={ false } isDoor={ true } />));
expect(container.querySelector('[data-testid="door-marker"]')).toBeTruthy();
});
it('applies selection ring when selected', () =>
{
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();
});
});
@@ -0,0 +1,56 @@
import { FC, memo } from 'react';
import { Tile } from '../state/types';
import { tileFill } from '../state/selectors';
import { TILE_SIZE } from '../state/constants';
import { tileToScreen } from '../hooks/usePointerToTile';
type Props = {
row: number;
col: number;
tile: Tile;
selected: boolean;
isDoor: boolean;
};
const diamondPoints = (row: number, col: number, h: number): string =>
{
const [ cx, cyBase ] = tileToScreen(row, col);
const cy = cyBase - h * (TILE_SIZE / 8);
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 }) =>
{
if(tile.blocked) return null;
const points = diamondPoints(row, col, tile.h);
const fill = tileFill(tile);
return (
<g>
<polygon points={ points } fill={ fill } stroke="#222" strokeWidth={ 0.5 } />
{ selected && (
<polygon
data-testid="selection-ring"
points={ points }
fill="none"
stroke="#fff"
strokeWidth={ 2 }
strokeDasharray="3 2"
/>
) }
{ isDoor && (
<polygon
data-testid="door-marker"
points={ points }
fill="rgba(255,255,255,0.85)"
stroke="#000"
strokeWidth={ 1 }
/>
) }
</g>
);
};
export const FloorplanTile = memo(FloorplanTileImpl);
@@ -0,0 +1,47 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { fireEvent, render, cleanup } from '@testing-library/react';
import { FloorplanToolbar } from './FloorplanToolbar';
import { initialState } from '../state/reducer';
describe('FloorplanToolbar', () =>
{
afterEach(() => cleanup());
it('clicking SET button dispatches BRUSH_SET action=SET', () =>
{
const dispatch = vi.fn();
const { getByTestId } = render(<FloorplanToolbar state={ initialState } dispatch={ dispatch } />);
fireEvent.click(getByTestId('tool-set'));
expect(dispatch).toHaveBeenCalledWith({ type: 'BRUSH_SET', action: 'SET' });
});
it('all 5 brush actions are reachable', () =>
{
const dispatch = vi.fn();
const { getByTestId } = render(<FloorplanToolbar state={ initialState } dispatch={ dispatch } />);
fireEvent.click(getByTestId('tool-unset'));
fireEvent.click(getByTestId('tool-up'));
fireEvent.click(getByTestId('tool-down'));
fireEvent.click(getByTestId('tool-door'));
const types = dispatch.mock.calls.map(c => c[0].action);
expect(types).toEqual([ 'UNSET', 'UP', 'DOWN', 'DOOR' ]);
});
it('select-all and square-select dispatch their actions', () =>
{
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' });
});
it('marks active brush button with data-active', () =>
{
const state = { ...initialState, brush: { h: 0, action: 'UP' as const } };
const { getByTestId } = render(<FloorplanToolbar state={ state } dispatch={ () => {} } />);
expect(getByTestId('tool-up').getAttribute('data-active')).toBe('true');
expect(getByTestId('tool-set').getAttribute('data-active')).toBe('false');
});
});
@@ -0,0 +1,122 @@
import { Dispatch, FC } from 'react';
import { FaHandPaper, FaRedo, FaUndo } from 'react-icons/fa';
import { LocalizeText } from '../../../api';
import { Base, Flex, Text } from '../../../common';
import { FloorplanAction, FloorActionMode, FloorplanState } from '../state/types';
type Props = {
state: FloorplanState;
dispatch: Dispatch<FloorplanAction>;
canUndo?: boolean;
canRedo?: boolean;
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;
};
const BRUSH_BUTTONS: { id: string; mode: FloorActionMode; iconClass: string }[] = [
{ id: 'tool-set', mode: 'SET', iconClass: 'icon-set-tile' },
{ id: 'tool-unset', mode: 'UNSET', iconClass: 'icon-unset-tile' },
{ id: 'tool-up', mode: 'UP', iconClass: 'icon-increase-height' },
{ id: 'tool-down', mode: 'DOWN', iconClass: 'icon-decrease-height' },
{ id: 'tool-door', mode: 'DOOR', iconClass: 'icon-set-door' }
];
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);
};
return (
<Flex gap={ 1 } alignItems="center">
<Text bold small>{ LocalizeText('floor.plan.editor.draw.mode') }</Text>
{ setPanMode && (
<Base
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' }
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) }
>
<FaHandPaper size={ 12 } />
</Base>
) }
{ BRUSH_BUTTONS.map(b =>
{
const active = state.brush.action === b.mode && !panMode;
return (
<Base
key={ b.id }
pointer
data-testid={ b.id }
data-active={ active ? 'true' : 'false' }
className={ `nitro-icon ${ b.iconClass } ${ active ? 'border border-primary' : '' }` }
onClick={ () =>
{
exitPan();
dispatch({ type: 'BRUSH_SET', action: b.mode });
} }
/>
);
}) }
<Base
pointer
data-testid="tool-select-all"
className={ `nitro-icon ${ state.selection.size > 0 ? 'icon-set-deselect' : 'icon-set-select' }` }
onClick={ () =>
{
exitPan();
dispatch({ type: 'SELECT_ALL' });
} }
/>
<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' : '' }` }
onClick={ () =>
{
exitPan();
dispatch({ type: 'SQUARE_SELECT_TOGGLE' });
} }
/>
{ (onUndo || onRedo) && (
<Flex gap={ 1 } alignItems="center" className="ml-2 pl-2 border-l border-zinc-300">
<Base
pointer={ Boolean(canUndo) }
data-testid="tool-undo"
title="Annulla (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 }
>
<FaUndo size={ 12 } />
</Base>
<Base
pointer={ Boolean(canRedo) }
data-testid="tool-redo"
title="Ripeti (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 }
>
<FaRedo size={ 12 } />
</Base>
</Flex>
) }
</Flex>
);
};
@@ -0,0 +1,234 @@
/* @vitest-environment jsdom */
import { act, cleanup, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FloorplanState } from '../../../components/floorplan-editor/state/types';
import {
buildLivePreviewPayload,
LivePreviewPayload,
livePreviewPayloadsEqual,
useFloorplanLiveSync
} from './useFloorplanLiveSync';
// Spy on the renderer hook seam — we hand back a manager that
// records calls to applyFloorModelLocally so we can assert what
// the hook pushed to the room.
const applyMock = vi.fn<(model: string, wallHeight: number, scale: boolean) => boolean>();
const thicknessMock = vi.fn<(roomId: number, wall: number, floor: number) => boolean>();
vi.mock('@nitrots/nitro-renderer', async (importOriginal) =>
{
const actual = await importOriginal<typeof import('@nitrots/nitro-renderer')>();
return {
...actual,
GetRoomMessageHandler: () => ({
applyFloorModelLocally: (model: string, wallHeight: number, scale: boolean) =>
applyMock(model, wallHeight, scale)
}),
GetRoomEngine: () => ({
updateRoomInstancePlaneThickness: (roomId: number, wall: number, floor: number) =>
thicknessMock(roomId, wall, floor)
})
};
});
// Pin the active room id so the engine.updateRoomInstancePlaneThickness
// branch is exercised. -1 is the "no active room" sentinel and skips
// the thickness call.
vi.mock('../../session/useSessionSnapshots', () => ({
useActiveRoomSessionSnapshot: () => ({ roomId: 42 })
}));
const baseState: FloorplanState = {
tiles: [
[ { h: 0, blocked: false }, { h: 0, blocked: false } ],
[ { h: 0, blocked: false }, { h: 0, blocked: false } ]
],
door: { x: 1, y: 1, dir: 2 },
thickness: { wall: 1, floor: 1 },
wallHeight: 1,
brush: { h: 0, action: 'SET' },
selection: new Set(),
squareSelect: false,
lease: { holder: null, me: false, expiresAt: null },
seq: 0
};
const samePayload = (s: FloorplanState): LivePreviewPayload => buildLivePreviewPayload(s);
describe('useFloorplanLiveSync', () =>
{
beforeEach(() =>
{
applyMock.mockReset().mockReturnValue(true);
thicknessMock.mockReset().mockReturnValue(true);
});
afterEach(() =>
{
cleanup();
});
it('does not call the renderer before a baseline is set', () =>
{
renderHook(() => useFloorplanLiveSync({ enabled: true, state: baseState }));
expect(applyMock).not.toHaveBeenCalled();
});
it('does not call the renderer when the state equals the baseline', () =>
{
const { result } = renderHook(() => useFloorplanLiveSync({ enabled: true, state: baseState }));
act(() =>
{
result.current.setBaseline(samePayload(baseState));
});
expect(applyMock).not.toHaveBeenCalled();
});
it('applies the new floor model locally on every diverging state change', () =>
{
const { result, rerender } = renderHook(
({ state }: { state: FloorplanState }) => useFloorplanLiveSync({ enabled: true, state }),
{ initialProps: { state: baseState } }
);
act(() =>
{
result.current.setBaseline(samePayload(baseState));
});
const next: FloorplanState = {
...baseState,
tiles: [
[ { h: 1, blocked: false }, { h: 0, blocked: false } ],
[ { h: 0, blocked: false }, { h: 0, blocked: false } ]
]
};
rerender({ state: next });
expect(applyMock).toHaveBeenCalledTimes(1);
// The renderer parser takes server-space wallHeight (editor - 1).
expect(applyMock.mock.calls[0][1]).toBe(0);
expect(applyMock.mock.calls[0][2]).toBe(true);
// Same brush/selection change should NOT push another paint.
rerender({ state: { ...next, selection: new Set([ '0,0' ]) } });
expect(applyMock).toHaveBeenCalledTimes(1);
});
it('mirrors the thickness slider into the renderer engine', () =>
{
const { result, rerender } = renderHook(
({ state }: { state: FloorplanState }) => useFloorplanLiveSync({ enabled: true, state }),
{ initialProps: { state: baseState } }
);
act(() =>
{
result.current.setBaseline(samePayload(baseState));
});
rerender({ state: { ...baseState, thickness: { wall: 3, floor: 2 } } });
// Thickness 3 -> 2.0, thickness 2 -> 1.0
const [ roomId, wall, floor ] = thicknessMock.mock.calls[thicknessMock.mock.calls.length - 1];
expect(roomId).toBe(42);
expect(wall).toBe(2);
expect(floor).toBe(1);
});
it('does not call the renderer when disabled', () =>
{
const { result, rerender } = renderHook(
({ enabled, state }: { enabled: boolean; state: FloorplanState }) =>
useFloorplanLiveSync({ enabled, state }),
{ initialProps: { enabled: false, state: baseState } }
);
act(() =>
{
result.current.setBaseline(samePayload(baseState));
});
rerender({ enabled: false, state: { ...baseState, wallHeight: 5 } });
expect(applyMock).not.toHaveBeenCalled();
});
it('revert re-applies the baseline to the renderer', () =>
{
const { result, rerender } = renderHook(
({ state }: { state: FloorplanState }) => useFloorplanLiveSync({ enabled: true, state }),
{ initialProps: { state: baseState } }
);
act(() =>
{
result.current.setBaseline(samePayload(baseState));
});
rerender({ state: { ...baseState, wallHeight: 7 } });
// One push from the wallHeight bump.
expect(applyMock).toHaveBeenCalledTimes(1);
act(() =>
{
result.current.revert();
});
expect(applyMock).toHaveBeenCalledTimes(2);
// Revert applies the baseline payload, which has wallHeight=1
// → server-space 0.
expect(applyMock.mock.calls[1][1]).toBe(0);
});
it('revert is a no-op when no baseline has been recorded', () =>
{
const { result } = renderHook(() => useFloorplanLiveSync({ enabled: true, state: baseState }));
act(() =>
{
result.current.revert();
});
expect(applyMock).not.toHaveBeenCalled();
});
});
describe('livePreviewPayloadsEqual', () =>
{
const p: LivePreviewPayload = {
tilemap: 'xx\rxx',
doorX: 0,
doorY: 0,
doorDir: 2,
thicknessWall: 1,
thicknessFloor: 1,
wallHeight: 3
};
it('returns true for identical payloads', () =>
{
expect(livePreviewPayloadsEqual(p, { ...p })).toBe(true);
});
it('detects a tilemap diff', () =>
{
expect(livePreviewPayloadsEqual(p, { ...p, tilemap: '00\r00' })).toBe(false);
});
it('detects a wallHeight diff', () =>
{
expect(livePreviewPayloadsEqual(p, { ...p, wallHeight: 4 })).toBe(false);
});
it('detects a door diff', () =>
{
expect(livePreviewPayloadsEqual(p, { ...p, doorDir: 4 })).toBe(false);
});
});
@@ -0,0 +1,170 @@
import { GetRoomEngine, GetRoomMessageHandler } from '@nitrots/nitro-renderer';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { serializeTilemap } from '../../../components/floorplan-editor/state/encoding';
import { FloorplanState } from '../../../components/floorplan-editor/state/types';
import { useActiveRoomSessionSnapshot } from '../../session/useSessionSnapshots';
/**
* Client-side live preview for the floor-plan editor.
*
* Every tile / door / thickness / wallHeight change in the editor
* is applied IMMEDIATELY to the 3D room behind the editor card
* via the renderer's local `RoomMessageHandler.applyFloorModelLocally`
* (added in the renderer's `feat/floorplan-live-preview` branch).
* Nothing is sent to the server until the user explicitly clicks
* Save — at that point `FloorplanEditorView` fires the
* `UpdateFloorPropertiesMessageComposer` directly.
*
* Closing the editor without saving leaves the live preview
* in place visually. To restore the pre-edit room, call `revert`
* — it re-applies the baseline payload locally. The next
* `FloorHeightMapEvent` from the server (e.g. on room re-enter)
* also wins and overwrites whatever preview is in place.
*
* Thickness changes additionally call
* `RoomEngine.updateRoomInstancePlaneThickness` for zero-latency
* wall/floor depth feedback (the full geometry rebuild that
* `applyFloorModelLocally` performs already reflects the new
* thickness in its plane data, but the dedicated thickness
* setter is cheaper and updates instantly as a slider is dragged).
*/
export type LivePreviewPayload = {
/** Newline-or-CR-separated tilemap (the renderer parser accepts \r). */
tilemap: string;
doorX: number;
doorY: number;
doorDir: number;
/** Editor-space (0..3). */
thicknessWall: number;
thicknessFloor: number;
/** Editor-space (1..N). Server space is `wallHeight - 1`. */
wallHeight: number;
};
export type UseFloorplanLiveSyncOptions = {
enabled: boolean;
state: FloorplanState;
};
export type UseFloorplanLiveSyncApi = {
/**
* Mark a payload as "currently shown in the room" so subsequent
* state diffs are computed against it. Editors call this on
* every server-driven snapshot push (FloorHeightMapEvent,
* RoomVisualizationSettingsEvent, …).
*/
setBaseline: (payload: LivePreviewPayload) => void;
/**
* Restore the in-room preview to the recorded baseline.
* Use when the user closes the editor without saving.
*/
revert: () => void;
};
const THICKNESS_RENDERER_VALUE: Record<number, number> = {
0: 0.25,
1: 0.5,
2: 1,
3: 2
};
export const buildLivePreviewPayload = (state: FloorplanState): LivePreviewPayload => ({
tilemap: serializeTilemap(state.tiles),
doorX: state.door.x,
doorY: state.door.y,
doorDir: state.door.dir,
thicknessWall: state.thickness.wall,
thicknessFloor: state.thickness.floor,
wallHeight: state.wallHeight
});
export const livePreviewPayloadsEqual = (a: LivePreviewPayload, b: LivePreviewPayload): boolean =>
a.tilemap === b.tilemap
&& a.doorX === b.doorX
&& a.doorY === b.doorY
&& a.doorDir === b.doorDir
&& a.thicknessWall === b.thicknessWall
&& a.thicknessFloor === b.thicknessFloor
&& a.wallHeight === b.wallHeight;
const applyToRenderer = (payload: LivePreviewPayload, roomId: number): boolean =>
{
const handler = GetRoomMessageHandler();
if(!handler || typeof handler.applyFloorModelLocally !== 'function') return false;
const ok = handler.applyFloorModelLocally(payload.tilemap, Math.max(0, (payload.wallHeight | 0) - 1), true);
if(!ok) return false;
if(roomId >= 0)
{
const engine = GetRoomEngine();
const wall = THICKNESS_RENDERER_VALUE[payload.thicknessWall];
const floor = THICKNESS_RENDERER_VALUE[payload.thicknessFloor];
if(engine && typeof engine.updateRoomInstancePlaneThickness === 'function' && wall !== undefined && floor !== undefined)
{
engine.updateRoomInstancePlaneThickness(roomId, wall, floor);
}
}
return true;
};
export const useFloorplanLiveSync = (opts: UseFloorplanLiveSyncOptions): UseFloorplanLiveSyncApi =>
{
const { enabled, state } = opts;
const session = useActiveRoomSessionSnapshot();
const roomId = session?.roomId ?? -1;
const baselineRef = useRef<LivePreviewPayload | null>(null);
const lastAppliedRef = useRef<LivePreviewPayload | null>(null);
// Destructure first so the memo deps stay precise without
// triggering exhaustive-deps on `state` as a whole.
const { tiles, door, thickness, wallHeight } = state;
const currentPayload = useMemo<LivePreviewPayload>(() => ({
tilemap: serializeTilemap(tiles),
doorX: door.x,
doorY: door.y,
doorDir: door.dir,
thicknessWall: thickness.wall,
thicknessFloor: thickness.floor,
wallHeight
}), [ tiles, door, thickness, wallHeight ]);
const setBaseline = useCallback((payload: LivePreviewPayload) =>
{
baselineRef.current = payload;
lastAppliedRef.current = payload;
}, []);
const revert = useCallback(() =>
{
const baseline = baselineRef.current;
if(!baseline) return;
if(applyToRenderer(baseline, roomId)) lastAppliedRef.current = baseline;
}, [ roomId ]);
// Apply the current payload to the renderer whenever it
// diverges from what's already in the room. Synchronous + no
// debounce — the renderer pipeline is fast enough that every
// brush stroke can land a paint.
useEffect(() =>
{
if(!enabled) return;
const previous = lastAppliedRef.current;
if(previous && livePreviewPayloadsEqual(currentPayload, previous)) return;
if(!previous && !baselineRef.current) return;
if(applyToRenderer(currentPayload, roomId)) lastAppliedRef.current = currentPayload;
}, [ enabled, currentPayload, roomId ]);
return { setBaseline, revert };
};
+123 -1
View File
@@ -233,6 +233,94 @@ export class UserProfileComposer extends StubClass {}
// `ChooserSelectionFilter` is used as a string enum in some call sites.
export const ChooserSelectionFilter = makeEnumProxy('ChooserSelectionFilter');
// ---------------------------------------------------------------------------
// Floor plan constants
// ---------------------------------------------------------------------------
export const FloorAction = makeEnumProxy('FloorAction');
export const TILE_SIZE = 32;
export const HEIGHT_SCHEME = 'x0123456789abcdefghijklmnopq';
export const MAX_NUM_TILE_PER_AXIS = 64;
export const COLORMAP: object = {
'x': '101010',
'0': '0065ff', '1': '0091ff', '2': '00bcff', '3': '00e8ff',
'4': '00ffea', '5': '00ffbf', '6': '00ff93', '7': '00ff68',
'8': '00ff3d', '9': '19ff00',
'a': '44ff00', 'b': '70ff00', 'c': '9bff00', 'd': 'f2ff00',
'e': 'ffe000', 'f': 'ffb500', 'g': 'ff8900', 'h': 'ff5e00',
'i': 'ff3200', 'j': 'ff0700', 'k': 'ff0023', 'l': 'ff007a',
'm': 'ff00a5', 'n': 'ff00d1', 'o': 'ff00fc',
'p': 'd600ff', 'q': 'aa00ff'
};
// ---------------------------------------------------------------------------
// Floor plan editor — composer stubs and event classes
// ---------------------------------------------------------------------------
// Composer stubs for floor-plan-editor message events
export class GetRoomEntryTileMessageComposer extends StubClass {}
export class GetOccupiedTilesMessageComposer extends StubClass {}
export class UpdateFloorPropertiesMessageComposer extends StubClass
{
public tilemap: string;
public doorX: number;
public doorY: number;
public dir: number;
public thicknessWall: number;
public thicknessFloor: number;
public wallHeight: number;
constructor(tilemap: string, doorX: number, doorY: number, dir: number, thicknessWall: number, thicknessFloor: number, wallHeight: number)
{
super();
this.tilemap = tilemap;
this.doorX = doorX;
this.doorY = doorY;
this.dir = dir;
this.thicknessWall = thicknessWall;
this.thicknessFloor = thicknessFloor;
this.wallHeight = wallHeight;
}
}
// Event class stubs for useMessageEvent registration
export class FloorHeightMapEvent extends StubClass {}
export class RoomVisualizationSettingsEvent extends StubClass {}
export class RoomEntryTileMessageEvent extends StubClass {}
export class RoomOccupiedTilesMessageEvent extends StubClass {}
export const RoomEngineEvent = makeEnumProxy('RoomEngineEvent');
// Link tracker stubs
export type ILinkEventTracker = { linkReceived: (url: string) => void; eventUrlPrefix: string };
export const AddLinkEventTracker = vi.fn();
export const RemoveLinkEventTracker = vi.fn();
// Thickness conversion helpers — mirror the renderer's real mapping
export const convertNumbersForSaving = (v: number): number =>
{
switch(v)
{
case 0: return -2;
case 1: return -1;
case 3: return 1;
default: return 0;
}
};
export const convertSettingToNumber = (v: number): number =>
{
switch(v)
{
case 0.25: return 0;
case 0.5: return 1;
case 2: return 3;
default: return 2;
}
};
// ---------------------------------------------------------------------------
// Singleton getters
// ---------------------------------------------------------------------------
@@ -267,10 +355,44 @@ export const GetCommunication = vi.fn(stubManager);
export const GetConfiguration = vi.fn(stubManager);
export const GetLocalizationManager = vi.fn(stubManager);
export const GetRoomEngine = vi.fn(stubManager);
export const GetRoomMessageHandler = vi.fn(stubManager);
export const GetRoomSessionManager = vi.fn(stubManager);
// RoomPreviewer — only the bits the editor's FloorplanRoomPreview
// component touches. PREVIEW_COUNTER is a static field that the
// real renderer increments to allocate unique preview-room IDs;
// keeping it as a mutable static lets the editor mount/unmount
// repeatedly across tests without colliding.
export class RoomPreviewer
{
static PREVIEW_COUNTER = 0;
constructor(public readonly _engine: unknown, public readonly _id: number) {}
public updatePreviewModel(_model: string, _wallHeight: number, _scale?: boolean): void {}
public modifyRoomCanvas(_w: number, _h: number): void {}
public getRoomCanvas(_w: number, _h: number): unknown { return null; }
public getRenderingCanvas(): unknown { return null; }
public updatePreviewRoomView(): void {}
public changeRoomObjectDirection(): void {}
public changeRoomObjectState(): void {}
public dispose(): void {}
}
export const GetSessionDataManager = vi.fn(stubManager);
export const GetTickerTime = vi.fn(() => 0);
export const TextureUtils = stubManager();
export const GetTicker = vi.fn(stubManager);
export const GetRenderer = vi.fn(stubManager);
export class NitroTicker {}
// TextureUtils — a real-enough stub of the createRenderTexture
// roundtrip. Tests that mount LayoutRoomPreviewerView allocate a
// texture on mount and destroy it on unmount; without a real
// `.destroy` method the unmount cleanup throws.
export const TextureUtils = {
createRenderTexture: (_w: number, _h: number) => ({
destroy: (_options?: unknown) => undefined
}),
generateImage: () => null
};
export const NitroVersion = stubManager();
// ---------------------------------------------------------------------------
+33
View File
@@ -1 +1,34 @@
import '@testing-library/jest-dom/vitest';
// Set up a container for React portals (used by NitroCardView's DraggableWindow)
const draggableWindowsContainer = document.createElement('div');
draggableWindowsContainer.id = 'draggable-windows-container';
document.body.appendChild(draggableWindowsContainer);
// jsdom doesn't ship ResizeObserver, but LayoutRoomPreviewerView (and
// any component that resizes a canvas to its container) constructs
// one at mount. A no-op stub is enough — the tests never assert
// resize-driven behavior, they just need the constructor to exist.
if(typeof globalThis.ResizeObserver === 'undefined')
{
class ResizeObserverStub
{
constructor(_callback: unknown)
{
// no-op
}
public observe(): void
{
// no-op
}
public unobserve(): void
{
// no-op
}
public disconnect(): void
{
// no-op
}
}
(globalThis as unknown as { ResizeObserver: typeof ResizeObserverStub }).ResizeObserver = ResizeObserverStub;
}