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(() => cloneLegend(legend)); const [activeItemId, setActiveItemId] = useState(null); const [view, setView] = useState(DEFAULT_SCENE_VIEW_CAMERA); const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>( null, ); const mapRef = useRef(null); const dragRef = useRef(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 (
{ 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}
); }); }; const mapBlock = previewUrl ? (
{ 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 ? ( ) : ( )} {renderMarkers()} {draft.enabled && !activeItem ? (
Выберите строку легенды, чтобы ставить метки
) : null} {largeMap ? (
Колесо — зум · СКМ / Space+ЛКМ — пан
) : null}
) : (
Нет изображения
); return (
{largeMap ? mapBlock : null} {draft.enabled ? ( <>
Активная строка подсвечена — клик по картинке ставит метку; клик по метке — перемещение
{draft.items.map((item) => { const active = item.id === activeItemId; return (
setActiveItemId(item.id)} onDragStart={(e) => { e.dataTransfer.setData('application/x-legend-number', String(item.number)); setActiveItemId(item.id); }} >
{item.number}
{ updateItems( draftRef.current.items.map((it) => (it.id === item.id ? { ...it, text } : it)), ); }} placeholder="Описание…" />
); })} {!largeMap ? mapBlock : null} ) : largeMap ? null : ( mapBlock )}
); }