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.
This commit is contained in:
medievalshell
2026-05-28 15:31:24 +02:00
parent bade7e2623
commit 49836bbeef
3 changed files with 204 additions and 1 deletions
+1 -1
View File
@@ -76,7 +76,7 @@ export const RadioView: FC<{}> = () =>
<div className="mt-0.5 flex items-center gap-1.5">
{ selectedPlaying &&
<span className="flex items-center gap-1 text-[9px] font-bold uppercase tracking-wide text-sky-400">
<span className="h-1.5 w-1.5 rounded-full bg-sky-400" /> Live
<span className="h-1.5 w-1.5 rounded-full bg-sky-400" /> { LocalizeText('radio.live') }
</span> }
{ selected?.genre &&
<span className="truncate text-[10px] text-white/45">{ selected.genre }</span> }
@@ -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> = 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<HTMLDivElement>(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<HTMLDivElement>) =>
{
draggingRef.current = true;
padRef.current?.setPointerCapture(event.pointerId);
setFromPointer(event.clientX, event.clientY);
};
const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) =>
{
if(draggingRef.current) setFromPointer(event.clientX, event.clientY);
};
const onPointerUp = (event: ReactPointerEvent<HTMLDivElement>) =>
{
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 (
<NitroCardView className="no-resize" uniqueKey="image-position-editor" theme="primary-slim">
<NitroCardHeaderView headerText={ LocalizeText('image.position.editor.title') } onCloseClick={ cancel } />
<NitroCardContentView>
<div className="flex flex-col gap-2">
<span className="text-[11px] text-black/60">{ LocalizeText('image.position.editor.hint') }</span>
<div
ref={ padRef }
onPointerDown={ onPointerDown }
onPointerMove={ onPointerMove }
onPointerUp={ onPointerUp }
className="relative cursor-crosshair self-center rounded border border-black/30 bg-[#1b2733]"
style={ { width: PAD_W, height: PAD_H } }>
{ /* center crosshair */ }
<div className="pointer-events-none absolute left-1/2 top-0 h-full w-px -translate-x-1/2 bg-white/10" />
<div className="pointer-events-none absolute left-0 top-1/2 h-px w-full -translate-y-1/2 bg-white/10" />
{ /* draggable dot */ }
<div
className="pointer-events-none absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-sky-400 shadow-[0_0_6px_rgba(56,189,248,0.8)] ring-2 ring-white/70"
style={ { left: clampedLeft, top: clampedTop } } />
</div>
<div className="flex items-center gap-2">
<span className="w-12 text-[11px] text-black/70">{ LocalizeText('image.position.editor.scale') }</span>
<input type="range" min={ 10 } max={ 500 } step={ 1 } value={ scale } onChange={ e => setScale(e.target.valueAsNumber || 100) } className="grow" />
<span className="w-12 text-right text-[11px] tabular-nums text-black/70">{ (scale / 100).toFixed(2) }x</span>
</div>
<div className="grid grid-cols-3 gap-2">
<label className="flex flex-col gap-0.5 text-[11px] text-black/70">{ LocalizeText('image.position.editor.offsetx') }
<input type="number" value={ x } onChange={ e => setX(e.target.valueAsNumber || 0) } className="form-control form-control-sm" />
</label>
<label className="flex flex-col gap-0.5 text-[11px] text-black/70">{ LocalizeText('image.position.editor.offsety') }
<input type="number" value={ y } onChange={ e => setY(e.target.valueAsNumber || 0) } className="form-control form-control-sm" />
</label>
<label className="flex flex-col gap-0.5 text-[11px] text-black/70">{ LocalizeText('image.position.editor.offsetz') }
<input type="number" value={ z } onChange={ e => setZ(e.target.valueAsNumber || 0) } className="form-control form-control-sm" />
</label>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={ cancel }>{ LocalizeText('image.position.editor.cancel') }</Button>
<Button variant="success" onClick={ save }>{ LocalizeText('save') }</Button>
</div>
</div>
</NitroCardContentView>
</NitroCardView>
);
};
@@ -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<InfoStandWidgetFurniViewProps> = props
const [ isJukeBox, setIsJukeBox ] = useState<boolean>(false);
const [ isSongDisk, setIsSongDisk ] = useState<boolean>(false);
const [ isBranded, setIsBranded ] = useState<boolean>(false);
const [ showPositionEditor, setShowPositionEditor ] = useState<boolean>(false);
const [ songId, setSongId ] = useState<number>(-1);
const [ songName, setSongName ] = useState<string>('');
const [ songCreator, setSongCreator ] = useState<string>('');
@@ -393,6 +395,45 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = 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<string, string>();
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<InfoStandWidgetFurniViewProps> = props
<Button variant="dark" onClick={ event => processButtonAction('use') }>
{ LocalizeText('infostand.button.use') }
</Button> }
{ hasBrandingOffsets &&
<Button variant="dark" onClick={ () => setShowPositionEditor(true) }>
{ LocalizeText('image.position.editor.button') }
</Button> }
{ ((furniKeys.length > 0 && furniValues.length > 0) && (furniKeys.length === furniValues.length)) &&
<Button variant="dark" onClick={ () => processButtonAction('save_branding_configuration') }>
{ LocalizeText('save') }
@@ -758,6 +803,17 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
{ LocalizeText('save') }
</Button> }
</Flex>
{ showPositionEditor &&
<ImagePositionEditorView
roomId={ roomSession.roomId }
objectId={ avatarInfo.id }
isWallItem={ avatarInfo.isWallItem }
initialX={ getBrandingOffset('offsetX') }
initialY={ getBrandingOffset('offsetY') }
initialZ={ getBrandingOffset('offsetZ') }
initialScale={ getBrandingOffset('scale') || 100 }
onClose={ () => setShowPositionEditor(false) }
onSave={ savePositionEditor } /> }
</Column>
);
};