feat: scene traps, material legends, and scene editor window
Add trap overlays with session state, material legend editor/panel, and a dedicated scene editor window wired through IPC and project persistence. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
||||
import type { SceneTrap, SceneTrapType } from '../../shared/types';
|
||||
import {
|
||||
asSceneTrapId,
|
||||
DEFAULT_SCENE_TRAP_SIZE_N,
|
||||
SCENE_TRAP_TYPES,
|
||||
trapTypeLabelRu,
|
||||
} from '../../shared/types/sceneTraps';
|
||||
import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { TrapGlyph } from '../shared/traps/TrapGlyph';
|
||||
import { Button } from '../shared/ui/controls';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import styles from './SceneEditorApp.module.css';
|
||||
|
||||
type LocalView = { scale: number; ox: number; oy: number };
|
||||
|
||||
type DragMode =
|
||||
| { kind: 'pan'; lastX: number; lastY: number }
|
||||
| { kind: 'move'; trapId: string; startNx: number; startNy: number; pointerNx: number; pointerNy: number }
|
||||
| { kind: 'resize'; trapId: string; startSize: number; startDist: number }
|
||||
| null;
|
||||
|
||||
function randomTrapId(): string {
|
||||
return `trap_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function SceneEditorApp() {
|
||||
const api = getDndApi();
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const [trapsOpen, setTrapsOpen] = useState(true);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [view, setView] = useState<LocalView>({ scale: 1, ox: 0.5, oy: 0.5 });
|
||||
const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(
|
||||
null,
|
||||
);
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragRef = useRef<DragMode>(null);
|
||||
const saveTimerRef = useRef(0);
|
||||
const spaceDownRef = useRef(false);
|
||||
|
||||
const project = session?.project ?? null;
|
||||
const sceneId = project?.currentSceneId ?? null;
|
||||
const scene = sceneId && project ? project.scenes[sceneId] : undefined;
|
||||
const url = useAssetUrl(scene?.previewAssetId ?? null);
|
||||
const rot = scene?.previewRotationDeg ?? 0;
|
||||
const [localTraps, setLocalTraps] = useState<SceneTrap[]>([]);
|
||||
const trapsRef = useRef<SceneTrap[]>([]);
|
||||
trapsRef.current = localTraps;
|
||||
|
||||
useEffect(() => {
|
||||
setLocalTraps(scene?.traps ?? []);
|
||||
setSelectedId(null);
|
||||
setView({ scale: 1, ox: 0.5, oy: 0.5 });
|
||||
}, [sceneId, scene?.previewAssetId]);
|
||||
|
||||
useEffect(() => {
|
||||
// External updates (other windows) — sync when not dragging
|
||||
if (dragRef.current) return;
|
||||
setLocalTraps(scene?.traps ?? []);
|
||||
}, [scene?.traps]);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.project.get, {}).then(({ project: p }) => {
|
||||
setSession({ project: p, currentSceneId: p?.currentSceneId ?? null });
|
||||
});
|
||||
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
|
||||
setSession(state);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const persistTraps = useCallback(
|
||||
async (next: SceneTrap[]) => {
|
||||
if (!sceneId) return;
|
||||
setLocalTraps(next);
|
||||
trapsRef.current = next;
|
||||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = window.setTimeout(() => {
|
||||
void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { traps: next } });
|
||||
}, 120);
|
||||
},
|
||||
[api, sceneId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code === 'Space') spaceDownRef.current = true;
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId && sceneId) {
|
||||
e.preventDefault();
|
||||
const next = trapsRef.current.filter((t) => t.id !== selectedId);
|
||||
setSelectedId(null);
|
||||
void persistTraps(next);
|
||||
}
|
||||
};
|
||||
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);
|
||||
};
|
||||
}, [persistTraps, sceneId, selectedId]);
|
||||
|
||||
const hostToNorm = (clientX: number, clientY: number): { x: number; y: number } | null => {
|
||||
const host = hostRef.current;
|
||||
const cr = contentRect;
|
||||
if (!host || !cr || cr.w < 1 || cr.h < 1) return null;
|
||||
const r = host.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.max(0, Math.min(1, (clientX - (r.left + cr.x)) / cr.w)),
|
||||
y: Math.max(0, Math.min(1, (clientY - (r.top + cr.y)) / cr.h)),
|
||||
};
|
||||
};
|
||||
|
||||
const onWheel = (_e: React.WheelEvent) => {
|
||||
// native non-passive listener handles zoom
|
||||
};
|
||||
void onWheel;
|
||||
|
||||
const viewCamera = useMemo(() => view, [view]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
const nativeWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
|
||||
const cr = contentRect;
|
||||
if (!cr) {
|
||||
setView((v) => {
|
||||
const nextScale = Math.max(1, Math.min(8, v.scale * factor));
|
||||
if (nextScale <= 1.001) return { scale: 1, ox: 0.5, oy: 0.5 };
|
||||
return { ...v, scale: nextScale };
|
||||
});
|
||||
return;
|
||||
}
|
||||
const r = host.getBoundingClientRect();
|
||||
setView((v) => {
|
||||
const containW = cr.w / Math.max(1e-6, v.scale);
|
||||
const containH = cr.h / Math.max(1e-6, v.scale);
|
||||
return sceneViewZoomAt(v, {
|
||||
hostW: r.width,
|
||||
hostH: r.height,
|
||||
containW,
|
||||
containH,
|
||||
hostX: e.clientX - r.left,
|
||||
hostY: e.clientY - r.top,
|
||||
factor,
|
||||
});
|
||||
});
|
||||
};
|
||||
host.addEventListener('wheel', nativeWheel, { passive: false });
|
||||
return () => host.removeEventListener('wheel', nativeWheel);
|
||||
}, [contentRect]);
|
||||
|
||||
const addTrapAt = (type: SceneTrapType, nx: number, ny: number) => {
|
||||
const trap: SceneTrap = {
|
||||
id: asSceneTrapId(randomTrapId()),
|
||||
type,
|
||||
nx,
|
||||
ny,
|
||||
sizeN: DEFAULT_SCENE_TRAP_SIZE_N,
|
||||
...(type === 'freeform' ? { label: trapTypeLabelRu(type) } : {}),
|
||||
};
|
||||
const next = [...trapsRef.current, trap];
|
||||
setSelectedId(trap.id);
|
||||
void persistTraps(next);
|
||||
};
|
||||
|
||||
const onPaletteDragStart = (type: SceneTrapType) => (e: React.DragEvent) => {
|
||||
e.dataTransfer.setData('application/x-dnd-trap-type', type);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
};
|
||||
|
||||
const onStageDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const type = e.dataTransfer.getData('application/x-dnd-trap-type') as SceneTrapType;
|
||||
if (!SCENE_TRAP_TYPES.includes(type)) return;
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
addTrapAt(type, p.x, p.y);
|
||||
};
|
||||
|
||||
const updateTrap = (id: string, patch: Partial<SceneTrap>) => {
|
||||
const next = trapsRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t));
|
||||
void persistTraps(next);
|
||||
};
|
||||
|
||||
const isImage = scene?.previewAssetType === 'image' && Boolean(url);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<aside className={styles.sidebar}>
|
||||
<div className={styles.sideTitle}>{scene?.title ?? 'Сцена'}</div>
|
||||
<div className={styles.hint}>
|
||||
Колесо — зум. СКМ / Space+ЛКМ — пан. Delete — удалить выбранную ловушку.
|
||||
</div>
|
||||
<div className={styles.accordion}>
|
||||
<button type="button" className={styles.accordionHead} onClick={() => setTrapsOpen((v) => !v)}>
|
||||
Ловушки {trapsOpen ? '▾' : '▸'}
|
||||
</button>
|
||||
{trapsOpen ? (
|
||||
<div className={styles.palette}>
|
||||
{SCENE_TRAP_TYPES.map((type) => (
|
||||
<div
|
||||
key={type}
|
||||
className={styles.paletteItem}
|
||||
draggable
|
||||
onDragStart={onPaletteDragStart(type)}
|
||||
title="Перетащите на карту"
|
||||
>
|
||||
<TrapGlyph type={type} size={22} />
|
||||
<span className={styles.paletteLabel}>{trapTypeLabelRu(type)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedId ? (
|
||||
<div className={styles.toolbar}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const next = localTraps.filter((t) => t.id !== selectedId);
|
||||
setSelectedId(null);
|
||||
void persistTraps(next);
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
|
||||
<div className={styles.stage}>
|
||||
{!isImage ? (
|
||||
<div className={styles.empty}>Нужно изображение сцены</div>
|
||||
) : (
|
||||
<div
|
||||
ref={hostRef}
|
||||
className={styles.viewport}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={onStageDrop}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button === 1 || (e.button === 0 && spaceDownRef.current)) {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
dragRef.current = { kind: 'pan', lastX: e.clientX, lastY: e.clientY };
|
||||
}
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
const d = dragRef.current;
|
||||
if (!d) return;
|
||||
if (d.kind === 'pan') {
|
||||
const cr = contentRect;
|
||||
if (!cr) return;
|
||||
const dx = e.clientX - d.lastX;
|
||||
const dy = e.clientY - d.lastY;
|
||||
d.lastX = e.clientX;
|
||||
d.lastY = e.clientY;
|
||||
setView((v) => {
|
||||
const containW = cr.w / Math.max(1e-6, v.scale);
|
||||
const containH = cr.h / Math.max(1e-6, v.scale);
|
||||
return sceneViewPanBy(v, { containW, containH, dx, dy });
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (d.kind === 'move') {
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
updateTrap(d.trapId, {
|
||||
nx: Math.max(0, Math.min(1, d.startNx + (p.x - d.pointerNx))),
|
||||
ny: Math.max(0, Math.min(1, d.startNy + (p.y - d.pointerNy))),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (d.kind === 'resize') {
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
const trap = trapsRef.current.find((t) => t.id === d.trapId);
|
||||
if (!p || !trap) return;
|
||||
const dist = Math.hypot(p.x - trap.nx, p.y - trap.ny);
|
||||
const ratio = d.startDist > 1e-6 ? dist / d.startDist : 1;
|
||||
updateTrap(d.trapId, {
|
||||
sizeN: Math.max(0.02, Math.min(0.45, d.startSize * ratio)),
|
||||
});
|
||||
}
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
dragRef.current = null;
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
dragRef.current = null;
|
||||
}}
|
||||
>
|
||||
<RotatedImage
|
||||
url={url!}
|
||||
rotationDeg={rot}
|
||||
mode="contain"
|
||||
viewCamera={viewCamera}
|
||||
onContentRectChange={setContentRect}
|
||||
/>
|
||||
{contentRect
|
||||
? localTraps.map((trap) => {
|
||||
const minDim = Math.min(contentRect.w, contentRect.h);
|
||||
const sizePx = Math.max(16, trap.sizeN * minDim);
|
||||
const left = contentRect.x + trap.nx * contentRect.w;
|
||||
const top = contentRect.y + trap.ny * contentRect.h;
|
||||
const selected = selectedId === trap.id;
|
||||
return (
|
||||
<div
|
||||
key={trap.id}
|
||||
className={[styles.trap, selected ? styles.trapSelected : ''].filter(Boolean).join(' ')}
|
||||
style={{ left, top, width: sizePx, height: sizePx }}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0 || spaceDownRef.current) return;
|
||||
e.stopPropagation();
|
||||
setSelectedId(trap.id);
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
dragRef.current = {
|
||||
kind: 'move',
|
||||
trapId: trap.id,
|
||||
startNx: trap.nx,
|
||||
startNy: trap.ny,
|
||||
pointerNx: p.x,
|
||||
pointerNy: p.y,
|
||||
};
|
||||
}}
|
||||
>
|
||||
<TrapGlyph type={trap.type} size={Math.max(14, sizePx * 0.55)} />
|
||||
{trap.label ? <div className={styles.trapLabel}>{trap.label}</div> : null}
|
||||
{selected ? (
|
||||
<div
|
||||
className={styles.handle}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
dragRef.current = {
|
||||
kind: 'resize',
|
||||
trapId: trap.id,
|
||||
startSize: trap.sizeN,
|
||||
startDist: Math.max(1e-4, Math.hypot(p.x - trap.nx, p.y - trap.ny)),
|
||||
};
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user