feat(npcs): free rotate overlays and drop session zoom tools
Drag the top-edge handle to rotate the whole frame with the avatar; remove unused zoom controls from the session NPC window. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -107,7 +107,10 @@ export class NpcsOverlayStore {
|
|||||||
focusNpcId: event.npcId,
|
focusNpcId: event.npcId,
|
||||||
layouts: {
|
layouts: {
|
||||||
...this.state.layouts,
|
...this.state.layouts,
|
||||||
[event.npcId]: clampNpcsLayout(event.layout),
|
[event.npcId]: clampNpcsLayout({
|
||||||
|
...layoutFor(this.state, event.npcId),
|
||||||
|
...event.layout,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return this.state;
|
return this.state;
|
||||||
|
|||||||
@@ -1631,20 +1631,15 @@ export function ControlApp() {
|
|||||||
<NpcsSceneOverlay
|
<NpcsSceneOverlay
|
||||||
items={items}
|
items={items}
|
||||||
editable
|
editable
|
||||||
zoomTool={npcsOverlay?.zoomTool ?? null}
|
|
||||||
showClose
|
showClose
|
||||||
closeLabel={t('npcs.closeOverlay')}
|
closeLabel={t('npcs.closeOverlay')}
|
||||||
|
rotateLabel={t('npcs.rotateOverlay')}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
void npcsApi.dispatch({ kind: 'hide' });
|
void npcsApi.dispatch({ kind: 'hide' });
|
||||||
}}
|
}}
|
||||||
onLayoutChange={(npcId, layout) => {
|
onLayoutChange={(npcId, layout) => {
|
||||||
void npcsApi.dispatch({ kind: 'layout.set', npcId, layout });
|
void npcsApi.dispatch({ kind: 'layout.set', npcId, layout });
|
||||||
}}
|
}}
|
||||||
onZoomAt={(npcId, nx, ny) => {
|
|
||||||
void npcsApi.dispatch(
|
|
||||||
npcId ? { kind: 'zoomAt', nx, ny, npcId } : { kind: 'zoomAt', nx, ny },
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -427,6 +427,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'npcs.windowEmpty': 'Добавьте НПС в редакторе.',
|
'npcs.windowEmpty': 'Добавьте НПС в редакторе.',
|
||||||
'npcs.selectToShow': 'Выберите персонажей в списке — они появятся на экране.',
|
'npcs.selectToShow': 'Выберите персонажей в списке — они появятся на экране.',
|
||||||
'npcs.closeOverlay': 'Закрыть всех',
|
'npcs.closeOverlay': 'Закрыть всех',
|
||||||
|
'npcs.rotateOverlay': 'Повернуть',
|
||||||
'npcs.zoomIn': 'Увеличить',
|
'npcs.zoomIn': 'Увеличить',
|
||||||
'npcs.zoomOut': 'Уменьшить',
|
'npcs.zoomOut': 'Уменьшить',
|
||||||
'npcs.zoomInHint': 'Кликните по аватару в предпросмотре пульта, чтобы увеличить.',
|
'npcs.zoomInHint': 'Кликните по аватару в предпросмотре пульта, чтобы увеличить.',
|
||||||
@@ -963,6 +964,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'npcs.windowEmpty': 'Add NPCs in the editor.',
|
'npcs.windowEmpty': 'Add NPCs in the editor.',
|
||||||
'npcs.selectToShow': 'Select characters in the list — they will appear on screen.',
|
'npcs.selectToShow': 'Select characters in the list — they will appear on screen.',
|
||||||
'npcs.closeOverlay': 'Close all',
|
'npcs.closeOverlay': 'Close all',
|
||||||
|
'npcs.rotateOverlay': 'Rotate',
|
||||||
'npcs.zoomIn': 'Zoom in',
|
'npcs.zoomIn': 'Zoom in',
|
||||||
'npcs.zoomOut': 'Zoom out',
|
'npcs.zoomOut': 'Zoom out',
|
||||||
'npcs.zoomInHint': 'Click the avatar on the control preview to zoom in.',
|
'npcs.zoomInHint': 'Click the avatar on the control preview to zoom in.',
|
||||||
|
|||||||
@@ -12,32 +12,6 @@ import { useAssetUrl } from '../shared/useAssetImageUrl';
|
|||||||
|
|
||||||
import styles from './NpcsApp.module.css';
|
import styles from './NpcsApp.module.css';
|
||||||
|
|
||||||
function ZoomInIcon() {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
|
|
||||||
<circle cx="10.5" cy="10.5" r="6.25" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
|
||||||
<path d="M15.2 15.2 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
|
||||||
<path
|
|
||||||
d="M10.5 7.8v5.4M7.8 10.5h5.4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.8"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ZoomOutIcon() {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
|
|
||||||
<circle cx="10.5" cy="10.5" r="6.25" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
|
||||||
<path d="M15.2 15.2 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
|
||||||
<path d="M7.8 10.5h5.4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Убрать пустые ветки групп (удобно при поиске). */
|
/** Убрать пустые ветки групп (удобно при поиске). */
|
||||||
function pruneEmptyGroupNodes(nodes: NpcGroupTreeNode[]): NpcGroupTreeNode[] {
|
function pruneEmptyGroupNodes(nodes: NpcGroupTreeNode[]): NpcGroupTreeNode[] {
|
||||||
const out: NpcGroupTreeNode[] = [];
|
const out: NpcGroupTreeNode[] = [];
|
||||||
@@ -155,7 +129,6 @@ export function NpcsApp() {
|
|||||||
|
|
||||||
const activeIds = overlay?.activeNpcIds ?? [];
|
const activeIds = overlay?.activeNpcIds ?? [];
|
||||||
const hasActive = activeIds.length > 0;
|
const hasActive = activeIds.length > 0;
|
||||||
const zoomTool = overlay?.zoomTool ?? null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@@ -164,16 +137,12 @@ export function NpcsApp() {
|
|||||||
void overlayApi.dispatch({ kind: 'hide' });
|
void overlayApi.dispatch({ kind: 'hide' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (zoomTool) {
|
|
||||||
void overlayApi.dispatch({ kind: 'zoomTool.set', tool: null });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.close();
|
window.close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, [hasActive, zoomTool, overlayApi]);
|
}, [hasActive, overlayApi]);
|
||||||
|
|
||||||
const npcs = useMemo(() => session?.project?.npcs ?? [], [session?.project?.npcs]);
|
const npcs = useMemo(() => session?.project?.npcs ?? [], [session?.project?.npcs]);
|
||||||
const npcGroups = useMemo(() => session?.project?.npcGroups ?? [], [session?.project?.npcGroups]);
|
const npcGroups = useMemo(() => session?.project?.npcGroups ?? [], [session?.project?.npcGroups]);
|
||||||
@@ -245,39 +214,9 @@ export function NpcsApp() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
|
{hasActive ? (
|
||||||
<div className={styles.toolbar}>
|
<div className={styles.toolbar}>
|
||||||
<div className={styles.toolbarRow}>
|
<div className={styles.toolbarRow}>
|
||||||
<Button
|
|
||||||
variant={zoomTool === 'zoomIn' ? 'primary' : 'ghost'}
|
|
||||||
iconOnly
|
|
||||||
title={t('npcs.zoomIn')}
|
|
||||||
ariaLabel={t('npcs.zoomIn')}
|
|
||||||
tooltipPlacement="bottom"
|
|
||||||
onClick={() => {
|
|
||||||
void overlayApi.dispatch({
|
|
||||||
kind: 'zoomTool.set',
|
|
||||||
tool: zoomTool === 'zoomIn' ? null : 'zoomIn',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ZoomInIcon />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={zoomTool === 'zoomOut' ? 'primary' : 'ghost'}
|
|
||||||
iconOnly
|
|
||||||
title={t('npcs.zoomOut')}
|
|
||||||
ariaLabel={t('npcs.zoomOut')}
|
|
||||||
tooltipPlacement="bottom"
|
|
||||||
onClick={() => {
|
|
||||||
void overlayApi.dispatch({
|
|
||||||
kind: 'zoomTool.set',
|
|
||||||
tool: zoomTool === 'zoomOut' ? null : 'zoomOut',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ZoomOutIcon />
|
|
||||||
</Button>
|
|
||||||
{hasActive ? (
|
|
||||||
<Button
|
<Button
|
||||||
title={t('npcs.closeOverlay')}
|
title={t('npcs.closeOverlay')}
|
||||||
ariaLabel={t('npcs.closeOverlay')}
|
ariaLabel={t('npcs.closeOverlay')}
|
||||||
@@ -288,16 +227,9 @@ export function NpcsApp() {
|
|||||||
>
|
>
|
||||||
{t('npcs.closeOverlay')}
|
{t('npcs.closeOverlay')}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
|
||||||
<div className={styles.toolbarHint}>
|
|
||||||
{zoomTool === 'zoomIn'
|
|
||||||
? t('npcs.zoomInHint')
|
|
||||||
: zoomTool === 'zoomOut'
|
|
||||||
? t('npcs.zoomOutHint')
|
|
||||||
: t('npcs.zoomIdleHint')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
<div className={styles.detail}>
|
<div className={styles.detail}>
|
||||||
|
|||||||
@@ -110,3 +110,35 @@
|
|||||||
.close:hover {
|
.close:hover {
|
||||||
background: rgba(24, 24, 32, 0.9);
|
background: rgba(24, 24, 32, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.frameRotate {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 0;
|
||||||
|
z-index: 3;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(12, 12, 16, 0.78);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0;
|
||||||
|
cursor: grab;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
pointer-events: auto;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.45);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frameRotate:hover {
|
||||||
|
background: rgba(24, 24, 32, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frameRotate:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frameRotateSvg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,10 +20,26 @@ type NpcsSceneOverlayProps = {
|
|||||||
showClose?: boolean;
|
showClose?: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
closeLabel?: string;
|
closeLabel?: string;
|
||||||
|
rotateLabel?: string;
|
||||||
onLayoutChange?: (npcId: NpcId, layout: NpcsOverlayLayout) => void;
|
onLayoutChange?: (npcId: NpcId, layout: NpcsOverlayLayout) => void;
|
||||||
onZoomAt?: (npcId: NpcId | undefined, nx: number, ny: number) => void;
|
onZoomAt?: (npcId: NpcId | undefined, nx: number, ny: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function RotateIcon() {
|
||||||
|
return (
|
||||||
|
<svg className={styles.frameRotateSvg} viewBox="0 0 24 24" width="16" height="16" aria-hidden>
|
||||||
|
<path
|
||||||
|
d="M20 12a8 8 0 1 1-2.2-5.4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path d="M20 4v5h-5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function nextBaseSize(
|
function nextBaseSize(
|
||||||
viewW: number,
|
viewW: number,
|
||||||
viewH: number,
|
viewH: number,
|
||||||
@@ -37,12 +53,48 @@ function nextBaseSize(
|
|||||||
return { w: naturalW * fit, h: naturalH * fit };
|
return { w: naturalW * fit, h: naturalH * fit };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pointerAngleDeg(centerX: number, centerY: number, clientX: number, clientY: number): number {
|
||||||
|
return (Math.atan2(clientY - centerY, clientX - centerX) * 180) / Math.PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortestAngleDelta(fromDeg: number, toDeg: number): number {
|
||||||
|
let d = toDeg - fromDeg;
|
||||||
|
while (d > 180) d -= 360;
|
||||||
|
while (d < -180) d += 360;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function screenToLocal(
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
centerX: number,
|
||||||
|
centerY: number,
|
||||||
|
rotationDeg: number,
|
||||||
|
): { x: number; y: number } {
|
||||||
|
const rad = (-rotationDeg * Math.PI) / 180;
|
||||||
|
const dx = clientX - centerX;
|
||||||
|
const dy = clientY - centerY;
|
||||||
|
return {
|
||||||
|
x: dx * Math.cos(rad) - dy * Math.sin(rad),
|
||||||
|
y: dx * Math.sin(rad) + dy * Math.cos(rad),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function localToScreenOffset(localX: number, localY: number, rotationDeg: number): { x: number; y: number } {
|
||||||
|
const rad = (rotationDeg * Math.PI) / 180;
|
||||||
|
return {
|
||||||
|
x: localX * Math.cos(rad) - localY * Math.sin(rad),
|
||||||
|
y: localX * Math.sin(rad) + localY * Math.cos(rad),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function NpcAvatarFrame({
|
function NpcAvatarFrame({
|
||||||
item,
|
item,
|
||||||
view,
|
view,
|
||||||
rootRef,
|
rootRef,
|
||||||
editable,
|
editable,
|
||||||
zoomTool,
|
zoomTool,
|
||||||
|
rotateLabel,
|
||||||
onLayoutChange,
|
onLayoutChange,
|
||||||
}: {
|
}: {
|
||||||
item: NpcsSceneOverlayItem;
|
item: NpcsSceneOverlayItem;
|
||||||
@@ -50,6 +102,7 @@ function NpcAvatarFrame({
|
|||||||
rootRef: React.RefObject<HTMLDivElement | null>;
|
rootRef: React.RefObject<HTMLDivElement | null>;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
zoomTool: NpcsZoomTool;
|
zoomTool: NpcsZoomTool;
|
||||||
|
rotateLabel: string;
|
||||||
onLayoutChange?: (npcId: NpcId, layout: NpcsOverlayLayout) => void;
|
onLayoutChange?: (npcId: NpcId, layout: NpcsOverlayLayout) => void;
|
||||||
}) {
|
}) {
|
||||||
const url = useAssetUrl(item.assetId);
|
const url = useAssetUrl(item.assetId);
|
||||||
@@ -59,13 +112,20 @@ function NpcAvatarFrame({
|
|||||||
| {
|
| {
|
||||||
mode: 'resize';
|
mode: 'resize';
|
||||||
corner: Corner;
|
corner: Corner;
|
||||||
startX: number;
|
|
||||||
startY: number;
|
|
||||||
origin: NpcsOverlayLayout;
|
origin: NpcsOverlayLayout;
|
||||||
baseW: number;
|
baseW: number;
|
||||||
baseH: number;
|
baseH: number;
|
||||||
viewW: number;
|
viewW: number;
|
||||||
viewH: number;
|
viewH: number;
|
||||||
|
centerClientX: number;
|
||||||
|
centerClientY: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
mode: 'rotate';
|
||||||
|
origin: NpcsOverlayLayout;
|
||||||
|
startPointerAngle: number;
|
||||||
|
centerX: number;
|
||||||
|
centerY: number;
|
||||||
}
|
}
|
||||||
| null
|
| null
|
||||||
>(null);
|
>(null);
|
||||||
@@ -73,6 +133,7 @@ function NpcAvatarFrame({
|
|||||||
if (!item.assetId || !url) return null;
|
if (!item.assetId || !url) return null;
|
||||||
|
|
||||||
const layout = item.layout ?? DEFAULT_NPCS_OVERLAY_LAYOUT;
|
const layout = item.layout ?? DEFAULT_NPCS_OVERLAY_LAYOUT;
|
||||||
|
const rotationDeg = layout.rotationDeg ?? 0;
|
||||||
const base = nextBaseSize(view.w, view.h, natural.w, natural.h);
|
const base = nextBaseSize(view.w, view.h, natural.w, natural.h);
|
||||||
const w = base.w * layout.scale;
|
const w = base.w * layout.scale;
|
||||||
const h = base.h * layout.scale;
|
const h = base.h * layout.scale;
|
||||||
@@ -82,13 +143,24 @@ function NpcAvatarFrame({
|
|||||||
const onPointerMove = (e: PointerEvent) => {
|
const onPointerMove = (e: PointerEvent) => {
|
||||||
const drag = dragRef.current;
|
const drag = dragRef.current;
|
||||||
if (!drag || !onLayoutChange) return;
|
if (!drag || !onLayoutChange) return;
|
||||||
|
|
||||||
|
if (drag.mode === 'rotate') {
|
||||||
|
const angle = pointerAngleDeg(drag.centerX, drag.centerY, e.clientX, e.clientY);
|
||||||
|
const delta = shortestAngleDelta(drag.startPointerAngle, angle);
|
||||||
|
onLayoutChange(item.npcId, {
|
||||||
|
...drag.origin,
|
||||||
|
rotationDeg: drag.origin.rotationDeg + delta,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const root = rootRef.current;
|
const root = rootRef.current;
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
const r = root.getBoundingClientRect();
|
const r = root.getBoundingClientRect();
|
||||||
const dx = (e.clientX - drag.startX) / Math.max(1, r.width);
|
|
||||||
const dy = (e.clientY - drag.startY) / Math.max(1, r.height);
|
|
||||||
|
|
||||||
if (drag.mode === 'move') {
|
if (drag.mode === 'move') {
|
||||||
|
const dx = (e.clientX - drag.startX) / Math.max(1, r.width);
|
||||||
|
const dy = (e.clientY - drag.startY) / Math.max(1, r.height);
|
||||||
onLayoutChange(item.npcId, {
|
onLayoutChange(item.npcId, {
|
||||||
...drag.origin,
|
...drag.origin,
|
||||||
cx: drag.origin.cx + dx,
|
cx: drag.origin.cx + dx,
|
||||||
@@ -97,13 +169,13 @@ function NpcAvatarFrame({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { baseW, baseH, viewW, viewH, origin, corner } = drag;
|
const { baseW, baseH, viewW, viewH, origin, corner, centerClientX, centerClientY } = drag;
|
||||||
const originW = baseW * origin.scale;
|
const originW = baseW * origin.scale;
|
||||||
const originH = baseH * origin.scale;
|
const originH = baseH * origin.scale;
|
||||||
const originLeft = origin.cx * viewW - originW / 2;
|
const originLeft = -originW / 2;
|
||||||
const originTop = origin.cy * viewH - originH / 2;
|
const originTop = -originH / 2;
|
||||||
const originRight = originLeft + originW;
|
const originRight = originW / 2;
|
||||||
const originBottom = originTop + originH;
|
const originBottom = originH / 2;
|
||||||
|
|
||||||
let anchorX = originLeft;
|
let anchorX = originLeft;
|
||||||
let anchorY = originTop;
|
let anchorY = originTop;
|
||||||
@@ -118,10 +190,15 @@ function NpcAvatarFrame({
|
|||||||
anchorY = originTop;
|
anchorY = originTop;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pointerX = e.clientX - r.left;
|
const local = screenToLocal(
|
||||||
const pointerY = e.clientY - r.top;
|
e.clientX,
|
||||||
const newW = Math.max(8, Math.abs(pointerX - anchorX));
|
e.clientY,
|
||||||
const newH = Math.max(8, Math.abs(pointerY - anchorY));
|
centerClientX,
|
||||||
|
centerClientY,
|
||||||
|
origin.rotationDeg ?? 0,
|
||||||
|
);
|
||||||
|
const newW = Math.max(8, Math.abs(local.x - anchorX));
|
||||||
|
const newH = Math.max(8, Math.abs(local.y - anchorY));
|
||||||
const nextScale = Math.max(newW / Math.max(1, baseW), newH / Math.max(1, baseH));
|
const nextScale = Math.max(newW / Math.max(1, baseW), newH / Math.max(1, baseH));
|
||||||
const ww = baseW * nextScale;
|
const ww = baseW * nextScale;
|
||||||
const hh = baseH * nextScale;
|
const hh = baseH * nextScale;
|
||||||
@@ -139,9 +216,14 @@ function NpcAvatarFrame({
|
|||||||
nextTop = anchorY;
|
nextTop = anchorY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const localCenterX = nextLeft + ww / 2;
|
||||||
|
const localCenterY = nextTop + hh / 2;
|
||||||
|
const screenOffset = localToScreenOffset(localCenterX, localCenterY, origin.rotationDeg ?? 0);
|
||||||
|
|
||||||
onLayoutChange(item.npcId, {
|
onLayoutChange(item.npcId, {
|
||||||
cx: (nextLeft + ww / 2) / Math.max(1, viewW),
|
...origin,
|
||||||
cy: (nextTop + hh / 2) / Math.max(1, viewH),
|
cx: origin.cx + screenOffset.x / Math.max(1, viewW),
|
||||||
|
cy: origin.cy + screenOffset.y / Math.max(1, viewH),
|
||||||
scale: nextScale,
|
scale: nextScale,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -152,23 +234,51 @@ function NpcAvatarFrame({
|
|||||||
window.removeEventListener('pointerup', endDrag);
|
window.removeEventListener('pointerup', endDrag);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const layoutCenterClient = () => {
|
||||||
|
const root = rootRef.current;
|
||||||
|
if (!root) return { x: 0, y: 0 };
|
||||||
|
const r = root.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: r.left + layout.cx * r.width,
|
||||||
|
y: r.top + layout.cy * r.height,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const startDrag = (e: React.PointerEvent, mode: 'move' | 'resize', corner?: Corner) => {
|
const startDrag = (e: React.PointerEvent, mode: 'move' | 'resize', corner?: Corner) => {
|
||||||
if (!editable || !onLayoutChange || zoomTool) return;
|
if (!editable || !onLayoutChange || zoomTool) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
dragRef.current =
|
if (mode === 'move') {
|
||||||
mode === 'move'
|
dragRef.current = { mode: 'move', startX: e.clientX, startY: e.clientY, origin: { ...layout } };
|
||||||
? { mode: 'move', startX: e.clientX, startY: e.clientY, origin: { ...layout } }
|
} else {
|
||||||
: {
|
const center = layoutCenterClient();
|
||||||
|
dragRef.current = {
|
||||||
mode: 'resize',
|
mode: 'resize',
|
||||||
corner: corner ?? 'se',
|
corner: corner ?? 'se',
|
||||||
startX: e.clientX,
|
|
||||||
startY: e.clientY,
|
|
||||||
origin: { ...layout },
|
origin: { ...layout },
|
||||||
baseW: base.w,
|
baseW: base.w,
|
||||||
baseH: base.h,
|
baseH: base.h,
|
||||||
viewW: view.w,
|
viewW: view.w,
|
||||||
viewH: view.h,
|
viewH: view.h,
|
||||||
|
centerClientX: center.x,
|
||||||
|
centerClientY: center.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
window.addEventListener('pointermove', onPointerMove);
|
||||||
|
window.addEventListener('pointerup', endDrag);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRotate = (e: React.PointerEvent) => {
|
||||||
|
if (!editable || !onLayoutChange || zoomTool) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const center = layoutCenterClient();
|
||||||
|
dragRef.current = {
|
||||||
|
mode: 'rotate',
|
||||||
|
origin: { ...layout },
|
||||||
|
startPointerAngle: pointerAngleDeg(center.x, center.y, e.clientX, e.clientY),
|
||||||
|
centerX: center.x,
|
||||||
|
centerY: center.y,
|
||||||
};
|
};
|
||||||
window.addEventListener('pointermove', onPointerMove);
|
window.addEventListener('pointermove', onPointerMove);
|
||||||
window.addEventListener('pointerup', endDrag);
|
window.addEventListener('pointerup', endDrag);
|
||||||
@@ -178,7 +288,14 @@ function NpcAvatarFrame({
|
|||||||
<div
|
<div
|
||||||
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : ''].filter(Boolean).join(' ')}
|
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : ''].filter(Boolean).join(' ')}
|
||||||
data-npc-id={item.npcId}
|
data-npc-id={item.npcId}
|
||||||
style={{ left, top, width: w, height: h }}
|
style={{
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
transform: `rotate(${String(rotationDeg)}deg)`,
|
||||||
|
transformOrigin: 'center center',
|
||||||
|
}}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
if (zoomTool) return;
|
if (zoomTool) return;
|
||||||
startDrag(e, 'move');
|
startDrag(e, 'move');
|
||||||
@@ -210,6 +327,17 @@ function NpcAvatarFrame({
|
|||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
: null}
|
: null}
|
||||||
|
{editable && !zoomTool && onLayoutChange ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.frameRotate}
|
||||||
|
aria-label={rotateLabel}
|
||||||
|
title={rotateLabel}
|
||||||
|
onPointerDown={startRotate}
|
||||||
|
>
|
||||||
|
<RotateIcon />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -221,6 +349,7 @@ export function NpcsSceneOverlay({
|
|||||||
showClose = false,
|
showClose = false,
|
||||||
onClose,
|
onClose,
|
||||||
closeLabel = 'Close',
|
closeLabel = 'Close',
|
||||||
|
rotateLabel = 'Rotate',
|
||||||
onLayoutChange,
|
onLayoutChange,
|
||||||
onZoomAt,
|
onZoomAt,
|
||||||
}: NpcsSceneOverlayProps) {
|
}: NpcsSceneOverlayProps) {
|
||||||
@@ -285,6 +414,7 @@ export function NpcsSceneOverlay({
|
|||||||
rootRef={rootRef}
|
rootRef={rootRef}
|
||||||
editable={editable}
|
editable={editable}
|
||||||
zoomTool={zoomTool}
|
zoomTool={zoomTool}
|
||||||
|
rotateLabel={rotateLabel}
|
||||||
onLayoutChange={onLayoutChange}
|
onLayoutChange={onLayoutChange}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -295,6 +425,7 @@ export function NpcsSceneOverlay({
|
|||||||
rootRef={rootRef}
|
rootRef={rootRef}
|
||||||
editable={editable}
|
editable={editable}
|
||||||
zoomTool={zoomTool}
|
zoomTool={zoomTool}
|
||||||
|
rotateLabel={rotateLabel}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export type NpcsOverlayLayout = {
|
|||||||
cx: number;
|
cx: number;
|
||||||
cy: number;
|
cy: number;
|
||||||
scale: number;
|
scale: number;
|
||||||
|
/** Свободный угол поворота, градусы. */
|
||||||
|
rotationDeg: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NpcsZoomTool = 'zoomIn' | 'zoomOut' | null;
|
export type NpcsZoomTool = 'zoomIn' | 'zoomOut' | null;
|
||||||
@@ -24,6 +26,7 @@ export const DEFAULT_NPCS_OVERLAY_LAYOUT: NpcsOverlayLayout = {
|
|||||||
cx: 0.5,
|
cx: 0.5,
|
||||||
cy: 0.5,
|
cy: 0.5,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
|
rotationDeg: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NpcsOverlayEvent =
|
export type NpcsOverlayEvent =
|
||||||
@@ -34,11 +37,19 @@ export type NpcsOverlayEvent =
|
|||||||
| { kind: 'zoomTool.set'; tool: NpcsZoomTool }
|
| { kind: 'zoomTool.set'; tool: NpcsZoomTool }
|
||||||
| { kind: 'zoomAt'; nx: number; ny: number; npcId?: NpcId };
|
| { kind: 'zoomAt'; nx: number; ny: number; npcId?: NpcId };
|
||||||
|
|
||||||
|
/** Нормализация угла в диапазон [0, 360). */
|
||||||
|
export function normalizeNpcsRotation(deg: number): number {
|
||||||
|
if (!Number.isFinite(deg)) return 0;
|
||||||
|
const n = deg % 360;
|
||||||
|
return n < 0 ? n + 360 : n;
|
||||||
|
}
|
||||||
|
|
||||||
export function clampNpcsLayout(layout: NpcsOverlayLayout): NpcsOverlayLayout {
|
export function clampNpcsLayout(layout: NpcsOverlayLayout): NpcsOverlayLayout {
|
||||||
return {
|
return {
|
||||||
cx: Math.min(1.2, Math.max(-0.2, layout.cx)),
|
cx: Math.min(1.2, Math.max(-0.2, layout.cx)),
|
||||||
cy: Math.min(1.2, Math.max(-0.2, layout.cy)),
|
cy: Math.min(1.2, Math.max(-0.2, layout.cy)),
|
||||||
scale: Math.min(8, Math.max(0.15, layout.scale)),
|
scale: Math.min(8, Math.max(0.15, layout.scale)),
|
||||||
|
rotationDeg: normalizeNpcsRotation(layout.rotationDeg ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user