import React, { useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; 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'; export type MaterialsBrowserProps = { materials: ProjectMaterial[]; /** editor: CRUD + ⋮; runtime: показ на сцене, без меню */ mode: 'editor' | 'runtime'; selectedId: MaterialId | null; onSelect: (id: MaterialId | null) => void; activeMaterialIds?: readonly MaterialId[]; onAdd?: () => void; onEdit?: (material: ProjectMaterial) => void; onDelete?: (materialId: MaterialId) => Promise; onReorder?: (materialIds: MaterialId[]) => Promise; onRotate?: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => void; onLegendChange?: (materialId: MaterialId, legend: MaterialLegend) => Promise; onTileActivate?: (materialId: MaterialId) => void; toolbar?: React.ReactNode; className?: string | undefined; /** Растянуть тело на всю высоту (окно Electron). */ fillHeight?: boolean; /** Только колонка списка (без большого превью). */ listOnly?: boolean; }; export function MaterialsBrowser({ materials, mode, selectedId, onSelect, activeMaterialIds = [], onAdd, onEdit, onDelete, onReorder, onRotate, onLegendChange, onTileActivate, toolbar, className, fillHeight = false, listOnly = false, }: MaterialsBrowserProps) { const { t } = useEditorI18n(); const [query, setQuery] = useState(''); const [menuFor, setMenuFor] = useState(null); const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null); const [dragId, setDragId] = useState(null); const [dropPlace, setDropPlace] = useState<{ id: MaterialId; place: 'before' | 'after' } | null>(null); const [pendingDelete, setPendingDelete] = useState(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 activeSet = useMemo(() => new Set(activeMaterialIds), [activeMaterialIds]); 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 (
{toolbar ?
{toolbar}
: null}
{mode === 'editor' && onAdd ? (
) : null}
{filtered.map((m) => ( { 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 ?
{t('materials.empty')}
: null} {materials.length > 0 && filtered.length === 0 ? (
{t('materials.searchEmpty')}
) : null}
{!listOnly ? (
{selected && selectedUrl && onLegendChange ? (
{ const cur = selected.rotationDeg ?? 0; const next = ((cur + 90) % 360) as 0 | 90 | 180 | 270; onRotate(selected.id, next); } : undefined } onChange={(next) => { void onLegendChange(selected.id, next); }} />
) : (
{selected && selectedUrl ? (
) : (
{t('materials.addPrompt')}
)}
)} {selected && onRotate && !onLegendChange ? (
) : null}
) : null}
{mode === 'editor' && menuFor && menuPos ? createPortal(
, document.body, ) : null} {pendingDelete ? createPortal( <>
{t('materials.deleteConfirm', { name: pendingDelete.name })}
, document.body, ) : null} ); } 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) => void; onDragStart: () => void; onDragEnd: () => void; onDragOver: (place: 'before' | 'after') => void; onDropReorder: () => void; }) { const { t } = useEditorI18n(); const url = useAssetUrl(material.assetId); return (
{ 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(); }} > {showMenu ? ( ) : null}
); }