feat(materials): add campaign materials overlay for sessions

Let GMs manage and show images over the scene from the editor and control panel, with zoom tools, help docs, and full ru/en i18n.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-17 11:05:38 +08:00
parent 195d4be086
commit 61875be857
39 changed files with 2521 additions and 22 deletions
+385
View File
@@ -0,0 +1,385 @@
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import type { MaterialId, 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 matStyles from './MaterialsModals.module.css';
const DND_MATERIAL_ID_MIME = 'application/x-dnd-material-id';
export type MaterialsBrowserProps = {
materials: ProjectMaterial[];
/** editor: CRUD + ⋮; runtime: показ на сцене, без меню */
mode: 'editor' | 'runtime';
selectedId: MaterialId | null;
onSelect: (id: MaterialId | null) => void;
activeMaterialId?: MaterialId | null;
onAdd?: () => void;
onEdit?: (material: ProjectMaterial) => void;
onDelete?: (materialId: MaterialId) => Promise<void>;
onReorder?: (materialIds: MaterialId[]) => Promise<void>;
onRotate?: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => void;
onTileActivate?: (materialId: MaterialId) => void;
toolbar?: React.ReactNode;
className?: string | undefined;
/** Растянуть тело на всю высоту (окно Electron). */
fillHeight?: boolean;
/** Только колонка списка (без большого превью). */
listOnly?: boolean;
};
export function MaterialsBrowser({
materials,
mode,
selectedId,
onSelect,
activeMaterialId = null,
onAdd,
onEdit,
onDelete,
onReorder,
onRotate,
onTileActivate,
toolbar,
className,
fillHeight = false,
listOnly = false,
}: MaterialsBrowserProps) {
const { t } = useEditorI18n();
const [query, setQuery] = useState('');
const [menuFor, setMenuFor] = useState<MaterialId | null>(null);
const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null);
const [dragId, setDragId] = useState<MaterialId | null>(null);
const [dropPlace, setDropPlace] = useState<{ id: MaterialId; place: 'before' | 'after' } | null>(null);
const [pendingDelete, setPendingDelete] = useState<ProjectMaterial | null>(null);
useEffect(() => {
if (!menuFor) return;
const onDown = (e: MouseEvent) => {
const tgt = e.target as HTMLElement | null;
if (!tgt) return;
if (tgt.closest('[data-material-menu-root="1"]')) return;
setMenuFor(null);
setMenuPos(null);
};
window.addEventListener('mousedown', onDown);
return () => window.removeEventListener('mousedown', onDown);
}, [menuFor]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return materials;
return materials.filter((m) => m.name.toLowerCase().includes(q));
}, [materials, query]);
useEffect(() => {
if (selectedId && filtered.some((m) => m.id === selectedId)) return;
const next = filtered[0]?.id ?? null;
if (next !== selectedId) onSelect(next);
// eslint-disable-next-line react-hooks/exhaustive-deps -- sync selection to filtered list
}, [filtered, selectedId]);
const selected = materials.find((m) => m.id === selectedId) ?? null;
const selectedUrl = useAssetUrl(selected?.assetId ?? null);
return (
<div
className={[
matStyles.browserRoot,
fillHeight ? matStyles.browserRootFill : '',
listOnly ? matStyles.browserRootListOnly : '',
className,
]
.filter(Boolean)
.join(' ')}
>
{toolbar ? <div className={matStyles.browserToolbar}>{toolbar}</div> : null}
<div
className={[
matStyles.managerBody,
fillHeight ? matStyles.managerBodyFill : '',
listOnly ? matStyles.managerBodyListOnly : '',
]
.filter(Boolean)
.join(' ')}
>
<div className={matStyles.side}>
<Input value={query} onChange={setQuery} placeholder={t('materials.search')} />
{mode === 'editor' && onAdd ? (
<Button variant="primary" onClick={onAdd}>
{t('materials.add')}
</Button>
) : null}
<div className={matStyles.list}>
{filtered.map((m) => (
<MaterialTile
key={m.id}
material={m}
selected={m.id === selectedId}
active={m.id === activeMaterialId}
showMenu={mode === 'editor'}
dragging={dragId === m.id}
dropPlace={dropPlace?.id === m.id ? dropPlace.place : null}
reorderEnabled={mode === 'editor' && Boolean(onReorder)}
onSelect={() => {
onSelect(m.id);
if (mode === 'runtime' && onTileActivate) onTileActivate(m.id);
}}
onMenu={(e) => {
if (mode !== 'editor') return;
const r = e.currentTarget.getBoundingClientRect();
const menuW = 180;
const menuH = 88;
const left = Math.max(8, Math.min(r.right - menuW, window.innerWidth - menuW - 8));
const top =
r.bottom + 8 + menuH > window.innerHeight - 8
? Math.max(8, r.top - menuH - 8)
: r.bottom + 8;
setMenuPos({ left, top });
setMenuFor((cur) => (cur === m.id ? null : m.id));
}}
onDragStart={() => setDragId(m.id)}
onDragEnd={() => {
setDragId(null);
setDropPlace(null);
}}
onDragOver={(place) => {
if (!dragId || dragId === m.id) {
setDropPlace(null);
return;
}
setDropPlace({ id: m.id, place });
}}
onDropReorder={async () => {
if (!onReorder || !dragId || !dropPlace || dragId === dropPlace.id) return;
const ids = materials.map((x) => x.id);
const from = ids.indexOf(dragId);
if (from < 0) return;
ids.splice(from, 1);
let to = ids.indexOf(dropPlace.id);
if (to < 0) return;
if (dropPlace.place === 'after') to += 1;
ids.splice(to, 0, dragId);
setDragId(null);
setDropPlace(null);
await onReorder(ids);
}}
/>
))}
{materials.length === 0 ? <div className={styles.muted}>{t('materials.empty')}</div> : null}
{materials.length > 0 && filtered.length === 0 ? (
<div className={styles.muted}>{t('materials.searchEmpty')}</div>
) : null}
</div>
</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>
{selected && onRotate ? (
<div className={matStyles.previewActions}>
<Button
onClick={() => {
const cur = selected.rotationDeg ?? 0;
const next = ((cur + 90) % 360) as 0 | 90 | 180 | 270;
onRotate(selected.id, next);
}}
>
{t('scene.rotate')}
</Button>
</div>
) : null}
</div>
) : null}
</div>
{mode === 'editor' && menuFor && menuPos
? createPortal(
<div
role="menu"
data-material-menu-root="1"
className={styles.fileMenu}
style={{ left: menuPos.left, top: menuPos.top }}
>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
const mat = materials.find((x) => x.id === menuFor);
setMenuFor(null);
setMenuPos(null);
if (mat && onEdit) onEdit(mat);
}}
>
{t('materials.edit')}
</button>
<button
type="button"
role="menuitem"
className={styles.fileMenuItemDanger}
onClick={() => {
const mat = materials.find((x) => x.id === menuFor);
setMenuFor(null);
setMenuPos(null);
if (mat) setPendingDelete(mat);
}}
>
{t('common.delete')}
</button>
</div>,
document.body,
)
: null}
{pendingDelete
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalBackdrop}
onClick={() => setPendingDelete(null)}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('materials.deleteTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalClose}
onClick={() => setPendingDelete(null)}
>
×
</button>
</div>
<div className={styles.muted}>
{t('materials.deleteConfirm', { name: pendingDelete.name })}
</div>
<div className={styles.modalFooter}>
<Button onClick={() => setPendingDelete(null)}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
const id = pendingDelete.id;
setPendingDelete(null);
if (onDelete) void onDelete(id);
}}
>
{t('common.delete')}
</Button>
</div>
</div>
</>,
document.body,
)
: null}
</div>
);
}
function MaterialTile({
material,
selected,
active,
showMenu,
dragging,
dropPlace,
reorderEnabled,
onSelect,
onMenu,
onDragStart,
onDragEnd,
onDragOver,
onDropReorder,
}: {
material: ProjectMaterial;
selected: boolean;
active: boolean;
showMenu: boolean;
dragging: boolean;
dropPlace: 'before' | 'after' | null;
reorderEnabled: boolean;
onSelect: () => void;
onMenu: (e: React.MouseEvent<HTMLButtonElement>) => void;
onDragStart: () => void;
onDragEnd: () => void;
onDragOver: (place: 'before' | 'after') => void;
onDropReorder: () => void;
}) {
const { t } = useEditorI18n();
const url = useAssetUrl(material.assetId);
return (
<div
className={[
matStyles.tile,
selected ? matStyles.tileSelected : '',
active ? matStyles.tileActive : '',
dragging ? matStyles.tileDragging : '',
dropPlace === 'before' ? matStyles.tileDropBefore : '',
dropPlace === 'after' ? matStyles.tileDropAfter : '',
]
.filter(Boolean)
.join(' ')}
draggable={reorderEnabled}
onDragStart={(e) => {
if (!reorderEnabled) return;
e.dataTransfer.setData(DND_MATERIAL_ID_MIME, material.id);
e.dataTransfer.effectAllowed = 'move';
onDragStart();
}}
onDragEnd={onDragEnd}
onDragOver={(e) => {
if (!reorderEnabled) return;
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
const place = e.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
onDragOver(place);
}}
onDrop={(e) => {
if (!reorderEnabled) return;
e.preventDefault();
onDropReorder();
}}
>
<button type="button" className={matStyles.tileBody} onClick={onSelect}>
{url ? (
<div className={matStyles.tileImg}>
<RotatedImage url={url} rotationDeg={material.rotationDeg ?? 0} mode="contain" />
</div>
) : (
<div className={matStyles.tileImgEmpty} />
)}
<div className={matStyles.tileName}>{material.name}</div>
</button>
{showMenu ? (
<button
type="button"
className={matStyles.tileMenu}
data-material-menu-root="1"
aria-label={t('materials.tileMenu')}
onClick={onMenu}
>
</button>
) : null}
</div>
);
}