From 30bea6fca59c786835e2ab9d70bd9f47e2cf37d9 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Fri, 20 Mar 2026 14:18:45 +0100 Subject: [PATCH 1/3] feat(floorplan): add real-time 3D preview to floor plan editor - Add FloorplanPreviewView with live isometric room preview - Add FloorplanHeightSelector component for height picking - Refactor FloorplanOptionsView for cleaner layout - Update FloorplanEditorContext with preview state management - Improve FloorplanCanvasView rendering --- .../FloorplanEditorContext.tsx | 14 +- .../floorplan-editor/FloorplanEditorView.tsx | 156 ++++++++- .../views/FloorplanCanvasView.tsx | 105 +++--- .../views/FloorplanHeightSelector.tsx | 54 +++ .../views/FloorplanOptionsView.tsx | 247 ++++--------- .../views/FloorplanPreviewView.tsx | 328 ++++++++++++++++++ 6 files changed, 647 insertions(+), 257 deletions(-) create mode 100644 src/components/floorplan-editor/views/FloorplanHeightSelector.tsx create mode 100644 src/components/floorplan-editor/views/FloorplanPreviewView.tsx diff --git a/src/components/floorplan-editor/FloorplanEditorContext.tsx b/src/components/floorplan-editor/FloorplanEditorContext.tsx index eb528cf..1b2a3c4 100644 --- a/src/components/floorplan-editor/FloorplanEditorContext.tsx +++ b/src/components/floorplan-editor/FloorplanEditorContext.tsx @@ -8,13 +8,25 @@ interface IFloorplanEditorContext 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 + setVisualizationSettings: null, + floorHeight: 0, + setFloorHeight: null, + floorAction: 3, + setFloorAction: null, + tilemapVersion: 0, + areaInfo: { total: 0, walkable: 0 } }); export const FloorplanEditorContextProvider: FC> = props => ; diff --git a/src/components/floorplan-editor/FloorplanEditorView.tsx b/src/components/floorplan-editor/FloorplanEditorView.tsx index 59f7709..4003d13 100644 --- a/src/components/floorplan-editor/FloorplanEditorView.tsx +++ b/src/components/floorplan-editor/FloorplanEditorView.tsx @@ -1,19 +1,22 @@ import { AddLinkEventTracker, FloorHeightMapEvent, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomVisualizationSettingsEvent, UpdateFloorPropertiesMessageComposer } from '@nitrots/nitro-renderer'; -import { FC, useEffect, useState } from 'react'; +import { FC, useCallback, useEffect, useState } from 'react'; +import { FaCaretLeft, FaCaretRight } from 'react-icons/fa'; import { LocalizeText, SendMessageComposer } from '../../api'; -import { Button, ButtonGroup, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common'; +import { Button, ButtonGroup, Column, 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 } 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'; - -type ScrollDirection = 'up' | 'down' | 'left' | 'right'; +const MIN_WALL_HEIGHT = 0; +const MAX_WALL_HEIGHT = 16; export const FloorplanEditorView: FC<{}> = props => { @@ -34,7 +37,65 @@ export const FloorplanEditorView: FC<{}> = props => thicknessWall: 1, thicknessFloor: 1 }); - const [ canvasScrollHandler, setCanvasScrollHandler ] = useState<((direction: ScrollDirection) => void) | null>(null); + 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 calculateArea = useCallback(() => + { + const tilemap = FloorplanEditor.instance.tilemap; + + if(!tilemap || tilemap.length === 0) + { + setAreaInfo({ total: 0, walkable: 0 }); + + return; + } + + 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; + + FloorplanEditor.instance.onTilemapChange = () => + { + setTilemapVersion(prev => prev + 1); + calculateArea(); + }; + + return () => + { + FloorplanEditor.instance.onTilemapChange = null; + }; + }, [ isVisible, calculateArea ]); const saveFloorChanges = () => { @@ -47,16 +108,50 @@ export const FloorplanEditorView: FC<{}> = props => 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.DISPOSED, event => setIsVisible(false)); @@ -117,7 +212,7 @@ export const FloorplanEditorView: FC<{}> = props => const parts = url.split('/'); if(parts.length < 2) return; - + switch(parts[1]) { case 'show': @@ -140,17 +235,42 @@ export const FloorplanEditorView: FC<{}> = props => }, []); return ( - + { isVisible && - + setIsVisible(false) } /> - - canvasScrollHandler && canvasScrollHandler(direction) } /> - + + + + + + + + + { LocalizeText('floor.editor.wall.height') } + + onWallHeightChange(event.target.valueAsNumber) } /> + + + + Area: { areaInfo.total } ({ areaInfo.walkable } caselle) + + + - + - @@ -161,4 +281,4 @@ export const FloorplanEditorView: FC<{}> = props => setImportExportVisible(false) } /> } ); -} +}; diff --git a/src/components/floorplan-editor/views/FloorplanCanvasView.tsx b/src/components/floorplan-editor/views/FloorplanCanvasView.tsx index e8f39a8..9db0903 100644 --- a/src/components/floorplan-editor/views/FloorplanCanvasView.tsx +++ b/src/components/floorplan-editor/views/FloorplanCanvasView.tsx @@ -1,25 +1,25 @@ 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'; -type ScrollDirection = 'up' | 'down' | 'left' | 'right'; - interface FloorplanCanvasViewProps extends ColumnProps { - setScrollHandler(handler: ((direction: ScrollDirection) => void) | null): void; } export const FloorplanCanvasView: FC = props => { - const { gap = 1, children = null, setScrollHandler = null, ...rest } = props; - const [ occupiedTilesReceived , setOccupiedTilesReceived ] = useState(false); + 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 => { @@ -37,7 +37,7 @@ export const FloorplanCanvasView: FC = props => }); setOccupiedTilesReceived(true); - + elementRef.current.scrollTo((FloorplanEditor.instance.renderer.canvas.width / 3), 0); }); @@ -63,39 +63,16 @@ export const FloorplanCanvasView: FC = props => return newValue; }); - + FloorplanEditor.instance.doorLocation = { x: parser.x, y: parser.y }; setEntryTileReceived(true); }); - const onClickArrowButton = (scrollDirection: ScrollDirection) => - { - const element = elementRef.current; - - if(!element) return; - - switch(scrollDirection) - { - case 'up': - element.scrollBy({ top: -10 }); - break; - case 'down': - element.scrollBy({ top: 10 }); - break; - case 'left': - element.scrollBy({ left: -10 }); - break; - case 'right': - element.scrollBy({ left: 10 }); - break; - } - } - const onPointerEvent = (event: PointerEvent) => { event.preventDefault(); - + switch(event.type) { case 'pointerout': @@ -109,7 +86,10 @@ export const FloorplanCanvasView: FC = props => 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(() => { @@ -124,15 +104,15 @@ export const FloorplanCanvasView: FC = props => 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 ]); @@ -144,45 +124,56 @@ export const FloorplanCanvasView: FC = props => const currentElement = elementRef.current; if(!currentElement) return; - - currentElement.appendChild(FloorplanEditor.instance.renderer.canvas); + + 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 () => + return () => { if(currentElement) { currentElement.removeEventListener('pointerup', onPointerEvent); - currentElement.removeEventListener('pointerout', onPointerEvent); - currentElement.removeEventListener('pointerdown', onPointerEvent); - currentElement.removeEventListener('pointermove', onPointerEvent); } - } + }; }, []); - useEffect(() => - { - if(!setScrollHandler) return; - - setScrollHandler(() => onClickArrowButton); - - return () => setScrollHandler(null); - }, [ setScrollHandler ]); - return ( - - + + +
+ +
+ + +
{ children } ); -} +}; diff --git a/src/components/floorplan-editor/views/FloorplanHeightSelector.tsx b/src/components/floorplan-editor/views/FloorplanHeightSelector.tsx new file mode 100644 index 0000000..8163c98 --- /dev/null +++ b/src/components/floorplan-editor/views/FloorplanHeightSelector.tsx @@ -0,0 +1,54 @@ +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/FloorplanOptionsView.tsx b/src/components/floorplan-editor/views/FloorplanOptionsView.tsx index 5207b15..d4e7705 100644 --- a/src/components/floorplan-editor/views/FloorplanOptionsView.tsx +++ b/src/components/floorplan-editor/views/FloorplanOptionsView.tsx @@ -1,45 +1,32 @@ -import { FC, useState } from 'react'; -import { FaArrowDown, FaArrowLeft, FaArrowRight, FaArrowUp, FaCaretLeft, FaCaretRight } from 'react-icons/fa'; +import { FC } from 'react'; import { LocalizeText } from '../../../api'; -import { Button, Column, Flex, LayoutGridItem, Slider, Text } from '../../../common'; -import { COLORMAP, FloorAction } from '@nitrots/nitro-renderer'; +import { Flex, LayoutGridItem, Text } from '../../../common'; +import { FloorAction } from '@nitrots/nitro-renderer'; import { FloorplanEditor } from '@nitrots/nitro-renderer'; import { useFloorplanEditorContext } from '../FloorplanEditorContext'; -const MIN_WALL_HEIGHT: number = 0; -const MAX_WALL_HEIGHT: number = 16; - -const MIN_FLOOR_HEIGHT: number = 0; -const MAX_FLOOR_HEIGHT: number = 26; - -type ScrollDirection = 'up' | 'down' | 'left' | 'right'; - interface FloorplanOptionsViewProps { - onCanvasScroll?(direction: ScrollDirection): void; } export const FloorplanOptionsView: FC = props => { - const { onCanvasScroll = () => {} } = props; - const { visualizationSettings = null, setVisualizationSettings = null } = useFloorplanEditorContext(); - const [ floorAction, setFloorAction ] = useState(FloorAction.SET); - const [ floorHeight, setFloorHeight ] = useState(0); - const [ isSquareSelectMode, setSquareSelectMode ] = useState(false); - + 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 = () => { - const nextValue = FloorplanEditor.instance.toggleSquareSelectMode(); - - setSquareSelectMode(nextValue); - } + FloorplanEditor.instance.toggleSquareSelectMode(); + // force re-render by toggling action to same value + setFloorAction(prev => prev); + }; const changeDoorDirection = () => { @@ -58,18 +45,19 @@ export const FloorplanOptionsView: FC = props => return newValue; }); - } + }; - const onFloorHeightChange = (value: number) => + const onWallThicknessChange = (value: number) => { - if(isNaN(value) || (value <= 0)) value = 0; + setVisualizationSettings(prevValue => + { + const newValue = { ...prevValue }; - if(value > 26) value = 26; + newValue.thicknessWall = value; - setFloorHeight(value); - - FloorplanEditor.instance.actionSettings.currentHeight = value.toString(36); - } + return newValue; + }); + }; const onFloorThicknessChange = (value: number) => { @@ -81,157 +69,54 @@ export const FloorplanOptionsView: FC = props => return newValue; }); - } - - const onWallThicknessChange = (value: number) => - { - setVisualizationSettings(prevValue => - { - const newValue = { ...prevValue }; - - newValue.thicknessWall = value; - - return newValue; - }); - } - - 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); - } + }; return ( - - - - { LocalizeText('floor.plan.editor.draw.mode') } - - - selectAction(FloorAction.SET) }> - - - selectAction(FloorAction.UNSET) }> - - - - - selectAction(FloorAction.UP) }> - - - selectAction(FloorAction.DOWN) }> - - - - selectAction(FloorAction.DOOR) }> - - - FloorplanEditor.instance.toggleSelectAll() }> - - - - - - - - - { LocalizeText('floor.plan.editor.enter.direction') } - - - - { LocalizeText('floor.editor.wall.height') } - - - onWallHeightChange(event.target.valueAsNumber) } /> - - - - - { LocalizeText('floor.plan.editor.room.options') } - - - - - + + + { LocalizeText('floor.plan.editor.draw.mode') } + + selectAction(FloorAction.SET) }> + + + selectAction(FloorAction.UNSET) }> + + + selectAction(FloorAction.UP) }> + + + selectAction(FloorAction.DOWN) }> + + + selectAction(FloorAction.DOOR) }> + + + FloorplanEditor.instance.toggleSelectAll() }> + + + + + + - - - { LocalizeText('floor.plan.editor.tile.height') }: { floorHeight } -
- onFloorHeightChange(event) } - renderThumb={ (props, state) => - { - const { key, style, ...rest } = (props as Record); - - return
{ state.valueNow }
; - } } /> -
-
- - - - - - -
- - - - - - + + { LocalizeText('floor.plan.editor.enter.direction') } + - + + + + + ); -} \ No newline at end of file +}; diff --git a/src/components/floorplan-editor/views/FloorplanPreviewView.tsx b/src/components/floorplan-editor/views/FloorplanPreviewView.tsx new file mode 100644 index 0000000..cd82a9c --- /dev/null +++ b/src/components/floorplan-editor/views/FloorplanPreviewView.tsx @@ -0,0 +1,328 @@ +import { FC, useEffect, useRef } from 'react'; +import { COLORMAP, HEIGHT_SCHEME, FloorplanEditor } from '@nitrots/nitro-renderer'; +import { useFloorplanEditorContext } from '../FloorplanEditorContext'; + +const colormap = COLORMAP as Record; + +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(null); + const rafRef = useRef(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 ( +
+ +
+ ); +}; From 11543bb64c84485b29a26ae7cc12fbc4c5f7a377 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Fri, 20 Mar 2026 17:07:33 +0100 Subject: [PATCH 2/3] feat: custom prefix system with effects, emoji picker and per-letter colors - Catalog page for creating custom prefixes with text, per-letter colors, emoji icon and visual effects - Emoji picker via @emoji-mart/react with createPortal + Shadow DOM blur fix - Inventory prefix management (activate/deactivate/delete) - Chat bubble rendering with multi-color prefix and effect support - Prefix utilities (getPrefixEffectStyle, parsePrefixColors, PREFIX_EFFECT_KEYFRAMES) - All UI text in English --- src/api/inventory/IPrefixItem.ts | 9 + src/api/inventory/UnseenItemCategory.ts | 1 + src/api/inventory/index.ts | 1 + src/api/room/widgets/ChatBubbleMessage.ts | 4 + src/api/room/widgets/index.ts | 1 + src/api/utils/PrefixUtils.ts | 53 ++ src/api/utils/index.ts | 1 + .../layout/CatalogLayoutCustomPrefixView.tsx | 470 ++++++++++++++++++ .../views/page/layout/GetCatalogLayout.tsx | 3 + src/components/inventory/InventoryView.tsx | 10 +- .../views/prefix/InventoryPrefixView.tsx | 136 +++++ .../widgets/chat/ChatWidgetMessageView.tsx | 23 +- src/hooks/inventory/index.ts | 1 + src/hooks/inventory/useInventoryPrefixes.ts | 126 +++++ src/hooks/rooms/widgets/index.ts | 1 + src/hooks/rooms/widgets/useChatWidget.ts | 5 + 16 files changed, 841 insertions(+), 4 deletions(-) create mode 100644 src/api/inventory/IPrefixItem.ts create mode 100644 src/api/utils/PrefixUtils.ts create mode 100644 src/components/catalog/views/page/layout/CatalogLayoutCustomPrefixView.tsx create mode 100644 src/components/inventory/views/prefix/InventoryPrefixView.tsx create mode 100644 src/hooks/inventory/useInventoryPrefixes.ts diff --git a/src/api/inventory/IPrefixItem.ts b/src/api/inventory/IPrefixItem.ts new file mode 100644 index 0000000..b65981f --- /dev/null +++ b/src/api/inventory/IPrefixItem.ts @@ -0,0 +1,9 @@ +export interface IPrefixItem +{ + id: number; + text: string; + color: string; + icon: string; + effect: string; + active: boolean; +} diff --git a/src/api/inventory/UnseenItemCategory.ts b/src/api/inventory/UnseenItemCategory.ts index cbd7e9b..0ac720c 100644 --- a/src/api/inventory/UnseenItemCategory.ts +++ b/src/api/inventory/UnseenItemCategory.ts @@ -6,4 +6,5 @@ export class UnseenItemCategory public static BADGE: number = 4; public static BOT: number = 5; public static GAMES: number = 6; + public static PREFIX: number = 7; } diff --git a/src/api/inventory/index.ts b/src/api/inventory/index.ts index 6a245d7..4e6ca21 100644 --- a/src/api/inventory/index.ts +++ b/src/api/inventory/index.ts @@ -5,6 +5,7 @@ export * from './GroupItem'; export * from './IBotItem'; export * from './IFurnitureItem'; export * from './IPetItem'; +export * from './IPrefixItem'; export * from './IUnseenItemTracker'; export * from './InventoryUtilities'; export * from './PetUtilities'; diff --git a/src/api/room/widgets/ChatBubbleMessage.ts b/src/api/room/widgets/ChatBubbleMessage.ts index 3e31e38..3fc6719 100644 --- a/src/api/room/widgets/ChatBubbleMessage.ts +++ b/src/api/room/widgets/ChatBubbleMessage.ts @@ -7,6 +7,10 @@ export class ChatBubbleMessage public height: number = 0; public elementRef: HTMLDivElement = null; public skipMovement: boolean = false; + public prefixText: string = ''; + public prefixColor: string = ''; + public prefixIcon: string = ''; + public prefixEffect: string = ''; private _top: number = 0; private _left: number = 0; diff --git a/src/api/room/widgets/index.ts b/src/api/room/widgets/index.ts index 5cef378..4892937 100644 --- a/src/api/room/widgets/index.ts +++ b/src/api/room/widgets/index.ts @@ -7,6 +7,7 @@ export * from './AvatarInfoUser'; export * from './AvatarInfoUtilities'; export * from './BotSkillsEnum'; export * from './ChatBubbleMessage'; +export * from './CommandDefinition'; export * from './ChatBubbleUtilities'; export * from './ChatMessageTypeEnum'; export * from './DimmerFurnitureWidgetPresetItem'; diff --git a/src/api/utils/PrefixUtils.ts b/src/api/utils/PrefixUtils.ts new file mode 100644 index 0000000..5da5133 --- /dev/null +++ b/src/api/utils/PrefixUtils.ts @@ -0,0 +1,53 @@ +export const PRESET_PREFIX_EFFECTS: { id: string; label: string; icon: string }[] = [ + { id: '', label: 'None', icon: '—' }, + { id: 'glow', label: 'Glow', icon: '✨' }, + { id: 'shadow', label: 'Shadow', icon: '🌑' }, + { id: 'italic', label: 'Italic', icon: '𝑰' }, + { id: 'outline', label: 'Outline', icon: '🔲' }, + { id: 'pulse', label: 'Pulse', icon: '💫' }, + { id: 'bold-glow', label: 'Neon', icon: '💡' }, +]; + +export const parsePrefixColors = (text: string, colorStr: string): string[] => +{ + if(!colorStr || !text) return []; + + const colors = colorStr.split(','); + return [ ...text ].map((_, i) => colors[Math.min(i, colors.length - 1)]); +}; + +export const getPrefixEffectStyle = (effect: string, color?: string): Record => +{ + const baseColor = color || '#FFFFFF'; + + switch(effect) + { + case 'glow': + return { textShadow: `0 0 6px ${ baseColor }, 0 0 12px ${ baseColor }80` }; + case 'shadow': + return { textShadow: '2px 2px 4px rgba(0,0,0,0.7), 1px 1px 2px rgba(0,0,0,0.5)' }; + case 'italic': + return { fontStyle: 'italic' }; + case 'outline': + return { + WebkitTextStroke: '0.5px rgba(0,0,0,0.6)', + textShadow: '1px 1px 0 rgba(0,0,0,0.3), -1px -1px 0 rgba(0,0,0,0.3), 1px -1px 0 rgba(0,0,0,0.3), -1px 1px 0 rgba(0,0,0,0.3)' + }; + case 'pulse': + return { animation: 'prefix-pulse 1.5s ease-in-out infinite' }; + case 'bold-glow': + return { + textShadow: `0 0 4px ${ baseColor }, 0 0 8px ${ baseColor }, 0 0 16px ${ baseColor }60`, + fontWeight: 900 + }; + default: + return {}; + } +}; + +export const PREFIX_EFFECT_KEYFRAMES = ` +@keyframes prefix-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} +`; diff --git a/src/api/utils/index.ts b/src/api/utils/index.ts index 1824add..4a4b221 100644 --- a/src/api/utils/index.ts +++ b/src/api/utils/index.ts @@ -11,6 +11,7 @@ export * from './LocalizeFormattedNumber'; export * from './LocalizeShortNumber'; export * from './LocalizeText'; export * from './PlaySound'; +export * from './PrefixUtils'; export * from './ProductImageUtility'; export * from './Randomizer'; export * from './RoomChatFormatter'; diff --git a/src/components/catalog/views/page/layout/CatalogLayoutCustomPrefixView.tsx b/src/components/catalog/views/page/layout/CatalogLayoutCustomPrefixView.tsx new file mode 100644 index 0000000..0b7f904 --- /dev/null +++ b/src/components/catalog/views/page/layout/CatalogLayoutCustomPrefixView.tsx @@ -0,0 +1,470 @@ +import { PurchasePrefixComposer } from '@nitrots/nitro-renderer'; +import { createPortal } from 'react-dom'; +import { FC, useEffect, useMemo, useRef, useState } from 'react'; +import { SendMessageComposer, PRESET_PREFIX_EFFECTS, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../../api'; +import { CatalogLayoutProps } from './CatalogLayout.types'; +import data from '@emoji-mart/data'; +import Picker from '@emoji-mart/react'; + +const PRESET_COLORS: string[] = [ + '#FF0000', '#FF6600', '#FFCC00', '#33CC00', '#00CCFF', + '#0066FF', '#9933FF', '#FF33CC', '#FFFFFF', '#CCCCCC', + '#999999', '#333333', '#FF9999', '#99FF99', '#9999FF', + '#FFD700', '#FF4500', '#00CED1', '#8A2BE2', '#DC143C' +]; + +export const CatalogLayoutCustomPrefixView: FC = props => +{ + const { page = null, hideNavigation = null } = props; + + useEffect(() => + { + hideNavigation(); + }, [ page, hideNavigation ]); + + const [ prefixText, setPrefixText ] = useState(''); + const [ colorMode, setColorMode ] = useState<'single' | 'perLetter'>('single'); + const [ singleColor, setSingleColor ] = useState('#FFFFFF'); + const [ letterColors, setLetterColors ] = useState>({}); + const [ selectedLetterIndex, setSelectedLetterIndex ] = useState(null); + const [ customColorInput, setCustomColorInput ] = useState('#FFFFFF'); + const [ selectedIcon, setSelectedIcon ] = useState(''); + const [ showIconPicker, setShowIconPicker ] = useState(false); + const [ selectedEffect, setSelectedEffect ] = useState(''); + const [ purchased, setPurchased ] = useState(false); + const pickerContainerRef = useRef(null); + + // Inject style into emoji-mart Shadow DOM to remove backdrop-filter blur + useEffect(() => + { + if(!showIconPicker) return; + + const timer = setTimeout(() => + { + const container = pickerContainerRef.current; + if(!container) return; + + const emPicker = container.querySelector('em-emoji-picker'); + if(!emPicker?.shadowRoot) return; + + const existing = emPicker.shadowRoot.querySelector('#no-blur-fix'); + if(existing) return; + + const style = document.createElement('style'); + style.id = 'no-blur-fix'; + style.textContent = `.sticky { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; background-color: rgb(var(--em-rgb-background)) !important; } .menu { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; background-color: rgb(var(--em-rgb-background)) !important; }`; + emPicker.shadowRoot.appendChild(style); + }, 50); + + return () => clearTimeout(timer); + }, [ showIconPicker ]); + + const colorString = useMemo(() => + { + if(colorMode === 'single') return singleColor; + + if(!prefixText.length) return singleColor; + + return [ ...prefixText ].map((_, i) => letterColors[i] || singleColor).join(','); + }, [ colorMode, singleColor, letterColors, prefixText ]); + + const previewColors = useMemo(() => + { + return parsePrefixColors(prefixText || '...', colorString || '#FFFFFF'); + }, [ prefixText, colorString ]); + + const isValid = useMemo(() => + { + if(!prefixText.trim().length || prefixText.trim().length > 15) return false; + + if(colorMode === 'single') return /^#[0-9A-Fa-f]{6}$/.test(singleColor); + + const colors = colorString.split(','); + return colors.every(c => /^#[0-9A-Fa-f]{6}$/.test(c)); + }, [ prefixText, colorMode, singleColor, colorString ]); + + const handlePurchase = () => + { + if(!isValid) return; + + SendMessageComposer(new PurchasePrefixComposer(prefixText.trim(), colorString, selectedIcon, selectedEffect)); + setPurchased(true); + setTimeout(() => setPurchased(false), 2000); + }; + + const handleColorSelect = (color: string) => + { + if(colorMode === 'single') + { + setSingleColor(color); + setCustomColorInput(color); + } + else if(selectedLetterIndex !== null) + { + setLetterColors(prev => ({ ...prev, [selectedLetterIndex]: color })); + setCustomColorInput(color); + + // Auto-advance to next letter + if(selectedLetterIndex < prefixText.length - 1) + { + const nextIdx = selectedLetterIndex + 1; + setSelectedLetterIndex(nextIdx); + setCustomColorInput(letterColors[nextIdx] || singleColor); + } + } + }; + + const handleCustomColorChange = (value: string) => + { + setCustomColorInput(value); + if(/^#[0-9A-Fa-f]{6}$/.test(value)) + { + if(colorMode === 'single') + { + setSingleColor(value); + } + else if(selectedLetterIndex !== null) + { + setLetterColors(prev => ({ ...prev, [selectedLetterIndex]: value })); + } + } + }; + + const handleTextChange = (newText: string) => + { + setPrefixText(newText); + if(selectedLetterIndex !== null && selectedLetterIndex >= newText.length) + { + setSelectedLetterIndex(newText.length > 0 ? newText.length - 1 : null); + } + }; + + const applyColorToAll = () => + { + if(!prefixText.length) return; + + const newColors: Record = {}; + [ ...prefixText ].forEach((_, i) => { newColors[i] = customColorInput; }); + setLetterColors(newColors); + }; + + const hasMultiColor = colorMode === 'perLetter' && previewColors.length > 1 && new Set(previewColors).size > 1; + + const currentActiveColor = colorMode === 'single' + ? singleColor + : (selectedLetterIndex !== null ? (letterColors[selectedLetterIndex] || singleColor) : singleColor); + + const effectStyle = getPrefixEffectStyle(selectedEffect, previewColors[0] || '#FFFFFF'); + + return ( +
+ + + { /* Header */ } + { page.localization.getImage(0) && + } + { page.localization.getText(0) && +
} + + { /* Live Preview */ } +
+
+ + { selectedIcon && { selectedIcon } } + + {'{'} + { hasMultiColor + ? [ ...(prefixText || '...') ].map((char, i) => ( + { char } + )) + : (prefixText || '...') + } + {'}'} + + + Username +
+ + { /* Text + Icon Row */ } +
+
+ +
+ handleTextChange(e.target.value) } /> + + { prefixText.length }/15 + +
+
+
+ +
+ + { selectedIcon && + + } +
+
+
+ + { /* Emoji Picker (emoji-mart) - portaled to body, no backdrop */ } + { showIconPicker && createPortal( + <> +
setShowIconPicker(false) } /> +
+ { setSelectedIcon(emoji.native); setShowIconPicker(false); } } + theme="dark" + previewPosition="none" + skinTonePosition="search" + perLine={ 8 } + maxFrequentRows={ 2 } + emojiSize={ 22 } + emojiButtonSize={ 30 } + dynamicWidth={ false } + set="native" + /> +
+ , + document.body + ) } + + { /* Effect Selector */ } +
+ +
+ { PRESET_PREFIX_EFFECTS.map(fx => ( + + )) } +
+
+ + { /* Color Mode Toggle */ } +
+ +
+ + +
+
+ + { /* Per-Letter Selector */ } + { colorMode === 'perLetter' && prefixText.length > 0 && ( +
+
+ + Select a letter, then choose a color. Auto-advances. + + +
+
+ { [ ...prefixText ].map((char, i) => + { + const charColor = letterColors[i] || singleColor; + const isSelected = selectedLetterIndex === i; + return ( +
{ setSelectedLetterIndex(i); setCustomColorInput(charColor); } }> + + { char } + +
+
+ ); + }) } +
+
+ ) } + + { /* Color Palette */ } +
+ { colorMode === 'perLetter' && selectedLetterIndex !== null && + + Selected letter: "{ prefixText[selectedLetterIndex] || '' }" + + } +
+ { PRESET_COLORS.map((color, idx) => + { + const isActive = currentActiveColor === color; + return ( +
handleColorSelect(color) } /> + ); + }) } +
+
+ + handleCustomColorChange(e.target.value) } /> +
+
+ + { /* Purchase Footer */ } +
+
+ Price: + 5 Credits +
+ +
+
+ ); +}; diff --git a/src/components/catalog/views/page/layout/GetCatalogLayout.tsx b/src/components/catalog/views/page/layout/GetCatalogLayout.tsx index a9a03ee..cae9fd4 100644 --- a/src/components/catalog/views/page/layout/GetCatalogLayout.tsx +++ b/src/components/catalog/views/page/layout/GetCatalogLayout.tsx @@ -2,6 +2,7 @@ import { ICatalogPage } from '../../../../../api'; import { CatalogLayoutProps } from './CatalogLayout.types'; import { CatalogLayoutBadgeDisplayView } from './CatalogLayoutBadgeDisplayView'; import { CatalogLayoutColorGroupingView } from './CatalogLayoutColorGroupingView'; +import { CatalogLayoutCustomPrefixView } from './CatalogLayoutCustomPrefixView'; import { CatalogLayoutDefaultView } from './CatalogLayoutDefaultView'; import { CatalogLayouGuildCustomFurniView } from './CatalogLayoutGuildCustomFurniView'; import { CatalogLayouGuildForumView } from './CatalogLayoutGuildForumView'; @@ -72,6 +73,8 @@ export const GetCatalogLayout = (page: ICatalogPage, hideNavigation: () => void) return ; case 'soundmachine': return ; + case 'custom_prefix': + return ; case 'bots': case 'default_3x3': default: diff --git a/src/components/inventory/InventoryView.tsx b/src/components/inventory/InventoryView.tsx index acafff6..65c3a11 100644 --- a/src/components/inventory/InventoryView.tsx +++ b/src/components/inventory/InventoryView.tsx @@ -2,7 +2,7 @@ import { AddLinkEventTracker, BadgePointLimitsEvent, GetLocalizationManager, Get import { FC, useEffect, useState } from 'react'; import { GroupItem, LocalizeText, UnseenItemCategory, isObjectMoverRequested, setObjectMoverRequested } from '../../api'; import { NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common'; -import { useInventoryBadges, useInventoryFurni, useInventoryTrade, useInventoryUnseenTracker, useMessageEvent, useNitroEvent } from '../../hooks'; +import { useInventoryBadges, useInventoryFurni, useInventoryPrefixes, useInventoryTrade, useInventoryUnseenTracker, useMessageEvent, useNitroEvent } from '../../hooks'; import { InventoryCategoryFilterView } from './views/InventoryCategoryFilterView'; import { InventoryBadgeView } from './views/badge/InventoryBadgeView'; import { InventoryBotView } from './views/bot/InventoryBotView'; @@ -10,13 +10,15 @@ import { InventoryFurnitureDeleteView } from './views/furniture/InventoryFurnitu import { InventoryFurnitureView } from './views/furniture/InventoryFurnitureView'; import { InventoryTradeView } from './views/furniture/InventoryTradeView'; import { InventoryPetView } from './views/pet/InventoryPetView'; +import { InventoryPrefixView } from './views/prefix/InventoryPrefixView'; const TAB_FURNITURE: string = 'inventory.furni'; const TAB_BOTS: string = 'inventory.bots'; const TAB_PETS: string = 'inventory.furni.tab.pets'; const TAB_BADGES: string = 'inventory.badges'; -const TABS = [ TAB_FURNITURE, TAB_PETS, TAB_BADGES, TAB_BOTS ]; -const UNSEEN_CATEGORIES = [ UnseenItemCategory.FURNI, UnseenItemCategory.PET, UnseenItemCategory.BADGE, UnseenItemCategory.BOT ]; +const TAB_PREFIXES: string = 'inventory.prefixes'; +const TABS = [ TAB_FURNITURE, TAB_PETS, TAB_BADGES, TAB_PREFIXES, TAB_BOTS ]; +const UNSEEN_CATEGORIES = [ UnseenItemCategory.FURNI, UnseenItemCategory.PET, UnseenItemCategory.BADGE, UnseenItemCategory.PREFIX, UnseenItemCategory.BOT ]; export const InventoryView: FC<{}> = props => { @@ -165,6 +167,8 @@ export const InventoryView: FC<{}> = props => } { (currentTab === TAB_BADGES) && } + { (currentTab === TAB_PREFIXES) && + } { (currentTab === TAB_BOTS) && }
diff --git a/src/components/inventory/views/prefix/InventoryPrefixView.tsx b/src/components/inventory/views/prefix/InventoryPrefixView.tsx new file mode 100644 index 0000000..d959546 --- /dev/null +++ b/src/components/inventory/views/prefix/InventoryPrefixView.tsx @@ -0,0 +1,136 @@ +import { FC, useEffect, useState } from 'react'; +import { FaTrashAlt } from 'react-icons/fa'; +import { IPrefixItem, LocalizeText, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../api'; +import { useInventoryPrefixes, useNotification } from '../../../../hooks'; +import { NitroButton } from '../../../../layout'; + +const PrefixPreview: FC<{ text: string; color: string; icon: string; effect?: string; className?: string; textSize?: string }> = ({ text, color, icon, effect = '', className = '', textSize = 'text-sm' }) => +{ + const colors = parsePrefixColors(text, color); + const hasMultiColor = colors.length > 1 && new Set(colors).size > 1; + const fxStyle = getPrefixEffectStyle(effect, colors[0] || '#FFFFFF'); + + return ( + + { effect === 'pulse' && } + { icon && { icon } } + + {'{'} + { hasMultiColor + ? [ ...text ].map((char, i) => ( + { char } + )) + : text + } + {'}'} + + + ); +}; + +const PrefixItemView: FC<{ + prefix: IPrefixItem; + isSelected: boolean; + onClick: () => void; +}> = ({ prefix, isSelected, onClick }) => +{ + return ( +
+ +
+ ); +}; + +export const InventoryPrefixView: FC<{}> = () => +{ + const [ isVisible, setIsVisible ] = useState(false); + const { prefixes = [], activePrefix = null, selectedPrefix = null, setSelectedPrefix = null, activatePrefix = null, deactivatePrefix = null, deletePrefix = null, activate = null, deactivate = null } = useInventoryPrefixes(); + const { showConfirm = null } = useNotification(); + + const attemptDeletePrefix = () => + { + if(!selectedPrefix) return; + + showConfirm( + `Are you sure you want to delete the prefix {${selectedPrefix.text}}?`, + () => deletePrefix(selectedPrefix.id), + null, + null, + null, + LocalizeText('inventory.delete.confirm_delete.title') + ); + }; + + useEffect(() => + { + if(!isVisible) return; + + const id = activate(); + + return () => deactivate(id); + }, [ isVisible, activate, deactivate ]); + + useEffect(() => + { + setIsVisible(true); + + return () => setIsVisible(false); + }, []); + + return ( +
+
+
+ { prefixes.map(prefix => ( + setSelectedPrefix(prefix) } /> + )) } +
+ { (!prefixes || prefixes.length === 0) && +
+ { LocalizeText('inventory.empty.title') } +
} +
+
+ { activePrefix && +
+ Active prefix +
+ +
+
} + { !activePrefix && +
+ Active prefix +
+ No active prefix +
+
} + { !!selectedPrefix && +
+
+ +
+
+ selectedPrefix.active ? deactivatePrefix() : activatePrefix(selectedPrefix.id) }> + { selectedPrefix.active ? 'Deactivate' : 'Activate' } + + { !selectedPrefix.active && + + + } +
+
} +
+
+ ); +}; diff --git a/src/components/room/widgets/chat/ChatWidgetMessageView.tsx b/src/components/room/widgets/chat/ChatWidgetMessageView.tsx index 46ef8ec..1dba1a9 100644 --- a/src/components/room/widgets/chat/ChatWidgetMessageView.tsx +++ b/src/components/room/widgets/chat/ChatWidgetMessageView.tsx @@ -1,6 +1,6 @@ import { GetRoomEngine, RoomChatSettings, RoomObjectCategory } from '@nitrots/nitro-renderer'; import { FC, useEffect, useMemo, useRef, useState } from 'react'; -import { ChatBubbleMessage } from '../../../../api'; +import { ChatBubbleMessage, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../api'; import { useOnClickChat } from '../../../../hooks'; interface ChatWidgetMessageViewProps @@ -90,6 +90,27 @@ export const ChatWidgetMessageView: FC = ({ ) }
+ { chat.prefixEffect === 'pulse' && } + { chat.prefixText && (() => { + const colors = parsePrefixColors(chat.prefixText, chat.prefixColor); + const hasMultiColor = colors.length > 1 && new Set(colors).size > 1; + const fxStyle = getPrefixEffectStyle(chat.prefixEffect, colors[0] || '#FFFFFF'); + return ( + + { chat.prefixIcon && { chat.prefixIcon } } + + {'{'} + { hasMultiColor + ? [ ...chat.prefixText ].map((char, i) => ( + { char } + )) + : chat.prefixText + } + {'}'} + + + ); + })() }
diff --git a/src/hooks/inventory/index.ts b/src/hooks/inventory/index.ts index 4e70819..ea39265 100644 --- a/src/hooks/inventory/index.ts +++ b/src/hooks/inventory/index.ts @@ -2,5 +2,6 @@ export * from './useInventoryBadges'; export * from './useInventoryBots'; export * from './useInventoryFurni'; export * from './useInventoryPets'; +export * from './useInventoryPrefixes'; export * from './useInventoryTrade'; export * from './useInventoryUnseenTracker'; diff --git a/src/hooks/inventory/useInventoryPrefixes.ts b/src/hooks/inventory/useInventoryPrefixes.ts new file mode 100644 index 0000000..1d761c1 --- /dev/null +++ b/src/hooks/inventory/useInventoryPrefixes.ts @@ -0,0 +1,126 @@ +import { ActivePrefixUpdatedEvent, PrefixReceivedEvent, RequestPrefixesComposer, SetActivePrefixComposer, DeletePrefixComposer, UserPrefixesEvent } from '@nitrots/nitro-renderer'; +import { useEffect, useState } from 'react'; +import { useBetween } from 'use-between'; +import { IPrefixItem, SendMessageComposer, UnseenItemCategory } from '../../api'; +import { useMessageEvent } from '../events'; +import { useSharedVisibility } from '../useSharedVisibility'; +import { useInventoryUnseenTracker } from './useInventoryUnseenTracker'; + +const useInventoryPrefixesState = () => +{ + const [ needsUpdate, setNeedsUpdate ] = useState(true); + const [ prefixes, setPrefixes ] = useState([]); + const [ activePrefix, setActivePrefix ] = useState(null); + const [ selectedPrefix, setSelectedPrefix ] = useState(null); + const { isVisible = false, activate = null, deactivate = null } = useSharedVisibility(); + const { isUnseen = null, resetCategory = null } = useInventoryUnseenTracker(); + + useMessageEvent(UserPrefixesEvent, event => + { + const parser = event.getParser(); + const newPrefixes: IPrefixItem[] = parser.prefixes.map(p => ({ + id: p.id, + text: p.text, + color: p.color, + icon: p.icon || '', + effect: p.effect || '', + active: p.active + })); + + setPrefixes(newPrefixes); + + const active = newPrefixes.find(p => p.active) || null; + setActivePrefix(active); + }); + + useMessageEvent(PrefixReceivedEvent, event => + { + const parser = event.getParser(); + const newPrefix: IPrefixItem = { + id: parser.id, + text: parser.text, + color: parser.color, + icon: parser.icon || '', + effect: parser.effect || '', + active: false + }; + + setPrefixes(prevValue => [ newPrefix, ...prevValue ]); + }); + + useMessageEvent(ActivePrefixUpdatedEvent, event => + { + const parser = event.getParser(); + + setPrefixes(prevValue => + { + return prevValue.map(p => ({ + ...p, + active: p.id === parser.prefixId + })); + }); + + if(parser.prefixId === 0) + { + setActivePrefix(null); + } + else + { + setActivePrefix(prev => + { + const found = prefixes.find(p => p.id === parser.prefixId); + if(found) return { ...found, active: true }; + return { id: parser.prefixId, text: parser.text, color: parser.color, icon: parser.icon || '', effect: parser.effect || '', active: true }; + }); + } + }); + + const activatePrefix = (prefixId: number) => + { + SendMessageComposer(new SetActivePrefixComposer(prefixId)); + }; + + const deactivatePrefix = () => + { + SendMessageComposer(new SetActivePrefixComposer(0)); + }; + + const deletePrefix = (prefixId: number) => + { + SendMessageComposer(new DeletePrefixComposer(prefixId)); + }; + + useEffect(() => + { + if(!prefixes || !prefixes.length) return; + + setSelectedPrefix(prevValue => + { + if(prevValue && prefixes.find(p => p.id === prevValue.id)) return prevValue; + return prefixes[0]; + }); + }, [ prefixes ]); + + useEffect(() => + { + if(!isVisible) return; + + return () => + { + resetCategory(UnseenItemCategory.PREFIX); + }; + }, [ isVisible, resetCategory ]); + + useEffect(() => + { + if(!isVisible || !needsUpdate) return; + + SendMessageComposer(new RequestPrefixesComposer()); + + setNeedsUpdate(false); + }, [ isVisible, needsUpdate ]); + + return { prefixes, activePrefix, selectedPrefix, setSelectedPrefix, activatePrefix, deactivatePrefix, deletePrefix, activate, deactivate }; +}; + +export const useInventoryPrefixes = () => useBetween(useInventoryPrefixesState); diff --git a/src/hooks/rooms/widgets/index.ts b/src/hooks/rooms/widgets/index.ts index 9984450..ea35008 100644 --- a/src/hooks/rooms/widgets/index.ts +++ b/src/hooks/rooms/widgets/index.ts @@ -1,5 +1,6 @@ export * from './furniture'; export * from './useAvatarInfoWidget'; +export * from './useChatCommandSelector'; export * from './useChatInputWidget'; export * from './useChatWidget'; export * from './useDoorbellWidget'; diff --git a/src/hooks/rooms/widgets/useChatWidget.ts b/src/hooks/rooms/widgets/useChatWidget.ts index 648a102..0ff6f24 100644 --- a/src/hooks/rooms/widgets/useChatWidget.ts +++ b/src/hooks/rooms/widgets/useChatWidget.ts @@ -149,6 +149,11 @@ const useChatWidgetState = () => imageUrl, color); + chatMessage.prefixText = event.prefixText || ''; + chatMessage.prefixColor = event.prefixColor || ''; + chatMessage.prefixIcon = event.prefixIcon || ''; + chatMessage.prefixEffect = event.prefixEffect || ''; + setChatMessages(prevValue => { const newValue = [ ...prevValue, chatMessage ]; From 466cc093878cd5580541be0a931dcb0dc5dcc2af Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 15 Mar 2026 11:08:04 +0100 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9B=20Fix=20crackableHits=20undefi?= =?UTF-8?q?ned=20TypeError=20in=20InfoStandWidgetFurniView?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: medievalshell --- .../avatar-info/infostand/InfoStandWidgetFurniView.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx index d58a720..4cd35a0 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx @@ -220,8 +220,8 @@ export const InfoStandWidgetFurniView: FC = props canUse = true; isCrackable = true; - crackableHits = stuffData.hits; - crackableTarget = stuffData.target; + crackableHits = stuffData?.hits ?? 0; + crackableTarget = stuffData?.target ?? 0; } else if(avatarInfo.extraParam === RoomWidgetEnumItemExtradataParameter.JUKEBOX) @@ -527,7 +527,7 @@ export const InfoStandWidgetFurniView: FC = props { isCrackable && <>
- { LocalizeText('infostand.crackable_furni.hits_remaining', [ 'hits', 'target' ], [ crackableHits.toString(), crackableTarget.toString() ]) } + { LocalizeText('infostand.crackable_furni.hits_remaining', [ 'hits', 'target' ], [ (crackableHits ?? 0).toString(), (crackableTarget ?? 0).toString() ]) } } { avatarInfo.groupId > 0 && <>