feat(scene): shared zoom/pan for control and presentation

Keep effects pinned to map coordinates when the viewport changes; materials/NPC overlays stay screen-fixed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-23 14:08:19 +08:00
parent 2c0e6fbf09
commit eb127c11c2
13 changed files with 688 additions and 19 deletions
+149 -4
View File
@@ -8,7 +8,12 @@ import {
isNodeInSideStoryline,
listSideStoryStarts,
} from '../../shared/graph/sceneGraphLineage';
import type { GraphNodeId, Scene, SceneId } from '../../shared/types';
import type { GraphNodeId, Scene, SceneId, SceneViewCamera } from '../../shared/types';
import {
DEFAULT_SCENE_VIEW_CAMERA,
sceneViewPanBy,
sceneViewZoomAt,
} from '../../shared/types/sceneView';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml';
import { getDndApi } from '../shared/dndApi';
@@ -27,6 +32,7 @@ import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverla
import { NpcsSceneOverlay } from '../shared/npcs/NpcsSceneOverlay';
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost';
import { useSceneViewState } from '../shared/sceneView/useSceneViewState';
import { Button } from '../shared/ui/controls';
import { Surface } from '../shared/ui/Surface';
import { useAssetUrl } from '../shared/useAssetImageUrl';
@@ -113,6 +119,8 @@ export function ControlApp() {
const [fxState, fx] = useEffectsState();
const [effectsSfxGainUi, setEffectsSfxGainUi] = useState(() => getEffectsSfxGain());
const [sdState, sd] = useSceneDarknessState();
const [sceneView, sceneViewApi] = useSceneViewState();
const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null);
const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
const [npcsOverlay, npcsApi] = useNpcsOverlayState();
const [session, setSession] = useState<SessionState | null>(null);
@@ -139,7 +147,13 @@ export function ControlApp() {
const allowCampaignAudioRef = useRef<boolean>(true);
const audioUnmountRef = useRef(false);
const previewHostRef = useRef<HTMLDivElement | null>(null);
const previewFrameRef = useRef<HTMLDivElement | null>(null);
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
const sceneViewRef = useRef<SceneViewCamera>(DEFAULT_SCENE_VIEW_CAMERA);
const scenePanRef = useRef<{ lastX: number; lastY: number; pointerId: number } | null>(null);
const spaceDownRef = useRef(false);
const sceneViewPublishRafRef = useRef(0);
const pendingSceneViewRef = useRef<SceneViewCamera | null>(null);
const brushRef = useRef<{
tool:
| 'fog'
@@ -649,6 +663,84 @@ export function ControlApp() {
return () => ro.disconnect();
}, []);
const activeSceneViewCamera: SceneViewCamera = sceneViewDraft ??
(sceneView
? { scale: sceneView.scale, ox: sceneView.ox, oy: sceneView.oy }
: DEFAULT_SCENE_VIEW_CAMERA);
sceneViewRef.current = activeSceneViewCamera;
useEffect(() => {
if (scenePanRef.current) return;
setSceneViewDraft(null);
}, [sceneView?.revision]);
function publishSceneViewCamera(camera: SceneViewCamera): void {
setSceneViewDraft(camera);
sceneViewRef.current = camera;
pendingSceneViewRef.current = camera;
if (sceneViewPublishRafRef.current !== 0) return;
sceneViewPublishRafRef.current = requestAnimationFrame(() => {
sceneViewPublishRafRef.current = 0;
const next = pendingSceneViewRef.current;
if (!next) return;
void sceneViewApi.dispatch({ kind: 'set', camera: next });
});
}
const publishSceneViewCameraRef = useRef(publishSceneViewCamera);
publishSceneViewCameraRef.current = publishSceneViewCamera;
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.code === 'Space') spaceDownRef.current = true;
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.code === 'Space') spaceDownRef.current = false;
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
}, []);
useEffect(() => {
const frame = previewFrameRef.current;
if (!frame || isVideoPreviewScene) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const host = previewHostRef.current;
const cr = previewContentRectRef.current;
if (!host || !cr) return;
const r = host.getBoundingClientRect();
const cam = sceneViewRef.current;
const containW = cr.w / Math.max(1e-6, cam.scale);
const containH = cr.h / Math.max(1e-6, cam.scale);
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
const next = sceneViewZoomAt(cam, {
hostW: r.width,
hostH: r.height,
containW,
containH,
hostX: e.clientX - r.left,
hostY: e.clientY - r.top,
factor,
});
publishSceneViewCameraRef.current(next);
};
frame.addEventListener('wheel', onWheel, { passive: false });
return () => frame.removeEventListener('wheel', onWheel);
}, [isVideoPreviewScene]);
useEffect(() => {
return () => {
if (sceneViewPublishRafRef.current !== 0) {
cancelAnimationFrame(sceneViewPublishRafRef.current);
sceneViewPublishRafRef.current = 0;
}
};
}, []);
function audioStatus(group: 'scene' | 'campaign', assetId: string): { label: string; detail?: string } {
const el =
group === 'scene'
@@ -1541,11 +1633,20 @@ export function ControlApp() {
<div className={styles.spacer10} />
{isVideoPreviewScene ? <div className={styles.videoHint}>{t('control.videoBrushHint')}</div> : null}
<div className={styles.spacer10} />
<div className={styles.previewFrame}>
<div
ref={previewFrameRef}
className={styles.previewFrame}
title={
isVideoPreviewScene
? undefined
: 'Колесо — зум; перетаскивание СКМ/ПКМ или Space+ЛКМ — пан'
}
>
<div ref={previewHostRef} className={styles.previewHost}>
<ControlScenePreview
session={session}
videoRef={previewVideoRef}
viewCamera={activeSceneViewCamera}
onContentRectChange={setPreviewContentRect}
/>
</div>
@@ -1577,6 +1678,9 @@ export function ControlApp() {
/>
<div
className={styles.brushLayer}
onContextMenu={(e) => {
e.preventDefault();
}}
onPointerEnter={(e) => {
const p = toNPoint(e);
if (!p) return;
@@ -1588,6 +1692,19 @@ export function ControlApp() {
layoutBrushCursor();
}}
onPointerDown={(e) => {
const panWithPrimary = e.button === 0 && (spaceDownRef.current || e.altKey);
const isPan = e.button === 1 || e.button === 2 || panWithPrimary;
if (isPan) {
e.preventDefault();
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
scenePanRef.current = {
lastX: e.clientX,
lastY: e.clientY,
pointerId: e.pointerId,
};
return;
}
if (e.button !== 0) return;
const p = toNPoint(e);
if (!p) return;
cursorPosRef.current = p;
@@ -1622,6 +1739,22 @@ export function ControlApp() {
pushDraftToPixi();
}}
onPointerMove={(e) => {
const pan = scenePanRef.current;
if (pan && pan.pointerId === e.pointerId) {
const cr = previewContentRectRef.current;
if (!cr) return;
const cam = sceneViewRef.current;
const containW = cr.w / Math.max(1e-6, cam.scale);
const containH = cr.h / Math.max(1e-6, cam.scale);
const dx = e.clientX - pan.lastX;
const dy = e.clientY - pan.lastY;
pan.lastX = e.clientX;
pan.lastY = e.clientY;
publishSceneViewCamera(
sceneViewPanBy(cam, { containW, containH, dx, dy }),
);
return;
}
const p = toNPoint(e);
if (!p) return;
cursorPosRef.current = p;
@@ -1662,10 +1795,22 @@ export function ControlApp() {
}
scheduleDraftRepaint();
}}
onPointerUp={() => {
onPointerUp={(e) => {
if (scenePanRef.current?.pointerId === e.pointerId) {
scenePanRef.current = null;
const pending = pendingSceneViewRef.current;
if (pending) {
void sceneViewApi.dispatch({ kind: 'set', camera: pending });
}
return;
}
void commitStroke();
}}
onPointerCancel={() => {
onPointerCancel={(e) => {
if (scenePanRef.current?.pointerId === e.pointerId) {
scenePanRef.current = null;
return;
}
if (brushRef.current?.tool === 'exploreBrush') {
void sd.dispatch({ kind: 'draft.set', draft: null });
}