2979d06f1c
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>
484 lines
15 KiB
TypeScript
484 lines
15 KiB
TypeScript
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>
|
||
);
|
||
}
|