Files
DndGamePlayer/app/renderer/editor/MaterialLegendEditor.tsx
T
Ivan Fontosh 02d73ddf81 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>
2026-07-24 16:29:43 +08:00

475 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useRef, useState } from 'react';
import type { MaterialLegend, MaterialLegendItem, MaterialLegendMarker } from '../../shared/types';
import {
asMaterialLegendItemId,
asMaterialLegendMarkerId,
EMPTY_MATERIAL_LEGEND,
hostPointToLegendImageUv,
legendImageUvToHostPoint,
nextLegendNumber,
} from '../../shared/types/materialLegend';
import {
DEFAULT_SCENE_VIEW_CAMERA,
sceneViewPanBy,
sceneViewZoomAt,
type SceneViewCamera,
} from '../../shared/types/sceneView';
import { RotatedImage } from '../shared/RotatedImage';
import { Button, Input } from '../shared/ui/controls';
import styles from './MaterialLegendEditor.module.css';
type Props = {
legend: MaterialLegend | undefined;
previewUrl: string | null;
rotationDeg?: 0 | 90 | 180 | 270;
/** Крупная карта (окно «Материалы»). */
largeMap?: boolean;
onChange: (legend: MaterialLegend) => void;
};
type DragMode =
| { kind: 'pan'; lastX: number; lastY: number }
| { kind: 'marker'; id: string }
| null;
const PERSIST_DEBOUNCE_MS = 180;
function rid(prefix: string): string {
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
}
function cloneLegend(legend: MaterialLegend | undefined): MaterialLegend {
const base = legend ?? EMPTY_MATERIAL_LEGEND;
return {
enabled: base.enabled,
items: base.items.map((it) => ({ ...it })),
markers: base.markers.map((m) => ({ ...m })),
};
}
export function MaterialLegendEditor({
legend,
previewUrl,
rotationDeg = 0,
largeMap = false,
onChange,
}: Props) {
const [draft, setDraft] = useState<MaterialLegend>(() => cloneLegend(legend));
const [activeItemId, setActiveItemId] = useState<string | null>(null);
const [view, setView] = useState<SceneViewCamera>(DEFAULT_SCENE_VIEW_CAMERA);
const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(
null,
);
const mapRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<DragMode>(null);
const skipMapClickRef = useRef(false);
const spaceDownRef = useRef(false);
const draftRef = useRef(draft);
draftRef.current = draft;
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const saveTimerRef = useRef(0);
const dirtyRef = useRef(false);
const flushPersist = () => {
if (saveTimerRef.current) {
window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = 0;
}
if (!dirtyRef.current) return;
dirtyRef.current = false;
onChangeRef.current(draftRef.current);
};
const schedulePersist = () => {
dirtyRef.current = true;
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = window.setTimeout(() => {
saveTimerRef.current = 0;
dirtyRef.current = false;
onChangeRef.current(draftRef.current);
}, PERSIST_DEBOUNCE_MS);
};
const applyDraft = (next: MaterialLegend) => {
draftRef.current = next;
setDraft(next);
schedulePersist();
};
useEffect(() => {
return () => {
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
if (dirtyRef.current) onChangeRef.current(draftRef.current);
};
}, []);
useEffect(() => {
flushPersist();
setDraft(cloneLegend(legend));
dirtyRef.current = false;
setActiveItemId(null);
setView(DEFAULT_SCENE_VIEW_CAMERA);
// eslint-disable-next-line react-hooks/exhaustive-deps -- reset only when switching material image
}, [previewUrl]);
useEffect(() => {
if (activeItemId && !draft.items.some((i) => i.id === activeItemId)) {
setActiveItemId(null);
}
}, [activeItemId, draft.items]);
useEffect(() => {
if (!largeMap) return;
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);
};
}, [largeMap]);
useEffect(() => {
if (!largeMap) return;
const host = mapRef.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 { ...DEFAULT_SCENE_VIEW_CAMERA };
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, largeMap, previewUrl]);
const activeItem = useMemo(
() => (activeItemId ? (draft.items.find((i) => i.id === activeItemId) ?? null) : null),
[activeItemId, draft.items],
);
const setEnabled = (enabled: boolean) => {
applyDraft({ ...draftRef.current, enabled });
};
const updateItems = (items: MaterialLegendItem[]) => {
applyDraft({ ...draftRef.current, enabled: true, items });
};
const updateMarkers = (markers: MaterialLegendMarker[]) => {
applyDraft({ ...draftRef.current, enabled: true, markers });
};
const addItem = () => {
const cur = draftRef.current;
const number = nextLegendNumber(cur.items);
const item: MaterialLegendItem = {
id: asMaterialLegendItemId(rid('li')),
number,
text: '',
};
updateItems([...cur.items, item]);
setActiveItemId(item.id);
};
const placeMarker = (nx: number, ny: number, number: number) => {
updateMarkers([
...draftRef.current.markers,
{
id: asMaterialLegendMarkerId(rid('lm')),
number,
nx,
ny,
},
]);
};
const toNorm = (clientX: number, clientY: number): { x: number; y: number } | null => {
const el = mapRef.current;
if (!el) return null;
const r = el.getBoundingClientRect();
// UV неповёрнутого изображения — тот же space, что у MaterialOverlay на пульте/презентации.
if (largeMap && contentRect && contentRect.w > 1 && contentRect.h > 1) {
return hostPointToLegendImageUv(
clientX - r.left,
clientY - r.top,
contentRect,
rotationDeg,
);
}
const img = el.querySelector('img');
if (!img) return null;
const ir = img.getBoundingClientRect();
if (ir.width < 1 || ir.height < 1) return null;
return {
x: Math.max(0, Math.min(1, (clientX - ir.left) / ir.width)),
y: Math.max(0, Math.min(1, (clientY - ir.top) / ir.height)),
};
};
const onMapPointerDown = (e: React.PointerEvent) => {
if (!largeMap) return;
if (e.button === 1 || (e.button === 0 && spaceDownRef.current)) {
e.preventDefault();
e.stopPropagation();
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
dragRef.current = { kind: 'pan', lastX: e.clientX, lastY: e.clientY };
skipMapClickRef.current = true;
}
};
const onMapPointerMove = (e: React.PointerEvent) => {
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 === 'marker') {
skipMapClickRef.current = true;
const p = toNorm(e.clientX, e.clientY);
if (!p) return;
const nextMarkers = draftRef.current.markers.map((x) =>
x.id === d.id ? { ...x, nx: p.x, ny: p.y } : x,
);
const next = { ...draftRef.current, enabled: true, markers: nextMarkers };
draftRef.current = next;
setDraft(next);
// persist только на pointerup
}
};
const onMapPointerUp = () => {
const d = dragRef.current;
if (d?.kind === 'marker') {
schedulePersist();
}
dragRef.current = null;
};
const onMapClick = (e: React.MouseEvent) => {
if (skipMapClickRef.current) {
skipMapClickRef.current = false;
return;
}
if (dragRef.current) return;
if (!draft.enabled || !activeItem) return;
const p = toNorm(e.clientX, e.clientY);
if (!p) return;
placeMarker(p.x, p.y, activeItem.number);
};
const renderMarkers = () => {
if (!draft.enabled) return null;
return draft.markers.map((m) => {
const style =
largeMap && contentRect
? (() => {
const p = legendImageUvToHostPoint(m.nx, m.ny, contentRect, rotationDeg);
return { left: p.x, top: p.y };
})()
: {
left: `${String(m.nx * 100)}%`,
top: `${String(m.ny * 100)}%`,
};
return (
<div
key={m.id}
className={styles.marker}
style={style}
onPointerDown={(e) => {
if (!draft.enabled) return;
e.stopPropagation();
e.preventDefault();
dragRef.current = { kind: 'marker', id: m.id };
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
}}
onPointerMove={onMapPointerMove}
onPointerUp={(e) => {
e.stopPropagation();
if (dragRef.current?.kind === 'marker') skipMapClickRef.current = true;
onMapPointerUp();
}}
onClick={(e) => {
e.stopPropagation();
skipMapClickRef.current = true;
}}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
updateMarkers(draftRef.current.markers.filter((x) => x.id !== m.id));
}}
title="Перетащите, чтобы переместить. ПКМ — удалить"
>
{m.number}
</div>
);
});
};
const mapBlock = previewUrl ? (
<div
ref={mapRef}
className={[
styles.map,
largeMap ? styles.mapLarge : '',
!draft.enabled ? styles.mapIdle : '',
]
.filter(Boolean)
.join(' ')}
onDragOver={(e) => {
if (!draft.enabled) return;
e.preventDefault();
}}
onDrop={(e) => {
if (!draft.enabled) return;
e.preventDefault();
const num = Number(e.dataTransfer.getData('application/x-legend-number'));
const p = toNorm(e.clientX, e.clientY);
if (!p || !Number.isFinite(num) || num < 1) return;
placeMarker(p.x, p.y, Math.round(num));
}}
onClick={onMapClick}
onPointerDown={onMapPointerDown}
onPointerMove={onMapPointerMove}
onPointerUp={onMapPointerUp}
onPointerCancel={onMapPointerUp}
>
{largeMap ? (
<RotatedImage
url={previewUrl}
rotationDeg={rotationDeg}
mode="contain"
viewCamera={view}
onContentRectChange={setContentRect}
/>
) : (
<img className={styles.mapImg} src={previewUrl} alt="" draggable={false} />
)}
{renderMarkers()}
{draft.enabled && !activeItem ? (
<div className={styles.mapHint}>Выберите строку легенды, чтобы ставить метки</div>
) : null}
{largeMap ? (
<div className={styles.mapHintZoom}>Колесо зум · СКМ / Space+ЛКМ пан</div>
) : null}
</div>
) : (
<div className={[styles.map, largeMap ? styles.mapLarge : '', styles.mapIdle].filter(Boolean).join(' ')}>
<div className={styles.mapEmpty}>Нет изображения</div>
</div>
);
return (
<div className={[styles.legendBlock, largeMap ? styles.legendBlockLarge : ''].filter(Boolean).join(' ')}>
{largeMap ? mapBlock : null}
<label className={styles.row}>
<input type="checkbox" checked={draft.enabled} onChange={(e) => setEnabled(e.target.checked)} />
<span>Легенда</span>
</label>
{draft.enabled ? (
<>
<div className={styles.row}>
<Button onClick={addItem}>Добавить пункт</Button>
<span className={styles.hint}>
Активная строка подсвечена клик по картинке ставит метку; клик по метке перемещение
</span>
</div>
{draft.items.map((item) => {
const active = item.id === activeItemId;
return (
<div
key={item.id}
className={[styles.itemRow, active ? styles.itemRowActive : ''].filter(Boolean).join(' ')}
draggable
onClick={() => setActiveItemId(item.id)}
onDragStart={(e) => {
e.dataTransfer.setData('application/x-legend-number', String(item.number));
setActiveItemId(item.id);
}}
>
<div className={styles.num}>{item.number}</div>
<Input
value={item.text}
onChange={(text) => {
updateItems(
draftRef.current.items.map((it) => (it.id === item.id ? { ...it, text } : it)),
);
}}
placeholder="Описание…"
/>
<button
type="button"
className={styles.itemDrag}
title="Удалить"
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const cur = draftRef.current;
applyDraft({
...cur,
enabled: true,
items: cur.items.filter((it) => it.id !== item.id),
markers: cur.markers.filter((m) => m.number !== item.number),
});
if (activeItemId === item.id) setActiveItemId(null);
}}
>
×
</button>
</div>
);
})}
{!largeMap ? mapBlock : null}
</>
) : largeMap ? null : (
mapBlock
)}
</div>
);
}