From 49836bbeef3bd7ee960708af6748fb8496fe2e99 Mon Sep 17 00:00:00 2001 From: medievalshell Date: Thu, 28 May 2026 15:31:24 +0200 Subject: [PATCH] feat: branding furni image position editor (move + scale) Adds an "Editor Posizione" button to the furni infostand action bar for branding / MPU furni, opening a dialog to position and zoom the image: - draggable dot moves offsetX/Y (live, local preview only) - slider zooms the image (scale, via the renderer's per-sprite scale) - offsetZ kept as z-index; Save persists + broadcasts via SetObjectData - radio "Live" + all editor labels go through LocalizeText (external texts) Pairs with the renderer branding scale/offset support and Arcturus' `scale` default on InteractionRoomAds. --- src/components/radio/RadioView.tsx | 2 +- .../infostand/ImagePositionEditorView.tsx | 147 ++++++++++++++++++ .../infostand/InfoStandWidgetFurniView.tsx | 56 +++++++ 3 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 src/components/room/widgets/avatar-info/infostand/ImagePositionEditorView.tsx diff --git a/src/components/radio/RadioView.tsx b/src/components/radio/RadioView.tsx index 15e7c95..6cd825b 100644 --- a/src/components/radio/RadioView.tsx +++ b/src/components/radio/RadioView.tsx @@ -76,7 +76,7 @@ export const RadioView: FC<{}> = () =>
{ selectedPlaying && - Live + { LocalizeText('radio.live') } } { selected?.genre && { selected.genre } } diff --git a/src/components/room/widgets/avatar-info/infostand/ImagePositionEditorView.tsx b/src/components/room/widgets/avatar-info/infostand/ImagePositionEditorView.tsx new file mode 100644 index 0000000..1362ae0 --- /dev/null +++ b/src/components/room/widgets/avatar-info/infostand/ImagePositionEditorView.tsx @@ -0,0 +1,147 @@ +import { GetRoomEngine, RoomObjectCategory, RoomObjectVariable } from '@nitrots/nitro-renderer'; +import { FC, PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState } from 'react'; +import { LocalizeText } from '../../../../../api'; +import { Button, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../../common'; + +const PAD_W = 230; +const PAD_H = 150; +// How many offset units one pixel of drag represents. +const UNITS_PER_PX = 1.5; + +interface Props +{ + roomId: number; + objectId: number; + isWallItem: boolean; + initialX: number; + initialY: number; + initialZ: number; + initialScale: number; + onClose: () => void; + onSave: (x: number, y: number, z: number, scale: number) => void; +} + +export const ImagePositionEditorView: FC = props => +{ + const { roomId, objectId, isWallItem, initialX, initialY, initialZ, initialScale, onClose, onSave } = props; + const [ x, setX ] = useState(initialX); + const [ y, setY ] = useState(initialY); + const [ z, setZ ] = useState(initialZ); + const [ scale, setScale ] = useState(initialScale || 100); + const padRef = useRef(null); + const draggingRef = useRef(false); + + const category = isWallItem ? RoomObjectCategory.WALL : RoomObjectCategory.FLOOR; + + // Local-only live preview: set the branding model values directly. The model + // bumps its update counter so the visualization re-renders next frame. + // Nothing is sent to the server until Save. + const applyLive = useCallback((nx: number, ny: number, nz: number, nScale: number) => + { + const roomObject = GetRoomEngine().getRoomObject(roomId, objectId, category); + if(!roomObject?.model) return; + + roomObject.model.setValue(RoomObjectVariable.FURNITURE_BRANDING_OFFSET_X, nx); + roomObject.model.setValue(RoomObjectVariable.FURNITURE_BRANDING_OFFSET_Y, ny); + roomObject.model.setValue(RoomObjectVariable.FURNITURE_BRANDING_OFFSET_Z, nz); + roomObject.model.setValue(RoomObjectVariable.FURNITURE_BRANDING_SCALE, nScale); + }, [ roomId, objectId, category ]); + + useEffect(() => { applyLive(x, y, z, scale); }, [ x, y, z, scale, applyLive ]); + + const setFromPointer = useCallback((clientX: number, clientY: number) => + { + const rect = padRef.current?.getBoundingClientRect(); + if(!rect) return; + + const cx = rect.left + (rect.width / 2); + const cy = rect.top + (rect.height / 2); + + setX(Math.round((clientX - cx) * UNITS_PER_PX)); + setY(Math.round((clientY - cy) * UNITS_PER_PX)); + }, []); + + const onPointerDown = (event: ReactPointerEvent) => + { + draggingRef.current = true; + padRef.current?.setPointerCapture(event.pointerId); + setFromPointer(event.clientX, event.clientY); + }; + + const onPointerMove = (event: ReactPointerEvent) => + { + if(draggingRef.current) setFromPointer(event.clientX, event.clientY); + }; + + const onPointerUp = (event: ReactPointerEvent) => + { + draggingRef.current = false; + padRef.current?.releasePointerCapture?.(event.pointerId); + }; + + const cancel = () => + { + applyLive(initialX, initialY, initialZ, initialScale || 100); + onClose(); + }; + + const save = () => + { + onSave(x, y, z, scale); + onClose(); + }; + + const dotLeft = (PAD_W / 2) + (x / UNITS_PER_PX); + const dotTop = (PAD_H / 2) + (y / UNITS_PER_PX); + const clampedLeft = Math.max(0, Math.min(PAD_W, dotLeft)); + const clampedTop = Math.max(0, Math.min(PAD_H, dotTop)); + + return ( + + + +
+ { LocalizeText('image.position.editor.hint') } +
+ { /* center crosshair */ } +
+
+ { /* draggable dot */ } +
+
+ +
+ { LocalizeText('image.position.editor.scale') } + setScale(e.target.valueAsNumber || 100) } className="grow" /> + { (scale / 100).toFixed(2) }x +
+ +
+ + + +
+ +
+ + +
+
+ + + ); +}; diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx index d2a861c..40b458f 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx @@ -6,6 +6,7 @@ import { AvatarInfoFurni, GetGroupInformation, LocalizeText, SendMessageComposer import { Button, Column, Flex, LayoutBadgeImageView, LayoutCurrencyIcon, LayoutLimitedEditionCompactPlateView, LayoutRarityLevelView, LayoutRoomObjectImageView, Text, UserProfileIconView } from '../../../../../common'; import { useHasPermission, useMessageEvent, useNitroEvent, useRareValues, useRoom, useWiredTools } from '../../../../../hooks'; import { NitroInput } from '../../../../../layout'; +import { ImagePositionEditorView } from './ImagePositionEditorView'; interface InfoStandWidgetFurniViewProps { @@ -43,6 +44,7 @@ export const InfoStandWidgetFurniView: FC = props const [ isJukeBox, setIsJukeBox ] = useState(false); const [ isSongDisk, setIsSongDisk ] = useState(false); const [ isBranded, setIsBranded ] = useState(false); + const [ showPositionEditor, setShowPositionEditor ] = useState(false); const [ songId, setSongId ] = useState(-1); const [ songName, setSongName ] = useState(''); const [ songCreator, setSongCreator ] = useState(''); @@ -393,6 +395,45 @@ export const InfoStandWidgetFurniView: FC = props return data; }, [ furniKeys, furniValues ]); + const getBrandingOffset = useCallback((key: string): number => + { + const index = furniKeys.indexOf(key); + if(index < 0) return 0; + const value = parseInt(furniValues[index]); + return isNaN(value) ? 0 : value; + }, [ furniKeys, furniValues ]); + + const hasBrandingOffsets = isBranded && (furniKeys.indexOf('offsetX') >= 0); + + // Persist the position from the editor: rebuild the branding map with the + // new offsets and send it (same path as Save), then reflect it in the fields. + const savePositionEditor = useCallback((x: number, y: number, z: number, scale: number) => + { + const map = new Map(); + const clone = Array.from(furniValues); + let hasScale = false; + + for(let i = 0; i < furniKeys.length; i++) + { + const key = furniKeys[i]; + let value = furniValues[i]; + + if(key === 'offsetX') value = String(x); + else if(key === 'offsetY') value = String(y); + else if(key === 'offsetZ') value = String(z); + else if(key === 'scale') { value = String(scale); hasScale = true; } + + clone[i] = value; + map.set(key, value); + } + + // older branding furni may not carry a scale key yet — always send it + if(!hasScale) map.set('scale', String(scale)); + + GetRoomEngine().modifyRoomObjectDataWithMap(avatarInfo.id, avatarInfo.category, RoomObjectOperationType.OBJECT_SAVE_STUFF_DATA, map); + setFurniValues(clone); + }, [ avatarInfo, furniKeys, furniValues ]); + const processButtonAction = useCallback((action: string) => { if(!action || (action === '')) return; @@ -749,6 +790,10 @@ export const InfoStandWidgetFurniView: FC = props } + { hasBrandingOffsets && + } { ((furniKeys.length > 0 && furniValues.length > 0) && (furniKeys.length === furniValues.length)) && } + { showPositionEditor && + setShowPositionEditor(false) } + onSave={ savePositionEditor } /> } ); };