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 } /> }
);
};