Files
DndGamePlayer/app/renderer/editor/MaterialLegendEditor.tsx
T
Ivan Fontosh 2979d06f1c feat: multi-material overlays, presentation guides, and release prebuilds
Align control/presentation with presentation screen rect and darkness z-order; sync window titles and session window cleanup. Pack Win/Mac/Linux with npmRebuild disabled and release-native-prep for classic-level and sharp.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 11:28:18 +08:00

484 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;
onRotate?: () => void;
rotateLabel?: string;
};
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,
onRotate,
rotateLabel = 'Повернуть',
}: 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}
<div className={styles.legendHeadRow}>
{onRotate ? (
<Button onClick={onRotate}>{rotateLabel}</Button>
) : null}
<label className={styles.row}>
<input type="checkbox" checked={draft.enabled} onChange={(e) => setEnabled(e.target.checked)} />
<span>Легенда</span>
</label>
</div>
{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>
);
}