diff --git a/src/assets/images/floorplaneditor/canvas_floor_pattern.png b/src/assets/images/floorplaneditor/canvas_floor_pattern.png new file mode 100644 index 0000000..e3544a3 Binary files /dev/null and b/src/assets/images/floorplaneditor/canvas_floor_pattern.png differ diff --git a/src/components/floorplan-editor/FloorplanEditorContext.tsx b/src/components/floorplan-editor/FloorplanEditorContext.tsx deleted file mode 100644 index 323be74..0000000 --- a/src/components/floorplan-editor/FloorplanEditorContext.tsx +++ /dev/null @@ -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>; - visualizationSettings: IVisualizationSettings; - setVisualizationSettings: Dispatch>; - floorHeight: number; - setFloorHeight: Dispatch>; - floorAction: number; - setFloorAction: Dispatch>; - tilemapVersion: number; - areaInfo: { total: number; walkable: number }; -} - -const FloorplanEditorContext = createContext({ - 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 => ; - -export const useFloorplanEditorContext = () => useContext(FloorplanEditorContext); diff --git a/src/components/floorplan-editor/FloorplanEditorView.test.tsx b/src/components/floorplan-editor/FloorplanEditorView.test.tsx new file mode 100644 index 0000000..5603025 --- /dev/null +++ b/src/components/floorplan-editor/FloorplanEditorView.test.tsx @@ -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 void>(); +const nitroHandlers = new Map 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(); + 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
(via Base), not - + - } - { importExportVisible && - setImportExportVisible(false) } /> } - + + ) } + { importExportVisible && ( + 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) } + /> + ) } + ); }; diff --git a/src/components/floorplan-editor/hooks/useFloorplanReducer.test.tsx b/src/components/floorplan-editor/hooks/useFloorplanReducer.test.tsx new file mode 100644 index 0000000..c9b9ae2 --- /dev/null +++ b/src/components/floorplan-editor/hooks/useFloorplanReducer.test.tsx @@ -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'); + }); +}); diff --git a/src/components/floorplan-editor/hooks/useFloorplanReducer.ts b/src/components/floorplan-editor/hooks/useFloorplanReducer.ts new file mode 100644 index 0000000..9d85cff --- /dev/null +++ b/src/components/floorplan-editor/hooks/useFloorplanReducer.ts @@ -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; + 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([]); + const futureRef = useRef([]); + const [ canUndo, setCanUndo ] = useState(false); + const [ canRedo, setCanRedo ] = useState(false); + const stateRef = useRef(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>((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'); +}; diff --git a/src/components/floorplan-editor/hooks/usePointerToTile.test.ts b/src/components/floorplan-editor/hooks/usePointerToTile.test.ts new file mode 100644 index 0000000..e478ea8 --- /dev/null +++ b/src/components/floorplan-editor/hooks/usePointerToTile.test.ts @@ -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; + const { result } = renderHook(() => usePointerToTile(ref, { width: 2048, height: 1024 })); + expect(result.current.fromClient(100, 100)).toBeNull(); + }); +}); diff --git a/src/components/floorplan-editor/hooks/usePointerToTile.ts b/src/components/floorplan-editor/hooks/usePointerToTile.ts new file mode 100644 index 0000000..6da475f --- /dev/null +++ b/src/components/floorplan-editor/hooks/usePointerToTile.ts @@ -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, + 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 ]); +}; diff --git a/src/components/floorplan-editor/hooks/useTool.test.ts b/src/components/floorplan-editor/hooks/useTool.test.ts new file mode 100644 index 0000000..41360c3 --- /dev/null +++ b/src/components/floorplan-editor/hooks/useTool.test.ts @@ -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, 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, 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, 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, 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, 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, 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, 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, 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(); + }); +}); diff --git a/src/components/floorplan-editor/hooks/useTool.ts b/src/components/floorplan-editor/hooks/useTool.ts new file mode 100644 index 0000000..e8ed57e --- /dev/null +++ b/src/components/floorplan-editor/hooks/useTool.ts @@ -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) => void; + onPointerMove: (e: PointerEvent) => void; + onPointerUp: (e: PointerEvent) => 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 +): 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, + projection: PointerProjection +): Handlers => +{ + const isDownRef = useRef(false); + const lastTileRef = useRef(null); + + const apply = useCallback((e: PointerEvent) => + { + 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) => + { + isDownRef.current = true; + lastTileRef.current = null; + try { e.currentTarget.setPointerCapture?.(e.pointerId); } catch {} + apply(e); + }, [ apply ]); + + const onPointerMove = useCallback((e: PointerEvent) => + { + 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) => + { + isDownRef.current = false; + lastTileRef.current = null; + try { e.currentTarget.releasePointerCapture?.(e.pointerId); } catch {} + }, []); + + return { onPointerDown, onPointerMove, onPointerUp }; +}; diff --git a/src/components/floorplan-editor/state/constants.ts b/src/components/floorplan-editor/state/constants.ts new file mode 100644 index 0000000..559fcfa --- /dev/null +++ b/src/components/floorplan-editor/state/constants.ts @@ -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 }; diff --git a/src/components/floorplan-editor/state/encoding.test.ts b/src/components/floorplan-editor/state/encoding.test.ts new file mode 100644 index 0000000..bb31616 --- /dev/null +++ b/src/components/floorplan-editor/state/encoding.test.ts @@ -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'); + }); +}); diff --git a/src/components/floorplan-editor/state/encoding.ts b/src/components/floorplan-editor/state/encoding.ts new file mode 100644 index 0000000..08fb97a --- /dev/null +++ b/src/components/floorplan-editor/state/encoding.ts @@ -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'); +}; diff --git a/src/components/floorplan-editor/state/reducer.test.ts b/src/components/floorplan-editor/state/reducer.test.ts new file mode 100644 index 0000000..a6b7e09 --- /dev/null +++ b/src/components/floorplan-editor/state/reducer.test.ts @@ -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); + }); +}); diff --git a/src/components/floorplan-editor/state/reducer.ts b/src/components/floorplan-editor/state/reducer.ts new file mode 100644 index 0000000..ba62ef4 --- /dev/null +++ b/src/components/floorplan-editor/state/reducer.ts @@ -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; + } +}; diff --git a/src/components/floorplan-editor/state/selectors.test.ts b/src/components/floorplan-editor/state/selectors.test.ts new file mode 100644 index 0000000..f885055 --- /dev/null +++ b/src/components/floorplan-editor/state/selectors.test.ts @@ -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 }); + }); +}); diff --git a/src/components/floorplan-editor/state/selectors.ts b/src/components/floorplan-editor/state/selectors.ts new file mode 100644 index 0000000..5b65917 --- /dev/null +++ b/src/components/floorplan-editor/state/selectors.ts @@ -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)[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; +}; diff --git a/src/components/floorplan-editor/state/types.ts b/src/components/floorplan-editor/state/types.ts new file mode 100644 index 0000000..ce536ce --- /dev/null +++ b/src/components/floorplan-editor/state/types.ts @@ -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 }; diff --git a/src/components/floorplan-editor/views/FloorplanCanvasSVG.test.tsx b/src/components/floorplan-editor/views/FloorplanCanvasSVG.test.tsx new file mode 100644 index 0000000..b205a71 --- /dev/null +++ b/src/components/floorplan-editor/views/FloorplanCanvasSVG.test.tsx @@ -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( {} } />); + // 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( {} } />); + 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(); + 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( {} } />); + 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); + }); +}); diff --git a/src/components/floorplan-editor/views/FloorplanCanvasSVG.tsx b/src/components/floorplan-editor/views/FloorplanCanvasSVG.tsx new file mode 100644 index 0000000..19411a9 --- /dev/null +++ b/src/components/floorplan-editor/views/FloorplanCanvasSVG.tsx @@ -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; + /** + * 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 = ({ state, dispatch, panMode }) => +{ + const svgRef = useRef(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 ; + }); + return { cells }; + }), [ 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) => + { + 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 ( +
+ + { + 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 } + +
+ + + + + +
+
+ ); +}; diff --git a/src/components/floorplan-editor/views/FloorplanCanvasView.tsx b/src/components/floorplan-editor/views/FloorplanCanvasView.tsx deleted file mode 100644 index 9db0903..0000000 --- a/src/components/floorplan-editor/views/FloorplanCanvasView.tsx +++ /dev/null @@ -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 = 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(null); - const canvasWrapperRef = useRef(null); - - useMessageEvent(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, 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 ( - - -
- -
- - -
- { children } - - ); -}; diff --git a/src/components/floorplan-editor/views/FloorplanHeightPicker.test.tsx b/src/components/floorplan-editor/views/FloorplanHeightPicker.test.tsx new file mode 100644 index 0000000..f29f1fe --- /dev/null +++ b/src/components/floorplan-editor/views/FloorplanHeightPicker.test.tsx @@ -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( 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(); + + 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(); + + 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(); + + 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(); + + 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( undefined } />); + + const colourAtZero = screen.getByTestId('height-thumb').getAttribute('data-thumb-color'); + + rerender( undefined } />); + + const colourAtThirteen = screen.getByTestId('height-thumb').getAttribute('data-thumb-color'); + + expect(colourAtZero).toBeTruthy(); + expect(colourAtThirteen).toBeTruthy(); + expect(colourAtZero).not.toBe(colourAtThirteen); + }); +}); diff --git a/src/components/floorplan-editor/views/FloorplanHeightPicker.tsx b/src/components/floorplan-editor/views/FloorplanHeightPicker.tsx new file mode 100644 index 0000000..b8dfd28 --- /dev/null +++ b/src/components/floorplan-editor/views/FloorplanHeightPicker.tsx @@ -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 = ({ selectedH, onSelect }) => +{ + const count = HEIGHT_BRUSH_MAX - HEIGHT_BRUSH_MIN + 1; + const trackRef = useRef(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) => + { + 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 ( +
+ + { HEIGHT_BRUSH_MAX } + +
+
setIsHovering(true) } + onPointerLeave={ () => setIsHovering(false) } + /> +
+ { selectedH } +
+
+ + { HEIGHT_BRUSH_MIN } + +
+ ); +}; diff --git a/src/components/floorplan-editor/views/FloorplanHeightSelector.tsx b/src/components/floorplan-editor/views/FloorplanHeightSelector.tsx deleted file mode 100644 index 8163c98..0000000 --- a/src/components/floorplan-editor/views/FloorplanHeightSelector.tsx +++ /dev/null @@ -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; - -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 ( - - { floorHeight } -
- { heights.map(h => - { - const char = HEIGHT_SCHEME[h + 1]; - const color = colormap[char] || '101010'; - const isActive = (floorHeight === h); - - return ( -
onSelectHeight(h) } - title={ `${ h }` } - /> - ); - }) } -
- - ); -}; diff --git a/src/components/floorplan-editor/views/FloorplanImportExport.test.tsx b/src/components/floorplan-editor/views/FloorplanImportExport.test.tsx new file mode 100644 index 0000000..6b5c810 --- /dev/null +++ b/src/components/floorplan-editor/views/FloorplanImportExport.test.tsx @@ -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( {} } 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( {} } 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( {} } 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'); + }); +}); diff --git a/src/components/floorplan-editor/views/FloorplanImportExport.tsx b/src/components/floorplan-editor/views/FloorplanImportExport.tsx new file mode 100644 index 0000000..0063f10 --- /dev/null +++ b/src/components/floorplan-editor/views/FloorplanImportExport.tsx @@ -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; + onClose: () => void; + onSaveFromText: (raw: string) => void; + onRevertText: () => string; +}; + +export const FloorplanImportExport: FC = ({ 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 ( + + + +