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:
@@ -1564,6 +1564,16 @@ export function EditorApp() {
|
||||
});
|
||||
});
|
||||
}}
|
||||
onLegendChange={async (materialId, legend) => {
|
||||
try {
|
||||
await actions.setMaterialLegend(materialId, legend);
|
||||
} catch (e) {
|
||||
setAppNotice({
|
||||
title: t('common.error'),
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<MaterialEditModal
|
||||
open={materialEdit !== null}
|
||||
@@ -2599,6 +2609,14 @@ function SceneInspector({
|
||||
/>
|
||||
<span className={styles.spanSm}>{t('scene.darkenScene')}</span>
|
||||
</label>
|
||||
<div className={styles.spacer6} />
|
||||
<Button
|
||||
onClick={() => {
|
||||
void getDndApi().invoke(ipcChannels.windows.openSceneEditor, {});
|
||||
}}
|
||||
>
|
||||
Редактор сцены
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<div className={styles.spacer6} />
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
.legendBlock {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.legendBlockLarge {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.map {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-height: 280px;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--stroke, #333);
|
||||
background: #0a0b0e;
|
||||
}
|
||||
|
||||
.mapLarge {
|
||||
max-height: none;
|
||||
height: min(48vh, 440px);
|
||||
min-height: 260px;
|
||||
flex: 0 0 auto;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.mapIdle {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.mapEmpty {
|
||||
color: var(--text2);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mapImg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: inherit;
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.marker {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #1e3a5f;
|
||||
border: 2px solid #f5c542;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
z-index: 2;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.mapHint {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
bottom: 12px;
|
||||
z-index: 3;
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mapHintZoom {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
z-index: 3;
|
||||
font-size: 11px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.itemRow {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.itemRowActive {
|
||||
border-color: color-mix(in srgb, var(--color-accent, #c9a227) 70%, transparent);
|
||||
background: color-mix(in srgb, var(--color-accent, #c9a227) 12%, transparent);
|
||||
}
|
||||
|
||||
.num {
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.itemDrag {
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
font-size: 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { MaterialId, ProjectMaterial } from '../../shared/types';
|
||||
import type { MaterialId, MaterialLegend, ProjectMaterial } from '../../shared/types';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import styles from './EditorApp.module.css';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
import { MaterialLegendEditor } from './MaterialLegendEditor';
|
||||
import matStyles from './MaterialsModals.module.css';
|
||||
|
||||
const DND_MATERIAL_ID_MIME = 'application/x-dnd-material-id';
|
||||
@@ -24,6 +25,7 @@ export type MaterialsBrowserProps = {
|
||||
onDelete?: (materialId: MaterialId) => Promise<void>;
|
||||
onReorder?: (materialIds: MaterialId[]) => Promise<void>;
|
||||
onRotate?: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => void;
|
||||
onLegendChange?: (materialId: MaterialId, legend: MaterialLegend) => Promise<void>;
|
||||
onTileActivate?: (materialId: MaterialId) => void;
|
||||
toolbar?: React.ReactNode;
|
||||
className?: string | undefined;
|
||||
@@ -44,6 +46,7 @@ export function MaterialsBrowser({
|
||||
onDelete,
|
||||
onReorder,
|
||||
onRotate,
|
||||
onLegendChange,
|
||||
onTileActivate,
|
||||
toolbar,
|
||||
className,
|
||||
@@ -179,20 +182,42 @@ export function MaterialsBrowser({
|
||||
</div>
|
||||
|
||||
{!listOnly ? (
|
||||
<div className={matStyles.previewColumn}>
|
||||
<div className={matStyles.previewPane}>
|
||||
{selected && selectedUrl ? (
|
||||
<div className={matStyles.previewLargeHost}>
|
||||
<RotatedImage
|
||||
url={selectedUrl}
|
||||
rotationDeg={selected.rotationDeg ?? 0}
|
||||
mode="contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={matStyles.previewEmpty}>{t('materials.addPrompt')}</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={[
|
||||
matStyles.previewColumn,
|
||||
onLegendChange ? matStyles.previewColumnLegend : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{selected && selectedUrl && onLegendChange ? (
|
||||
<div className={matStyles.previewLegendScroll}>
|
||||
<MaterialLegendEditor
|
||||
key={selected.id}
|
||||
largeMap
|
||||
legend={selected.legend}
|
||||
previewUrl={selectedUrl}
|
||||
rotationDeg={selected.rotationDeg ?? 0}
|
||||
onChange={(next) => {
|
||||
void onLegendChange(selected.id, next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={matStyles.previewPane}>
|
||||
{selected && selectedUrl ? (
|
||||
<div className={matStyles.previewLargeHost}>
|
||||
<RotatedImage
|
||||
url={selectedUrl}
|
||||
rotationDeg={selected.rotationDeg ?? 0}
|
||||
mode="contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={matStyles.previewEmpty}>{t('materials.addPrompt')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{selected && onRotate ? (
|
||||
<div className={matStyles.previewActions}>
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.managerDialog {
|
||||
width: min(960px, calc(100vw - 48px));
|
||||
max-width: 960px;
|
||||
width: min(1100px, calc(100vw - 48px));
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.browserRoot {
|
||||
@@ -49,7 +49,7 @@
|
||||
gap: 14px;
|
||||
/* ~3 плитки по высоте + поиск/кнопка; дальше скролл в списке */
|
||||
min-height: 560px;
|
||||
max-height: min(78vh, 720px);
|
||||
max-height: min(84vh, 820px);
|
||||
}
|
||||
|
||||
.managerBodyFill {
|
||||
@@ -180,6 +180,17 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.previewColumnLegend {
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.previewLegendScroll {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.previewPane {
|
||||
width: 100%;
|
||||
/* высота ≈ 3 плитки списка */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { createPortal, flushSync } from 'react-dom';
|
||||
|
||||
import type { MaterialId, ProjectMaterial } from '../../shared/types';
|
||||
import type { MaterialId, MaterialLegend, ProjectMaterial } from '../../shared/types';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
@@ -81,7 +81,6 @@ export function MaterialEditModal({
|
||||
onDropPaths: (paths) => {
|
||||
const picked = pickFirstMaterialImagePath(paths);
|
||||
if (!picked) return;
|
||||
// Prefer File blob URL if available from the last drop via entries — handled in onDrop below.
|
||||
setPreviewFromPathAndUrl(picked, '');
|
||||
},
|
||||
filterPaths: filterMaterialImagePaths,
|
||||
@@ -226,6 +225,7 @@ type MaterialsManagerModalProps = {
|
||||
onDelete: (materialId: MaterialId) => Promise<void>;
|
||||
onReorder: (materialIds: MaterialId[]) => Promise<void>;
|
||||
onRotate: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => void;
|
||||
onLegendChange?: (materialId: MaterialId, legend: MaterialLegend) => Promise<void>;
|
||||
};
|
||||
|
||||
export function MaterialsManagerModal({
|
||||
@@ -237,6 +237,7 @@ export function MaterialsManagerModal({
|
||||
onDelete,
|
||||
onReorder,
|
||||
onRotate,
|
||||
onLegendChange,
|
||||
}: MaterialsManagerModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const [selectedId, setSelectedId] = useState<MaterialId | null>(null);
|
||||
@@ -278,6 +279,7 @@ export function MaterialsManagerModal({
|
||||
onDelete={onDelete}
|
||||
onReorder={onReorder}
|
||||
onRotate={onRotate}
|
||||
onLegendChange={onLegendChange}
|
||||
/>
|
||||
</div>
|
||||
</>,
|
||||
|
||||
@@ -58,6 +58,10 @@ type Actions = {
|
||||
deleteMaterial: (materialId: MaterialId) => Promise<void>;
|
||||
setMaterialsOrder: (materialIds: MaterialId[]) => Promise<void>;
|
||||
setMaterialRotation: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => Promise<void>;
|
||||
setMaterialLegend: (
|
||||
materialId: MaterialId,
|
||||
legend: import('../../shared/types').MaterialLegend | null,
|
||||
) => Promise<void>;
|
||||
pickMaterialImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>;
|
||||
updateScene: (
|
||||
sceneId: SceneId,
|
||||
@@ -344,6 +348,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
previewVideoAutostart: false,
|
||||
previewRotationDeg: 0,
|
||||
darkenScene: false,
|
||||
traps: [],
|
||||
media: { videos: [], audios: [] },
|
||||
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
|
||||
connections: [],
|
||||
@@ -531,6 +536,15 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const setMaterialLegend = async (
|
||||
materialId: MaterialId,
|
||||
legend: import('../../shared/types').MaterialLegend | null,
|
||||
) => {
|
||||
const res = await api.invoke(ipcChannels.project.setMaterialLegend, { materialId, legend });
|
||||
// Список проектов не меняется — не дергаем refreshProjects на каждое обновление легенды.
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
};
|
||||
|
||||
const pickMaterialImage = async () => {
|
||||
const res = await api.invoke(ipcChannels.project.pickMaterialImage, {});
|
||||
if (res.canceled) return null;
|
||||
@@ -548,6 +562,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
previewVideoAutostart?: boolean;
|
||||
previewRotationDeg?: 0 | 90 | 180 | 270;
|
||||
darkenScene?: boolean;
|
||||
traps?: import('../../shared/types').SceneTrap[];
|
||||
settings?: Partial<Scene['settings']>;
|
||||
media?: Partial<Scene['media']>;
|
||||
layout?: { x: number; y: number };
|
||||
@@ -574,6 +589,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
? { previewRotationDeg: patch.previewRotationDeg }
|
||||
: null),
|
||||
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
|
||||
...(patch.traps !== undefined ? { traps: patch.traps } : null),
|
||||
...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null),
|
||||
...(patch.media ? { media: { ...scene.media, ...patch.media } } : null),
|
||||
layout: patch.layout ? { ...scene.layout, ...patch.layout } : scene.layout,
|
||||
@@ -896,6 +912,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
deleteMaterial,
|
||||
setMaterialsOrder,
|
||||
setMaterialRotation,
|
||||
setMaterialLegend,
|
||||
pickMaterialImage,
|
||||
updateScene,
|
||||
updateConnections,
|
||||
|
||||
Reference in New Issue
Block a user