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
+116 -3
View File
@@ -1,5 +1,9 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { app, BrowserWindow, dialog, Menu, protocol } from 'electron';
import { openDialogFilterLabel } from '../shared/appBranding';
import { ipcChannels, type ScenePreviewImportEvent, type SessionState } from '../shared/ipc/contracts';
import {
PROJECT_ZIP_OPEN_DIALOG_FILTER,
@@ -15,6 +19,7 @@ import { EffectsStore } from './effects/effectsStore';
import { SceneDarknessStore } from './effects/sceneDarknessStore';
import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/router';
import { LicenseService } from './license/licenseService';
import { MaterialsOverlayStore } from './materials/materialsOverlayStore';
import { ZipProjectStore } from './project/zipStore';
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
import { installAutoUpdater } from './update/installAutoUpdater';
@@ -36,8 +41,10 @@ import {
getSceneDescriptionContent,
isMultiWindowOpen,
markAppQuitting,
openMaterialsWindow,
openMultiWindow,
openSceneDescriptionWindow,
closeMaterialsWindow,
togglePresentationFullscreen,
waitForEditorWindowReady,
} from './windows/createWindows';
@@ -130,6 +137,7 @@ function installAppMenuForSession(): void {
const effectsStore = new EffectsStore();
const sceneDarknessStore = new SceneDarknessStore();
const videoStore = new VideoPlaybackStore();
const materialsOverlayStore = new MaterialsOverlayStore();
function emitEffectsState(): void {
const state = effectsStore.getState();
@@ -138,6 +146,22 @@ function emitEffectsState(): void {
}
}
function emitMaterialsOverlayState(): void {
const state = materialsOverlayStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.materialsOverlay.stateChanged, { state });
}
}
function syncMaterialsOverlayWithProject(project: Project | null): void {
if (!project) {
materialsOverlayStore.clear();
return;
}
const ids = new Set((project.materials ?? []).map((m) => m.id));
materialsOverlayStore.ensureMaterialStillExists(ids);
}
function emitSceneDarknessState(): void {
const state = sceneDarknessStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
@@ -196,6 +220,7 @@ async function runStartupAfterHandlers(licenseService: LicenseService): Promise<
createWindows();
emitSessionState();
emitEffectsState();
emitMaterialsOverlayState();
emitVideoState();
return;
}
@@ -209,6 +234,7 @@ async function runStartupAfterHandlers(licenseService: LicenseService): Promise<
createWindows();
emitSessionState();
emitEffectsState();
emitMaterialsOverlayState();
emitVideoState();
return;
}
@@ -246,6 +272,7 @@ async function runStartupAfterHandlers(licenseService: LicenseService): Promise<
emitSessionState();
emitEffectsState();
emitMaterialsOverlayState();
emitVideoState();
}
@@ -322,6 +349,23 @@ async function main() {
registerHandler(ipcChannels.windows.getSceneDescriptionContent, () => {
return { html: getSceneDescriptionContent() };
});
registerHandler(ipcChannels.windows.openMaterials, () => {
openMaterialsWindow();
return { ok: true };
});
registerHandler(ipcChannels.windows.closeMaterials, () => {
closeMaterialsWindow();
return { ok: true };
});
registerHandler(ipcChannels.materialsOverlay.getState, () => {
return { state: materialsOverlayStore.getState() };
});
registerHandler(ipcChannels.materialsOverlay.dispatch, ({ event }) => {
materialsOverlayStore.dispatch(event);
emitMaterialsOverlayState();
return { ok: true };
});
registerHandler(ipcChannels.project.list, async () => {
const projects = await projectStore.listProjects();
@@ -347,8 +391,10 @@ async function main() {
registerHandler(ipcChannels.project.close, async () => {
await projectStore.closeOpenProject();
effectsStore.clear();
materialsOverlayStore.clear();
sceneDarknessStore.resetSession();
emitEffectsState();
emitMaterialsOverlayState();
emitSceneDarknessState();
emitSessionState();
return { ok: true };
@@ -363,9 +409,11 @@ async function main() {
registerHandler(ipcChannels.project.setCurrentScene, async ({ sceneId }) => {
await projectStore.updateProject((p) => ({ ...p, currentSceneId: sceneId, currentGraphNodeId: null }));
effectsStore.clear();
materialsOverlayStore.clear();
const project = projectStore.getOpenProject();
if (project) syncSceneDarknessForProject(project);
emitEffectsState();
emitMaterialsOverlayState();
emitSceneDarknessState();
emitSessionState();
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
@@ -380,9 +428,11 @@ async function main() {
currentSceneId: gn ? gn.sceneId : null,
}));
effectsStore.clear();
materialsOverlayStore.clear();
const project = projectStore.getOpenProject();
if (project) syncSceneDarknessForProject(project);
emitEffectsState();
emitMaterialsOverlayState();
emitSceneDarknessState();
emitSessionState();
const p = projectStore.getOpenProject();
@@ -413,7 +463,7 @@ async function main() {
properties: ['openFile', 'multiSelections'],
filters: [
{
name: 'Видео и аудио',
name: openDialogFilterLabel('videoAndAudio', app.getLocale()),
extensions: ['mp4', 'webm', 'mov', 'mp3', 'wav', 'ogg', 'm4a', 'aac'],
},
],
@@ -436,7 +486,7 @@ async function main() {
properties: ['openFile', 'multiSelections'],
filters: [
{
name: 'Аудио',
name: openDialogFilterLabel('audio', app.getLocale()),
extensions: ['mp3', 'wav', 'ogg', 'm4a', 'aac'],
},
],
@@ -457,6 +507,69 @@ async function main() {
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.upsertMaterial, async ({ materialId, name, filePath: pathFromDrop }) => {
let filePath = pathFromDrop;
if (!filePath && !materialId) {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: openDialogFilterLabel('images', app.getLocale()),
extensions: ['png', 'jpg', 'jpeg', 'webp'],
},
],
});
if (canceled || filePaths.length === 0) {
throw new Error('Material image is required');
}
filePath = filePaths[0];
}
const project = await projectStore.upsertMaterial({
...(materialId ? { materialId } : {}),
name,
...(filePath ? { filePath } : {}),
});
syncMaterialsOverlayWithProject(project);
emitMaterialsOverlayState();
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.deleteMaterial, async ({ materialId }) => {
const project = await projectStore.deleteMaterial(materialId);
syncMaterialsOverlayWithProject(project);
emitMaterialsOverlayState();
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.setMaterialsOrder, async ({ materialIds }) => {
const project = await projectStore.setMaterialsOrder(materialIds);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.setMaterialRotation, async ({ materialId, rotationDeg }) => {
const project = await projectStore.setMaterialRotation(materialId, rotationDeg);
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.pickMaterialImage, async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: openDialogFilterLabel('images', app.getLocale()),
extensions: ['png', 'jpg', 'jpeg', 'webp'],
},
],
});
if (canceled || filePaths.length === 0) return { canceled: true as const };
const filePath = filePaths[0]!;
const buf = await fs.readFile(filePath);
const ext = path.extname(filePath).toLowerCase();
const mime =
ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
return { canceled: false as const, filePath, previewDataUrl };
});
registerHandler(ipcChannels.project.importScenePreview, async ({ sceneId, filePath: pathFromDrop }) => {
let filePath = pathFromDrop;
if (!filePath) {
@@ -464,7 +577,7 @@ async function main() {
properties: ['openFile'],
filters: [
{
name: 'Изображения и видео',
name: openDialogFilterLabel('imagesAndVideo', app.getLocale()),
extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'mp4', 'webm', 'mov'],
},
],
+1
View File
@@ -19,6 +19,7 @@ function channelRequiresLicense(channel: string): boolean {
if (channel.startsWith('app.')) return false;
if (channel === ipcChannels.windows.closeMultiWindow) return false;
if (channel === ipcChannels.windows.closeSceneDescription) return false;
if (channel === ipcChannels.windows.closeMaterials) return false;
if (channel === ipcChannels.windows.togglePresentationFullscreen) return false;
// Список файлов в %userData%/projects — только чтение; без лицензии список не должен «пропадать».
if (channel === ipcChannels.project.list) return false;
+109
View File
@@ -0,0 +1,109 @@
import {
clampMaterialsLayout,
DEFAULT_MATERIALS_OVERLAY_LAYOUT,
type MaterialId,
type MaterialsOverlayEvent,
type MaterialsOverlayLayout,
type MaterialsOverlayState,
type MaterialsZoomTool,
zoomMaterialsLayoutAt,
} from '../../shared/types';
function emptyState(): MaterialsOverlayState {
return {
revision: 1,
activeMaterialId: null,
layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT },
zoomTool: null,
};
}
export class MaterialsOverlayStore {
private state: MaterialsOverlayState = emptyState();
getState(): MaterialsOverlayState {
return this.state;
}
clear(): MaterialsOverlayState {
if (this.state.activeMaterialId === null && this.state.zoomTool === null) {
return this.state;
}
this.state = {
revision: this.state.revision + 1,
activeMaterialId: null,
layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT },
zoomTool: null,
};
return this.state;
}
dispatch(event: MaterialsOverlayEvent): MaterialsOverlayState {
switch (event.kind) {
case 'hide':
return this.clear();
case 'show':
this.state = {
revision: this.state.revision + 1,
activeMaterialId: event.materialId,
layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT },
zoomTool: this.state.zoomTool,
};
return this.state;
case 'toggle': {
if (this.state.activeMaterialId === event.materialId) {
return this.clear();
}
this.state = {
revision: this.state.revision + 1,
activeMaterialId: event.materialId,
layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT },
zoomTool: this.state.zoomTool,
};
return this.state;
}
case 'layout.set': {
if (this.state.activeMaterialId === null) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
layout: clampMaterialsLayout(event.layout),
};
return this.state;
}
case 'zoomTool.set': {
const tool: MaterialsZoomTool = event.tool;
this.state = {
...this.state,
revision: this.state.revision + 1,
zoomTool: tool,
};
return this.state;
}
case 'zoomAt': {
if (this.state.activeMaterialId === null || !this.state.zoomTool) return this.state;
const factor = this.state.zoomTool === 'zoomIn' ? 1.25 : 1 / 1.25;
const layout: MaterialsOverlayLayout = zoomMaterialsLayoutAt(
this.state.layout,
event.nx,
event.ny,
factor,
);
this.state = {
...this.state,
revision: this.state.revision + 1,
layout,
};
return this.state;
}
default:
return this.state;
}
}
ensureMaterialStillExists(materialIds: ReadonlySet<MaterialId>): MaterialsOverlayState {
const active = this.state.activeMaterialId;
if (active === null || materialIds.has(active)) return this.state;
return this.clear();
}
}
+5 -2
View File
@@ -22,9 +22,10 @@ void test('collectReferencedAssetIds: превью, видео и аудио', (
},
},
campaignAudios: [{ assetId: 'ca1' as AssetId, autoplay: true, loop: true }],
materials: [{ id: 'm1', name: 'Map', assetId: 'mat1' as AssetId }],
} as unknown as Project;
const s = collectReferencedAssetIds(p);
assert.deepEqual([...s].sort(), ['a1', 'ca1', 'pr', 'th', 'v1'].sort());
assert.deepEqual([...s].sort(), ['a1', 'ca1', 'mat1', 'pr', 'th', 'v1'].sort());
});
void test('reconcileAssetFiles: снимает осиротевшие assets и удаляет файлы', async () => {
@@ -65,12 +66,14 @@ void test('reconcileAssetFiles: снимает осиротевшие assets и
...base,
scenes: {},
campaignAudios: [],
materials: [],
assets: { orphan: asset } as Project['assets'],
};
const next: Project = {
...base,
scenes: {},
campaignAudios: [],
materials: [],
assets: { orphan: asset } as Project['assets'],
};
@@ -115,7 +118,7 @@ void test('reconcileAssetFiles: удаляет файл при исключен
} as unknown as Project;
const prev: Project = { ...base, assets: { gone: asset } as Project['assets'] };
const next: Project = { ...base, campaignAudios: [], assets: {} as Project['assets'] };
const next: Project = { ...base, campaignAudios: [], materials: [], assets: {} as Project['assets'] };
const out = await reconcileAssetFiles(prev, next, tmp);
assert.deepEqual(out.assets, {});
+1
View File
@@ -14,6 +14,7 @@ export function collectReferencedAssetIds(p: Project): Set<AssetId> {
for (const au of sc.media.audios) refs.add(au.assetId);
}
for (const au of p.campaignAudios) refs.add(au.assetId);
for (const m of p.materials ?? []) refs.add(m.assetId);
return refs;
}
+151 -2
View File
@@ -43,8 +43,8 @@ import type {
SceneId,
} from '../../shared/types';
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
import type { AssetId, GraphNodeId } from '../../shared/types/ids';
import { asAssetId, asGraphNodeId, asProjectId } from '../../shared/types/ids';
import type { AssetId, GraphNodeId, MaterialId } from '../../shared/types/ids';
import { asAssetId, asGraphNodeId, asMaterialId, asProjectId } from '../../shared/types/ids';
import { getAppSemanticVersion } from '../versionInfo';
import { reconcileAssetFiles } from './assetPrune';
@@ -225,6 +225,7 @@ export class ZipProjectStore {
sceneListOrder: [],
assets: {},
campaignAudios: [],
materials: [],
currentSceneId: null,
currentGraphNodeId: null,
sceneGraphNodes: [],
@@ -1002,6 +1003,137 @@ export class ZipProjectStore {
return latest;
}
/**
* Создаёт или обновляет материал кампании.
* При создании `filePath` обязателен; при обновлении можно сменить только имя или только картинку.
*/
async upsertMaterial(input: {
materialId?: MaterialId;
name: string;
filePath?: string;
}): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const name = input.name.trim();
if (name.length < 1) throw new Error('Material name is required');
const nameKey = name.toLowerCase();
const existing = open.project.materials ?? [];
const editingId = input.materialId ?? null;
if (existing.some((m) => m.id !== editingId && m.name.trim().toLowerCase() === nameKey)) {
throw new Error('Material name already exists');
}
let nextAssetId: AssetId | null = null;
let stagedAsset: MediaAsset | null = null;
if (input.filePath) {
const kind = classifyMediaPath(input.filePath);
if (kind?.type !== 'image') throw new Error('Material must be an image (png/jpg/webp)');
const ext = path.extname(input.filePath).toLowerCase();
if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
throw new Error('Material must be an image (png/jpg/webp)');
}
let buf = await fs.readFile(input.filePath);
try {
const opt = await optimizeImageBufferVisuallyLossless(buf);
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
} catch {
// keep original buffer
}
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
const id = asAssetId(this.randomId());
const orig = path.basename(input.filePath);
const safeOrig = sanitizeFileName(orig);
const relPath = `assets/${id}_${safeOrig}`;
const abs = path.join(open.cacheDir, relPath);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, buf);
stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
nextAssetId = id;
}
await this.updateProject((p) => {
const materials = [...(p.materials ?? [])];
const assets = { ...p.assets };
if (stagedAsset) assets[stagedAsset.id] = stagedAsset;
if (editingId) {
const idx = materials.findIndex((m) => m.id === editingId);
if (idx < 0) throw new Error('Material not found');
const prev = materials[idx]!;
const assetId = nextAssetId ?? prev.assetId;
materials[idx] = {
id: editingId,
name,
assetId,
rotationDeg: prev.rotationDeg ?? 0,
};
} else {
if (!nextAssetId) throw new Error('Material image is required');
materials.push({
id: asMaterialId(`mat_${this.randomId()}`),
name,
assetId: nextAssetId,
rotationDeg: 0,
});
}
return { ...p, assets, materials };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async setMaterialRotation(
materialId: MaterialId,
rotationDeg: 0 | 90 | 180 | 270,
): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => {
const materials = (p.materials ?? []).map((m) =>
m.id === materialId ? { ...m, rotationDeg } : m,
);
return { ...p, materials };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async deleteMaterial(materialId: MaterialId): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => ({
...p,
materials: (p.materials ?? []).filter((m) => m.id !== materialId),
}));
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async setMaterialsOrder(materialIds: MaterialId[]): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => {
const byId = new Map((p.materials ?? []).map((m) => [m.id, m]));
const next: NonNullable<Project['materials']> = [];
for (const id of materialIds) {
const m = byId.get(id);
if (m) {
next.push(m);
byId.delete(id);
}
}
for (const m of byId.values()) next.push(m);
return { ...p, materials: next };
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
}
async saveNow(): Promise<void> {
const open = this.openProject;
if (!open) return;
@@ -1717,6 +1849,22 @@ function normalizeProject(p: Project): Project {
return null;
})
.filter((x): x is { assetId: AssetId; autoplay: boolean; loop: boolean } => Boolean(x));
const rawMaterials = (p as unknown as { materials?: unknown[] }).materials;
const materials = (Array.isArray(rawMaterials) ? rawMaterials : [])
.map((m) => {
if (!m || typeof m !== 'object') return null;
const obj = m as { id?: string; name?: string; assetId?: AssetId; rotationDeg?: number };
if (!obj.id || !obj.assetId || typeof obj.name !== 'string') return null;
const name = obj.name.trim();
if (!name) return null;
const rot = obj.rotationDeg;
const rotationDeg: 0 | 90 | 180 | 270 = rot === 90 || rot === 180 || rot === 270 ? rot : 0;
return { id: asMaterialId(String(obj.id)), name, assetId: obj.assetId, rotationDeg };
})
.filter(
(x): x is { id: MaterialId; name: string; assetId: AssetId; rotationDeg: 0 | 90 | 180 | 270 } =>
Boolean(x),
);
const metaRaw = p.meta as unknown as { createdWithAppVersion?: string; appVersion?: string };
const createdWithAppVersion = (() => {
const c = metaRaw.createdWithAppVersion?.trim();
@@ -1737,6 +1885,7 @@ function normalizeProject(p: Project): Project {
},
scenes,
campaignAudios,
materials,
sceneGraphNodes,
sceneGraphEdges,
currentGraphNodeId,
@@ -39,11 +39,19 @@ void test('createWindows: окно описания сцены закрывае
const src = readCreateWindows();
assert.ok(src.includes('openSceneDescriptionWindow'));
assert.ok(src.includes('closeSceneDescriptionWindow'));
assert.ok(src.includes("createWindow('sceneDescription')"));
assert.ok(src.includes("createWindow('sceneDescription'"));
assert.match(src, /export function closeMultiWindow[\s\S]*closeSceneDescriptionWindow/);
assert.match(src, /kind !== 'presentation' && kind !== 'control'[\s\S]*closeSceneDescriptionWindow/);
});
void test('createWindows: окно материалов закрывается с multi-window', () => {
const src = readCreateWindows();
assert.ok(src.includes('openMaterialsWindow'));
assert.ok(src.includes('closeMaterialsWindow'));
assert.ok(src.includes("createWindow('materials'"));
assert.match(src, /export function closeMultiWindow[\s\S]*closeMaterialsWindow/);
});
void test('createWindows: production — loadFile для HTML (не только file://)', () => {
const src = readCreateWindows();
assert.ok(src.includes('loadFile'));
+66 -2
View File
@@ -8,13 +8,17 @@ import { ipcChannels } from '../../shared/ipc/contracts';
import { getBootSplashWindow } from './bootWindow';
import { loadBrandingWindowIcon } from './brandingIcon';
type WindowKind = 'editor' | 'presentation' | 'control' | 'sceneDescription';
type WindowKind = 'editor' | 'presentation' | 'control' | 'sceneDescription' | 'materials';
const windows = new Map<WindowKind, BrowserWindow>();
let appQuitting = false;
let pendingSceneDescriptionHtml = '';
/** Окно материалов — только колонка списка. */
const MATERIALS_WINDOW_WIDTH = 300;
const MATERIALS_WINDOW_HEIGHT = 720;
/** Учитываем окна, которые уже уничтожены при каскадном закрытии (родитель → дочернее). */
function broadcastMultiWindowStateChanged(open: boolean): void {
for (const w of BrowserWindow.getAllWindows()) {
@@ -71,6 +75,8 @@ function pageNameForKind(kind: WindowKind): string {
return 'control.html';
case 'sceneDescription':
return 'sceneDescription.html';
case 'materials':
return 'materials.html';
}
}
@@ -137,6 +143,7 @@ function windowSizeForKind(kind: WindowKind): { width: number; height: number }
if (kind === 'editor') return { width: 1280, height: 800 };
if (kind === 'control') return { width: 1200, height: 800 };
if (kind === 'sceneDescription') return { width: 720, height: 640 };
if (kind === 'materials') return { width: MATERIALS_WINDOW_WIDTH, height: MATERIALS_WINDOW_HEIGHT };
return { width: 1280, height: 800 };
}
@@ -154,6 +161,16 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
autoHideMenuBar: true,
}
: {}),
...(kind === 'materials'
? {
width: MATERIALS_WINDOW_WIDTH,
height: MATERIALS_WINDOW_HEIGHT,
minWidth: 260,
maxWidth: 360,
minHeight: 480,
autoHideMenuBar: true,
}
: {}),
show: false,
backgroundColor: '#09090B',
...(icon ? { icon } : {}),
@@ -178,7 +195,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
}
win.setTitle(windowChromeTitle(kind, app.getLocale()));
if (kind === 'sceneDescription') {
if (kind === 'sceneDescription' || kind === 'materials') {
win.setMenuBarVisibility(false);
}
@@ -209,6 +226,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
const open = windows.has('presentation') || windows.has('control');
if (!open) {
closeSceneDescriptionWindow();
closeMaterialsWindow();
}
broadcastMultiWindowStateChanged(open);
});
@@ -293,6 +311,7 @@ export function openMultiWindow() {
export function closeMultiWindow(): void {
closeSceneDescriptionWindow();
closeMaterialsWindow();
const pres = windows.get('presentation');
const ctrl = windows.get('control');
if (pres) pres.close();
@@ -310,6 +329,13 @@ export function closeSceneDescriptionWindow(): void {
}
}
export function closeMaterialsWindow(): void {
const win = windows.get('materials');
if (win && !win.isDestroyed()) {
win.close();
}
}
export function getSceneDescriptionContent(): string {
return pendingSceneDescriptionHtml;
}
@@ -349,6 +375,44 @@ export function openSceneDescriptionWindow(html: string): void {
});
}
/** Полоса материалов: фиксированная ширина плитки, переиспользование окна. */
export function openMaterialsWindow(): void {
const existing = windows.get('materials');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
const b = existing.getBounds();
existing.setBounds({
x: b.x,
y: b.y,
width: MATERIALS_WINDOW_WIDTH,
height: MATERIALS_WINDOW_HEIGHT,
});
existing.show();
existing.focus();
existing.moveTop();
return;
}
const parent = windows.get('control') ?? windows.get('presentation');
const win = createWindow('materials', parent ? { parent } : undefined);
const { width, height } = win.getBounds();
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
const { x, y, width: dw, height: dh } = display.workArea;
win.setBounds({
x: Math.round(x + Math.max(0, dw - width - 24)),
y: Math.round(y + (dh - height) / 2),
width,
height,
});
win.webContents.once('did-finish-load', () => {
if (!win.isDestroyed()) {
win.show();
win.focus();
win.moveTop();
}
});
}
export function togglePresentationFullscreen(): boolean {
const pres = windows.get('presentation');
if (!pres) return false;
+63
View File
@@ -17,6 +17,9 @@ import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay';
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
import { useEffectsState } from '../shared/effects/useEffectsState';
import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState';
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../shared/types';
import { MaterialOverlay } from '../shared/materials/MaterialOverlay';
import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverlayState';
import { Button } from '../shared/ui/controls';
import { Surface } from '../shared/ui/Surface';
import { useAssetUrl } from '../shared/useAssetImageUrl';
@@ -87,6 +90,7 @@ export function ControlApp() {
tRef.current = t;
const [fxState, fx] = useEffectsState();
const [sdState, sd] = useSceneDarknessState();
const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
const [session, setSession] = useState<SessionState | null>(null);
const historyRef = useRef<GraphNodeId[]>([]);
const [history, setHistory] = useState<GraphNodeId[]>([]);
@@ -1167,6 +1171,38 @@ export function ControlApp() {
📖
</span>
</Button>
<Button
variant="ghost"
iconOnly
title={t('control.materialsTool')}
ariaLabel={t('control.materialsTool')}
onClick={() => {
void api.invoke(ipcChannels.windows.openMaterials, {}).catch((err) => {
console.error('[control] openMaterials failed', err);
});
}}
>
<span className={styles.iconGlyph} aria-hidden>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
<path
fill="#c9a227"
d="M4.2 5.4c1.6-.9 3.5-.7 5 .5l1.3 1.1c.4.3 1 .3 1.4 0L13.2 5.9c1.5-1.2 3.4-1.4 5-.5l1.4.8c.8.5 1.3 1.4 1.3 2.3v8.6c0 1.4-1.4 2.3-2.7 1.8l-2.3-.9c-.7-.3-1.5-.2-2.1.2l-1.3.9c-.6.4-1.4.4-2 0l-1.3-.9c-.6-.4-1.4-.5-2.1-.2l-2.3.9c-1.3.5-2.7-.4-2.7-1.8V8.5c0-.9.5-1.8 1.3-2.3l1.5-.8z"
/>
<path
fill="#7a4e1d"
d="M8.2 10.2c1.3-.4 2.5.2 3.3 1.1.3.3.8.3 1.1 0 .8-.9 2-1.5 3.3-1.1.5.2.8.7.6 1.2-.5 1.4-1.7 2.5-3.1 3.1-.5.2-1 .2-1.4 0-1.4-.6-2.6-1.7-3.1-3.1-.2-.5.1-1 .6-1.2z"
/>
<circle cx="12" cy="12.2" r="1.15" fill="#e8c547" />
<path
fill="none"
stroke="#5c3a16"
strokeWidth="1.1"
strokeLinecap="round"
d="M7.5 15.8c1.2.7 2.7 1.1 4.5 1.1s3.3-.4 4.5-1.1"
/>
</svg>
</span>
</Button>
</div>
<div className={styles.spacer12} />
{!isVideoPreviewScene ? (
@@ -1523,6 +1559,33 @@ export function ControlApp() {
/>
</>
) : null}
{(() => {
const activeMaterial =
session?.project && materialsOverlay?.activeMaterialId
? (session.project.materials ?? []).find((m) => m.id === materialsOverlay.activeMaterialId)
: undefined;
if (!activeMaterial) return null;
return (
<MaterialOverlay
assetId={activeMaterial.assetId}
rotationDeg={activeMaterial.rotationDeg ?? 0}
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
editable
zoomTool={materialsOverlay?.zoomTool ?? null}
showClose
closeLabel={t('materials.closeOverlay')}
onClose={() => {
void materialsApi.dispatch({ kind: 'hide' });
}}
onLayoutChange={(layout) => {
void materialsApi.dispatch({ kind: 'layout.set', layout });
}}
onZoomAt={(nx, ny) => {
void materialsApi.dispatch({ kind: 'zoomAt', nx, ny });
}}
/>
);
})()}
</div>
</Surface>
+57
View File
@@ -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')}
+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>
);
}
@@ -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);
}
+268
View File
@@ -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,
);
}
+12
View File
@@ -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: [],
+1
View File
@@ -8,6 +8,7 @@ export const HELP_SECTION_IDS = [
'sideStorylines',
'sceneProps',
'campaignAudio',
'materials',
'session',
'controlPanel',
'transitions',
+11 -1
View File
@@ -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(), '');
}
});
+72 -2
View File
@@ -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 scenes 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 scenes 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',
+63 -1
View File
@@ -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,
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/app-window-icon.png" type="image/png" />
<title>TTRPG</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/materials/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,27 @@
:global(html),
:global(body),
:global(#root) {
height: 100%;
overflow: hidden;
}
.page {
height: 100%;
width: 100%;
margin: 0;
background: var(--bg0);
color: var(--text0);
overflow: hidden;
box-sizing: border-box;
padding: 10px;
display: flex;
flex-direction: column;
min-height: 0;
}
.browser {
flex: 1 1 auto;
height: 100%;
min-height: 0;
overflow: hidden;
}
+153
View File
@@ -0,0 +1,153 @@
import React, { useCallback, useEffect, useState } from 'react';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import type { MaterialId } from '../../shared/types';
import { MaterialsBrowser } from '../editor/MaterialsBrowser';
import matStyles from '../editor/MaterialsModals.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverlayState';
import { Button } from '../shared/ui/controls';
import styles from './MaterialsApp.module.css';
function ZoomInIcon() {
return (
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
<circle cx="10.5" cy="10.5" r="6.25" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M15.2 15.2 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<path
d="M10.5 7.8v5.4M7.8 10.5h5.4"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
);
}
function ZoomOutIcon() {
return (
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
<circle cx="10.5" cy="10.5" r="6.25" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M15.2 15.2 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<path d="M7.8 10.5h5.4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
export function MaterialsApp() {
const { t } = useEditorI18n();
const api = getDndApi();
const [session, setSession] = useState<SessionState | null>(null);
const [overlay, overlayApi] = useMaterialsOverlayState();
const [selectedId, setSelectedId] = useState<MaterialId | null>(null);
const onSelect = useCallback((id: MaterialId | null) => setSelectedId(id), []);
useEffect(() => {
void api.invoke(ipcChannels.project.get, {}).then(({ project }) => {
setSession({ project, currentSceneId: project?.currentSceneId ?? null });
const mats = project?.materials ?? [];
setSelectedId(mats[0]?.id ?? null);
});
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
setSession(state);
});
}, [api]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (overlay?.activeMaterialId) {
void overlayApi.dispatch({ kind: 'hide' });
return;
}
if (overlay?.zoomTool) {
void overlayApi.dispatch({ kind: 'zoomTool.set', tool: null });
return;
}
window.close();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [overlay?.activeMaterialId, overlay?.zoomTool, overlayApi]);
const materials = session?.project?.materials ?? [];
const activeId = overlay?.activeMaterialId ?? null;
const zoomTool = overlay?.zoomTool ?? null;
return (
<div className={styles.page}>
<MaterialsBrowser
mode="runtime"
fillHeight
listOnly
className={styles.browser}
materials={materials}
selectedId={selectedId}
onSelect={onSelect}
activeMaterialId={activeId}
onTileActivate={(id) => {
void overlayApi.dispatch({ kind: 'toggle', materialId: id });
}}
toolbar={
<>
<div className={matStyles.browserToolbarRow}>
<Button
variant={zoomTool === 'zoomIn' ? 'primary' : 'ghost'}
iconOnly
title={t('materials.zoomIn')}
ariaLabel={t('materials.zoomIn')}
tooltipPlacement="bottom"
onClick={() => {
void overlayApi.dispatch({
kind: 'zoomTool.set',
tool: zoomTool === 'zoomIn' ? null : 'zoomIn',
});
}}
>
<ZoomInIcon />
</Button>
<Button
variant={zoomTool === 'zoomOut' ? 'primary' : 'ghost'}
iconOnly
title={t('materials.zoomOut')}
ariaLabel={t('materials.zoomOut')}
tooltipPlacement="bottom"
onClick={() => {
void overlayApi.dispatch({
kind: 'zoomTool.set',
tool: zoomTool === 'zoomOut' ? null : 'zoomOut',
});
}}
>
<ZoomOutIcon />
</Button>
</div>
{activeId ? (
<Button
title={t('materials.closeOverlay')}
ariaLabel={t('materials.closeOverlay')}
tooltipPlacement="bottom"
onClick={() => {
void overlayApi.dispatch({ kind: 'hide' });
}}
>
{t('materials.closeOverlay')}
</Button>
) : null}
<div className={matStyles.browserToolbarHint}>
{zoomTool === 'zoomIn'
? t('materials.zoomInHint')
: zoomTool === 'zoomOut'
? t('materials.zoomOutHint')
: t('materials.zoomIdleHint')}
</div>
</>
}
/>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
import { MaterialsApp } from './MaterialsApp';
const rootEl = document.getElementById('root');
if (!rootEl) {
throw new Error('Missing #root element');
}
createRoot(rootEl).render(
<React.StrictMode>
<EditorI18nProvider>
<MaterialsApp />
</EditorI18nProvider>
</React.StrictMode>,
);
+15
View File
@@ -7,6 +7,9 @@ import { PixiEffectsOverlay } from './effects/PxiEffectsOverlay';
import { SceneDarknessOverlay } from './effects/SceneDarknessOverlay';
import { useEffectsState } from './effects/useEffectsState';
import { useSceneDarknessState } from './effects/useSceneDarknessState';
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../shared/types';
import { MaterialOverlay } from './materials/MaterialOverlay';
import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState';
import styles from './PresentationView.module.css';
import { RotatedImage } from './RotatedImage';
import { useAssetUrl } from './useAssetImageUrl';
@@ -28,6 +31,7 @@ export function PresentationView({
}: PresentationViewProps) {
const [fxState] = useEffectsState();
const [sdState] = useSceneDarknessState();
const [materialsOverlay] = useMaterialsOverlayState();
const [vp] = useVideoPlaybackState();
const videoElRef = useRef<HTMLVideoElement | null>(null);
const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>(
@@ -35,6 +39,10 @@ export function PresentationView({
);
const scene =
session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined;
const activeMaterial =
session?.project && materialsOverlay?.activeMaterialId
? (session.project.materials ?? []).find((m) => m.id === materialsOverlay.activeMaterialId)
: undefined;
const originalUrl = useAssetUrl(scene?.previewAssetId ?? null);
const thumbUrl = useAssetUrl(scene?.previewThumbAssetId ?? null);
const [shownImageUrl, setShownImageUrl] = useState<string | null>(null);
@@ -138,6 +146,13 @@ export function PresentationView({
{showEffects && scene?.previewAssetType === 'image' && scene.darkenScene && contentRect ? (
<SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} />
) : null}
{activeMaterial ? (
<MaterialOverlay
assetId={activeMaterial.assetId}
rotationDeg={activeMaterial.rotationDeg ?? 0}
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
/>
) : null}
{showTitle ? (
<div className={styles.titleWrap}>
<div className={compact ? styles.titleCompact : styles.titleFull}>
@@ -0,0 +1,112 @@
.root {
position: absolute;
inset: 0;
z-index: 40;
}
.interactive {
pointer-events: auto;
}
.passive {
pointer-events: none;
}
.cursorZoomIn {
cursor: zoom-in;
}
.cursorZoomOut {
cursor: zoom-out;
}
.dim {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.62);
pointer-events: none;
}
.frame {
position: absolute;
z-index: 1;
pointer-events: none;
overflow: visible;
}
.frameEditable {
pointer-events: auto;
cursor: move;
}
.image {
position: absolute;
left: 50%;
top: 50%;
display: block;
object-fit: fill;
border-radius: 6px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55);
user-select: none;
pointer-events: none;
transform-origin: center center;
}
.handle {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid #fff;
background: var(--color-accent, #c9a227);
padding: 0;
z-index: 2;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.45);
pointer-events: auto;
}
.handle_nw {
left: -6px;
top: -6px;
cursor: nwse-resize;
}
.handle_ne {
right: -6px;
top: -6px;
cursor: nesw-resize;
}
.handle_sw {
left: -6px;
bottom: -6px;
cursor: nesw-resize;
}
.handle_se {
right: -6px;
bottom: -6px;
cursor: nwse-resize;
}
.close {
position: absolute;
top: 14px;
right: 14px;
z-index: 3;
width: 36px;
height: 36px;
border: none;
border-radius: 10px;
background: rgba(12, 12, 16, 0.72);
color: #fff;
font-size: 24px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
}
.close:hover {
background: rgba(24, 24, 32, 0.9);
}
@@ -0,0 +1,269 @@
import React, { useEffect, useRef, useState } from 'react';
import type { AssetId, MaterialsOverlayLayout, MaterialsZoomTool } from '../../../shared/types';
import { useAssetUrl } from '../useAssetImageUrl';
import styles from './MaterialOverlay.module.css';
type Corner = 'nw' | 'ne' | 'sw' | 'se';
type MaterialOverlayProps = {
assetId: AssetId | null;
rotationDeg?: 0 | 90 | 180 | 270;
layout: MaterialsOverlayLayout;
editable?: boolean;
zoomTool?: MaterialsZoomTool;
showClose?: boolean;
onClose?: () => void;
closeLabel?: string;
onLayoutChange?: (layout: MaterialsOverlayLayout) => void;
onZoomAt?: (nx: number, ny: number) => void;
};
function nextBaseSize(
viewW: number,
viewH: number,
naturalW: number,
naturalH: number,
rotationDeg: number,
): { w: number; h: number } {
const swapped = rotationDeg === 90 || rotationDeg === 270;
const iw = swapped ? naturalH : naturalW;
const ih = swapped ? naturalW : naturalH;
if (iw <= 0 || ih <= 0 || viewW <= 0 || viewH <= 0) return { w: 200, h: 120 };
const maxW = viewW * 0.92;
const maxH = viewH * 0.88;
const fit = Math.min(maxW / iw, maxH / ih);
return { w: iw * fit, h: ih * fit };
}
export function MaterialOverlay({
assetId,
rotationDeg = 0,
layout,
editable = false,
zoomTool = null,
showClose = false,
onClose,
closeLabel = 'Close',
onLayoutChange,
onZoomAt,
}: MaterialOverlayProps) {
const url = useAssetUrl(assetId);
const rootRef = useRef<HTMLDivElement | null>(null);
const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 1600, h: 900 });
const [view, setView] = useState({ w: 1, h: 1 });
const dragRef = useRef<
| { mode: 'move'; startX: number; startY: number; origin: MaterialsOverlayLayout }
| {
mode: 'resize';
corner: Corner;
startX: number;
startY: number;
origin: MaterialsOverlayLayout;
baseW: number;
baseH: number;
viewW: number;
viewH: number;
}
| null
>(null);
useEffect(() => {
const el = rootRef.current;
if (!el) return;
const sync = () => setView({ w: el.clientWidth, h: el.clientHeight });
sync();
const ro = new ResizeObserver(sync);
ro.observe(el);
return () => ro.disconnect();
}, [url]);
if (!assetId || !url) return null;
const interactive = editable || showClose || Boolean(zoomTool);
const zoomCursor =
zoomTool === 'zoomIn' ? styles.cursorZoomIn : zoomTool === 'zoomOut' ? styles.cursorZoomOut : '';
const base = nextBaseSize(view.w, view.h, natural.w, natural.h, rotationDeg);
const w = base.w * layout.scale;
const h = base.h * layout.scale;
const left = layout.cx * view.w - w / 2;
const top = layout.cy * view.h - h / 2;
// Рамка — AABB; до CSS-rotate картинка имеет «натуральную» ориентацию.
const swapped = rotationDeg === 90 || rotationDeg === 270;
const contentW = swapped ? h : w;
const contentH = swapped ? w : h;
const toNorm = (clientX: number, clientY: number) => {
const root = rootRef.current;
if (!root) return { nx: 0.5, ny: 0.5 };
const r = root.getBoundingClientRect();
return {
nx: (clientX - r.left) / Math.max(1, r.width),
ny: (clientY - r.top) / Math.max(1, r.height),
};
};
const onPointerMove = (e: PointerEvent) => {
const drag = dragRef.current;
if (!drag || !onLayoutChange) return;
const root = rootRef.current;
if (!root) return;
const r = root.getBoundingClientRect();
const dx = (e.clientX - drag.startX) / Math.max(1, r.width);
const dy = (e.clientY - drag.startY) / Math.max(1, r.height);
if (drag.mode === 'move') {
onLayoutChange({
...drag.origin,
cx: drag.origin.cx + dx,
cy: drag.origin.cy + dy,
});
return;
}
const { baseW, baseH, viewW, viewH, origin, corner } = drag;
const originW = baseW * origin.scale;
const originH = baseH * origin.scale;
const originLeft = origin.cx * viewW - originW / 2;
const originTop = origin.cy * viewH - originH / 2;
const originRight = originLeft + originW;
const originBottom = originTop + originH;
let anchorX = originLeft;
let anchorY = originTop;
if (corner === 'nw') {
anchorX = originRight;
anchorY = originBottom;
} else if (corner === 'ne') {
anchorX = originLeft;
anchorY = originBottom;
} else if (corner === 'sw') {
anchorX = originRight;
anchorY = originTop;
}
const pointerX = e.clientX - r.left;
const pointerY = e.clientY - r.top;
const newW = Math.max(8, Math.abs(pointerX - anchorX));
const newH = Math.max(8, Math.abs(pointerY - anchorY));
const nextScale = Math.max(newW / Math.max(1, baseW), newH / Math.max(1, baseH));
const ww = baseW * nextScale;
const hh = baseH * nextScale;
let nextLeft = anchorX;
let nextTop = anchorY;
if (corner === 'nw') {
nextLeft = anchorX - ww;
nextTop = anchorY - hh;
} else if (corner === 'ne') {
nextLeft = anchorX;
nextTop = anchorY - hh;
} else if (corner === 'sw') {
nextLeft = anchorX - ww;
nextTop = anchorY;
}
onLayoutChange({
cx: (nextLeft + ww / 2) / Math.max(1, viewW),
cy: (nextTop + hh / 2) / Math.max(1, viewH),
scale: nextScale,
});
};
const endDrag = () => {
dragRef.current = null;
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', endDrag);
};
const startDrag = (e: React.PointerEvent, mode: 'move' | 'resize', corner?: Corner) => {
if (!editable || !onLayoutChange || zoomTool) return;
e.preventDefault();
e.stopPropagation();
dragRef.current =
mode === 'move'
? { mode: 'move', startX: e.clientX, startY: e.clientY, origin: { ...layout } }
: {
mode: 'resize',
corner: corner ?? 'se',
startX: e.clientX,
startY: e.clientY,
origin: { ...layout },
baseW: base.w,
baseH: base.h,
viewW: view.w,
viewH: view.h,
};
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', endDrag);
};
return (
<div
ref={rootRef}
className={[styles.root, interactive ? styles.interactive : styles.passive, zoomCursor]
.filter(Boolean)
.join(' ')}
role="dialog"
aria-modal="true"
onClick={(e) => {
if (!zoomTool || !onZoomAt) return;
e.stopPropagation();
const { nx, ny } = toNorm(e.clientX, e.clientY);
onZoomAt(nx, ny);
}}
>
<div className={styles.dim} />
<div
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : '']
.filter(Boolean)
.join(' ')}
style={{ left, top, width: w, height: h }}
onPointerDown={(e) => {
if (zoomTool) return;
startDrag(e, 'move');
}}
>
<img
className={styles.image}
src={url}
alt=""
draggable={false}
style={{
width: contentW,
height: contentH,
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
}}
onLoad={(e) => {
const img = e.currentTarget;
setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 });
}}
/>
{editable && !zoomTool
? (['nw', 'ne', 'sw', 'se'] as const).map((corner) => (
<button
key={corner}
type="button"
className={[styles.handle, styles[`handle_${corner}`]].join(' ')}
aria-label={corner}
onPointerDown={(e) => startDrag(e, 'resize', corner)}
/>
))
: null}
</div>
{showClose ? (
<button
type="button"
className={styles.close}
onClick={onClose}
aria-label={closeLabel}
title={closeLabel}
>
×
</button>
) : null}
</div>
);
}
@@ -0,0 +1,31 @@
import { useEffect, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { MaterialsOverlayEvent, MaterialsOverlayState } from '../../../shared/types';
import { getDndApi } from '../dndApi';
export function useMaterialsOverlayState(): readonly [
MaterialsOverlayState | null,
{ dispatch: (event: MaterialsOverlayEvent) => Promise<void> },
] {
const api = getDndApi();
const [state, setState] = useState<MaterialsOverlayState | null>(null);
useEffect(() => {
void api.invoke(ipcChannels.materialsOverlay.getState, {}).then((r) => {
setState(r.state);
});
return api.on(ipcChannels.materialsOverlay.stateChanged, ({ state: next }) => {
setState(next);
});
}, [api]);
return [
state,
{
dispatch: async (event) => {
await api.invoke(ipcChannels.materialsOverlay.dispatch, { event });
},
},
] as const;
}
@@ -63,6 +63,22 @@
white-space: normal;
}
.tooltipBottom {
position: fixed;
transform: translate(-50%, 8px);
padding: 6px 10px;
border-radius: var(--radius-xs);
font-size: var(--text-xs);
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
background: var(--color-tooltip-bg);
border: 1px solid var(--stroke-2);
box-shadow: var(--shadow-tooltip);
pointer-events: none;
z-index: var(--z-tooltip);
white-space: nowrap;
}
.input {
height: 34px;
width: 100%;
+11 -2
View File
@@ -14,7 +14,7 @@ type ButtonProps = {
/** Компактная кнопка под одну иконку. */
iconOnly?: boolean;
/** Позиция тултипа относительно кнопки. */
tooltipPlacement?: 'top' | 'bottom-left';
tooltipPlacement?: 'top' | 'bottom' | 'bottom-left';
};
export function Button({
@@ -40,6 +40,10 @@ export function Button({
setTipPos({ x: r.right, y: r.bottom });
return;
}
if (tooltipPlacement === 'bottom') {
setTipPos({ x: r.left + r.width / 2, y: r.bottom });
return;
}
setTipPos({ x: r.left + r.width / 2, y: r.top });
}, [disabled, title, tooltipPlacement]);
@@ -55,7 +59,12 @@ export function Button({
.filter(Boolean)
.join(' ');
const tipClass = tooltipPlacement === 'bottom-left' ? styles.tooltipBottomLeft : styles.tooltipTop;
const tipClass =
tooltipPlacement === 'bottom-left'
? styles.tooltipBottomLeft
: tooltipPlacement === 'bottom'
? styles.tooltipBottom
: styles.tooltipTop;
const tip =
title && tipPos && typeof document !== 'undefined'
+20 -1
View File
@@ -15,7 +15,7 @@ export function appDisplayNameForLocale(localeTag: string): string {
/** Префикс заголовка окон: `TTRPG - Редактор`. */
export const APP_WINDOW_BRAND = 'TTRPG';
export type AppWindowKind = 'editor' | 'presentation' | 'control' | 'boot' | 'sceneDescription';
export type AppWindowKind = 'editor' | 'presentation' | 'control' | 'boot' | 'sceneDescription' | 'materials';
const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
editor: { ru: 'Редактор', en: 'Editor' },
@@ -23,6 +23,7 @@ const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
control: { ru: 'Пульт', en: 'Control' },
boot: { ru: 'Загрузка', en: 'Loading' },
sceneDescription: { ru: 'Описание сцены', en: 'Scene description' },
materials: { ru: 'Материалы', en: 'Materials' },
};
export function windowChromeTitle(kind: AppWindowKind, localeTag: string): string {
@@ -30,3 +31,21 @@ export function windowChromeTitle(kind: AppWindowKind, localeTag: string): strin
const suffix = tag.startsWith('ru') ? WINDOW_SUFFIX[kind].ru : WINDOW_SUFFIX[kind].en;
return `${APP_WINDOW_BRAND} - ${suffix}`;
}
/** Подписи фильтров в системных диалогах выбора файла (main process). */
export function openDialogFilterLabel(
kind: 'images' | 'imagesAndVideo' | 'audio' | 'videoAndAudio',
localeTag: string,
): string {
const ru = localeTag.trim().toLowerCase().startsWith('ru');
switch (kind) {
case 'imagesAndVideo':
return ru ? 'Изображения и видео' : 'Images and video';
case 'audio':
return ru ? 'Аудио' : 'Audio';
case 'videoAndAudio':
return ru ? 'Видео и аудио' : 'Video and audio';
default:
return ru ? 'Изображения' : 'Images';
}
}
@@ -61,12 +61,13 @@ function minimalProject(overrides: Partial<Project> = {}): Project {
updatedAt: '2020-01-01T00:00:00.000Z',
createdWithAppVersion: '1',
appVersion: '1',
schemaVersion: 5,
schemaVersion: 7,
},
scenes: {},
sceneListOrder: [],
assets: {},
campaignAudios: [],
materials: [],
currentSceneId: null,
currentGraphNodeId: null,
sceneGraphNodes: [],
+28 -1
View File
@@ -14,7 +14,7 @@ import type {
SceneId,
} from '../types';
import type { AssetId, ProjectId } from '../types/ids';
import { asAssetId, asGraphNodeId, asProjectId, asSceneId } from '../types/ids';
import { asAssetId, asGraphNodeId, asMaterialId, asProjectId, asSceneId } from '../types/ids';
export type StorylineKind = 'main' | 'side';
@@ -273,6 +273,7 @@ export function buildPartialExportProject(
...draft,
assets,
campaignAudios: source.campaignAudios.map((a) => ({ ...a })),
materials: (source.materials ?? []).map((m) => ({ ...m })),
currentSceneId: mainStart?.sceneId ?? firstSide?.sceneId ?? null,
currentGraphNodeId: mainStart?.id ?? firstSide?.id ?? null,
};
@@ -287,6 +288,7 @@ function collectReferencedAssetIdsForProject(p: Project): Set<AssetId> {
for (const au of sc.media.audios) refs.add(au.assetId);
}
for (const au of p.campaignAudios) refs.add(au.assetId);
for (const m of p.materials ?? []) refs.add(m.assetId);
return refs;
}
@@ -373,6 +375,7 @@ export function mergeStorylinesIntoProject(
for (const au of sc.media.audios) neededAssetIds.add(au.assetId);
}
for (const au of source.campaignAudios) neededAssetIds.add(au.assetId);
for (const m of source.materials ?? []) neededAssetIds.add(m.assetId);
const assetMap = new Map<AssetId, AssetId>();
const targetSha = new Map<string, AssetId>();
@@ -483,11 +486,35 @@ export function mergeStorylinesIntoProject(
}
}
const materials = [...(target.materials ?? [])];
const materialNameKeys = new Set(materials.map((m) => m.name.trim().toLowerCase()));
const materialAssetIds = new Set(materials.map((m) => m.assetId));
for (const m of source.materials ?? []) {
const mapped = assetMap.get(m.assetId) ?? m.assetId;
if (materialAssetIds.has(mapped)) continue;
let name = m.name.trim();
const baseKey = name.toLowerCase();
if (materialNameKeys.has(baseKey)) {
let n = 2;
while (materialNameKeys.has(`${baseKey} (${String(n)})`)) n += 1;
name = `${name} (${String(n)})`;
}
materials.push({
id: asMaterialId(`mat_${generateId()}`),
name,
assetId: mapped,
rotationDeg: m.rotationDeg ?? 0,
});
materialNameKeys.add(name.toLowerCase());
materialAssetIds.add(mapped);
}
let merged: Project = {
...target,
scenes,
assets,
campaignAudios,
materials,
sceneGraphNodes: [...target.sceneGraphNodes, ...newGraphNodes],
sceneGraphEdges: [...target.sceneGraphEdges, ...newEdges],
};
+55
View File
@@ -4,6 +4,9 @@ import type {
EffectsEvent,
EffectsState,
GraphNodeId,
MaterialId,
MaterialsOverlayEvent,
MaterialsOverlayState,
MediaAsset,
Project,
ProjectId,
@@ -46,6 +49,11 @@ export const ipcChannels = {
importMedia: 'project.importMedia',
importCampaignAudio: 'project.importCampaignAudio',
updateCampaignAudios: 'project.updateCampaignAudios',
upsertMaterial: 'project.upsertMaterial',
setMaterialRotation: 'project.setMaterialRotation',
deleteMaterial: 'project.deleteMaterial',
setMaterialsOrder: 'project.setMaterialsOrder',
pickMaterialImage: 'project.pickMaterialImage',
importScenePreview: 'project.importScenePreview',
clearScenePreview: 'project.clearScenePreview',
assetFileUrl: 'project.assetFileUrl',
@@ -85,6 +93,8 @@ export const ipcChannels = {
closeSceneDescription: 'windows.closeSceneDescription',
getSceneDescriptionContent: 'windows.getSceneDescriptionContent',
sceneDescriptionContent: 'windows.sceneDescriptionContent',
openMaterials: 'windows.openMaterials',
closeMaterials: 'windows.closeMaterials',
},
session: {
stateChanged: 'session.stateChanged',
@@ -94,6 +104,11 @@ export const ipcChannels = {
dispatch: 'effects.dispatch',
stateChanged: 'effects.stateChanged',
},
materialsOverlay: {
getState: 'materialsOverlay.getState',
dispatch: 'materialsOverlay.dispatch',
stateChanged: 'materialsOverlay.stateChanged',
},
sceneDarkness: {
getState: 'sceneDarkness.getState',
dispatch: 'sceneDarkness.dispatch',
@@ -156,6 +171,7 @@ export type UpdaterProgressEvent = {
export type IpcEventMap = {
[ipcChannels.session.stateChanged]: { state: SessionState };
[ipcChannels.effects.stateChanged]: { state: EffectsState };
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
@@ -236,6 +252,28 @@ export type IpcInvokeMap = {
req: { audios: Project['campaignAudios'] };
res: { project: Project };
};
[ipcChannels.project.upsertMaterial]: {
req: { materialId?: MaterialId; name: string; filePath?: string };
res: { project: Project };
};
[ipcChannels.project.setMaterialRotation]: {
req: { materialId: MaterialId; rotationDeg: 0 | 90 | 180 | 270 };
res: { project: Project };
};
[ipcChannels.project.deleteMaterial]: {
req: { materialId: MaterialId };
res: { project: Project };
};
[ipcChannels.project.setMaterialsOrder]: {
req: { materialIds: MaterialId[] };
res: { project: Project };
};
[ipcChannels.project.pickMaterialImage]: {
req: Record<string, never>;
res:
| { canceled: true }
| { canceled: false; filePath: string; previewDataUrl: string };
};
[ipcChannels.project.importScenePreview]: {
req: { sceneId: SceneId; filePath?: string };
res: { project: Project; assetId: AssetId | null; background: boolean };
@@ -390,6 +428,22 @@ export type IpcInvokeMap = {
req: Record<string, never>;
res: { html: string };
};
[ipcChannels.windows.openMaterials]: {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.windows.closeMaterials]: {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.materialsOverlay.getState]: {
req: Record<string, never>;
res: { state: MaterialsOverlayState };
};
[ipcChannels.materialsOverlay.dispatch]: {
req: { event: MaterialsOverlayEvent };
res: { ok: true };
};
[ipcChannels.effects.getState]: {
req: Record<string, never>;
res: { state: EffectsState };
@@ -440,6 +494,7 @@ export type SessionState = {
export type LegacyIpcEventMap = {
[ipcChannels.session.stateChanged]: { state: SessionState };
[ipcChannels.effects.stateChanged]: { state: EffectsState };
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: Record<string, never>;
+12 -2
View File
@@ -1,6 +1,14 @@
import type { AssetId, GraphNodeId, ProjectId, SceneId } from './ids';
import type { AssetId, GraphNodeId, MaterialId, ProjectId, SceneId } from './ids';
export const PROJECT_SCHEMA_VERSION = 5 as const;
export const PROJECT_SCHEMA_VERSION = 7 as const;
/** Материал кампании: изображение, показываемое поверх сцены во время игры. */
export type ProjectMaterial = {
id: MaterialId;
name: string;
assetId: AssetId;
rotationDeg: 0 | 90 | 180 | 270;
};
export type IsoDateTimeString = string;
@@ -135,6 +143,8 @@ export type Project = {
assets: Record<AssetId, MediaAsset>;
/** Аудио кампании: играет в пульте на протяжении всей презентации, если в сцене нет своей музыки. */
campaignAudios: SceneAudioRef[];
/** Материалы кампании: изображения для показа поверх сцены (порядок = порядок в списке). */
materials: ProjectMaterial[];
currentSceneId: SceneId | null;
/** Текущая нода графа (важно, когда одна сцена имеет несколько нод). */
currentGraphNodeId: GraphNodeId | null;
+5
View File
@@ -4,6 +4,7 @@ export type ProjectId = Brand<string, 'ProjectId'>;
export type SceneId = Brand<string, 'SceneId'>;
export type AssetId = Brand<string, 'AssetId'>;
export type GraphNodeId = Brand<string, 'GraphNodeId'>;
export type MaterialId = Brand<string, 'MaterialId'>;
export function asProjectId(value: string): ProjectId {
return value as ProjectId;
@@ -20,3 +21,7 @@ export function asAssetId(value: string): AssetId {
export function asGraphNodeId(value: string): GraphNodeId {
return value as GraphNodeId;
}
export function asMaterialId(value: string): MaterialId {
return value as MaterialId;
}
+1
View File
@@ -1,5 +1,6 @@
export * from './domain';
export * from './effects';
export * from './ids';
export * from './materials';
export * from './sceneDarkness';
export * from './videoPlayback';
+61
View File
@@ -0,0 +1,61 @@
import type { MaterialId } from './ids';
/** Нормированная раскладка материала в области показа (0..1). */
export type MaterialsOverlayLayout = {
/** Центр по X относительно вьюпорта. */
cx: number;
/** Центр по Y относительно вьюпорта. */
cy: number;
/** Масштаб относительно «базового contain» (1 = по умолчанию). */
scale: number;
};
export type MaterialsZoomTool = 'zoomIn' | 'zoomOut' | null;
/** Session-only: какой материал сейчас показан поверх сцены. */
export type MaterialsOverlayState = {
revision: number;
activeMaterialId: MaterialId | null;
layout: MaterialsOverlayLayout;
zoomTool: MaterialsZoomTool;
};
export const DEFAULT_MATERIALS_OVERLAY_LAYOUT: MaterialsOverlayLayout = {
cx: 0.5,
cy: 0.5,
scale: 1,
};
export type MaterialsOverlayEvent =
| { kind: 'show'; materialId: MaterialId }
| { kind: 'hide' }
| { kind: 'toggle'; materialId: MaterialId }
| { kind: 'layout.set'; layout: MaterialsOverlayLayout }
| { kind: 'zoomTool.set'; tool: MaterialsZoomTool }
| { kind: 'zoomAt'; nx: number; ny: number };
export function clampMaterialsLayout(layout: MaterialsOverlayLayout): MaterialsOverlayLayout {
return {
cx: Math.min(1.2, Math.max(-0.2, layout.cx)),
cy: Math.min(1.2, Math.max(-0.2, layout.cy)),
scale: Math.min(8, Math.max(0.15, layout.scale)),
};
}
/** Zoom around normalized point so that point stays fixed. */
export function zoomMaterialsLayoutAt(
layout: MaterialsOverlayLayout,
nx: number,
ny: number,
factor: number,
): MaterialsOverlayLayout {
const nextScale = layout.scale * factor;
const clamped = clampMaterialsLayout({ ...layout, scale: nextScale });
const ratio = clamped.scale / layout.scale;
return clampMaterialsLayout({
...layout,
cx: nx - (nx - layout.cx) * ratio,
cy: ny - (ny - layout.cy) * ratio,
scale: clamped.scale,
});
}
+1
View File
@@ -54,6 +54,7 @@ export default defineConfig(({ mode }) => {
presentation: path.resolve(__dirname, 'app/renderer/presentation.html'),
control: path.resolve(__dirname, 'app/renderer/control.html'),
sceneDescription: path.resolve(__dirname, 'app/renderer/sceneDescription.html'),
materials: path.resolve(__dirname, 'app/renderer/materials.html'),
},
},
},