c7bf7cf449
Stretch key action buttons, center empty audio/image pickers, and require confirmation before deleting a scene. Co-authored-by: Cursor <cursoragent@cursor.com>
413 lines
14 KiB
TypeScript
413 lines
14 KiB
TypeScript
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;
|
||
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;
|
||
onLegendChange?: (materialId: MaterialId, legend: MaterialLegend) => Promise<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,
|
||
onLegendChange,
|
||
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 ? (
|
||
<div className={matStyles.sideAddBtn}>
|
||
<Button variant="primary" onClick={onAdd}>
|
||
{t('materials.add')}
|
||
</Button>
|
||
</div>
|
||
) : 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,
|
||
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
|
||
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>
|
||
);
|
||
}
|