From 0cab7ce7ca1087a677f75ac7605169a128eae21b Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Tue, 21 Jul 2026 14:22:46 +0800 Subject: [PATCH] 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 --- app/main/npcs/npcsOverlayStore.ts | 5 +- app/renderer/control/ControlApp.tsx | 7 +- app/renderer/editor/i18n/editorMessages.ts | 2 + app/renderer/npcs/NpcsApp.tsx | 80 +------- .../materials/MaterialOverlay.module.css | 32 +++ app/renderer/shared/npcs/NpcsSceneOverlay.tsx | 191 +++++++++++++++--- app/shared/types/npcs.ts | 11 + 7 files changed, 217 insertions(+), 111 deletions(-) diff --git a/app/main/npcs/npcsOverlayStore.ts b/app/main/npcs/npcsOverlayStore.ts index e3981f3..0efa3bf 100644 --- a/app/main/npcs/npcsOverlayStore.ts +++ b/app/main/npcs/npcsOverlayStore.ts @@ -107,7 +107,10 @@ export class NpcsOverlayStore { focusNpcId: event.npcId, layouts: { ...this.state.layouts, - [event.npcId]: clampNpcsLayout(event.layout), + [event.npcId]: clampNpcsLayout({ + ...layoutFor(this.state, event.npcId), + ...event.layout, + }), }, }; return this.state; diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index aca16da..b1b489a 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -1631,20 +1631,15 @@ export function ControlApp() { { void npcsApi.dispatch({ kind: 'hide' }); }} onLayoutChange={(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 }, - ); - }} /> ); })()} diff --git a/app/renderer/editor/i18n/editorMessages.ts b/app/renderer/editor/i18n/editorMessages.ts index cca390b..0fe5618 100644 --- a/app/renderer/editor/i18n/editorMessages.ts +++ b/app/renderer/editor/i18n/editorMessages.ts @@ -427,6 +427,7 @@ export const EDITOR_MESSAGES: Record> = { 'npcs.windowEmpty': 'Добавьте НПС в редакторе.', 'npcs.selectToShow': 'Выберите персонажей в списке — они появятся на экране.', 'npcs.closeOverlay': 'Закрыть всех', + 'npcs.rotateOverlay': 'Повернуть', 'npcs.zoomIn': 'Увеличить', 'npcs.zoomOut': 'Уменьшить', 'npcs.zoomInHint': 'Кликните по аватару в предпросмотре пульта, чтобы увеличить.', @@ -963,6 +964,7 @@ export const EDITOR_MESSAGES: Record> = { 'npcs.windowEmpty': 'Add NPCs in the editor.', 'npcs.selectToShow': 'Select characters in the list — they will appear on screen.', 'npcs.closeOverlay': 'Close all', + 'npcs.rotateOverlay': 'Rotate', 'npcs.zoomIn': 'Zoom in', 'npcs.zoomOut': 'Zoom out', 'npcs.zoomInHint': 'Click the avatar on the control preview to zoom in.', diff --git a/app/renderer/npcs/NpcsApp.tsx b/app/renderer/npcs/NpcsApp.tsx index 4ef2886..0913d1c 100644 --- a/app/renderer/npcs/NpcsApp.tsx +++ b/app/renderer/npcs/NpcsApp.tsx @@ -12,32 +12,6 @@ import { useAssetUrl } from '../shared/useAssetImageUrl'; import styles from './NpcsApp.module.css'; -function ZoomInIcon() { - return ( - - - - - - ); -} - -function ZoomOutIcon() { - return ( - - - - - - ); -} - /** Убрать пустые ветки групп (удобно при поиске). */ function pruneEmptyGroupNodes(nodes: NpcGroupTreeNode[]): NpcGroupTreeNode[] { const out: NpcGroupTreeNode[] = []; @@ -155,7 +129,6 @@ export function NpcsApp() { const activeIds = overlay?.activeNpcIds ?? []; const hasActive = activeIds.length > 0; - const zoomTool = overlay?.zoomTool ?? null; useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -164,16 +137,12 @@ export function NpcsApp() { void overlayApi.dispatch({ kind: 'hide' }); return; } - if (zoomTool) { - void overlayApi.dispatch({ kind: 'zoomTool.set', tool: null }); - return; - } window.close(); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [hasActive, zoomTool, overlayApi]); + }, [hasActive, overlayApi]); const npcs = useMemo(() => session?.project?.npcs ?? [], [session?.project?.npcs]); const npcGroups = useMemo(() => session?.project?.npcGroups ?? [], [session?.project?.npcGroups]); @@ -245,39 +214,9 @@ export function NpcsApp() { return (
-
-
- - - {hasActive ? ( + {hasActive ? ( +
+
- ) : null} +
-
- {zoomTool === 'zoomIn' - ? t('npcs.zoomInHint') - : zoomTool === 'zoomOut' - ? t('npcs.zoomOutHint') - : t('npcs.zoomIdleHint')} -
-
+ ) : null}
diff --git a/app/renderer/shared/materials/MaterialOverlay.module.css b/app/renderer/shared/materials/MaterialOverlay.module.css index 61a80be..4c6aa99 100644 --- a/app/renderer/shared/materials/MaterialOverlay.module.css +++ b/app/renderer/shared/materials/MaterialOverlay.module.css @@ -110,3 +110,35 @@ .close:hover { 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; +} diff --git a/app/renderer/shared/npcs/NpcsSceneOverlay.tsx b/app/renderer/shared/npcs/NpcsSceneOverlay.tsx index b6d9404..af9b31a 100644 --- a/app/renderer/shared/npcs/NpcsSceneOverlay.tsx +++ b/app/renderer/shared/npcs/NpcsSceneOverlay.tsx @@ -20,10 +20,26 @@ type NpcsSceneOverlayProps = { showClose?: boolean; onClose?: () => void; closeLabel?: string; + rotateLabel?: string; onLayoutChange?: (npcId: NpcId, layout: NpcsOverlayLayout) => void; onZoomAt?: (npcId: NpcId | undefined, nx: number, ny: number) => void; }; +function RotateIcon() { + return ( + + + + + ); +} + function nextBaseSize( viewW: number, viewH: number, @@ -37,12 +53,48 @@ function nextBaseSize( 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({ item, view, rootRef, editable, zoomTool, + rotateLabel, onLayoutChange, }: { item: NpcsSceneOverlayItem; @@ -50,6 +102,7 @@ function NpcAvatarFrame({ rootRef: React.RefObject; editable: boolean; zoomTool: NpcsZoomTool; + rotateLabel: string; onLayoutChange?: (npcId: NpcId, layout: NpcsOverlayLayout) => void; }) { const url = useAssetUrl(item.assetId); @@ -59,13 +112,20 @@ function NpcAvatarFrame({ | { mode: 'resize'; corner: Corner; - startX: number; - startY: number; origin: NpcsOverlayLayout; baseW: number; baseH: number; viewW: number; viewH: number; + centerClientX: number; + centerClientY: number; + } + | { + mode: 'rotate'; + origin: NpcsOverlayLayout; + startPointerAngle: number; + centerX: number; + centerY: number; } | null >(null); @@ -73,6 +133,7 @@ function NpcAvatarFrame({ if (!item.assetId || !url) return null; 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 w = base.w * layout.scale; const h = base.h * layout.scale; @@ -82,13 +143,24 @@ function NpcAvatarFrame({ const onPointerMove = (e: PointerEvent) => { const drag = dragRef.current; 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; if (!root) return; 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') { + 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, { ...drag.origin, cx: drag.origin.cx + dx, @@ -97,13 +169,13 @@ function NpcAvatarFrame({ 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 originH = baseH * origin.scale; - const originLeft = origin.cx * viewW - originW / 2; - const originTop = origin.cy * viewH - originH / 2; - const originRight = originLeft + originW; - const originBottom = originTop + originH; + const originLeft = -originW / 2; + const originTop = -originH / 2; + const originRight = originW / 2; + const originBottom = originH / 2; let anchorX = originLeft; let anchorY = originTop; @@ -118,10 +190,15 @@ function NpcAvatarFrame({ anchorY = originTop; } - const pointerX = e.clientX - r.left; - const pointerY = e.clientY - r.top; - const newW = Math.max(8, Math.abs(pointerX - anchorX)); - const newH = Math.max(8, Math.abs(pointerY - anchorY)); + const local = screenToLocal( + e.clientX, + e.clientY, + 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 ww = baseW * nextScale; const hh = baseH * nextScale; @@ -139,9 +216,14 @@ function NpcAvatarFrame({ nextTop = anchorY; } + const localCenterX = nextLeft + ww / 2; + const localCenterY = nextTop + hh / 2; + const screenOffset = localToScreenOffset(localCenterX, localCenterY, origin.rotationDeg ?? 0); + onLayoutChange(item.npcId, { - cx: (nextLeft + ww / 2) / Math.max(1, viewW), - cy: (nextTop + hh / 2) / Math.max(1, viewH), + ...origin, + cx: origin.cx + screenOffset.x / Math.max(1, viewW), + cy: origin.cy + screenOffset.y / Math.max(1, viewH), scale: nextScale, }); }; @@ -152,24 +234,52 @@ function NpcAvatarFrame({ 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) => { if (!editable || !onLayoutChange || zoomTool) return; e.preventDefault(); e.stopPropagation(); - dragRef.current = - mode === 'move' - ? { mode: 'move', startX: e.clientX, startY: e.clientY, origin: { ...layout } } - : { - mode: 'resize', - corner: corner ?? 'se', - startX: e.clientX, - startY: e.clientY, - origin: { ...layout }, - baseW: base.w, - baseH: base.h, - viewW: view.w, - viewH: view.h, - }; + if (mode === 'move') { + dragRef.current = { mode: 'move', startX: e.clientX, startY: e.clientY, origin: { ...layout } }; + } else { + const center = layoutCenterClient(); + dragRef.current = { + mode: 'resize', + corner: corner ?? 'se', + origin: { ...layout }, + baseW: base.w, + baseH: base.h, + viewW: view.w, + 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('pointerup', endDrag); }; @@ -178,7 +288,14 @@ function NpcAvatarFrame({
{ if (zoomTool) return; startDrag(e, 'move'); @@ -210,6 +327,17 @@ function NpcAvatarFrame({ /> )) : null} + {editable && !zoomTool && onLayoutChange ? ( + + ) : null}
); } @@ -221,6 +349,7 @@ export function NpcsSceneOverlay({ showClose = false, onClose, closeLabel = 'Close', + rotateLabel = 'Rotate', onLayoutChange, onZoomAt, }: NpcsSceneOverlayProps) { @@ -285,6 +414,7 @@ export function NpcsSceneOverlay({ rootRef={rootRef} editable={editable} zoomTool={zoomTool} + rotateLabel={rotateLabel} onLayoutChange={onLayoutChange} /> ) : ( @@ -295,6 +425,7 @@ export function NpcsSceneOverlay({ rootRef={rootRef} editable={editable} zoomTool={zoomTool} + rotateLabel={rotateLabel} /> ), )} diff --git a/app/shared/types/npcs.ts b/app/shared/types/npcs.ts index 6e30441..d43d4f9 100644 --- a/app/shared/types/npcs.ts +++ b/app/shared/types/npcs.ts @@ -5,6 +5,8 @@ export type NpcsOverlayLayout = { cx: number; cy: number; scale: number; + /** Свободный угол поворота, градусы. */ + rotationDeg: number; }; export type NpcsZoomTool = 'zoomIn' | 'zoomOut' | null; @@ -24,6 +26,7 @@ export const DEFAULT_NPCS_OVERLAY_LAYOUT: NpcsOverlayLayout = { cx: 0.5, cy: 0.5, scale: 1, + rotationDeg: 0, }; export type NpcsOverlayEvent = @@ -34,11 +37,19 @@ export type NpcsOverlayEvent = | { kind: 'zoomTool.set'; tool: NpcsZoomTool } | { 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 { return { cx: Math.min(1.2, Math.max(-0.2, layout.cx)), cy: Math.min(1.2, Math.max(-0.2, layout.cy)), scale: Math.min(8, Math.max(0.15, layout.scale)), + rotationDeg: normalizeNpcsRotation(layout.rotationDeg ?? 0), }; }