mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user