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:
@@ -18,9 +18,11 @@ import { PROJECT_ZIP_EXTENSION } from '../../shared/project/projectZipExtension'
|
||||
import type {
|
||||
AssetId,
|
||||
GraphNodeId,
|
||||
MaterialId,
|
||||
MediaAsset,
|
||||
Project,
|
||||
ProjectId,
|
||||
ProjectMaterial,
|
||||
SceneAudioRef,
|
||||
SceneId,
|
||||
} from '../../shared/types';
|
||||
@@ -50,6 +52,7 @@ import {
|
||||
import type { HelpSectionId } from './help/helpSections';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals';
|
||||
import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals';
|
||||
import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml';
|
||||
import { SceneDescriptionModal } from './SceneDescriptionModal';
|
||||
import type { ProjectNoticeCode } from './state/projectState';
|
||||
@@ -138,6 +141,8 @@ export function EditorApp() {
|
||||
const [openKeyAfterEula, setOpenKeyAfterEula] = useState(false);
|
||||
const licenseActive = licenseSnap?.active === true;
|
||||
const [appNotice, setAppNotice] = useState<{ title?: string; message: string } | null>(null);
|
||||
const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false);
|
||||
const [materialEdit, setMaterialEdit] = useState<ProjectMaterial | null | 'new'>(null);
|
||||
const onProjectNotice = useCallback(
|
||||
(code: ProjectNoticeCode) => {
|
||||
const handlers: Record<ProjectNoticeCode, () => void> = {
|
||||
@@ -961,6 +966,8 @@ export function EditorApp() {
|
||||
})();
|
||||
}}
|
||||
/>
|
||||
<div className={styles.spacer6} />
|
||||
<Button onClick={() => setMaterialsManagerOpen(true)}>{t('materials.open')}</Button>
|
||||
<div className={styles.spacer18} />
|
||||
<div className={styles.inspectorTitle}>{t('scenes.inspectorScene')}</div>
|
||||
{state.selectedSceneId ? (
|
||||
@@ -1411,6 +1418,56 @@ export function EditorApp() {
|
||||
}}
|
||||
/>
|
||||
<CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(false)} />
|
||||
<MaterialsManagerModal
|
||||
open={materialsManagerOpen}
|
||||
materials={state.project?.materials ?? []}
|
||||
onClose={() => setMaterialsManagerOpen(false)}
|
||||
onAdd={() => setMaterialEdit('new')}
|
||||
onEdit={(m) => setMaterialEdit(m)}
|
||||
onDelete={async (materialId: MaterialId) => {
|
||||
try {
|
||||
await actions.deleteMaterial(materialId);
|
||||
} catch (e) {
|
||||
setAppNotice({
|
||||
title: t('common.error'),
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}}
|
||||
onReorder={async (materialIds) => {
|
||||
try {
|
||||
await actions.setMaterialsOrder(materialIds);
|
||||
} catch (e) {
|
||||
setAppNotice({
|
||||
title: t('common.error'),
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}}
|
||||
onRotate={(materialId, rotationDeg) => {
|
||||
void actions.setMaterialRotation(materialId, rotationDeg).catch((e) => {
|
||||
setAppNotice({
|
||||
title: t('common.error'),
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<MaterialEditModal
|
||||
open={materialEdit !== null}
|
||||
initial={materialEdit && materialEdit !== 'new' ? materialEdit : null}
|
||||
existingNames={(state.project?.materials ?? []).map((m) => m.name)}
|
||||
onClose={() => setMaterialEdit(null)}
|
||||
onPickImage={() => actions.pickMaterialImage()}
|
||||
onSave={async ({ name, filePath }) => {
|
||||
const materialId = materialEdit && materialEdit !== 'new' ? materialEdit.id : undefined;
|
||||
await actions.upsertMaterial({
|
||||
...(materialId ? { materialId } : {}),
|
||||
name,
|
||||
...(filePath ? { filePath } : {}),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<SimpleMessageModal
|
||||
open={appNotice !== null}
|
||||
title={appNotice?.title ?? t('common.message')}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
.managerDialog {
|
||||
width: min(960px, calc(100vw - 48px));
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.browserRoot {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.browserRootFill {
|
||||
height: 100%;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.browserRootListOnly {
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.browserToolbar {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.browserToolbarRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.browserToolbarRow > * {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.browserToolbarHint {
|
||||
color: var(--text2);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
padding: 0 2px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.managerBody {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
gap: 14px;
|
||||
/* ~3 плитки по высоте + поиск/кнопка; дальше скролл в списке */
|
||||
min-height: 560px;
|
||||
max-height: min(78vh, 720px);
|
||||
}
|
||||
|
||||
.managerBodyFill {
|
||||
max-height: none;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.managerBodyListOnly {
|
||||
grid-template-columns: 1fr;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
overflow: auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 12px;
|
||||
background: var(--color-overlay-dark-2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tileSelected {
|
||||
border-color: var(--color-accent, #a78bfa);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-accent, #a78bfa) 45%, transparent);
|
||||
}
|
||||
|
||||
.tileActive {
|
||||
outline: 1px solid color-mix(in srgb, var(--color-accent, #c9a227) 70%, transparent);
|
||||
}
|
||||
|
||||
.tileDragging {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.tileDropBefore::before,
|
||||
.tileDropAfter::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
height: 2px;
|
||||
background: var(--color-accent, #a78bfa);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.tileDropBefore::before {
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
.tileDropAfter::after {
|
||||
bottom: -1px;
|
||||
}
|
||||
|
||||
.tileBody {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.tileImg,
|
||||
.tileImgEmpty {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
border-radius: 8px;
|
||||
background: var(--color-overlay-dark-4);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tileName {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tileMenu {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
padding: 8px 10px;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.tileMenu:hover {
|
||||
color: var(--text0);
|
||||
}
|
||||
|
||||
.previewColumn {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.previewPane {
|
||||
width: 100%;
|
||||
/* высота ≈ 3 плитки списка */
|
||||
height: 520px;
|
||||
min-height: 520px;
|
||||
max-height: 520px;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 14px;
|
||||
background: var(--color-overlay-dark-3);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.managerBodyFill .previewPane {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.previewLargeHost {
|
||||
position: absolute;
|
||||
inset: 16px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.previewEmpty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text2);
|
||||
font-size: var(--text-sm);
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.previewActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.imageDrop {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px dashed var(--stroke-2);
|
||||
background: var(--color-overlay-dark-2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.imageDropOver {
|
||||
border-color: var(--color-accent, #a78bfa);
|
||||
}
|
||||
|
||||
.previewThumb {
|
||||
width: 100%;
|
||||
max-height: 160px;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
background: var(--color-overlay-dark-4);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { MaterialId, ProjectMaterial } from '../../shared/types';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import styles from './EditorApp.module.css';
|
||||
import {
|
||||
filterMaterialImagePaths,
|
||||
getDroppedFileEntries,
|
||||
pickFirstMaterialImagePath,
|
||||
useFileDropZone,
|
||||
} from './fileDrop';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
import { MaterialsBrowser } from './MaterialsBrowser';
|
||||
import matStyles from './MaterialsModals.module.css';
|
||||
|
||||
function normalizeName(input: string): string {
|
||||
return input.trim().toLowerCase();
|
||||
}
|
||||
|
||||
type MaterialEditModalProps = {
|
||||
open: boolean;
|
||||
initial: ProjectMaterial | null;
|
||||
existingNames: string[];
|
||||
onClose: () => void;
|
||||
onPickImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>;
|
||||
onSave: (input: { name: string; filePath?: string }) => Promise<void>;
|
||||
};
|
||||
|
||||
export function MaterialEditModal({
|
||||
open,
|
||||
initial,
|
||||
existingNames,
|
||||
onClose,
|
||||
onPickImage,
|
||||
onSave,
|
||||
}: MaterialEditModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const [name, setName] = useState('');
|
||||
const [filePath, setFilePath] = useState<string | null>(null);
|
||||
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const existingUrl = useAssetUrl(initial?.assetId ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(initial?.name ?? '');
|
||||
setFilePath(null);
|
||||
setLocalPreviewUrl(null);
|
||||
setSaving(false);
|
||||
setError(null);
|
||||
}, [initial, open]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (localPreviewUrl?.startsWith('blob:')) URL.revokeObjectURL(localPreviewUrl);
|
||||
};
|
||||
}, [localPreviewUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, open]);
|
||||
|
||||
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
|
||||
setFilePath(path);
|
||||
setLocalPreviewUrl((prev) => {
|
||||
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
|
||||
return previewUrl;
|
||||
});
|
||||
};
|
||||
|
||||
const drop = useFileDropZone({
|
||||
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,
|
||||
});
|
||||
|
||||
const trimmed = name.trim();
|
||||
const nameOk = trimmed.length >= 1;
|
||||
const nameDup =
|
||||
nameOk &&
|
||||
existingNames.some(
|
||||
(n) => normalizeName(n) === normalizeName(trimmed) && normalizeName(n) !== normalizeName(initial?.name ?? ''),
|
||||
);
|
||||
const hasImage = Boolean(filePath) || Boolean(initial?.assetId);
|
||||
const canSave = nameOk && !nameDup && hasImage && !saving;
|
||||
const previewSrc = localPreviewUrl || existingUrl;
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalBackdrop} />
|
||||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>
|
||||
{initial ? t('materials.editTitle') : t('materials.addTitle')}
|
||||
</div>
|
||||
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.fieldLabel}>{t('materials.name')}</div>
|
||||
<Input value={name} onChange={setName} placeholder={t('materials.namePlaceholder')} />
|
||||
{!nameOk ? <div className={styles.fieldError}>{t('materials.nameRequired')}</div> : null}
|
||||
{nameDup ? <div className={styles.fieldError}>{t('materials.nameDup')}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.fieldLabel}>{t('materials.image')}</div>
|
||||
<div
|
||||
className={[matStyles.imageDrop, drop.dragOver ? matStyles.imageDropOver : ''].join(' ')}
|
||||
onDragEnter={drop.onDragEnter}
|
||||
onDragLeave={drop.onDragLeave}
|
||||
onDragOver={drop.onDragOver}
|
||||
onDrop={(e) => {
|
||||
drop.onDrop(e);
|
||||
const entries = getDroppedFileEntries(e);
|
||||
const files = e.dataTransfer?.files;
|
||||
for (let i = 0; i < entries.length; i += 1) {
|
||||
const entry = entries[i]!;
|
||||
if (!pickFirstMaterialImagePath([entry.path])) continue;
|
||||
const file = files?.[i];
|
||||
if (file) {
|
||||
setPreviewFromPathAndUrl(entry.path, URL.createObjectURL(file));
|
||||
return;
|
||||
}
|
||||
setPreviewFromPathAndUrl(entry.path, '');
|
||||
return;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{drop.dragOver ? <div className={styles.dropHintOverlay}>{t('materials.dropHint')}</div> : null}
|
||||
{previewSrc ? (
|
||||
<img className={matStyles.previewThumb} src={previewSrc} alt="" />
|
||||
) : (
|
||||
<div className={styles.muted}>{t('materials.imageEmpty')}</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
const picked = await onPickImage();
|
||||
if (!picked) return;
|
||||
setPreviewFromPathAndUrl(picked.filePath, picked.previewDataUrl);
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('materials.chooseImage')}
|
||||
</Button>
|
||||
</div>
|
||||
{!hasImage ? <div className={styles.fieldError}>{t('materials.imageRequired')}</div> : null}
|
||||
</div>
|
||||
|
||||
{error ? <div className={styles.fieldError}>{error}</div> : null}
|
||||
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={onClose} disabled={saving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!canSave}
|
||||
onClick={() => {
|
||||
if (!canSave) return;
|
||||
void (async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave(filePath ? { name: trimmed, filePath } : { name: trimmed });
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type MaterialsManagerModalProps = {
|
||||
open: boolean;
|
||||
materials: ProjectMaterial[];
|
||||
onClose: () => void;
|
||||
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;
|
||||
};
|
||||
|
||||
export function MaterialsManagerModal({
|
||||
open,
|
||||
materials,
|
||||
onClose,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReorder,
|
||||
onRotate,
|
||||
}: MaterialsManagerModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const [selectedId, setSelectedId] = useState<MaterialId | null>(null);
|
||||
const onSelect = useCallback((id: MaterialId | null) => setSelectedId(id), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSelectedId(materials[0]?.id ?? null);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalBackdrop} />
|
||||
<div role="dialog" aria-modal="true" className={[styles.modalDialog, matStyles.managerDialog].join(' ')}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('materials.managerTitle')}</div>
|
||||
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<MaterialsBrowser
|
||||
mode="editor"
|
||||
materials={materials}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
onAdd={onAdd}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onReorder={onReorder}
|
||||
onRotate={onRotate}
|
||||
/>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState, type DragEvent } from 'react'
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
|
||||
const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.aac']);
|
||||
const MATERIAL_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
||||
const PREVIEW_EXTENSIONS = new Set([
|
||||
'.png',
|
||||
'.jpg',
|
||||
@@ -46,6 +47,17 @@ export function filterAudioFilePaths(paths: string[]): string[] {
|
||||
return paths.filter((path) => AUDIO_EXTENSIONS.has(fileExtension(path)));
|
||||
}
|
||||
|
||||
export function filterMaterialImagePaths(paths: string[]): string[] {
|
||||
return paths.filter((path) => MATERIAL_IMAGE_EXTENSIONS.has(fileExtension(path)));
|
||||
}
|
||||
|
||||
export function pickFirstMaterialImagePath(paths: string[]): string | null {
|
||||
for (const path of paths) {
|
||||
if (MATERIAL_IMAGE_EXTENSIONS.has(fileExtension(path))) return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function pickFirstPreviewFilePath(paths: string[]): string | null {
|
||||
for (const path of paths) {
|
||||
if (PREVIEW_EXTENSIONS.has(fileExtension(path))) return path;
|
||||
|
||||
@@ -22,6 +22,7 @@ function minimalProject(overrides: Partial<Project>): Project {
|
||||
sceneListOrder: [],
|
||||
assets: {},
|
||||
campaignAudios: [],
|
||||
materials: [],
|
||||
currentSceneId: null,
|
||||
currentGraphNodeId: null,
|
||||
sceneGraphNodes: [],
|
||||
|
||||
@@ -8,6 +8,7 @@ export const HELP_SECTION_IDS = [
|
||||
'sideStorylines',
|
||||
'sceneProps',
|
||||
'campaignAudio',
|
||||
'materials',
|
||||
'session',
|
||||
'controlPanel',
|
||||
'transitions',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
EDITOR_LOCALE_STORAGE_KEY,
|
||||
@@ -36,6 +36,16 @@ export function EditorI18nProvider({ children }: { children: React.ReactNode })
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Другие окна Electron (пульт, материалы) подхватывают смену языка из редактора.
|
||||
useEffect(() => {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key !== EDITOR_LOCALE_STORAGE_KEY) return;
|
||||
setLocaleState(normalizeEditorLocale(e.newValue));
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}, []);
|
||||
|
||||
const t = useCallback(
|
||||
(key: string, vars?: Record<string, string | number>) => translateEditorMessage(locale, key, vars),
|
||||
[locale],
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { inferEditorLocaleFromSystem, normalizeEditorLocale } from './editorMessages';
|
||||
import { HELP_SECTION_IDS, helpSectionBodyKey, helpSectionTitleKey } from '../help/helpSections';
|
||||
|
||||
import { EDITOR_MESSAGES, inferEditorLocaleFromSystem, normalizeEditorLocale } from './editorMessages';
|
||||
|
||||
void test('inferEditorLocaleFromSystem: en-* wins when listed first', () => {
|
||||
assert.equal(inferEditorLocaleFromSystem(['en-GB', 'ru-RU']), 'en');
|
||||
@@ -28,3 +30,31 @@ void test('normalizeEditorLocale: blank or invalid defers to infer (explicit lis
|
||||
assert.equal(normalizeEditorLocale(''), inferEditorLocaleFromSystem([]));
|
||||
assert.equal(normalizeEditorLocale('xx'), inferEditorLocaleFromSystem([]));
|
||||
});
|
||||
|
||||
void test('EDITOR_MESSAGES: ru and en have the same keys', () => {
|
||||
const ruKeys = Object.keys(EDITOR_MESSAGES.ru).sort();
|
||||
const enKeys = Object.keys(EDITOR_MESSAGES.en).sort();
|
||||
assert.deepEqual(enKeys, ruKeys);
|
||||
});
|
||||
|
||||
void test('EDITOR_MESSAGES: every help section has title and body in both locales', () => {
|
||||
for (const id of HELP_SECTION_IDS) {
|
||||
const title = helpSectionTitleKey(id);
|
||||
const body = helpSectionBodyKey(id);
|
||||
for (const locale of ['ru', 'en'] as const) {
|
||||
assert.ok(EDITOR_MESSAGES[locale][title], `missing ${locale} ${title}`);
|
||||
assert.ok(EDITOR_MESSAGES[locale][body], `missing ${locale} ${body}`);
|
||||
assert.notEqual(EDITOR_MESSAGES[locale][title]!.trim(), '');
|
||||
assert.notEqual(EDITOR_MESSAGES[locale][body]!.trim(), '');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('EDITOR_MESSAGES: materials.* keys exist in both locales', () => {
|
||||
const materialKeys = Object.keys(EDITOR_MESSAGES.ru).filter((k) => k.startsWith('materials.'));
|
||||
assert.ok(materialKeys.length >= 20, `expected materials.* keys, got ${String(materialKeys.length)}`);
|
||||
for (const key of materialKeys) {
|
||||
assert.ok(EDITOR_MESSAGES.en[key], `missing en ${key}`);
|
||||
assert.notEqual(EDITOR_MESSAGES.en[key]!.trim(), '');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -175,13 +175,17 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'help.section.campaignAudio.body':
|
||||
'«Аудио игры» в блоке «Свойства игры» — музыка всей кампании: тема, фон, атмосфера. Она не привязана к одной сцене.\n\n1) Нажмите «Загрузить» и выберите файлы.\n\n2) Для каждого трека отметьте «Авто» и «Цикл» по желанию.\n\n3) Удалить трек — иконка корзины.\n\nНа пульте музыка сцены важнее общей: пока играет трек сцены, кампанийная музыка приглушается. Когда у сцены нет своего звука или вы переключитесь вручную — общая музыка снова может играть.',
|
||||
|
||||
'help.section.materials.title': 'Материалы',
|
||||
'help.section.materials.body':
|
||||
'Материалы — изображения кампании (карты, записки, чертежи), которые можно показать игрокам поверх сцены во время игры. Они общие для проекта и не привязаны к одной сцене.\n\nВ редакторе:\n\n1) В блоке «Свойства игры» нажмите «Материалы».\n\n2) «Добавить» — укажите уникальное название и изображение (PNG, JPG или WebP): кнопка выбора или перетаскивание файла.\n\n3) В списке можно искать, менять порядок перетаскиванием, править или удалять через меню «⋮» (перед удалением будет подтверждение).\n\n4) Под большим превью — «Повернуть»: поворот на 90° (учитывается и в плитке, и при показе на экране).\n\nВо время сессии:\n\n1) На пульте в «Инструменты» нажмите кнопку материалов (иконка карты сокровищ) — откроется отдельное окно со списком.\n\n2) Клик по плитке показывает материал поверх сцены на пульте и на презентации; повторный клик по той же плитке скрывает его.\n\n3) На предпросмотре пульта материал можно перетаскивать и менять размер за углы; крестик закрывает показ.\n\n4) В окне материалов лупы «+» / «−» — инструменты масштаба: выберите лупу, затем кликните по материалу в предпросмотре пульта.\n\nПри смене сцены показ материала сбрасывается. Описание сцены и эффекты поля с материалами не связаны.',
|
||||
|
||||
'help.section.session.title': 'Запуск сессии',
|
||||
'help.section.session.body':
|
||||
'Когда кампания готова, можно начать игру.\n\nОбычный запуск:\n\n1) На карте связей щёлкните правой кнопкой по карточке старта → «Начальная сцена».\n\n2) Нажмите «Запустить» в шапке редактора.\n\nБыстрый запуск с любой карточки: правый клик по нужной карточке на карте → «Запустить с этой сцены». Презентация и пульт откроются сразу с выбранного места.\n\nОткроются «Презентация» (для игроков) и «Пульт управления» (для вас). Редактор на время показа затемняется — так и должно быть.\n\nВернуться к подготовке:\n\n1) На пульте нажмите «Выключить демонстрацию» или «Завершить показ» (если дальше некуда переходить).\n\n2) Дождитесь закрытия обоих окон.\n\nОкно «Презентация» перенесите на второй монитор, проектор или ТВ и разверните на весь экран (F11). Игроки увидят только картинку, видео и эффекты — без ваших кнопок.',
|
||||
|
||||
'help.section.controlPanel.title': 'Пульт управления',
|
||||
'help.section.controlPanel.body':
|
||||
'Пульт — ваш стол во время игры. Игроки смотрят на презентацию, вы управляете всем отсюда.\n\nСлева сверху — «Инструменты», затем эффекты и ход сюжета; справа — мини-копия экрана игроков, варианты переходов и музыка.\n\nВ «Инструменты» кнопка с книгой открывает отдельное окно с оформленным описанием текущей сцены. Если описания нет, кнопка неактивна — при наведении подсказка «Описание отсутствует».\n\n«Предпросмотр экрана» показывает то же, что видят игроки. На сценах с картинкой здесь же рисуют эффекты — они сразу появляются на большом экране.\n\n«Сюжетная линия» — цепочка пройденных эпизодов. Текущая сцена помечена «ТЕКУЩАЯ СЦЕНА». Клик по прошлому шагу переносит партию к тому месту на карте и добавляет новый шаг в историю.\n\n«Выключить демонстрацию» — закончить показ и вернуться в редактор.',
|
||||
'Пульт — ваш стол во время игры. Игроки смотрят на презентацию, вы управляете всем отсюда.\n\nСлева сверху — «Инструменты», затем эффекты и ход сюжета; справа — мини-копия экрана игроков, варианты переходов и музыка.\n\nВ «Инструменты» кнопка с книгой открывает отдельное окно с оформленным описанием текущей сцены. Если описания нет, кнопка неактивна — при наведении подсказка «Описание отсутствует». Кнопка материалов (иконка карты) открывает окно со списком материалов кампании — подробнее в разделе «Материалы».\n\n«Предпросмотр экрана» показывает то же, что видят игроки. На сценах с картинкой здесь же рисуют эффекты — они сразу появляются на большом экране.\n\n«Сюжетная линия» — цепочка пройденных эпизодов. Текущая сцена помечена «ТЕКУЩАЯ СЦЕНА». Клик по прошлому шагу переносит партию к тому месту на карте и добавляет новый шаг в историю.\n\n«Выключить демонстрацию» — закончить показ и вернуться в редактор.',
|
||||
|
||||
'help.section.transitions.title': 'Переходы между сценами',
|
||||
'help.section.transitions.body':
|
||||
@@ -326,6 +330,36 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'drop.hintAudio': 'Перетащите аудиофайлы сюда',
|
||||
'drop.hintPreview': 'Перетащите изображение или видео',
|
||||
|
||||
'materials.open': 'Материалы',
|
||||
'materials.managerTitle': 'Материалы',
|
||||
'materials.add': 'Добавить',
|
||||
'materials.addTitle': 'Новый материал',
|
||||
'materials.editTitle': 'Изменить материал',
|
||||
'materials.edit': 'Изменить',
|
||||
'materials.search': 'Поиск материалов…',
|
||||
'materials.searchEmpty': 'Ничего не найдено.',
|
||||
'materials.empty': 'Материалов пока нет.',
|
||||
'materials.addPrompt': 'Добавьте материал',
|
||||
'materials.name': 'НАЗВАНИЕ',
|
||||
'materials.namePlaceholder': 'Название материала…',
|
||||
'materials.nameRequired': 'Укажите название.',
|
||||
'materials.nameDup': 'Материал с таким названием уже есть.',
|
||||
'materials.image': 'ИЗОБРАЖЕНИЕ',
|
||||
'materials.imageEmpty': 'Изображение не выбрано',
|
||||
'materials.imageRequired': 'Выберите изображение.',
|
||||
'materials.chooseImage': 'Выбрать изображение',
|
||||
'materials.dropHint': 'Перетащите изображение (PNG, JPG, WebP)',
|
||||
'materials.tileMenu': 'Меню материала',
|
||||
'materials.windowEmpty': 'Добавьте материалы в редакторе.',
|
||||
'materials.closeOverlay': 'Закрыть материал',
|
||||
'materials.deleteTitle': 'Удаление материала',
|
||||
'materials.deleteConfirm': 'Вы уверены, что хотите удалить материал «{name}»?',
|
||||
'materials.zoomIn': 'Увеличить',
|
||||
'materials.zoomOut': 'Уменьшить',
|
||||
'materials.zoomInHint': 'Кликните по материалу в предпросмотре пульта, чтобы увеличить.',
|
||||
'materials.zoomOutHint': 'Кликните по материалу в предпросмотре пульта, чтобы уменьшить.',
|
||||
'materials.zoomIdleHint': 'Выберите лупу, затем кликните по материалу в предпросмотре пульта.',
|
||||
|
||||
'scene.title': 'НАЗВАНИЕ СЦЕНЫ',
|
||||
'scene.description': 'ОПИСАНИЕ',
|
||||
'scene.descriptionEmpty': 'описание отсутствует',
|
||||
@@ -388,6 +422,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.remoteTitle': 'ПУЛЬТ УПРАВЛЕНИЯ',
|
||||
'control.instruments': 'ИНСТРУМЕНТЫ',
|
||||
'control.descriptionTool': 'Описание',
|
||||
'control.materialsTool': 'Материалы',
|
||||
'control.descriptionMissing': 'Описание отсутствует',
|
||||
'control.effects': 'ЭФФЕКТЫ',
|
||||
'control.tools': 'Очистка',
|
||||
@@ -579,13 +614,17 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'help.section.campaignAudio.body':
|
||||
'Game audio under Game properties is music for the whole campaign: theme, ambience, background. It is not tied to one scene.\n\n1) Click Upload and choose files.\n\n2) Set Auto and Loop per track as you like.\n\n3) Remove a track with the trash icon.\n\nOn the control panel, scene music comes first: while a scene track plays, campaign music pauses. When the scene has no track or you take manual control, campaign music can play again.',
|
||||
|
||||
'help.section.materials.title': 'Materials',
|
||||
'help.section.materials.body':
|
||||
'Materials are campaign images (maps, notes, sketches) you can show players on top of the scene during play. They belong to the project and are not tied to one scene.\n\nIn the editor:\n\n1) Under Game properties, click Materials.\n\n2) Add — enter a unique name and an image (PNG, JPG, or WebP) via Choose image or by dropping a file.\n\n3) In the list you can search, reorder by drag-and-drop, and edit or delete via the ⋮ menu (delete asks for confirmation).\n\n4) Under the large preview, Rotate turns the image by 90° (applied in the tile and when shown on screen).\n\nDuring a session:\n\n1) On the control panel under Tools, click the materials button (treasure-map icon) to open a separate window with the list.\n\n2) Click a tile to show the material over the scene on the control preview and presentation; click the same tile again to hide it.\n\n3) On the control preview you can drag the material and resize it from the corners; the × button closes the overlay.\n\n4) In the materials window, the + / − magnifiers are zoom tools: pick one, then click the material on the control preview.\n\nChanging scenes clears the material overlay. Scene description and field effects are separate from materials.',
|
||||
|
||||
'help.section.session.title': 'Starting a session',
|
||||
'help.section.session.body':
|
||||
'When your campaign is ready, you can start playing.\n\nStandard start:\n\n1) On the story map, right-click the starting card → Start scene.\n\n2) Click Run in the editor header.\n\nQuick start from any card: right-click the card on the map → Start from this scene. Presentation and the control panel open at that spot right away.\n\nPresentation (for players) and the Control panel (for you) open. The editor dims while the show runs — that is expected.\n\nReturn to prep:\n\n1) On the control panel, click Stop presentation or End presentation (when there is nowhere left to go).\n\n2) Wait until both windows close.\n\nMove the Presentation window to a second monitor, projector, or TV and go fullscreen (F11). Players see only the image, video, and effects — not your buttons.',
|
||||
|
||||
'help.section.controlPanel.title': 'Control panel',
|
||||
'help.section.controlPanel.body':
|
||||
'The control panel is your desk during play. Players watch presentation; you run everything from here.\n\nTop-left: Tools, then effects and storyline. On the right: a mini copy of the player screen, branch options, and music.\n\nUnder Tools, the book button opens a separate window with the current scene’s formatted description. If there is no description, the button is disabled — the tooltip reads “No description”.\n\nScreen preview shows what players see. On image scenes, paint effects here — they appear on the big screen right away.\n\nStoryline lists episodes you have visited. The current scene is marked CURRENT SCENE. Click an earlier step to move the party back to that spot and add a new history entry.\n\nStop presentation ends the show and unlocks the editor.',
|
||||
'The control panel is your desk during play. Players watch presentation; you run everything from here.\n\nTop-left: Tools, then effects and storyline. On the right: a mini copy of the player screen, branch options, and music.\n\nUnder Tools, the book button opens a separate window with the current scene’s formatted description. If there is no description, the button is disabled — the tooltip reads “No description”. The materials button (map icon) opens a window with the campaign materials list — see the Materials section for details.\n\nScreen preview shows what players see. On image scenes, paint effects here — they appear on the big screen right away.\n\nStoryline lists episodes you have visited. The current scene is marked CURRENT SCENE. Click an earlier step to move the party back to that spot and add a new history entry.\n\nStop presentation ends the show and unlocks the editor.',
|
||||
|
||||
'help.section.transitions.title': 'Scene transitions',
|
||||
'help.section.transitions.body':
|
||||
@@ -731,6 +770,36 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'drop.hintAudio': 'Drop audio files here',
|
||||
'drop.hintPreview': 'Drop an image or video',
|
||||
|
||||
'materials.open': 'Materials',
|
||||
'materials.managerTitle': 'Materials',
|
||||
'materials.add': 'Add',
|
||||
'materials.addTitle': 'New material',
|
||||
'materials.editTitle': 'Edit material',
|
||||
'materials.edit': 'Edit',
|
||||
'materials.search': 'Search materials…',
|
||||
'materials.searchEmpty': 'No matches.',
|
||||
'materials.empty': 'No materials yet.',
|
||||
'materials.addPrompt': 'Add a material',
|
||||
'materials.name': 'NAME',
|
||||
'materials.namePlaceholder': 'Material name…',
|
||||
'materials.nameRequired': 'Name is required.',
|
||||
'materials.nameDup': 'A material with this name already exists.',
|
||||
'materials.image': 'IMAGE',
|
||||
'materials.imageEmpty': 'No image selected',
|
||||
'materials.imageRequired': 'Choose an image.',
|
||||
'materials.chooseImage': 'Choose image',
|
||||
'materials.dropHint': 'Drop an image (PNG, JPG, WebP)',
|
||||
'materials.tileMenu': 'Material menu',
|
||||
'materials.windowEmpty': 'Add materials in the editor.',
|
||||
'materials.closeOverlay': 'Close material',
|
||||
'materials.deleteTitle': 'Delete material',
|
||||
'materials.deleteConfirm': 'Are you sure you want to delete material “{name}”?',
|
||||
'materials.zoomIn': 'Zoom in',
|
||||
'materials.zoomOut': 'Zoom out',
|
||||
'materials.zoomInHint': 'Click the material on the control preview to zoom in.',
|
||||
'materials.zoomOutHint': 'Click the material on the control preview to zoom out.',
|
||||
'materials.zoomIdleHint': 'Pick a magnifier, then click the material on the control preview.',
|
||||
|
||||
'scene.title': 'SCENE TITLE',
|
||||
'scene.description': 'DESCRIPTION',
|
||||
'scene.descriptionEmpty': 'no description',
|
||||
@@ -792,6 +861,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.remoteTitle': 'CONTROL PANEL',
|
||||
'control.instruments': 'TOOLS',
|
||||
'control.descriptionTool': 'Description',
|
||||
'control.materialsTool': 'Materials',
|
||||
'control.descriptionMissing': 'No description',
|
||||
'control.effects': 'EFFECTS',
|
||||
'control.tools': 'Cleanup',
|
||||
|
||||
@@ -8,7 +8,15 @@ import type {
|
||||
StorylineListItem,
|
||||
StorylineSelection,
|
||||
} from '../../../shared/graph/storylineExportImport';
|
||||
import type { AssetId, GraphNodeId, Project, ProjectId, Scene, SceneId } from '../../../shared/types';
|
||||
import type {
|
||||
AssetId,
|
||||
GraphNodeId,
|
||||
MaterialId,
|
||||
Project,
|
||||
ProjectId,
|
||||
Scene,
|
||||
SceneId,
|
||||
} from '../../../shared/types';
|
||||
import { getDndApi } from '../../shared/dndApi';
|
||||
|
||||
type ProjectSummary = { id: ProjectId; name: string; updatedAt: string; fileName: string };
|
||||
@@ -40,6 +48,15 @@ type Actions = {
|
||||
importCampaignAudio: () => Promise<void>;
|
||||
importCampaignAudioFromPaths: (filePaths: string[]) => Promise<void>;
|
||||
updateCampaignAudios: (next: Project['campaignAudios']) => Promise<void>;
|
||||
upsertMaterial: (input: {
|
||||
materialId?: MaterialId;
|
||||
name: string;
|
||||
filePath?: string;
|
||||
}) => Promise<void>;
|
||||
deleteMaterial: (materialId: MaterialId) => Promise<void>;
|
||||
setMaterialsOrder: (materialIds: MaterialId[]) => Promise<void>;
|
||||
setMaterialRotation: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => Promise<void>;
|
||||
pickMaterialImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>;
|
||||
updateScene: (
|
||||
sceneId: SceneId,
|
||||
patch: {
|
||||
@@ -469,6 +486,46 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const upsertMaterial = async (input: {
|
||||
materialId?: MaterialId;
|
||||
name: string;
|
||||
filePath?: string;
|
||||
}) => {
|
||||
const res = await api.invoke(ipcChannels.project.upsertMaterial, input);
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const deleteMaterial = async (materialId: MaterialId) => {
|
||||
const res = await api.invoke(ipcChannels.project.deleteMaterial, { materialId });
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const setMaterialsOrder = async (materialIds: MaterialId[]) => {
|
||||
const res = await api.invoke(ipcChannels.project.setMaterialsOrder, { materialIds });
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const setMaterialRotation = async (
|
||||
materialId: MaterialId,
|
||||
rotationDeg: 0 | 90 | 180 | 270,
|
||||
) => {
|
||||
const res = await api.invoke(ipcChannels.project.setMaterialRotation, {
|
||||
materialId,
|
||||
rotationDeg,
|
||||
});
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const pickMaterialImage = async () => {
|
||||
const res = await api.invoke(ipcChannels.project.pickMaterialImage, {});
|
||||
if (res.canceled) return null;
|
||||
return { filePath: res.filePath, previewDataUrl: res.previewDataUrl };
|
||||
};
|
||||
|
||||
const updateScene = async (
|
||||
sceneId: SceneId,
|
||||
patch: {
|
||||
@@ -802,6 +859,11 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
importCampaignAudio,
|
||||
importCampaignAudioFromPaths,
|
||||
updateCampaignAudios,
|
||||
upsertMaterial,
|
||||
deleteMaterial,
|
||||
setMaterialsOrder,
|
||||
setMaterialRotation,
|
||||
pickMaterialImage,
|
||||
updateScene,
|
||||
updateConnections,
|
||||
importMediaToScene,
|
||||
|
||||
Reference in New Issue
Block a user