Files
DndGamePlayer/app/renderer/editor/MaterialsBrowser.tsx
T
Ivan Fontosh 2979d06f1c feat: multi-material overlays, presentation guides, and release prebuilds
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>
2026-08-03 11:28:18 +08:00

424 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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,
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<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 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 (
<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={activeSet.has(m.id)}
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}
rotateLabel={t('scene.rotate')}
onRotate={
onRotate
? () => {
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);
}}
/>
</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 && !onLegendChange ? (
<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>
);
}