Compare commits
25 Commits
c7bf7cf449
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ab6ffd593 | |||
| 46bec1a86a | |||
| 7362a36fe5 | |||
| 1d94c95cc8 | |||
| 1fbaaa6e77 | |||
| 8d5a68c71e | |||
| 4456eb0277 | |||
| e53d1ea934 | |||
| cbb6edc378 | |||
| 4e6f7321b8 | |||
| f75444a5dd | |||
| 10cbb3b256 | |||
| c46ff34393 | |||
| 7a25b18268 | |||
| d4dc4e7f3c | |||
| 2979d06f1c | |||
| 61446dacfc | |||
| 41b112159f | |||
| 0f01950cc1 | |||
| cc50e64e21 | |||
| 101f595bac | |||
| a3a03eb9e3 | |||
| e08f5ef550 | |||
| 04c75cd725 | |||
| e687303c57 |
@@ -17,3 +17,6 @@ Thumbs.db
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
*.tsbuildinfo
|
||||
test-results/
|
||||
playwright-report/
|
||||
e2e/fixtures/sample.png
|
||||
|
||||
@@ -48,9 +48,10 @@ void test('SceneDarknessStore: draft синхронизируется и сбр
|
||||
store.switchScene('scene_a', true);
|
||||
store.dispatch({
|
||||
kind: 'draft.set',
|
||||
draft: { points: [{ x: 0.1, y: 0.1, tMs: 1 }], radiusN: 0.05 },
|
||||
draft: { points: [{ x: 0.1, y: 0.1, tMs: 1 }], radiusN: 0.05, mode: 'cover' },
|
||||
});
|
||||
assert.ok(store.getState().draft);
|
||||
assert.equal(store.getState().draft?.mode, 'cover');
|
||||
store.dispatch({
|
||||
kind: 'stroke.add',
|
||||
stroke: {
|
||||
@@ -59,8 +60,42 @@ void test('SceneDarknessStore: draft синхронизируется и сбр
|
||||
createdAtMs: 100,
|
||||
points: [{ x: 0.1, y: 0.1, tMs: 1 }],
|
||||
radiusN: 0.05,
|
||||
mode: 'cover',
|
||||
},
|
||||
});
|
||||
assert.equal(store.getState().draft, null);
|
||||
assert.equal(store.getState().strokes.length, 1);
|
||||
});
|
||||
|
||||
void test('SceneDarknessStore: cover-штрих сохраняется в кэше сцены', () => {
|
||||
const store = new SceneDarknessStore();
|
||||
store.switchScene('scene_a', true);
|
||||
store.dispatch({
|
||||
kind: 'stroke.add',
|
||||
stroke: {
|
||||
id: 'open1',
|
||||
seed: 1,
|
||||
createdAtMs: 100,
|
||||
points: [{ x: 0.5, y: 0.5, tMs: 100 }],
|
||||
radiusN: 0.08,
|
||||
mode: 'reveal',
|
||||
},
|
||||
});
|
||||
store.dispatch({
|
||||
kind: 'stroke.add',
|
||||
stroke: {
|
||||
id: 'close1',
|
||||
seed: 2,
|
||||
createdAtMs: 200,
|
||||
points: [{ x: 0.5, y: 0.5, tMs: 200 }],
|
||||
radiusN: 0.08,
|
||||
mode: 'cover',
|
||||
},
|
||||
});
|
||||
assert.equal(store.getState().strokes.length, 2);
|
||||
assert.equal(store.getState().strokes[1]?.mode, 'cover');
|
||||
store.switchScene('scene_b', true);
|
||||
store.switchScene('scene_a', true);
|
||||
assert.equal(store.getState().strokes.length, 2);
|
||||
assert.equal(store.getState().strokes[1]?.mode, 'cover');
|
||||
});
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {
|
||||
FoundryPlaylistDoc,
|
||||
FoundrySceneDoc,
|
||||
} from '../../shared/foundry/foundryTypes';
|
||||
import { noneBinding } from '../../shared/npcs/npcBinding';
|
||||
import { DEFAULT_NPC_GROUP_COLOR, normalizeHexColor } from '../../shared/npcs/npcGroups';
|
||||
import type {
|
||||
MediaAsset,
|
||||
@@ -388,6 +387,7 @@ export async function buildProjectFromFoundryDocuments(
|
||||
darkenScene: false,
|
||||
traps: [],
|
||||
tokens: [],
|
||||
npcTokens: [],
|
||||
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
|
||||
media: { videos: [], audios: audioRefs },
|
||||
settings: {
|
||||
@@ -447,7 +447,10 @@ export async function buildProjectFromFoundryDocuments(
|
||||
x: 80 + (npcIndex % 4) * 220,
|
||||
y: 80 + Math.floor(npcIndex / 4) * 200,
|
||||
groupId,
|
||||
binding: noneBinding(),
|
||||
ringColor: '#c9a227',
|
||||
disposition: 'neutral',
|
||||
imageOffset: { x: 0, y: 0 },
|
||||
imageScale: 1,
|
||||
});
|
||||
npcIndex += 1;
|
||||
}
|
||||
|
||||
+482
-152
@@ -8,6 +8,7 @@ import { installStdoutEpipeGuards } from './safeConsole';
|
||||
installStdoutEpipeGuards();
|
||||
|
||||
import { openDialogFilterLabel } from '../shared/appBranding';
|
||||
import { USERS_BRANCH_FEATURES_ENABLED } from '../shared/features/usersBranchFeatures';
|
||||
import { ipcChannels, type ScenePreviewImportEvent, type SessionState } from '../shared/ipc/contracts';
|
||||
import {
|
||||
PROJECT_ZIP_OPEN_DIALOG_FILTER,
|
||||
@@ -18,6 +19,11 @@ import {
|
||||
stripProjectZipExtension,
|
||||
} from '../shared/project/projectZipExtension';
|
||||
import type { Project } from '../shared/types';
|
||||
import { asNpcId } from '../shared/types/ids';
|
||||
import {
|
||||
asPreviewRotationDeg,
|
||||
previewRotationStepsCw,
|
||||
} from '../shared/types/scenePreviewRotation';
|
||||
|
||||
import { EffectsStore, effectsDefaultTool } from './effects/effectsStore';
|
||||
import { SceneDarknessStore } from './effects/sceneDarknessStore';
|
||||
@@ -29,7 +35,11 @@ import { NpcsOverlayStore } from './npcs/npcsOverlayStore';
|
||||
import { ZipProjectStore } from './project/zipStore';
|
||||
import { SceneViewStore } from './sceneView/sceneViewStore';
|
||||
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
|
||||
import { PlayersStore } from './players/playersStore';
|
||||
import { SceneNpcTokensSessionStore } from './players/sceneNpcTokensSessionStore';
|
||||
import { ScenePlayerTokensSessionStore } from './players/scenePlayerTokensSessionStore';
|
||||
import { SceneTokensSessionStore } from './tokens/sceneTokensSessionStore';
|
||||
import { TokenGridSnapSessionStore } from './tokens/tokenGridSnapSessionStore';
|
||||
import { TokensStore } from './tokens/tokensStore';
|
||||
import { installAutoUpdater } from './update/installAutoUpdater';
|
||||
import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo';
|
||||
@@ -47,23 +57,32 @@ import {
|
||||
createEditorWindowDeferred,
|
||||
createWindows,
|
||||
focusEditorWindow,
|
||||
getPresentationContentSize,
|
||||
getSceneDescriptionContent,
|
||||
getTokenPathEditorTarget,
|
||||
isMultiWindowOpen,
|
||||
markAppQuitting,
|
||||
openMaterialsWindow,
|
||||
openMultiWindow,
|
||||
openNpcsEditorWindow,
|
||||
openSceneEditorWindow,
|
||||
openTokenPathEditorWindow,
|
||||
openNpcsWindow,
|
||||
openSceneDescriptionWindow,
|
||||
closeMaterialsWindow,
|
||||
closeNpcsEditorWindow,
|
||||
closeSceneEditorWindow,
|
||||
closeTokenPathEditorWindow,
|
||||
closeNpcsWindow,
|
||||
sendToAppWindows,
|
||||
syncAllWindowChromeTitles,
|
||||
togglePresentationFullscreen,
|
||||
waitForEditorWindowReady,
|
||||
warmNpcsEditorWindow,
|
||||
} from './windows/createWindows';
|
||||
import { TokenPathSessionStore } from './tokens/tokenPathSessionStore';
|
||||
import { tokenPathTotalLength } from '../shared/types/tokenPath';
|
||||
import type { TokenPathTargetKind } from '../shared/types/tokenPathSession';
|
||||
|
||||
function emitZipProgress(evt: {
|
||||
kind: 'import' | 'export';
|
||||
@@ -85,16 +104,18 @@ function emitScenePreviewImportProgress(evt: ScenePreviewImportEvent): void {
|
||||
}
|
||||
}
|
||||
|
||||
function emitMaterialUpsertProgress(evt: {
|
||||
percent: number;
|
||||
stage: string;
|
||||
detail?: string;
|
||||
}): void {
|
||||
function emitMaterialUpsertProgress(evt: { percent: number; stage: string; detail?: string }): void {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(ipcChannels.project.materialUpsertProgress, evt);
|
||||
}
|
||||
}
|
||||
|
||||
function emitNpcUpsertProgress(evt: { percent: number; stage: string; detail?: string }): void {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(ipcChannels.project.npcUpsertProgress, evt);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отключение GPU ломает скорость вторичных окон (презентация/пульт — WebGL). По умолчанию не трогаем.
|
||||
* При чёрном экране в упакованной сборке: `DND_DISABLE_GPU=1`.
|
||||
@@ -132,32 +153,9 @@ if (!gotTheLock) {
|
||||
|
||||
const projectStore = new ZipProjectStore();
|
||||
|
||||
/** Без меню Electron не вешает горячие клавиши DevTools (Ctrl+Shift+I / F12). */
|
||||
function wantsDevToolsMenu(): boolean {
|
||||
return (
|
||||
process.env.NODE_ENV === 'development' ||
|
||||
Boolean(process.env.VITE_DEV_SERVER_URL) ||
|
||||
process.env.DND_OPEN_DEVTOOLS === '1'
|
||||
);
|
||||
}
|
||||
|
||||
/** Системная полоса меню Electron отключена во всех режимах (в т.ч. без пункта «Вид»). */
|
||||
function installAppMenuForSession(): void {
|
||||
if (!wantsDevToolsMenu()) {
|
||||
Menu.setApplicationMenu(null);
|
||||
return;
|
||||
}
|
||||
const template: Electron.MenuItemConstructorOptions[] = [];
|
||||
if (process.platform === 'darwin') {
|
||||
template.push({
|
||||
label: app.name,
|
||||
submenu: [{ role: 'about' }, { type: 'separator' }, { role: 'quit' }],
|
||||
});
|
||||
}
|
||||
template.push({
|
||||
label: 'Вид',
|
||||
submenu: [{ role: 'reload' }, { role: 'forceReload' }, { type: 'separator' }, { role: 'toggleDevTools' }],
|
||||
});
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
||||
Menu.setApplicationMenu(null);
|
||||
}
|
||||
|
||||
const effectsStore = new EffectsStore();
|
||||
@@ -168,7 +166,58 @@ const videoStore = new VideoPlaybackStore();
|
||||
const materialsOverlayStore = new MaterialsOverlayStore();
|
||||
const npcsOverlayStore = new NpcsOverlayStore();
|
||||
const sceneTokensSessionStore = new SceneTokensSessionStore();
|
||||
const tokenPathSessionStore = new TokenPathSessionStore();
|
||||
const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore();
|
||||
const scenePlayerTokensSessionStore = new ScenePlayerTokensSessionStore();
|
||||
const tokenGridSnapSessionStore = new TokenGridSnapSessionStore();
|
||||
let tokensStore: TokensStore | null = null;
|
||||
let playersStore: PlayersStore | null = null;
|
||||
|
||||
function emitTokenPathSessionState(): void {
|
||||
const state = tokenPathSessionStore.getState();
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(ipcChannels.tokenPathSession.stateChanged, { state });
|
||||
}
|
||||
}
|
||||
|
||||
function seedTokenPathPlaybackForProject(project: Project | null): void {
|
||||
tokenPathSessionStore.reset();
|
||||
if (!project?.currentSceneId) {
|
||||
emitTokenPathSessionState();
|
||||
return;
|
||||
}
|
||||
const scene = project.scenes[project.currentSceneId];
|
||||
if (!scene) {
|
||||
emitTokenPathSessionState();
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const seedOne = (kind: TokenPathTargetKind, placementId: string, path: NonNullable<(typeof scene.tokens)[number]['path']>) => {
|
||||
const pathLength = tokenPathTotalLength(path);
|
||||
if (pathLength <= 1e-9) return;
|
||||
tokenPathSessionStore.dispatch({
|
||||
kind: 'seedPlayback',
|
||||
entry: {
|
||||
kind,
|
||||
placementId,
|
||||
phase: path.startMode === 'delayed' ? 'delay' : 'moving',
|
||||
baseDist: 0,
|
||||
direction: 1,
|
||||
rejoinDist: null,
|
||||
durationSec: path.durationSec,
|
||||
pathLength,
|
||||
segmentStartedAtMs: now,
|
||||
},
|
||||
});
|
||||
};
|
||||
for (const t of scene.tokens ?? []) {
|
||||
if (t.path && t.path.points.length >= 2) seedOne('token', String(t.id), t.path);
|
||||
}
|
||||
for (const t of scene.npcTokens ?? []) {
|
||||
if (t.path && t.path.points.length >= 2) seedOne('npcToken', String(t.id), t.path);
|
||||
}
|
||||
emitTokenPathSessionState();
|
||||
}
|
||||
|
||||
function emitEffectsState(): void {
|
||||
const state = effectsStore.getState();
|
||||
@@ -237,10 +286,47 @@ function emitSceneTokensSessionState(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function emitTokenGridSnapState(): void {
|
||||
const { enabled } = tokenGridSnapSessionStore.getState();
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(ipcChannels.tokenGridSnap.stateChanged, { enabled });
|
||||
}
|
||||
}
|
||||
|
||||
function emitPlayersState(): void {
|
||||
const players = playersStore?.listPlayers() ?? [];
|
||||
const teams = playersStore?.listTeams() ?? [];
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(ipcChannels.players.stateChanged, { players, teams });
|
||||
}
|
||||
}
|
||||
|
||||
function emitPlayerUpsertProgress(p: { percent: number; stage: string; detail?: string }): void {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(ipcChannels.players.upsertProgress, p);
|
||||
}
|
||||
}
|
||||
|
||||
function emitSceneNpcTokensSessionState(): void {
|
||||
const state = sceneNpcTokensSessionStore.getState();
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(ipcChannels.sceneNpcTokensSession.stateChanged, { state });
|
||||
}
|
||||
}
|
||||
|
||||
function emitScenePlayerTokensSessionState(): void {
|
||||
const state = scenePlayerTokensSessionStore.getState();
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(ipcChannels.scenePlayerTokensSession.stateChanged, { state });
|
||||
}
|
||||
}
|
||||
|
||||
function syncSceneDarknessForProject(project: Project): void {
|
||||
const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null;
|
||||
const scene = project.currentSceneId ? project.scenes[project.currentSceneId] : undefined;
|
||||
const enabled = Boolean(scene?.darkenScene) && scene?.previewAssetType === 'image';
|
||||
const enabled =
|
||||
Boolean(scene?.darkenScene) &&
|
||||
(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video');
|
||||
sceneDarknessStore.switchScene(cacheKey, enabled);
|
||||
}
|
||||
|
||||
@@ -386,11 +472,15 @@ async function main() {
|
||||
const licenseService = new LicenseService(app.getPath('userData'));
|
||||
tokensStore = new TokensStore(app.getPath('userData'));
|
||||
await tokensStore.ensureLoaded();
|
||||
if (USERS_BRANCH_FEATURES_ENABLED) {
|
||||
playersStore = new PlayersStore(app.getPath('userData'));
|
||||
await playersStore.ensureLoaded();
|
||||
}
|
||||
setLicenseAssert(() => {
|
||||
licenseService.assertForIpc();
|
||||
});
|
||||
installAppMenuForSession();
|
||||
registerDndAssetProtocol(projectStore, tokensStore);
|
||||
registerDndAssetProtocol(projectStore, tokensStore, playersStore);
|
||||
registerHandler(ipcChannels.app.quit, () => {
|
||||
markAppQuitting();
|
||||
app.quit();
|
||||
@@ -405,25 +495,47 @@ async function main() {
|
||||
registerHandler(ipcChannels.license.setToken, async ({ token }) => licenseService.setToken(token));
|
||||
registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken());
|
||||
registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version));
|
||||
registerHandler(ipcChannels.windows.openMultiWindow, () => {
|
||||
registerHandler(ipcChannels.windows.openMultiWindow, (req) => {
|
||||
sceneDarknessStore.resetSession();
|
||||
sceneTrapsStore.resetSession();
|
||||
sceneTokensSessionStore.reset();
|
||||
tokenPathSessionStore.reset();
|
||||
sceneNpcTokensSessionStore.reset();
|
||||
scenePlayerTokensSessionStore.reset();
|
||||
tokenGridSnapSessionStore.reset();
|
||||
if (USERS_BRANCH_FEATURES_ENABLED) {
|
||||
const playerIds = Array.isArray(req?.playerIds) ? req.playerIds.map(String).filter(Boolean) : [];
|
||||
if (playerIds.length > 0) {
|
||||
scenePlayerTokensSessionStore.dispatch({ kind: 'setSelection', playerIds });
|
||||
}
|
||||
}
|
||||
effectsStore.dispatch({ kind: 'tool.set', tool: effectsDefaultTool() });
|
||||
openMultiWindow();
|
||||
const project = projectStore.getOpenProject();
|
||||
if (project) {
|
||||
syncSceneDarknessForProject(project);
|
||||
syncSceneTrapsForProject(project);
|
||||
seedTokenPathPlaybackForProject(project);
|
||||
} else {
|
||||
emitTokenPathSessionState();
|
||||
}
|
||||
emitSceneDarknessState();
|
||||
emitSceneTrapsState();
|
||||
emitSceneTokensSessionState();
|
||||
emitSceneNpcTokensSessionState();
|
||||
emitScenePlayerTokensSessionState();
|
||||
emitTokenGridSnapState();
|
||||
emitEffectsState();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.closeMultiWindow, () => {
|
||||
closeMultiWindow();
|
||||
tokenGridSnapSessionStore.reset();
|
||||
emitTokenGridSnapState();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.syncChromeTitles, ({ localeTag }) => {
|
||||
syncAllWindowChromeTitles(localeTag);
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.togglePresentationFullscreen, () => {
|
||||
@@ -433,6 +545,10 @@ async function main() {
|
||||
registerHandler(ipcChannels.windows.getMultiWindowState, () => {
|
||||
return { open: isMultiWindowOpen() };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.getPresentationContentSize, () => {
|
||||
const size = getPresentationContentSize();
|
||||
return size ?? { width: null, height: null };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.openSceneDescription, ({ html }) => {
|
||||
openSceneDescriptionWindow(html);
|
||||
return { ok: true };
|
||||
@@ -468,8 +584,22 @@ async function main() {
|
||||
closeSceneEditorWindow();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.openNpcs, () => {
|
||||
registerHandler(ipcChannels.windows.openTokenPathEditor, ({ kind, placementId }) => {
|
||||
openTokenPathEditorWindow(kind, placementId);
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.closeTokenPathEditor, () => {
|
||||
closeTokenPathEditorWindow();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.getTokenPathEditorTarget, () => getTokenPathEditorTarget());
|
||||
registerHandler(ipcChannels.windows.openNpcs, (req) => {
|
||||
openNpcsWindow();
|
||||
const npcId = req?.npcId ? asNpcId(String(req.npcId)) : null;
|
||||
if (npcId) {
|
||||
npcsOverlayStore.dispatch({ kind: 'show', npcId });
|
||||
emitNpcsOverlayState();
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.windows.closeNpcs, () => {
|
||||
@@ -508,18 +638,25 @@ async function main() {
|
||||
registerHandler(ipcChannels.project.create, async ({ name }) => {
|
||||
const project = await projectStore.createProject(name);
|
||||
emitSessionState();
|
||||
warmNpcsEditorWindow();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.open, async ({ projectId }) => {
|
||||
const project = await projectStore.openProjectById(projectId);
|
||||
sceneViewStore.reset();
|
||||
sceneTokensSessionStore.reset();
|
||||
sceneNpcTokensSessionStore.reset();
|
||||
scenePlayerTokensSessionStore.reset();
|
||||
emitSceneViewState();
|
||||
emitSceneTokensSessionState();
|
||||
emitSceneNpcTokensSessionState();
|
||||
emitScenePlayerTokensSessionState();
|
||||
emitSessionState();
|
||||
warmNpcsEditorWindow();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.close, async () => {
|
||||
closeNpcsEditorWindow();
|
||||
await projectStore.closeOpenProject();
|
||||
effectsStore.clear();
|
||||
materialsOverlayStore.clear();
|
||||
@@ -528,6 +665,9 @@ async function main() {
|
||||
sceneTrapsStore.resetSession();
|
||||
sceneViewStore.reset();
|
||||
sceneTokensSessionStore.reset();
|
||||
sceneNpcTokensSessionStore.reset();
|
||||
scenePlayerTokensSessionStore.reset();
|
||||
tokenGridSnapSessionStore.reset();
|
||||
emitEffectsState();
|
||||
emitMaterialsOverlayState();
|
||||
emitNpcsOverlayState();
|
||||
@@ -535,6 +675,9 @@ async function main() {
|
||||
emitSceneTrapsState();
|
||||
emitSceneViewState();
|
||||
emitSceneTokensSessionState();
|
||||
emitSceneNpcTokensSessionState();
|
||||
emitScenePlayerTokensSessionState();
|
||||
emitTokenGridSnapState();
|
||||
emitSessionState();
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -551,6 +694,7 @@ async function main() {
|
||||
materialsOverlayStore.clear();
|
||||
npcsOverlayStore.clear();
|
||||
sceneViewStore.reset();
|
||||
scenePlayerTokensSessionStore.clearPlacements();
|
||||
// Token moves persist for the whole play session (reset only on project open/close).
|
||||
const project = projectStore.getOpenProject();
|
||||
if (project) {
|
||||
@@ -564,6 +708,8 @@ async function main() {
|
||||
emitSceneTrapsState();
|
||||
emitSceneViewState();
|
||||
emitSceneTokensSessionState();
|
||||
emitScenePlayerTokensSessionState();
|
||||
seedTokenPathPlaybackForProject(project);
|
||||
emitSessionState();
|
||||
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
|
||||
});
|
||||
@@ -580,6 +726,7 @@ async function main() {
|
||||
materialsOverlayStore.clear();
|
||||
npcsOverlayStore.clear();
|
||||
sceneViewStore.reset();
|
||||
scenePlayerTokensSessionStore.clearPlacements();
|
||||
// Token moves persist for the whole play session (reset only on project open/close).
|
||||
const project = projectStore.getOpenProject();
|
||||
if (project) {
|
||||
@@ -593,6 +740,8 @@ async function main() {
|
||||
emitSceneTrapsState();
|
||||
emitSceneViewState();
|
||||
emitSceneTokensSessionState();
|
||||
emitScenePlayerTokensSessionState();
|
||||
seedTokenPathPlaybackForProject(project);
|
||||
emitSessionState();
|
||||
const p = projectStore.getOpenProject();
|
||||
return {
|
||||
@@ -601,6 +750,7 @@ async function main() {
|
||||
};
|
||||
});
|
||||
registerHandler(ipcChannels.project.updateScene, async ({ sceneId, patch }) => {
|
||||
const before = projectStore.getOpenProject()?.scenes[sceneId];
|
||||
const next = await projectStore.updateScene(sceneId, patch);
|
||||
const project = projectStore.getOpenProject();
|
||||
if (project?.currentSceneId === sceneId && patch.darkenScene !== undefined) {
|
||||
@@ -611,6 +761,29 @@ async function main() {
|
||||
syncSceneTrapsForProject(project);
|
||||
emitSceneTrapsState();
|
||||
}
|
||||
if (
|
||||
project?.currentSceneId === sceneId &&
|
||||
patch.previewRotationDeg !== undefined &&
|
||||
before
|
||||
) {
|
||||
const steps = previewRotationStepsCw(
|
||||
asPreviewRotationDeg(before.previewRotationDeg),
|
||||
asPreviewRotationDeg(patch.previewRotationDeg),
|
||||
);
|
||||
if (steps !== 0) {
|
||||
sceneTokensSessionStore.rotateMapCwSteps(steps);
|
||||
sceneNpcTokensSessionStore.rotateMapCwSteps(steps);
|
||||
scenePlayerTokensSessionStore.rotateMapCwSteps(steps);
|
||||
emitSceneTokensSessionState();
|
||||
emitSceneNpcTokensSessionState();
|
||||
emitScenePlayerTokensSessionState();
|
||||
}
|
||||
}
|
||||
if (project && project.currentSceneId === sceneId && patch.previewRotationDeg !== undefined) {
|
||||
// Trap ids stay the same; runtime statuses remain valid after coordinate remap in project.
|
||||
syncSceneTrapsForProject(project);
|
||||
emitSceneTrapsState();
|
||||
}
|
||||
emitSessionState();
|
||||
return { scene: next };
|
||||
});
|
||||
@@ -670,36 +843,39 @@ 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');
|
||||
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];
|
||||
}
|
||||
filePath = filePaths[0];
|
||||
}
|
||||
const project = await projectStore.upsertMaterial(
|
||||
{
|
||||
...(materialId ? { materialId } : {}),
|
||||
name,
|
||||
...(filePath ? { filePath } : {}),
|
||||
},
|
||||
(p) => emitMaterialUpsertProgress(p),
|
||||
);
|
||||
syncMaterialsOverlayWithProject(project);
|
||||
emitMaterialsOverlayState();
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
const project = await projectStore.upsertMaterial(
|
||||
{
|
||||
...(materialId ? { materialId } : {}),
|
||||
name,
|
||||
...(filePath ? { filePath } : {}),
|
||||
},
|
||||
(p) => emitMaterialUpsertProgress(p),
|
||||
);
|
||||
syncMaterialsOverlayWithProject(project);
|
||||
emitMaterialsOverlayState();
|
||||
emitSessionState();
|
||||
return { project };
|
||||
},
|
||||
);
|
||||
registerHandler(ipcChannels.project.deleteMaterial, async ({ materialId }) => {
|
||||
const project = await projectStore.deleteMaterial(materialId);
|
||||
syncMaterialsOverlayWithProject(project);
|
||||
@@ -736,14 +912,13 @@ async function main() {
|
||||
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 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.upsertNpc,
|
||||
async ({ npcId, name, description, filePath: pathFromDrop, groupId, binding }) => {
|
||||
async ({ npcId, name, description, filePath: pathFromDrop, groupId, ringColor, disposition, imageOffset }) => {
|
||||
let filePath = pathFromDrop;
|
||||
if (!filePath && !npcId) {
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||
@@ -760,14 +935,19 @@ async function main() {
|
||||
}
|
||||
filePath = filePaths[0];
|
||||
}
|
||||
const project = await projectStore.upsertNpc({
|
||||
...(npcId ? { npcId } : {}),
|
||||
name,
|
||||
...(typeof description === 'string' ? { description } : {}),
|
||||
...(filePath ? { filePath } : {}),
|
||||
...(groupId !== undefined ? { groupId } : {}),
|
||||
...(binding !== undefined ? { binding } : {}),
|
||||
});
|
||||
const project = await projectStore.upsertNpc(
|
||||
{
|
||||
...(npcId ? { npcId } : {}),
|
||||
name,
|
||||
...(typeof description === 'string' ? { description } : {}),
|
||||
...(filePath ? { filePath } : {}),
|
||||
...(groupId !== undefined ? { groupId } : {}),
|
||||
...(ringColor !== undefined ? { ringColor } : {}),
|
||||
...(disposition !== undefined ? { disposition } : {}),
|
||||
...(imageOffset !== undefined ? { imageOffset } : {}),
|
||||
},
|
||||
(p) => emitNpcUpsertProgress(p),
|
||||
);
|
||||
syncNpcsOverlayWithProject(project);
|
||||
emitNpcsOverlayState();
|
||||
emitSessionState();
|
||||
@@ -776,12 +956,15 @@ async function main() {
|
||||
);
|
||||
registerHandler(
|
||||
ipcChannels.project.updateNpcFields,
|
||||
async ({ npcId, name, description, groupId, binding }) => {
|
||||
async ({ npcId, name, description, groupId, ringColor, disposition, imageOffset, imageScale }) => {
|
||||
const project = await projectStore.updateNpcFields(npcId, {
|
||||
...(typeof name === 'string' ? { name } : {}),
|
||||
...(typeof description === 'string' ? { description } : {}),
|
||||
...(groupId !== undefined ? { groupId } : {}),
|
||||
...(binding !== undefined ? { binding } : {}),
|
||||
...(ringColor !== undefined ? { ringColor } : {}),
|
||||
...(disposition !== undefined ? { disposition } : {}),
|
||||
...(imageOffset !== undefined ? { imageOffset } : {}),
|
||||
...(imageScale !== undefined ? { imageScale } : {}),
|
||||
});
|
||||
emitSessionState();
|
||||
return { project };
|
||||
@@ -818,8 +1001,7 @@ async function main() {
|
||||
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 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 };
|
||||
});
|
||||
@@ -841,19 +1023,16 @@ async function main() {
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(
|
||||
ipcChannels.project.upsertNpcGroup,
|
||||
async ({ groupId, name, color, parentId }) => {
|
||||
const project = await projectStore.upsertNpcGroup({
|
||||
...(groupId ? { groupId } : {}),
|
||||
name,
|
||||
...(typeof color === 'string' ? { color } : {}),
|
||||
...(parentId !== undefined ? { parentId } : {}),
|
||||
});
|
||||
emitSessionState();
|
||||
return { project };
|
||||
},
|
||||
);
|
||||
registerHandler(ipcChannels.project.upsertNpcGroup, async ({ groupId, name, color, parentId }) => {
|
||||
const project = await projectStore.upsertNpcGroup({
|
||||
...(groupId ? { groupId } : {}),
|
||||
name,
|
||||
...(typeof color === 'string' ? { color } : {}),
|
||||
...(parentId !== undefined ? { parentId } : {}),
|
||||
});
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.deleteNpcGroup, async ({ groupId }) => {
|
||||
const project = await projectStore.deleteNpcGroup(groupId);
|
||||
syncNpcsOverlayWithProject(project);
|
||||
@@ -899,13 +1078,14 @@ async function main() {
|
||||
const finalized = await projectStore.finalizeScenePreviewImport(sceneId, result.assetId);
|
||||
if (finalized.changed) {
|
||||
emitSessionState();
|
||||
emitScenePreviewImportProgress({
|
||||
sceneId,
|
||||
assetId: result.assetId,
|
||||
phase: 'done',
|
||||
project: finalized.project,
|
||||
});
|
||||
}
|
||||
// Always clear optimizing UI — including abort on project close (changed: false).
|
||||
emitScenePreviewImportProgress({
|
||||
sceneId,
|
||||
assetId: result.assetId,
|
||||
phase: 'done',
|
||||
...(finalized.changed && finalized.project ? { project: finalized.project } : {}),
|
||||
});
|
||||
} catch (e) {
|
||||
emitScenePreviewImportProgress({
|
||||
sceneId,
|
||||
@@ -1000,8 +1180,7 @@ async function main() {
|
||||
return { canceled: false as const, project };
|
||||
});
|
||||
registerHandler(ipcChannels.project.getProjectStorylines, async ({ projectId, labels }) => {
|
||||
const storylines = await projectStore.getProjectStorylines(projectId, labels);
|
||||
return { storylines };
|
||||
return projectStore.getProjectStorylines(projectId, labels);
|
||||
});
|
||||
registerHandler(ipcChannels.project.peekImportZip, async ({ labels, targetHasMainStart }) => {
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||
@@ -1028,9 +1207,12 @@ async function main() {
|
||||
registerHandler(ipcChannels.project.peekImportZipPath, async ({ filePath, labels, targetHasMainStart }) => {
|
||||
return projectStore.peekImportFromZipPath(filePath, labels, targetHasMainStart);
|
||||
});
|
||||
registerHandler(ipcChannels.project.peekImportFromProject, async ({ sourceProjectId, labels, targetHasMainStart }) => {
|
||||
return projectStore.peekImportFromProjectId(sourceProjectId, labels, targetHasMainStart);
|
||||
});
|
||||
registerHandler(
|
||||
ipcChannels.project.peekImportFromProject,
|
||||
async ({ sourceProjectId, labels, targetHasMainStart }) => {
|
||||
return projectStore.peekImportFromProjectId(sourceProjectId, labels, targetHasMainStart);
|
||||
},
|
||||
);
|
||||
registerHandler(
|
||||
ipcChannels.project.mergeImportZip,
|
||||
async ({ filePath, storylineSelections, sceneResolutions, npcResolutions }) => {
|
||||
@@ -1129,7 +1311,8 @@ async function main() {
|
||||
const project = await projectStore.importProjectFromFoundry(sourcePath, (p) => {
|
||||
emitZipProgress({
|
||||
kind: 'import',
|
||||
stage: p.stage === 'unzip' ? 'unzip' : p.stage === 'zip' ? 'zip' : p.stage === 'done' ? 'done' : 'copy',
|
||||
stage:
|
||||
p.stage === 'unzip' ? 'unzip' : p.stage === 'zip' ? 'zip' : p.stage === 'done' ? 'done' : 'copy',
|
||||
percent: p.percent,
|
||||
...(p.detail ? { detail: p.detail } : null),
|
||||
});
|
||||
@@ -1142,52 +1325,56 @@ async function main() {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
registerHandler(ipcChannels.project.exportZip, async ({ projectId, storylineSelections, labels }) => {
|
||||
const list = await projectStore.listProjects();
|
||||
const entry = list.find((p) => p.id === projectId);
|
||||
if (!entry) {
|
||||
throw new Error('Проект не найден');
|
||||
}
|
||||
const defaultName = isProjectZipFileName(entry.fileName)
|
||||
? entry.fileName.toLowerCase().endsWith('.ttrpg.zip')
|
||||
? entry.fileName
|
||||
: projectZipFileNameFromBase(stripProjectZipExtension(entry.fileName))
|
||||
: projectZipFileNameFromBase(stripProjectZipExtension(entry.fileName));
|
||||
const { canceled, filePath } = await dialog.showSaveDialog({
|
||||
defaultPath: defaultName,
|
||||
filters: [PROJECT_ZIP_SAVE_DIALOG_FILTER],
|
||||
});
|
||||
if (canceled || !filePath) {
|
||||
return { canceled: true as const };
|
||||
}
|
||||
const dest = normalizeSaveProjectZipPath(filePath);
|
||||
try {
|
||||
emitZipProgress({ kind: 'export', stage: 'zip', percent: 0, detail: 'Экспорт…' });
|
||||
await projectStore.exportStorylinesZipToPath(
|
||||
projectId,
|
||||
storylineSelections,
|
||||
dest,
|
||||
labels,
|
||||
(p) => {
|
||||
emitZipProgress({
|
||||
kind: 'export',
|
||||
stage: p.stage,
|
||||
percent: p.percent,
|
||||
...(p.detail ? { detail: p.detail } : null),
|
||||
});
|
||||
},
|
||||
async (tokenIds, exportRoot) => {
|
||||
await tokensStore!.packForExport(tokenIds, exportRoot);
|
||||
},
|
||||
);
|
||||
emitZipProgress({ kind: 'export', stage: 'done', percent: 100, detail: 'Готово' });
|
||||
return { canceled: false as const };
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : 'Ошибка экспорта';
|
||||
emitZipProgress({ kind: 'export', stage: 'error', percent: 0, detail });
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
registerHandler(
|
||||
ipcChannels.project.exportZip,
|
||||
async ({ projectId, storylineSelections, npcIds, labels }) => {
|
||||
const list = await projectStore.listProjects();
|
||||
const entry = list.find((p) => p.id === projectId);
|
||||
if (!entry) {
|
||||
throw new Error('Проект не найден');
|
||||
}
|
||||
const defaultName = isProjectZipFileName(entry.fileName)
|
||||
? entry.fileName.toLowerCase().endsWith('.ttrpg.zip')
|
||||
? entry.fileName
|
||||
: projectZipFileNameFromBase(stripProjectZipExtension(entry.fileName))
|
||||
: projectZipFileNameFromBase(stripProjectZipExtension(entry.fileName));
|
||||
const { canceled, filePath } = await dialog.showSaveDialog({
|
||||
defaultPath: defaultName,
|
||||
filters: [PROJECT_ZIP_SAVE_DIALOG_FILTER],
|
||||
});
|
||||
if (canceled || !filePath) {
|
||||
return { canceled: true as const };
|
||||
}
|
||||
const dest = normalizeSaveProjectZipPath(filePath);
|
||||
try {
|
||||
emitZipProgress({ kind: 'export', stage: 'zip', percent: 0, detail: 'Экспорт…' });
|
||||
await projectStore.exportStorylinesZipToPath(
|
||||
projectId,
|
||||
storylineSelections,
|
||||
npcIds ?? [],
|
||||
dest,
|
||||
labels,
|
||||
(p) => {
|
||||
emitZipProgress({
|
||||
kind: 'export',
|
||||
stage: p.stage,
|
||||
percent: p.percent,
|
||||
...(p.detail ? { detail: p.detail } : null),
|
||||
});
|
||||
},
|
||||
async (tokenIds, exportRoot) => {
|
||||
await tokensStore!.packForExport(tokenIds, exportRoot);
|
||||
},
|
||||
);
|
||||
emitZipProgress({ kind: 'export', stage: 'done', percent: 100, detail: 'Готово' });
|
||||
return { canceled: false as const };
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : 'Ошибка экспорта';
|
||||
emitZipProgress({ kind: 'export', stage: 'error', percent: 0, detail });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
registerHandler(ipcChannels.project.deleteProject, async ({ projectId }) => {
|
||||
await projectStore.deleteProjectById(projectId);
|
||||
emitSessionState();
|
||||
@@ -1238,7 +1425,11 @@ async function main() {
|
||||
return { tokens: tokensStore!.list() };
|
||||
});
|
||||
registerHandler(ipcChannels.tokens.upsert, async ({ id, name, filePath }) => {
|
||||
const token = await tokensStore!.upsert({ id, name, filePath });
|
||||
const token = await tokensStore!.upsert({
|
||||
name,
|
||||
...(id !== undefined ? { id } : {}),
|
||||
...(filePath !== undefined ? { filePath } : {}),
|
||||
});
|
||||
emitTokensState();
|
||||
return { token };
|
||||
});
|
||||
@@ -1273,8 +1464,7 @@ async function main() {
|
||||
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 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 };
|
||||
});
|
||||
@@ -1289,6 +1479,146 @@ async function main() {
|
||||
emitSceneTokensSessionState();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.tokenPathSession.getState, () => {
|
||||
return { state: tokenPathSessionStore.getState() };
|
||||
});
|
||||
registerHandler(ipcChannels.tokenPathSession.dispatch, ({ event }) => {
|
||||
tokenPathSessionStore.dispatch(event);
|
||||
emitTokenPathSessionState();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
registerHandler(ipcChannels.tokenGridSnap.getState, () => tokenGridSnapSessionStore.getState());
|
||||
registerHandler(ipcChannels.tokenGridSnap.setEnabled, ({ enabled }) => {
|
||||
const next = tokenGridSnapSessionStore.setEnabled(enabled);
|
||||
emitTokenGridSnapState();
|
||||
return next;
|
||||
});
|
||||
|
||||
registerHandler(ipcChannels.players.list, async () => {
|
||||
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
|
||||
return { players: [], teams: [] };
|
||||
}
|
||||
await playersStore.ensureLoaded();
|
||||
return { players: playersStore.listPlayers(), teams: playersStore.listTeams() };
|
||||
});
|
||||
registerHandler(
|
||||
ipcChannels.players.upsert,
|
||||
async ({ id, name, filePath, teamId, ringColor, imageOffset, imageScale }) => {
|
||||
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
|
||||
throw new Error('Players library is disabled in this build');
|
||||
}
|
||||
const player = await playersStore.upsert(
|
||||
{
|
||||
name,
|
||||
...(id !== undefined ? { id } : {}),
|
||||
...(filePath !== undefined ? { filePath } : {}),
|
||||
...(teamId !== undefined ? { teamId } : {}),
|
||||
...(ringColor !== undefined ? { ringColor } : {}),
|
||||
...(imageOffset !== undefined ? { imageOffset } : {}),
|
||||
...(imageScale !== undefined ? { imageScale } : {}),
|
||||
},
|
||||
(p) => emitPlayerUpsertProgress(p),
|
||||
);
|
||||
emitPlayersState();
|
||||
return { player };
|
||||
},
|
||||
);
|
||||
registerHandler(ipcChannels.players.delete, async ({ id }) => {
|
||||
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
|
||||
return { ok: true };
|
||||
}
|
||||
await playersStore.delete(id);
|
||||
emitPlayersState();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.players.setOrder, async ({ playerIds }) => {
|
||||
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
|
||||
return { players: [] };
|
||||
}
|
||||
const players = await playersStore.setPlayersOrder(playerIds);
|
||||
emitPlayersState();
|
||||
return { players };
|
||||
});
|
||||
registerHandler(ipcChannels.players.upsertTeam, async ({ id, name, color }) => {
|
||||
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
|
||||
throw new Error('Players library is disabled in this build');
|
||||
}
|
||||
const team = await playersStore.upsertTeam({
|
||||
name,
|
||||
...(id !== undefined ? { id } : {}),
|
||||
...(color !== undefined ? { color } : {}),
|
||||
});
|
||||
emitPlayersState();
|
||||
return { team };
|
||||
});
|
||||
registerHandler(ipcChannels.players.deleteTeam, async ({ id }) => {
|
||||
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
|
||||
return { ok: true };
|
||||
}
|
||||
await playersStore.deleteTeam(id);
|
||||
emitPlayersState();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.players.setTeamsOrder, async ({ teamIds }) => {
|
||||
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
|
||||
return { teams: [] };
|
||||
}
|
||||
const teams = await playersStore.setTeamsOrder(teamIds);
|
||||
emitPlayersState();
|
||||
return { teams };
|
||||
});
|
||||
registerHandler(ipcChannels.players.assignTeam, async ({ playerId, teamId }) => {
|
||||
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
|
||||
throw new Error('Players library is disabled in this build');
|
||||
}
|
||||
const player = await playersStore.assignPlayerTeam(playerId, teamId);
|
||||
emitPlayersState();
|
||||
return { player };
|
||||
});
|
||||
registerHandler(ipcChannels.players.pickImage, async () => {
|
||||
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
|
||||
return { canceled: true as const };
|
||||
}
|
||||
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.players.imageUrl, ({ id }) => {
|
||||
if (!USERS_BRANCH_FEATURES_ENABLED || !playersStore) {
|
||||
return { url: null };
|
||||
}
|
||||
return { url: playersStore.getImageUrl(id) };
|
||||
});
|
||||
registerHandler(ipcChannels.sceneNpcTokensSession.getState, () => {
|
||||
return { state: sceneNpcTokensSessionStore.getState() };
|
||||
});
|
||||
registerHandler(ipcChannels.sceneNpcTokensSession.dispatch, ({ event }) => {
|
||||
sceneNpcTokensSessionStore.dispatch(event);
|
||||
emitSceneNpcTokensSessionState();
|
||||
return { ok: true };
|
||||
});
|
||||
registerHandler(ipcChannels.scenePlayerTokensSession.getState, () => {
|
||||
return { state: scenePlayerTokensSessionStore.getState() };
|
||||
});
|
||||
registerHandler(ipcChannels.scenePlayerTokensSession.dispatch, ({ event }) => {
|
||||
scenePlayerTokensSessionStore.dispatch(event);
|
||||
emitScenePlayerTokensSessionState();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
registerHandler(ipcChannels.video.getState, () => {
|
||||
return { state: videoStore.getState() };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
clampMaterialsLayout,
|
||||
DEFAULT_MATERIALS_OVERLAY_LAYOUT,
|
||||
defaultLegendLayoutForMaterial,
|
||||
type MaterialId,
|
||||
type MaterialsOverlayEvent,
|
||||
type MaterialsOverlayLayout,
|
||||
@@ -12,9 +13,10 @@ import {
|
||||
function emptyState(): MaterialsOverlayState {
|
||||
return {
|
||||
revision: 1,
|
||||
activeMaterialId: null,
|
||||
layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT },
|
||||
legendLayout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT, cx: 0.82, cy: 0.5, scale: 0.85 },
|
||||
activeMaterialIds: [],
|
||||
layouts: {},
|
||||
legendLayouts: {},
|
||||
focusMaterialId: null,
|
||||
zoomTool: null,
|
||||
};
|
||||
}
|
||||
@@ -26,14 +28,12 @@ function initialLayout(rotationDeg?: number): MaterialsOverlayLayout {
|
||||
});
|
||||
}
|
||||
|
||||
function initialLegendLayout(): MaterialsOverlayLayout {
|
||||
return clampMaterialsLayout({
|
||||
...DEFAULT_MATERIALS_OVERLAY_LAYOUT,
|
||||
cx: 0.82,
|
||||
cy: 0.5,
|
||||
scale: 0.85,
|
||||
rotationDeg: 0,
|
||||
});
|
||||
function layoutFor(state: MaterialsOverlayState, materialId: MaterialId): MaterialsOverlayLayout {
|
||||
return state.layouts[materialId] ?? { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT };
|
||||
}
|
||||
|
||||
function legendLayoutFor(state: MaterialsOverlayState, materialId: MaterialId): MaterialsOverlayLayout {
|
||||
return state.legendLayouts[materialId] ?? defaultLegendLayoutForMaterial();
|
||||
}
|
||||
|
||||
export class MaterialsOverlayStore {
|
||||
@@ -44,14 +44,15 @@ export class MaterialsOverlayStore {
|
||||
}
|
||||
|
||||
clear(): MaterialsOverlayState {
|
||||
if (this.state.activeMaterialId === null && this.state.zoomTool === null) {
|
||||
if (this.state.activeMaterialIds.length === 0 && this.state.zoomTool === null) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeMaterialId: null,
|
||||
layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT },
|
||||
legendLayout: initialLegendLayout(),
|
||||
activeMaterialIds: [],
|
||||
layouts: {},
|
||||
legendLayouts: {},
|
||||
focusMaterialId: null,
|
||||
zoomTool: null,
|
||||
};
|
||||
return this.state;
|
||||
@@ -61,50 +62,98 @@ export class MaterialsOverlayStore {
|
||||
switch (event.kind) {
|
||||
case 'hide':
|
||||
return this.clear();
|
||||
case 'show':
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeMaterialId: event.materialId,
|
||||
layout: initialLayout(event.rotationDeg),
|
||||
legendLayout: initialLegendLayout(),
|
||||
zoomTool: this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
case 'toggle': {
|
||||
if (this.state.activeMaterialId === event.materialId) {
|
||||
return this.clear();
|
||||
case 'show': {
|
||||
if (this.state.activeMaterialIds.includes(event.materialId)) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
focusMaterialId: event.materialId,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeMaterialId: event.materialId,
|
||||
layout: initialLayout(event.rotationDeg),
|
||||
legendLayout: initialLegendLayout(),
|
||||
activeMaterialIds: [...this.state.activeMaterialIds, event.materialId],
|
||||
layouts: {
|
||||
...this.state.layouts,
|
||||
[event.materialId]: initialLayout(event.rotationDeg),
|
||||
},
|
||||
legendLayouts: {
|
||||
...this.state.legendLayouts,
|
||||
[event.materialId]: defaultLegendLayoutForMaterial(),
|
||||
},
|
||||
focusMaterialId: event.materialId,
|
||||
zoomTool: this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'toggle': {
|
||||
if (this.state.activeMaterialIds.includes(event.materialId)) {
|
||||
const activeMaterialIds = this.state.activeMaterialIds.filter((id) => id !== event.materialId);
|
||||
const layouts = { ...this.state.layouts };
|
||||
const legendLayouts = { ...this.state.legendLayouts };
|
||||
delete layouts[event.materialId];
|
||||
delete legendLayouts[event.materialId];
|
||||
const focusMaterialId =
|
||||
this.state.focusMaterialId === event.materialId
|
||||
? (activeMaterialIds[activeMaterialIds.length - 1] ?? null)
|
||||
: this.state.focusMaterialId;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeMaterialIds,
|
||||
layouts,
|
||||
legendLayouts,
|
||||
focusMaterialId,
|
||||
zoomTool: activeMaterialIds.length === 0 ? null : this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeMaterialIds: [...this.state.activeMaterialIds, event.materialId],
|
||||
layouts: {
|
||||
...this.state.layouts,
|
||||
[event.materialId]: initialLayout(event.rotationDeg),
|
||||
},
|
||||
legendLayouts: {
|
||||
...this.state.legendLayouts,
|
||||
[event.materialId]: defaultLegendLayoutForMaterial(),
|
||||
},
|
||||
focusMaterialId: event.materialId,
|
||||
zoomTool: this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'layout.set': {
|
||||
if (this.state.activeMaterialId === null) return this.state;
|
||||
if (!this.state.activeMaterialIds.includes(event.materialId)) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
layout: clampMaterialsLayout({
|
||||
...this.state.layout,
|
||||
...event.layout,
|
||||
}),
|
||||
focusMaterialId: event.materialId,
|
||||
layouts: {
|
||||
...this.state.layouts,
|
||||
[event.materialId]: clampMaterialsLayout({
|
||||
...layoutFor(this.state, event.materialId),
|
||||
...event.layout,
|
||||
}),
|
||||
},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'legendLayout.set': {
|
||||
if (this.state.activeMaterialId === null) return this.state;
|
||||
if (!this.state.activeMaterialIds.includes(event.materialId)) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
legendLayout: clampMaterialsLayout({
|
||||
...this.state.legendLayout,
|
||||
...event.layout,
|
||||
rotationDeg: 0,
|
||||
}),
|
||||
focusMaterialId: event.materialId,
|
||||
legendLayouts: {
|
||||
...this.state.legendLayouts,
|
||||
[event.materialId]: clampMaterialsLayout({
|
||||
...legendLayoutFor(this.state, event.materialId),
|
||||
...event.layout,
|
||||
rotationDeg: 0,
|
||||
}),
|
||||
},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
@@ -118,10 +167,18 @@ export class MaterialsOverlayStore {
|
||||
return this.state;
|
||||
}
|
||||
case 'zoomAt': {
|
||||
if (this.state.activeMaterialId === null || !this.state.zoomTool) return this.state;
|
||||
if (this.state.activeMaterialIds.length === 0 || !this.state.zoomTool) return this.state;
|
||||
const targetId =
|
||||
(event.materialId && this.state.activeMaterialIds.includes(event.materialId)
|
||||
? event.materialId
|
||||
: null) ??
|
||||
this.state.focusMaterialId ??
|
||||
this.state.activeMaterialIds[this.state.activeMaterialIds.length - 1] ??
|
||||
null;
|
||||
if (!targetId) return this.state;
|
||||
const factor = this.state.zoomTool === 'zoomIn' ? 1.25 : 1 / 1.25;
|
||||
const layout: MaterialsOverlayLayout = zoomMaterialsLayoutAt(
|
||||
this.state.layout,
|
||||
layoutFor(this.state, targetId),
|
||||
event.nx,
|
||||
event.ny,
|
||||
factor,
|
||||
@@ -129,7 +186,11 @@ export class MaterialsOverlayStore {
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
layout,
|
||||
focusMaterialId: targetId,
|
||||
layouts: {
|
||||
...this.state.layouts,
|
||||
[targetId]: layout,
|
||||
},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
@@ -139,8 +200,29 @@ export class MaterialsOverlayStore {
|
||||
}
|
||||
|
||||
ensureMaterialStillExists(materialIds: ReadonlySet<MaterialId>): MaterialsOverlayState {
|
||||
const active = this.state.activeMaterialId;
|
||||
if (active === null || materialIds.has(active)) return this.state;
|
||||
return this.clear();
|
||||
const activeMaterialIds = this.state.activeMaterialIds.filter((id) => materialIds.has(id));
|
||||
if (activeMaterialIds.length === this.state.activeMaterialIds.length) return this.state;
|
||||
if (activeMaterialIds.length === 0) return this.clear();
|
||||
const layouts: Record<string, MaterialsOverlayLayout> = {};
|
||||
const legendLayouts: Record<string, MaterialsOverlayLayout> = {};
|
||||
for (const id of activeMaterialIds) {
|
||||
const layout = this.state.layouts[id];
|
||||
if (layout) layouts[id] = layout;
|
||||
const legend = this.state.legendLayouts[id];
|
||||
if (legend) legendLayouts[id] = legend;
|
||||
}
|
||||
const focusMaterialId =
|
||||
this.state.focusMaterialId && activeMaterialIds.includes(this.state.focusMaterialId)
|
||||
? this.state.focusMaterialId
|
||||
: (activeMaterialIds[activeMaterialIds.length - 1] ?? null);
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
activeMaterialIds,
|
||||
layouts,
|
||||
legendLayouts,
|
||||
focusMaterialId,
|
||||
zoomTool: this.state.zoomTool,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { PlayersStore } from './playersStore';
|
||||
import { asPlayerId } from '../../shared/types/ids';
|
||||
|
||||
async function withTempStore(run: (store: PlayersStore, root: string) => Promise<void>) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-players-'));
|
||||
const store = new PlayersStore(root);
|
||||
try {
|
||||
await run(store, root);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
void test('PlayersStore upsert list delete and teams', async () => {
|
||||
await withTempStore(async (store, root) => {
|
||||
const png = path.join(root, 'sample.png');
|
||||
// minimal 1x1 png
|
||||
const buf = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
await fs.writeFile(png, buf);
|
||||
|
||||
const progress: number[] = [];
|
||||
const player = await store.upsert(
|
||||
{ name: 'Ada', filePath: png, ringColor: '#112233', imageOffset: { x: 0.1, y: -0.1 } },
|
||||
(p) => progress.push(p.percent),
|
||||
);
|
||||
assert.equal(player.name, 'Ada');
|
||||
assert.equal(player.ringColor, '#112233');
|
||||
assert.ok(progress.length > 0);
|
||||
assert.equal(store.listPlayers().length, 1);
|
||||
|
||||
const team = await store.upsertTeam({ name: 'Party', color: '#abcdef' });
|
||||
assert.equal(team.name, 'Party');
|
||||
const assigned = await store.assignPlayerTeam(player.id, team.id);
|
||||
assert.equal(assigned?.teamId, team.id);
|
||||
|
||||
await store.deleteTeam(team.id);
|
||||
assert.equal(store.listTeams().length, 0);
|
||||
assert.equal(store.getById(player.id)?.teamId, null);
|
||||
|
||||
await store.delete(player.id);
|
||||
assert.equal(store.listPlayers().length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
void test('PlayersStore persists across reload', async () => {
|
||||
await withTempStore(async (store, root) => {
|
||||
const png = path.join(root, 'sample.png');
|
||||
await fs.writeFile(
|
||||
png,
|
||||
Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
),
|
||||
);
|
||||
const created = await store.upsert({ name: 'Bob', filePath: png });
|
||||
const store2 = new PlayersStore(root);
|
||||
await store2.ensureLoaded();
|
||||
assert.equal(store2.listPlayers().length, 1);
|
||||
assert.equal(store2.getById(asPlayerId(created.id))?.name, 'Bob');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
assignPlayerToTeam,
|
||||
createPlayerTeamDraft,
|
||||
deletePlayerTeam,
|
||||
normalizeAppPlayerTeams,
|
||||
uniquePlayerTeamName,
|
||||
} from '../../shared/players/playerTeams';
|
||||
import type {
|
||||
AppPlayer,
|
||||
AppPlayerTeam,
|
||||
PlayerId,
|
||||
PlayerImageOffset,
|
||||
PlayersUpsertProgressEvent,
|
||||
PlayerTeamId,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import {
|
||||
clampPlayerImageOffset,
|
||||
clampPlayerImageScale,
|
||||
DEFAULT_PLAYER_IMAGE_OFFSET,
|
||||
DEFAULT_PLAYER_IMAGE_SCALE,
|
||||
DEFAULT_PLAYER_RING_COLOR,
|
||||
normalizeAppPlayer,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import { asPlayerId } from '../../shared/types/ids';
|
||||
import { normalizeHexColor } from '../../shared/npcs/npcGroups';
|
||||
import { optimizeImageBufferVisuallyLossless } from '../project/optimizeImageImport.lib.mjs';
|
||||
|
||||
type PlayersManifest = {
|
||||
players: AppPlayer[];
|
||||
teams: AppPlayerTeam[];
|
||||
};
|
||||
|
||||
function mimeFromExt(ext: string): string {
|
||||
const e = ext.toLowerCase();
|
||||
if (e === '.png') return 'image/png';
|
||||
if (e === '.jpg' || e === '.jpeg') return 'image/jpeg';
|
||||
if (e === '.webp') return 'image/webp';
|
||||
if (e === '.gif') return 'image/gif';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
function safeFileBase(name: string): string {
|
||||
const base = name.replace(/[^\w.\-]+/gu, '_').slice(0, 48);
|
||||
return base || 'player';
|
||||
}
|
||||
|
||||
function randomPlayerId(): PlayerId {
|
||||
return asPlayerId(`player_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
|
||||
}
|
||||
|
||||
export class PlayersStore {
|
||||
private readonly rootDir: string;
|
||||
private readonly filesDir: string;
|
||||
private readonly manifestPath: string;
|
||||
private players: AppPlayer[] = [];
|
||||
private teams: AppPlayerTeam[] = [];
|
||||
private loaded = false;
|
||||
|
||||
constructor(userData: string) {
|
||||
this.rootDir = path.join(userData, 'players');
|
||||
this.filesDir = path.join(this.rootDir, 'files');
|
||||
this.manifestPath = path.join(this.rootDir, 'players.json');
|
||||
}
|
||||
|
||||
async ensureLoaded(): Promise<void> {
|
||||
if (this.loaded) return;
|
||||
await fs.mkdir(this.filesDir, { recursive: true });
|
||||
try {
|
||||
const raw = await fs.readFile(this.manifestPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as PlayersManifest;
|
||||
this.teams = normalizeAppPlayerTeams(parsed.teams);
|
||||
const teamIds = new Set(this.teams.map((t) => t.id));
|
||||
this.players = (Array.isArray(parsed.players) ? parsed.players : [])
|
||||
.map((p) => normalizeAppPlayer(p, teamIds))
|
||||
.filter((p): p is AppPlayer => Boolean(p));
|
||||
} catch {
|
||||
this.players = [];
|
||||
this.teams = [];
|
||||
await this.persist();
|
||||
}
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
listPlayers(): AppPlayer[] {
|
||||
return [...this.players];
|
||||
}
|
||||
|
||||
listTeams(): AppPlayerTeam[] {
|
||||
return [...this.teams];
|
||||
}
|
||||
|
||||
getById(id: PlayerId): AppPlayer | null {
|
||||
return this.players.find((p) => p.id === id) ?? null;
|
||||
}
|
||||
|
||||
getImageReadInfo(id: PlayerId): { absPath: string; mime: string } | null {
|
||||
const player = this.getById(id);
|
||||
if (!player) return null;
|
||||
const absPath = path.join(this.rootDir, player.imageRelPath);
|
||||
return { absPath, mime: mimeFromExt(path.extname(player.imageRelPath)) };
|
||||
}
|
||||
|
||||
getImageUrl(id: PlayerId): string | null {
|
||||
if (!this.getImageReadInfo(id)) return null;
|
||||
return `dnd://player?id=${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
async upsert(
|
||||
input: {
|
||||
id?: PlayerId | null;
|
||||
name: string;
|
||||
filePath?: string | null;
|
||||
teamId?: PlayerTeamId | null;
|
||||
ringColor?: string;
|
||||
imageOffset?: PlayerImageOffset;
|
||||
imageScale?: number;
|
||||
},
|
||||
onProgress?: (p: PlayersUpsertProgressEvent) => void,
|
||||
): Promise<AppPlayer> {
|
||||
await this.ensureLoaded();
|
||||
const name = input.name.trim();
|
||||
if (!name) throw new Error('Player name is required');
|
||||
|
||||
const existing = input.id ? this.getById(input.id) : null;
|
||||
if (input.id && !existing) throw new Error('Player not found');
|
||||
if (!existing && !input.filePath) throw new Error('Player image is required');
|
||||
|
||||
const emit = (percent: number, stage: string, detail?: string) => {
|
||||
onProgress?.({ percent, stage, ...(detail ? { detail } : {}) });
|
||||
};
|
||||
|
||||
emit(5, 'prepare', 'Подготовка…');
|
||||
|
||||
let imageRelPath = existing?.imageRelPath ?? '';
|
||||
let sha256 = existing?.sha256 ?? '';
|
||||
const id = existing?.id ?? randomPlayerId();
|
||||
|
||||
if (input.filePath) {
|
||||
emit(15, 'read', 'Чтение изображения…');
|
||||
let buf = await fs.readFile(input.filePath);
|
||||
emit(40, 'optimize', 'Оптимизация…');
|
||||
try {
|
||||
const opt = await optimizeImageBufferVisuallyLossless(buf);
|
||||
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
|
||||
} catch {
|
||||
/* keep original */
|
||||
}
|
||||
sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
||||
const ext = path.extname(input.filePath) || '.png';
|
||||
const fileName = `${id}_${safeFileBase(name)}${ext.toLowerCase()}`;
|
||||
imageRelPath = path.join('files', fileName).replace(/\\/gu, '/');
|
||||
const abs = path.join(this.rootDir, imageRelPath);
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||
emit(75, 'write', 'Сохранение…');
|
||||
await fs.writeFile(abs, buf);
|
||||
if (existing && existing.imageRelPath !== imageRelPath) {
|
||||
try {
|
||||
await fs.unlink(path.join(this.rootDir, existing.imageRelPath));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const teamIds = new Set(this.teams.map((t) => t.id));
|
||||
let teamId: PlayerTeamId | null =
|
||||
input.teamId !== undefined ? input.teamId : (existing?.teamId ?? null);
|
||||
if (teamId && !teamIds.has(teamId)) teamId = null;
|
||||
|
||||
const player: AppPlayer = {
|
||||
id,
|
||||
name,
|
||||
imageRelPath,
|
||||
sha256,
|
||||
teamId,
|
||||
ringColor: normalizeHexColor(
|
||||
input.ringColor ?? existing?.ringColor,
|
||||
DEFAULT_PLAYER_RING_COLOR,
|
||||
),
|
||||
imageOffset: clampPlayerImageOffset(
|
||||
input.imageOffset ?? existing?.imageOffset ?? DEFAULT_PLAYER_IMAGE_OFFSET,
|
||||
),
|
||||
imageScale: clampPlayerImageScale(
|
||||
input.imageScale ?? existing?.imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE,
|
||||
),
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
this.players = this.players.map((p) => (p.id === player.id ? player : p));
|
||||
} else {
|
||||
this.players = [...this.players, player];
|
||||
}
|
||||
emit(95, 'persist', 'Запись…');
|
||||
await this.persist();
|
||||
emit(100, 'done', 'Готово');
|
||||
return player;
|
||||
}
|
||||
|
||||
async delete(id: PlayerId): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
const existing = this.getById(id);
|
||||
if (!existing) return;
|
||||
this.players = this.players.filter((p) => p.id !== id);
|
||||
await this.persist();
|
||||
try {
|
||||
await fs.unlink(path.join(this.rootDir, existing.imageRelPath));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async setPlayersOrder(playerIds: PlayerId[]): Promise<AppPlayer[]> {
|
||||
await this.ensureLoaded();
|
||||
const byId = new Map(this.players.map((p) => [p.id, p]));
|
||||
const next: AppPlayer[] = [];
|
||||
for (const id of playerIds) {
|
||||
const p = byId.get(id);
|
||||
if (p) {
|
||||
next.push(p);
|
||||
byId.delete(id);
|
||||
}
|
||||
}
|
||||
for (const p of byId.values()) next.push(p);
|
||||
this.players = next;
|
||||
await this.persist();
|
||||
return this.listPlayers();
|
||||
}
|
||||
|
||||
async upsertTeam(input: {
|
||||
id?: PlayerTeamId | null;
|
||||
name: string;
|
||||
color?: string;
|
||||
}): Promise<AppPlayerTeam> {
|
||||
await this.ensureLoaded();
|
||||
const existing = input.id ? this.teams.find((t) => t.id === input.id) : null;
|
||||
if (input.id && !existing) throw new Error('Team not found');
|
||||
if (existing) {
|
||||
const team: AppPlayerTeam = {
|
||||
id: existing.id,
|
||||
name: uniquePlayerTeamName(input.name, this.teams, existing.id),
|
||||
color: normalizeHexColor(input.color ?? existing.color, DEFAULT_PLAYER_RING_COLOR),
|
||||
};
|
||||
this.teams = this.teams.map((t) => (t.id === team.id ? team : t));
|
||||
await this.persist();
|
||||
return team;
|
||||
}
|
||||
const team = createPlayerTeamDraft(input.name, input.color, this.teams);
|
||||
this.teams = [...this.teams, team];
|
||||
await this.persist();
|
||||
return team;
|
||||
}
|
||||
|
||||
async deleteTeam(id: PlayerTeamId): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
const next = deletePlayerTeam(this.teams, this.players, id);
|
||||
this.teams = next.teams;
|
||||
this.players = next.players;
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
async setTeamsOrder(teamIds: PlayerTeamId[]): Promise<AppPlayerTeam[]> {
|
||||
await this.ensureLoaded();
|
||||
const byId = new Map(this.teams.map((t) => [t.id, t]));
|
||||
const next: AppPlayerTeam[] = [];
|
||||
for (const id of teamIds) {
|
||||
const t = byId.get(id);
|
||||
if (t) {
|
||||
next.push(t);
|
||||
byId.delete(id);
|
||||
}
|
||||
}
|
||||
for (const t of byId.values()) next.push(t);
|
||||
this.teams = next;
|
||||
await this.persist();
|
||||
return this.listTeams();
|
||||
}
|
||||
|
||||
async assignPlayerTeam(playerId: PlayerId, teamId: PlayerTeamId | null): Promise<AppPlayer | null> {
|
||||
await this.ensureLoaded();
|
||||
const teamIds = new Set(this.teams.map((t) => t.id as string));
|
||||
this.players = assignPlayerToTeam(this.players, playerId, teamId, teamIds);
|
||||
await this.persist();
|
||||
return this.getById(playerId);
|
||||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
await fs.mkdir(this.rootDir, { recursive: true });
|
||||
const payload: PlayersManifest = { players: this.players, teams: this.teams };
|
||||
await fs.writeFile(this.manifestPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../shared/types/appPlayers';
|
||||
|
||||
import { SceneNpcTokensSessionStore } from './sceneNpcTokensSessionStore';
|
||||
|
||||
void test('SceneNpcTokensSessionStore move and reset', () => {
|
||||
const store = new SceneNpcTokensSessionStore();
|
||||
const s1 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.3 });
|
||||
assert.equal(s1.byPlacementId.a?.nx, 0.2);
|
||||
assert.equal(s1.revision, 2);
|
||||
const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.3 });
|
||||
assert.equal(s2.revision, s1.revision); // no-op
|
||||
const s3 = store.reset();
|
||||
assert.deepEqual(s3.byPlacementId, {});
|
||||
assert.equal(s3.scale, DEFAULT_NPC_TOKEN_SESSION_SCALE);
|
||||
assert.ok(s3.revision > s1.revision);
|
||||
});
|
||||
|
||||
void test('SceneNpcTokensSessionStore setScale', () => {
|
||||
const store = new SceneNpcTokensSessionStore();
|
||||
const s1 = store.dispatch({ kind: 'setScale', scale: 1.5 });
|
||||
assert.equal(s1.scale, 1.5);
|
||||
store.dispatch({ kind: 'move', placementId: 'a', nx: 0.1, ny: 0.2 });
|
||||
const s2 = store.dispatch({ kind: 'setScale', scale: 0.1 });
|
||||
assert.equal(s2.scale, 0.4); // clamped min
|
||||
assert.ok(s2.byPlacementId.a);
|
||||
const s3 = store.reset();
|
||||
assert.equal(s3.scale, DEFAULT_NPC_TOKEN_SESSION_SCALE);
|
||||
assert.deepEqual(s3.byPlacementId, {});
|
||||
});
|
||||
|
||||
void test('SceneNpcTokensSessionStore setDisposition without inventing position', () => {
|
||||
const store = new SceneNpcTokensSessionStore();
|
||||
const s1 = store.dispatch({
|
||||
kind: 'setDisposition',
|
||||
placementId: 'a',
|
||||
disposition: 'hostile',
|
||||
});
|
||||
assert.equal(s1.byPlacementId.a?.disposition, 'hostile');
|
||||
assert.equal(s1.byPlacementId.a?.nx, undefined);
|
||||
assert.equal(s1.byPlacementId.a?.ny, undefined);
|
||||
const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.4, ny: 0.6 });
|
||||
assert.equal(s2.byPlacementId.a?.nx, 0.4);
|
||||
assert.equal(s2.byPlacementId.a?.disposition, 'hostile');
|
||||
});
|
||||
|
||||
void test('SceneNpcTokensSessionStore setInactive is session flag', () => {
|
||||
const store = new SceneNpcTokensSessionStore();
|
||||
const s1 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: true });
|
||||
assert.equal(s1.byPlacementId.a?.inactive, true);
|
||||
const s2 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: false });
|
||||
assert.deepEqual(s2.byPlacementId, {});
|
||||
store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.3 });
|
||||
store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: true });
|
||||
const s3 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: false });
|
||||
assert.equal(s3.byPlacementId.a?.nx, 0.2);
|
||||
assert.equal(s3.byPlacementId.a?.inactive, undefined);
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
clampNpcTokenSessionScale,
|
||||
DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
type SceneNpcTokensSessionEvent,
|
||||
type SceneNpcTokensSessionPlacement,
|
||||
type SceneNpcTokensSessionState,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import { normalizeNpcDisposition } from '../../shared/types/npcDisposition';
|
||||
import { rotateMapNormPointByCwSteps } from '../../shared/types/scenePreviewRotation';
|
||||
|
||||
function emptyState(revision = 1): SceneNpcTokensSessionState {
|
||||
return {
|
||||
revision,
|
||||
byPlacementId: {},
|
||||
scale: DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
};
|
||||
}
|
||||
|
||||
function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
|
||||
return p.nx === undefined && p.ny === undefined && !p.disposition && !p.inactive;
|
||||
}
|
||||
|
||||
function withMove(
|
||||
prev: SceneNpcTokensSessionPlacement | undefined,
|
||||
nx: number,
|
||||
ny: number,
|
||||
): SceneNpcTokensSessionPlacement {
|
||||
return {
|
||||
nx,
|
||||
ny,
|
||||
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
||||
...(prev?.inactive ? { inactive: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function withoutPlacement(
|
||||
state: SceneNpcTokensSessionState,
|
||||
placementId: string,
|
||||
): SceneNpcTokensSessionState {
|
||||
if (!(placementId in state.byPlacementId)) return state;
|
||||
const { [placementId]: _removed, ...rest } = state.byPlacementId;
|
||||
return {
|
||||
revision: state.revision + 1,
|
||||
byPlacementId: rest,
|
||||
scale: state.scale,
|
||||
};
|
||||
}
|
||||
|
||||
export class SceneNpcTokensSessionStore {
|
||||
private state: SceneNpcTokensSessionState = emptyState();
|
||||
|
||||
getState(): SceneNpcTokensSessionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
reset(): SceneNpcTokensSessionState {
|
||||
if (
|
||||
Object.keys(this.state.byPlacementId).length === 0 &&
|
||||
this.state.scale === DEFAULT_NPC_TOKEN_SESSION_SCALE
|
||||
) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = emptyState(this.state.revision + 1);
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Rotate session position overrides with the scene preview (CSS rotate steps). */
|
||||
rotateMapCwSteps(steps: number): SceneNpcTokensSessionState {
|
||||
const n = ((steps % 4) + 4) % 4;
|
||||
if (n === 0) return this.state;
|
||||
const ids = Object.keys(this.state.byPlacementId);
|
||||
if (ids.length === 0) return this.state;
|
||||
let changed = false;
|
||||
const byPlacementId: SceneNpcTokensSessionState['byPlacementId'] = {};
|
||||
for (const id of ids) {
|
||||
const prev = this.state.byPlacementId[id];
|
||||
if (!prev) continue;
|
||||
if (typeof prev.nx === 'number' && typeof prev.ny === 'number') {
|
||||
const p = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n);
|
||||
byPlacementId[id] = { ...prev, nx: p.nx, ny: p.ny };
|
||||
changed = true;
|
||||
} else {
|
||||
byPlacementId[id] = prev;
|
||||
}
|
||||
}
|
||||
if (!changed) return this.state;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId,
|
||||
scale: this.state.scale,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: SceneNpcTokensSessionEvent): SceneNpcTokensSessionState {
|
||||
switch (event.kind) {
|
||||
case 'clear':
|
||||
return this.reset();
|
||||
case 'setScale': {
|
||||
const scale = clampNpcTokenSessionScale(event.scale);
|
||||
if (this.state.scale === scale) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
scale,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'move': {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return this.state;
|
||||
const nx = Math.max(0, Math.min(1, event.nx));
|
||||
const ny = Math.max(0, Math.min(1, event.ny));
|
||||
if (!Number.isFinite(nx) || !Number.isFinite(ny)) return this.state;
|
||||
const prev = this.state.byPlacementId[placementId];
|
||||
if (prev && prev.nx === nx && prev.ny === ny) return this.state;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: {
|
||||
...this.state.byPlacementId,
|
||||
[placementId]: withMove(prev, nx, ny),
|
||||
},
|
||||
scale: this.state.scale,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'setDisposition': {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return this.state;
|
||||
const disposition = normalizeNpcDisposition(event.disposition);
|
||||
const prev = this.state.byPlacementId[placementId];
|
||||
if (prev?.disposition === disposition) return this.state;
|
||||
const next: SceneNpcTokensSessionPlacement = {
|
||||
...(prev?.nx !== undefined ? { nx: prev.nx } : {}),
|
||||
...(prev?.ny !== undefined ? { ny: prev.ny } : {}),
|
||||
disposition,
|
||||
...(prev?.inactive ? { inactive: true } : {}),
|
||||
};
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: {
|
||||
...this.state.byPlacementId,
|
||||
[placementId]: next,
|
||||
},
|
||||
scale: this.state.scale,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'setInactive': {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return this.state;
|
||||
const inactive = Boolean(event.inactive);
|
||||
const prev = this.state.byPlacementId[placementId];
|
||||
if (Boolean(prev?.inactive) === inactive) return this.state;
|
||||
const next: SceneNpcTokensSessionPlacement = {
|
||||
...(prev?.nx !== undefined ? { nx: prev.nx } : {}),
|
||||
...(prev?.ny !== undefined ? { ny: prev.ny } : {}),
|
||||
...(prev?.disposition ? { disposition: prev.disposition } : {}),
|
||||
...(inactive ? { inactive: true } : {}),
|
||||
};
|
||||
if (isEmptyPlacement(next)) {
|
||||
this.state = withoutPlacement(this.state, placementId);
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: {
|
||||
...this.state.byPlacementId,
|
||||
[placementId]: next,
|
||||
},
|
||||
scale: this.state.scale,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
default: {
|
||||
const _exhaustive: never = event;
|
||||
void _exhaustive;
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { ScenePlayerTokensSessionStore } from './scenePlayerTokensSessionStore';
|
||||
|
||||
void test('ScenePlayerTokensSessionStore selection and show/hide', () => {
|
||||
const store = new ScenePlayerTokensSessionStore();
|
||||
store.dispatch({ kind: 'setSelection', playerIds: ['a', 'b', 'a'] });
|
||||
assert.deepEqual(store.getState().selectedPlayerIds, ['a', 'b']);
|
||||
assert.equal(store.getState().visible, false);
|
||||
|
||||
store.dispatch({ kind: 'show', sizeN: 0.08 });
|
||||
assert.equal(store.getState().visible, true);
|
||||
assert.equal(Object.keys(store.getState().byPlayerId).length, 2);
|
||||
assert.ok(store.getState().byPlayerId.a);
|
||||
assert.equal(store.getState().byPlayerId.a?.ny, 0.88);
|
||||
|
||||
store.dispatch({ kind: 'setVisible', visible: false });
|
||||
assert.equal(store.getState().visible, false);
|
||||
assert.ok(store.getState().byPlayerId.a);
|
||||
|
||||
store.dispatch({ kind: 'clearPlacements' });
|
||||
assert.equal(store.getState().visible, false);
|
||||
assert.deepEqual(store.getState().byPlayerId, {});
|
||||
assert.deepEqual(store.getState().selectedPlayerIds, ['a', 'b']);
|
||||
});
|
||||
|
||||
void test('ScenePlayerTokensSessionStore move and reset', () => {
|
||||
const store = new ScenePlayerTokensSessionStore();
|
||||
store.dispatch({ kind: 'setSelection', playerIds: ['p1'] });
|
||||
store.dispatch({ kind: 'show', sizeN: 0.1 });
|
||||
const s1 = store.dispatch({ kind: 'move', playerId: 'p1', nx: 0.3, ny: 0.4 });
|
||||
assert.equal(s1.byPlayerId.p1?.nx, 0.3);
|
||||
assert.equal(s1.byPlayerId.p1?.ny, 0.4);
|
||||
const s2 = store.reset();
|
||||
assert.deepEqual(s2.selectedPlayerIds, []);
|
||||
assert.equal(s2.visible, false);
|
||||
assert.deepEqual(s2.byPlayerId, {});
|
||||
});
|
||||
|
||||
void test('ScenePlayerTokensSessionStore rotateMapCwSteps', () => {
|
||||
const store = new ScenePlayerTokensSessionStore();
|
||||
store.dispatch({ kind: 'setSelection', playerIds: ['p1'] });
|
||||
store.dispatch({ kind: 'show', sizeN: 0.1 });
|
||||
store.dispatch({ kind: 'move', playerId: 'p1', nx: 0.2, ny: 0.1 });
|
||||
const s = store.rotateMapCwSteps(1);
|
||||
assert.equal(s.byPlayerId.p1?.nx, 0.9);
|
||||
assert.equal(s.byPlayerId.p1?.ny, 0.2);
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
clampSceneNpcTokenSizeN,
|
||||
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
|
||||
layoutPlayerTokensBottom,
|
||||
type ScenePlayerTokensSessionEvent,
|
||||
type ScenePlayerTokensSessionState,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import { rotateMapNormPointByCwSteps } from '../../shared/types/scenePreviewRotation';
|
||||
|
||||
function emptyState(revision = 1): ScenePlayerTokensSessionState {
|
||||
return {
|
||||
revision,
|
||||
selectedPlayerIds: [],
|
||||
visible: false,
|
||||
byPlayerId: {},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlayerIds(raw: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const id of raw) {
|
||||
const s = String(id ?? '').trim();
|
||||
if (!s || seen.has(s)) continue;
|
||||
seen.add(s);
|
||||
out.push(s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export class ScenePlayerTokensSessionStore {
|
||||
private state: ScenePlayerTokensSessionState = emptyState();
|
||||
|
||||
getState(): ScenePlayerTokensSessionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
reset(): ScenePlayerTokensSessionState {
|
||||
if (
|
||||
this.state.selectedPlayerIds.length === 0 &&
|
||||
!this.state.visible &&
|
||||
Object.keys(this.state.byPlayerId).length === 0
|
||||
) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = emptyState(this.state.revision + 1);
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Смена сцены: спрятать токены, сбросить позиции; выбор игроков сохранить. */
|
||||
clearPlacements(): ScenePlayerTokensSessionState {
|
||||
if (!this.state.visible && Object.keys(this.state.byPlayerId).length === 0) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
visible: false,
|
||||
byPlayerId: {},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Rotate live player tokens with the scene preview (CSS rotate steps). */
|
||||
rotateMapCwSteps(steps: number): ScenePlayerTokensSessionState {
|
||||
const n = ((steps % 4) + 4) % 4;
|
||||
if (n === 0) return this.state;
|
||||
const ids = Object.keys(this.state.byPlayerId);
|
||||
if (ids.length === 0) return this.state;
|
||||
const byPlayerId: ScenePlayerTokensSessionState['byPlayerId'] = {};
|
||||
for (const id of ids) {
|
||||
const prev = this.state.byPlayerId[id];
|
||||
if (!prev) continue;
|
||||
const p = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n);
|
||||
byPlayerId[id] = { ...prev, nx: p.nx, ny: p.ny };
|
||||
}
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
byPlayerId,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: ScenePlayerTokensSessionEvent): ScenePlayerTokensSessionState {
|
||||
switch (event.kind) {
|
||||
case 'clear':
|
||||
return this.reset();
|
||||
case 'clearPlacements':
|
||||
return this.clearPlacements();
|
||||
case 'setSelection': {
|
||||
const selectedPlayerIds = normalizePlayerIds(event.playerIds);
|
||||
const same =
|
||||
selectedPlayerIds.length === this.state.selectedPlayerIds.length &&
|
||||
selectedPlayerIds.every((id, i) => id === this.state.selectedPlayerIds[i]);
|
||||
if (same) return this.state;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
selectedPlayerIds,
|
||||
visible: false,
|
||||
byPlayerId: {},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'setVisible': {
|
||||
const visible = Boolean(event.visible);
|
||||
if (this.state.visible === visible) return this.state;
|
||||
if (visible && this.state.selectedPlayerIds.length === 0) return this.state;
|
||||
this.state = { ...this.state, revision: this.state.revision + 1, visible };
|
||||
return this.state;
|
||||
}
|
||||
case 'show': {
|
||||
if (this.state.selectedPlayerIds.length === 0) return this.state;
|
||||
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
|
||||
const byPlayerId =
|
||||
Object.keys(this.state.byPlayerId).length > 0
|
||||
? this.state.byPlayerId
|
||||
: layoutPlayerTokensBottom(this.state.selectedPlayerIds, sizeN);
|
||||
if (this.state.visible && byPlayerId === this.state.byPlayerId) return this.state;
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
visible: true,
|
||||
byPlayerId,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'seedBottom': {
|
||||
if (this.state.selectedPlayerIds.length === 0) return this.state;
|
||||
if (Object.keys(this.state.byPlayerId).length > 0) return this.state;
|
||||
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
byPlayerId: layoutPlayerTokensBottom(this.state.selectedPlayerIds, sizeN),
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
case 'move': {
|
||||
const playerId = String(event.playerId ?? '');
|
||||
if (!playerId || !this.state.selectedPlayerIds.includes(playerId)) return this.state;
|
||||
const nx = Math.max(0, Math.min(1, event.nx));
|
||||
const ny = Math.max(0, Math.min(1, event.ny));
|
||||
if (!Number.isFinite(nx) || !Number.isFinite(ny)) return this.state;
|
||||
const prev = this.state.byPlayerId[playerId];
|
||||
if (prev && prev.nx === nx && prev.ny === ny) return this.state;
|
||||
const sizeN = clampSceneNpcTokenSizeN(prev?.sizeN ?? DEFAULT_SCENE_NPC_TOKEN_SIZE_N);
|
||||
this.state = {
|
||||
...this.state,
|
||||
revision: this.state.revision + 1,
|
||||
byPlayerId: {
|
||||
...this.state.byPlayerId,
|
||||
[playerId]: { nx, ny, sizeN },
|
||||
},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
default: {
|
||||
const _exhaustive: never = event;
|
||||
void _exhaustive;
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* Visually lossless re-encode for imported raster images (same pixel dimensions).
|
||||
* Node-only; shared by the main app and ../project-converter (monorepo sibling).
|
||||
*/
|
||||
import sharp from 'sharp';
|
||||
import { getSharp } from './sharpRuntime.mjs';
|
||||
|
||||
/** @typedef {import('node:buffer').Buffer} Buffer */
|
||||
|
||||
@@ -102,6 +102,7 @@ function makePassthrough(buf, meta) {
|
||||
* @param {number} h0
|
||||
*/
|
||||
async function sameDimensionsOrThrow(outBuf, w0, h0) {
|
||||
const sharp = getSharp();
|
||||
const m = await sharp(outBuf).metadata();
|
||||
if ((m.width ?? 0) !== w0 || (m.height ?? 0) !== h0) {
|
||||
const err = new Error('encode changed dimensions');
|
||||
@@ -120,6 +121,13 @@ export async function optimizeImageBufferVisuallyLossless(src) {
|
||||
return makePassthrough(input, { width: 0, height: 0, format: 'png' });
|
||||
}
|
||||
|
||||
let sharp;
|
||||
try {
|
||||
sharp = getSharp();
|
||||
} catch {
|
||||
return makePassthrough(input, null);
|
||||
}
|
||||
|
||||
let meta0;
|
||||
try {
|
||||
meta0 = await sharp(input, { failOn: 'error', unlimited: true }).metadata();
|
||||
|
||||
@@ -33,3 +33,19 @@ void test('generateScenePreviewThumbnailBytes: image scales to max edge', async
|
||||
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
void test('generateScenePreviewThumbnailBytes: accepts image Buffer', async () => {
|
||||
const png = await sharp({
|
||||
create: {
|
||||
width: 120,
|
||||
height: 80,
|
||||
channels: 3,
|
||||
background: { r: 10, g: 20, b: 30 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const buf = await generateScenePreviewThumbnailBytes(png, 'image');
|
||||
assert.ok(buf !== null);
|
||||
assert.ok(buf.length > 0);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,8 @@ import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import ffmpegStatic from 'ffmpeg-static';
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { getSharp } from './sharpRuntime.mjs';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -14,14 +15,16 @@ export const SCENE_PREVIEW_THUMB_MAX_PX = 320;
|
||||
|
||||
/**
|
||||
* Builds a small WebP still for graph/list previews. Returns null if generation fails (import still succeeds).
|
||||
* For images, prefer a Buffer so callers are not racy with reconcileAssetFiles unlinking paths.
|
||||
*/
|
||||
export async function generateScenePreviewThumbnailBytes(
|
||||
sourceAbsPath: string,
|
||||
source: string | Buffer,
|
||||
kind: 'image' | 'video',
|
||||
): Promise<Buffer | null> {
|
||||
try {
|
||||
const sharp = getSharp();
|
||||
if (kind === 'image') {
|
||||
return await sharp(sourceAbsPath)
|
||||
return await sharp(source)
|
||||
.rotate()
|
||||
.resize(SCENE_PREVIEW_THUMB_MAX_PX, SCENE_PREVIEW_THUMB_MAX_PX, {
|
||||
fit: 'inside',
|
||||
@@ -31,6 +34,9 @@ export async function generateScenePreviewThumbnailBytes(
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
const sourceAbsPath = typeof source === 'string' ? source : null;
|
||||
if (!sourceAbsPath) return null;
|
||||
|
||||
const ffmpegPath = ffmpegStatic;
|
||||
if (!ffmpegPath) return null;
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Lazy `sharp` load so a corrupt/missing native install does not crash Electron at import time.
|
||||
* Call only from image-processing paths; errors are recoverable for the rest of the app.
|
||||
*
|
||||
* Note: main is bundled to CJS (esbuild). `import.meta.url` is empty there — prefer `__filename`.
|
||||
*/
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
function requireBaseFilename() {
|
||||
// CJS bundle / Electron main
|
||||
if (typeof __filename === 'string' && __filename.length > 0) {
|
||||
return __filename;
|
||||
}
|
||||
// Direct ESM (unit tests)
|
||||
const metaUrl = import.meta.url;
|
||||
if (typeof metaUrl === 'string' && metaUrl.startsWith('file:')) {
|
||||
return fileURLToPath(metaUrl);
|
||||
}
|
||||
return path.join(process.cwd(), 'package.json');
|
||||
}
|
||||
|
||||
const require = createRequire(requireBaseFilename());
|
||||
|
||||
/** @type {typeof import('sharp') | null} */
|
||||
let cached = null;
|
||||
/** @type {Error | null} */
|
||||
let loadError = null;
|
||||
|
||||
/**
|
||||
* @param {unknown} err
|
||||
* @returns {Error}
|
||||
*/
|
||||
export function sharpLoadFailure(err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
return new Error(
|
||||
[
|
||||
'Не удалось загрузить модуль обработки изображений (sharp).',
|
||||
'Переустановите приложение полностью (удалите и поставьте заново)',
|
||||
'или исключите папку установки из проверки антивируса.',
|
||||
detail ? `Детали: ${detail}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {typeof import('sharp')}
|
||||
*/
|
||||
export function getSharp() {
|
||||
if (cached) return cached;
|
||||
if (loadError) throw loadError;
|
||||
try {
|
||||
cached = require('sharp');
|
||||
return cached;
|
||||
} catch (err) {
|
||||
loadError = sharpLoadFailure(err);
|
||||
throw loadError;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset cache (tests only). */
|
||||
export function __resetSharpRuntimeForTests() {
|
||||
cached = null;
|
||||
loadError = null;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
__resetSharpRuntimeForTests,
|
||||
getSharp,
|
||||
sharpLoadFailure,
|
||||
} from './sharpRuntime.mjs';
|
||||
|
||||
void test('getSharp: loads sharp when install is healthy', () => {
|
||||
__resetSharpRuntimeForTests();
|
||||
const sharp = getSharp();
|
||||
assert.equal(typeof sharp, 'function');
|
||||
});
|
||||
|
||||
void test('sharpLoadFailure: includes reinstall hint', () => {
|
||||
const err = sharpLoadFailure(new Error('SyntaxError: Unexpected end of input'));
|
||||
assert.match(err.message, /переустановите/i);
|
||||
assert.match(err.message, /SyntaxError/);
|
||||
});
|
||||
@@ -50,6 +50,17 @@ void test('zipStore: pack and open operations are serialized', () => {
|
||||
assert.match(src, /enqueueProjectSwitch/);
|
||||
});
|
||||
|
||||
void test('zipStore: project updates and preview finalize are serialized', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||
assert.match(src, /private projectUpdateChain: Promise<unknown>/);
|
||||
assert.match(src, /private previewFinalizeChain: Promise<unknown>/);
|
||||
assert.match(src, /enqueueProjectUpdate/);
|
||||
assert.match(src, /enqueuePreviewFinalize/);
|
||||
assert.match(src, /finalizeScenePreviewImportInner/);
|
||||
assert.match(src, /drainProjectMutations/);
|
||||
assert.match(src, /addBuffer/);
|
||||
});
|
||||
|
||||
void test('zipStore: closeOpenProject is serialized with open on projectSwitchChain', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||
assert.match(src, /async closeOpenProject\(\): Promise<void> \{[\s\S]*enqueueProjectSwitch/);
|
||||
|
||||
+367
-198
@@ -39,7 +39,6 @@ import type {
|
||||
MaterialLegend,
|
||||
MediaAsset,
|
||||
MediaAssetType,
|
||||
NpcBinding,
|
||||
Project,
|
||||
ProjectId,
|
||||
ProjectNpc,
|
||||
@@ -54,9 +53,35 @@ import type {
|
||||
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
|
||||
import { normalizeMaterialLegend } from '../../shared/types/materialLegend';
|
||||
import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens';
|
||||
import {
|
||||
clampPlayerImageOffset,
|
||||
clampPlayerImageScale,
|
||||
DEFAULT_PLAYER_RING_COLOR,
|
||||
normalizeSceneNpcToken,
|
||||
type SceneNpcToken,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import {
|
||||
DEFAULT_NPC_DISPOSITION,
|
||||
normalizeNpcDisposition,
|
||||
npcDispositionRingColor,
|
||||
type NpcDisposition,
|
||||
} from '../../shared/types/npcDisposition';
|
||||
import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid';
|
||||
import {
|
||||
asPreviewRotationDeg,
|
||||
previewRotationStepsCw,
|
||||
rotateMapMarkersByCwSteps,
|
||||
rotateSceneTokensByCwSteps,
|
||||
} from '../../shared/types/scenePreviewRotation';
|
||||
import { normalizeSceneTrap } from '../../shared/types/sceneTraps';
|
||||
import type { AssetId, GraphNodeId, MaterialId, NpcGroupId, NpcId, NpcRelationId } from '../../shared/types/ids';
|
||||
import type {
|
||||
AssetId,
|
||||
GraphNodeId,
|
||||
MaterialId,
|
||||
NpcGroupId,
|
||||
NpcId,
|
||||
NpcRelationId,
|
||||
} from '../../shared/types/ids';
|
||||
import {
|
||||
asAssetId,
|
||||
asGraphNodeId,
|
||||
@@ -66,12 +91,6 @@ import {
|
||||
asNpcRelationId,
|
||||
asProjectId,
|
||||
} from '../../shared/types/ids';
|
||||
import {
|
||||
clearNpcBindingsForDeletedScene,
|
||||
clearNpcBindingsForRemovedStoryline,
|
||||
noneBinding,
|
||||
normalizeNpcBinding,
|
||||
} from '../../shared/npcs/npcBinding';
|
||||
import {
|
||||
DEFAULT_NPC_GROUP_COLOR,
|
||||
normalizeHexColor,
|
||||
@@ -117,12 +136,44 @@ export class ZipProjectStore {
|
||||
private projectSession = 0;
|
||||
/** Serializes project.json writes — parallel renames caused ENOENT on Windows. */
|
||||
private projectWriteChain: Promise<void> = Promise.resolve();
|
||||
/**
|
||||
* Serializes project mutations (mutator + reconcileAssetFiles).
|
||||
* Parallel updateProject during batch scene-preview import caused lost updates and
|
||||
* reconcileAssetFiles deleting preview files still referenced after a stale write (ENOENT).
|
||||
*/
|
||||
private projectUpdateChain: Promise<unknown> = Promise.resolve();
|
||||
/** Serializes background preview optimize/thumb — avoids parallel sharp + orphan races. */
|
||||
private previewFinalizeChain: Promise<unknown> = Promise.resolve();
|
||||
/** Serializes zip pack operations — parallel yazl/yauzl caused «unexpected number of bytes». */
|
||||
private packChain: Promise<void> = Promise.resolve();
|
||||
/** Serializes open/close/unzip — concurrent IPC caused ghost open projects and deadlocks. */
|
||||
private projectSwitchChain: Promise<void> = Promise.resolve();
|
||||
private saveDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private enqueueProjectUpdate<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const task = this.projectUpdateChain.then(() => fn());
|
||||
this.projectUpdateChain = task.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return task;
|
||||
}
|
||||
|
||||
private enqueuePreviewFinalize<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const task = this.previewFinalizeChain.then(() => fn());
|
||||
this.previewFinalizeChain = task.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return task;
|
||||
}
|
||||
|
||||
/** Wait until preview optimize/thumb and project mutators finish (before pack/close). */
|
||||
private async drainProjectMutations(): Promise<void> {
|
||||
await this.previewFinalizeChain;
|
||||
await this.projectUpdateChain;
|
||||
}
|
||||
|
||||
private enqueuePack(cacheDir: string, zipPath: string): Promise<void> {
|
||||
const next = this.packChain.then(async () => {
|
||||
await this.packZipFromCache(cacheDir, zipPath);
|
||||
@@ -150,6 +201,8 @@ export class ZipProjectStore {
|
||||
clearTimeout(this.saveDebounceTimer);
|
||||
this.saveDebounceTimer = null;
|
||||
}
|
||||
// Finish preview finalizers before flush/pack — otherwise yazl ENOENT on deleted assets.
|
||||
await this.drainProjectMutations();
|
||||
if (this.saveQueued) {
|
||||
this.saveQueued = false;
|
||||
await this.flushSave();
|
||||
@@ -159,6 +212,7 @@ export class ZipProjectStore {
|
||||
}
|
||||
await this.packChain;
|
||||
await this.projectWriteChain;
|
||||
await this.drainProjectMutations();
|
||||
}
|
||||
|
||||
async ensureRoots(): Promise<void> {
|
||||
@@ -444,32 +498,68 @@ export class ZipProjectStore {
|
||||
sceneId: SceneId,
|
||||
assetId: AssetId,
|
||||
): Promise<{ project: Project; changed: boolean }> {
|
||||
return this.enqueuePreviewFinalize(() => this.finalizeScenePreviewImportInner(sceneId, assetId));
|
||||
}
|
||||
|
||||
private async finalizeScenePreviewImportInner(
|
||||
sceneId: SceneId,
|
||||
assetId: AssetId,
|
||||
): Promise<{ project: Project; changed: boolean }> {
|
||||
const sessionAtStart = this.projectSession;
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
const sceneAtStart = open.project.scenes[sceneId];
|
||||
if (!open) {
|
||||
// Queued finalize after close — nothing to apply.
|
||||
return { project: undefined as unknown as Project, changed: false };
|
||||
}
|
||||
const projectAtStart = open.project;
|
||||
const sceneAtStart = projectAtStart.scenes[sceneId];
|
||||
if (sceneAtStart?.previewAssetId !== assetId) {
|
||||
return { project: open.project, changed: false };
|
||||
return { project: projectAtStart, changed: false };
|
||||
}
|
||||
const sourceAsset = open.project.assets[assetId];
|
||||
const sourceAsset = projectAtStart.assets[assetId];
|
||||
if (!sourceAsset || (sourceAsset.type !== 'image' && sourceAsset.type !== 'video')) {
|
||||
return { project: open.project, changed: false };
|
||||
return { project: projectAtStart, changed: false };
|
||||
}
|
||||
|
||||
const cacheDir = open.cacheDir;
|
||||
const stillOpen = () =>
|
||||
this.openProject !== null &&
|
||||
this.projectSession === sessionAtStart &&
|
||||
this.openProject.cacheDir === cacheDir;
|
||||
|
||||
const generatedRelPaths: string[] = [];
|
||||
let finalAsset = sourceAsset;
|
||||
let finalAssetId = assetId;
|
||||
let finalAbs = path.join(open.cacheDir, sourceAsset.relPath);
|
||||
let finalAbs = path.join(cacheDir, sourceAsset.relPath);
|
||||
/** Image bytes kept in memory so thumb/optimize do not re-open a path that reconcile may unlink. */
|
||||
let imageBytes: Buffer | null = null;
|
||||
|
||||
if (sourceAsset.type === 'image') {
|
||||
const input = await fs.readFile(finalAbs);
|
||||
const opt = await optimizeImageBufferVisuallyLossless(input);
|
||||
try {
|
||||
imageBytes = await fs.readFile(finalAbs);
|
||||
} catch (e) {
|
||||
const err = e as NodeJS.ErrnoException;
|
||||
if (err?.code === 'ENOENT') {
|
||||
const latest = this.getOpenProject();
|
||||
return { project: latest ?? projectAtStart, changed: false };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
if (!stillOpen()) {
|
||||
return { project: this.getOpenProject() ?? projectAtStart, changed: false };
|
||||
}
|
||||
const opt = await optimizeImageBufferVisuallyLossless(imageBytes);
|
||||
if (!stillOpen()) {
|
||||
return { project: this.getOpenProject() ?? projectAtStart, changed: false };
|
||||
}
|
||||
if (!opt.passthrough) {
|
||||
finalAssetId = asAssetId(this.randomId());
|
||||
const optimizedName = `${path.parse(sourceAsset.originalName).name}.${opt.ext}`;
|
||||
const safeOptimizedName = sanitizeFileName(optimizedName);
|
||||
const optimizedRelPath = `assets/${finalAssetId}_${safeOptimizedName}`;
|
||||
finalAbs = path.join(open.cacheDir, optimizedRelPath);
|
||||
finalAbs = path.join(cacheDir, optimizedRelPath);
|
||||
const optimizedBuffer = Buffer.from(opt.buffer);
|
||||
imageBytes = optimizedBuffer;
|
||||
await fs.writeFile(finalAbs, optimizedBuffer);
|
||||
generatedRelPaths.push(optimizedRelPath);
|
||||
const optimizedAsset = buildMediaAsset(
|
||||
@@ -487,14 +577,26 @@ export class ZipProjectStore {
|
||||
}
|
||||
}
|
||||
|
||||
if (!stillOpen()) {
|
||||
await Promise.all(
|
||||
generatedRelPaths.map((relPath) =>
|
||||
fs.unlink(path.join(cacheDir, relPath)).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
return { project: this.getOpenProject() ?? projectAtStart, changed: false };
|
||||
}
|
||||
|
||||
const thumbKind = finalAsset.type === 'image' ? 'image' : 'video';
|
||||
const thumbBytes = await generateScenePreviewThumbnailBytes(finalAbs, thumbKind);
|
||||
const thumbBytes =
|
||||
thumbKind === 'image' && imageBytes
|
||||
? await generateScenePreviewThumbnailBytes(imageBytes, 'image')
|
||||
: await generateScenePreviewThumbnailBytes(finalAbs, thumbKind);
|
||||
let thumbAsset: MediaAsset | null = null;
|
||||
let thumbId: AssetId | null = null;
|
||||
if (thumbBytes !== null && thumbBytes.length > 0) {
|
||||
thumbId = asAssetId(this.randomId());
|
||||
const thumbRelPath = `assets/${thumbId}_preview_thumb.webp`;
|
||||
const thumbAbs = path.join(open.cacheDir, thumbRelPath);
|
||||
const thumbAbs = path.join(cacheDir, thumbRelPath);
|
||||
await fs.writeFile(thumbAbs, thumbBytes);
|
||||
generatedRelPaths.push(thumbRelPath);
|
||||
const thumbOrigName = `${path.parse(finalAsset.originalName).name}_preview_thumb.webp`;
|
||||
@@ -508,46 +610,67 @@ export class ZipProjectStore {
|
||||
);
|
||||
}
|
||||
|
||||
await this.updateProject((p) => {
|
||||
const scene = p.scenes[sceneId];
|
||||
if (scene?.previewAssetId !== assetId) {
|
||||
return p;
|
||||
}
|
||||
const assets: Record<AssetId, MediaAsset> = { ...p.assets, [finalAssetId]: finalAsset };
|
||||
if (thumbAsset !== null && thumbId !== null) {
|
||||
assets[thumbId] = thumbAsset;
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
assets,
|
||||
scenes: {
|
||||
...p.scenes,
|
||||
[sceneId]: {
|
||||
...scene,
|
||||
previewAssetId: finalAssetId,
|
||||
previewAssetType: finalAsset.type,
|
||||
previewThumbAssetId: thumbId,
|
||||
previewVideoAutostart: finalAsset.type === 'video' ? scene.previewVideoAutostart : false,
|
||||
if (!stillOpen()) {
|
||||
await Promise.all(
|
||||
generatedRelPaths.map((relPath) =>
|
||||
fs.unlink(path.join(cacheDir, relPath)).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
return { project: this.getOpenProject() ?? projectAtStart, changed: false };
|
||||
}
|
||||
|
||||
let applied = false;
|
||||
try {
|
||||
await this.updateProject((p) => {
|
||||
const scene = p.scenes[sceneId];
|
||||
if (scene?.previewAssetId !== assetId) {
|
||||
return p;
|
||||
}
|
||||
applied = true;
|
||||
const assets: Record<AssetId, MediaAsset> = { ...p.assets, [finalAssetId]: finalAsset };
|
||||
if (finalAssetId !== assetId) {
|
||||
delete assets[assetId];
|
||||
}
|
||||
if (thumbAsset !== null && thumbId !== null) {
|
||||
assets[thumbId] = thumbAsset;
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
assets,
|
||||
scenes: {
|
||||
...p.scenes,
|
||||
[sceneId]: {
|
||||
...scene,
|
||||
previewAssetId: finalAssetId,
|
||||
previewAssetType: finalAsset.type,
|
||||
previewThumbAssetId: thumbId,
|
||||
previewVideoAutostart: finalAsset.type === 'video' ? scene.previewVideoAutostart : false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
if (!stillOpen() || (e instanceof Error && e.message === 'No open project')) {
|
||||
await Promise.all(
|
||||
generatedRelPaths.map((relPath) =>
|
||||
fs.unlink(path.join(cacheDir, relPath)).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
return { project: this.getOpenProject() ?? projectAtStart, changed: false };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
const latestScene = latest.scenes[sceneId];
|
||||
const applied =
|
||||
latestScene?.previewAssetId === finalAssetId &&
|
||||
(thumbId === null || latestScene.previewThumbAssetId === thumbId);
|
||||
if (!applied) {
|
||||
await Promise.all(
|
||||
generatedRelPaths.map((relPath) =>
|
||||
fs.unlink(path.join(open.cacheDir, relPath)).catch(() => undefined),
|
||||
fs.unlink(path.join(cacheDir, relPath)).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return { project: latest, changed: applied };
|
||||
return { project: latest ?? projectAtStart, changed: applied };
|
||||
}
|
||||
|
||||
async clearScenePreview(sceneId: SceneId): Promise<Project> {
|
||||
@@ -588,15 +711,17 @@ export class ZipProjectStore {
|
||||
}
|
||||
|
||||
async updateProject(mutator: (draft: Project) => Project): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
const prev = open.project;
|
||||
let next = mutator(prev);
|
||||
next = await reconcileAssetFiles(prev, next, open.cacheDir);
|
||||
open.project = next;
|
||||
await this.writeCacheProject(open.cacheDir, next);
|
||||
this.queueSave();
|
||||
return next;
|
||||
return this.enqueueProjectUpdate(async () => {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
const prev = open.project;
|
||||
let next = mutator(prev);
|
||||
next = await reconcileAssetFiles(prev, next, open.cacheDir);
|
||||
open.project = next;
|
||||
await this.writeCacheProject(open.cacheDir, next);
|
||||
this.queueSave();
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async updateScene(sceneId: SceneId, patch: ScenePatch): Promise<Scene> {
|
||||
@@ -621,6 +746,7 @@ export class ZipProjectStore {
|
||||
darkenScene: false,
|
||||
traps: [],
|
||||
tokens: [],
|
||||
npcTokens: [],
|
||||
grid: { ...DEFAULT_SCENE_GRID },
|
||||
} satisfies Scene);
|
||||
|
||||
@@ -628,6 +754,7 @@ export class ZipProjectStore {
|
||||
...base,
|
||||
traps: base.traps ?? [],
|
||||
tokens: base.tokens ?? [],
|
||||
npcTokens: base.npcTokens ?? [],
|
||||
grid: base.grid ?? { ...DEFAULT_SCENE_GRID },
|
||||
...(patch.title !== undefined ? { title: patch.title } : null),
|
||||
...(patch.description !== undefined ? { description: patch.description } : null),
|
||||
@@ -643,9 +770,7 @@ export class ZipProjectStore {
|
||||
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
|
||||
...(patch.traps !== undefined
|
||||
? {
|
||||
traps: patch.traps
|
||||
.map((t) => normalizeSceneTrap(t))
|
||||
.filter((t): t is SceneTrap => Boolean(t)),
|
||||
traps: patch.traps.map((t) => normalizeSceneTrap(t)).filter((t): t is SceneTrap => Boolean(t)),
|
||||
}
|
||||
: null),
|
||||
...(patch.tokens !== undefined
|
||||
@@ -655,12 +780,37 @@ export class ZipProjectStore {
|
||||
.filter((t): t is SceneToken => Boolean(t)),
|
||||
}
|
||||
: null),
|
||||
...(patch.npcTokens !== undefined
|
||||
? {
|
||||
npcTokens: patch.npcTokens
|
||||
.map((t) => normalizeSceneNpcToken(t))
|
||||
.filter((t): t is SceneNpcToken => Boolean(t)),
|
||||
}
|
||||
: null),
|
||||
...(patch.grid !== undefined ? { grid: normalizeSceneGrid(patch.grid) } : null),
|
||||
...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null),
|
||||
...(patch.media ? { media: { ...base.media, ...patch.media } } : null),
|
||||
...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null),
|
||||
};
|
||||
|
||||
// Keep map markers glued to the art when previewRotationDeg changes (image/video).
|
||||
if (patch.previewRotationDeg !== undefined) {
|
||||
const fromRot = asPreviewRotationDeg(base.previewRotationDeg);
|
||||
const toRot = asPreviewRotationDeg(patch.previewRotationDeg);
|
||||
const steps = previewRotationStepsCw(fromRot, toRot);
|
||||
if (steps !== 0) {
|
||||
if (patch.tokens === undefined) {
|
||||
next.tokens = rotateSceneTokensByCwSteps(base.tokens ?? [], steps);
|
||||
}
|
||||
if (patch.npcTokens === undefined) {
|
||||
next.npcTokens = rotateMapMarkersByCwSteps(base.npcTokens ?? [], steps);
|
||||
}
|
||||
if (patch.traps === undefined) {
|
||||
next.traps = rotateMapMarkersByCwSteps(base.traps ?? [], steps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.updateProject((p) => {
|
||||
const scenes = { ...p.scenes, [sceneId]: next };
|
||||
const sceneListOrder =
|
||||
@@ -712,25 +862,9 @@ export class ZipProjectStore {
|
||||
currentSceneId = ids[0] ?? null;
|
||||
}
|
||||
|
||||
const removedSideStarts = p.sceneGraphNodes.filter(
|
||||
(n) => n.sceneId === sceneId && n.isSideStoryStart,
|
||||
);
|
||||
let npcs = clearNpcBindingsForDeletedScene(p.npcs ?? [], sceneId);
|
||||
for (const side of removedSideStarts) {
|
||||
npcs = clearNpcBindingsForRemovedStoryline(npcs, {
|
||||
kind: 'side',
|
||||
startGraphNodeId: side.id,
|
||||
});
|
||||
}
|
||||
const hadMainOnScene = p.sceneGraphNodes.some((n) => n.sceneId === sceneId && n.isStartScene);
|
||||
if (hadMainOnScene) {
|
||||
npcs = clearNpcBindingsForRemovedStoryline(npcs, { kind: 'main' });
|
||||
}
|
||||
|
||||
return {
|
||||
...withGraph,
|
||||
scenes: nextScenes,
|
||||
npcs,
|
||||
sceneListOrder: removeFromSceneListOrder(
|
||||
reconcileSceneListOrder(withGraph.scenes, p.sceneListOrder),
|
||||
sceneId,
|
||||
@@ -792,25 +926,9 @@ export class ZipProjectStore {
|
||||
if (graphNodeId !== null && !open.project.sceneGraphNodes.some((n) => n.id === graphNodeId)) {
|
||||
throw new Error('Graph node not found');
|
||||
}
|
||||
const prevMain = open.project.sceneGraphNodes.find((n) => n.isStartScene);
|
||||
const clearingMain = graphNodeId === null || (prevMain && prevMain.id !== graphNodeId);
|
||||
await this.updateProject((p) => {
|
||||
let npcs = p.npcs ?? [];
|
||||
if (clearingMain && prevMain) {
|
||||
npcs = clearNpcBindingsForRemovedStoryline(npcs, { kind: 'main' });
|
||||
}
|
||||
const demotedSides = p.sceneGraphNodes.filter(
|
||||
(n) => n.isSideStoryStart && graphNodeId !== null && n.id === graphNodeId,
|
||||
);
|
||||
for (const side of demotedSides) {
|
||||
npcs = clearNpcBindingsForRemovedStoryline(npcs, {
|
||||
kind: 'side',
|
||||
startGraphNodeId: side.id,
|
||||
});
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
npcs,
|
||||
sceneGraphNodes: p.sceneGraphNodes.map((n) => {
|
||||
const isMain = graphNodeId !== null && n.id === graphNodeId;
|
||||
if (isMain) {
|
||||
@@ -837,23 +955,15 @@ export class ZipProjectStore {
|
||||
const node = open.project.sceneGraphNodes.find((n) => n.id === graphNodeId);
|
||||
if (!node) throw new Error('Graph node not found');
|
||||
const enabling = !node.isSideStoryStart;
|
||||
if (enabling && !canSetSideStoryStart(open.project.sceneGraphNodes, open.project.sceneGraphEdges, graphNodeId)) {
|
||||
if (
|
||||
enabling &&
|
||||
!canSetSideStoryStart(open.project.sceneGraphNodes, open.project.sceneGraphEdges, graphNodeId)
|
||||
) {
|
||||
return open.project;
|
||||
}
|
||||
await this.updateProject((p) => {
|
||||
let npcs = p.npcs ?? [];
|
||||
if (!enabling) {
|
||||
npcs = clearNpcBindingsForRemovedStoryline(npcs, {
|
||||
kind: 'side',
|
||||
startGraphNodeId: graphNodeId,
|
||||
});
|
||||
}
|
||||
if (enabling && node.isStartScene) {
|
||||
npcs = clearNpcBindingsForRemovedStoryline(npcs, { kind: 'main' });
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
npcs,
|
||||
sceneGraphNodes: p.sceneGraphNodes.map((n) => {
|
||||
if (n.id !== graphNodeId) return n;
|
||||
if (enabling) {
|
||||
@@ -916,23 +1026,7 @@ export class ZipProjectStore {
|
||||
await this.updateProject((p) => {
|
||||
const withGraph = { ...p, sceneGraphNodes: nextNodes, sceneGraphEdges: nextEdges };
|
||||
const out = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges);
|
||||
const npcs = (p.npcs ?? []).map((n) => {
|
||||
if (
|
||||
n.binding?.kind === 'storyline' &&
|
||||
n.binding.storyline.kind === 'side' &&
|
||||
n.binding.storyline.startGraphNodeId === nodeId
|
||||
) {
|
||||
return {
|
||||
...n,
|
||||
binding: {
|
||||
kind: 'storyline' as const,
|
||||
storyline: { kind: 'side' as const, startGraphNodeId: newStartId },
|
||||
},
|
||||
};
|
||||
}
|
||||
return n;
|
||||
});
|
||||
return { ...withGraph, npcs, scenes: applyConnectionSets(withGraph.scenes, out) };
|
||||
return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, out) };
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
@@ -1110,7 +1204,7 @@ export class ZipProjectStore {
|
||||
for (const asset of staged) {
|
||||
assets[asset.id] = asset;
|
||||
if (asset.type !== 'audio') continue;
|
||||
campaignAudios.push({ assetId: asset.id, autoplay: true, loop: true });
|
||||
campaignAudios.push({ assetId: asset.id, autoplay: false, loop: false });
|
||||
}
|
||||
return { ...p, assets, campaignAudios };
|
||||
});
|
||||
@@ -1224,16 +1318,11 @@ export class ZipProjectStore {
|
||||
return latest;
|
||||
}
|
||||
|
||||
async setMaterialRotation(
|
||||
materialId: MaterialId,
|
||||
rotationDeg: 0 | 90 | 180 | 270,
|
||||
): Promise<Project> {
|
||||
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,
|
||||
);
|
||||
const materials = (p.materials ?? []).map((m) => (m.id === materialId ? { ...m, rotationDeg } : m));
|
||||
return { ...p, materials };
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
@@ -1302,14 +1391,23 @@ export class ZipProjectStore {
|
||||
* Создаёт или обновляет НПС.
|
||||
* При создании `filePath` (аватар) обязателен; при обновлении можно сменить только имя/описание/аватар.
|
||||
*/
|
||||
async upsertNpc(input: {
|
||||
npcId?: NpcId;
|
||||
name: string;
|
||||
description?: string;
|
||||
filePath?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
binding?: NpcBinding;
|
||||
}): Promise<Project> {
|
||||
async upsertNpc(
|
||||
input: {
|
||||
npcId?: NpcId;
|
||||
name: string;
|
||||
description?: string;
|
||||
filePath?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
ringColor?: string;
|
||||
disposition?: NpcDisposition;
|
||||
imageOffset?: { x: number; y: number };
|
||||
imageScale?: number;
|
||||
},
|
||||
onProgress?: (p: { percent: number; stage: string; detail?: string }) => void,
|
||||
): Promise<Project> {
|
||||
const report = (percent: number, stage: string, detail?: string) => {
|
||||
onProgress?.({ percent, stage, ...(detail ? { detail } : {}) });
|
||||
};
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
const name = input.name.trim();
|
||||
@@ -1321,6 +1419,8 @@ export class ZipProjectStore {
|
||||
throw new Error('NPC name already exists');
|
||||
}
|
||||
|
||||
report(2, 'start', 'Подождите…');
|
||||
|
||||
let nextAssetId: AssetId | null = null;
|
||||
let stagedAsset: MediaAsset | null = null;
|
||||
if (input.filePath) {
|
||||
@@ -1330,13 +1430,16 @@ export class ZipProjectStore {
|
||||
if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
|
||||
throw new Error('NPC avatar must be an image (png/jpg/webp)');
|
||||
}
|
||||
report(8, 'read', 'Чтение изображения…');
|
||||
let buf = await fs.readFile(input.filePath);
|
||||
report(18, 'optimize', 'Оптимизация изображения…');
|
||||
try {
|
||||
const opt = await optimizeImageBufferVisuallyLossless(buf);
|
||||
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
|
||||
} catch {
|
||||
// keep original buffer
|
||||
}
|
||||
report(72, 'write', 'Сохранение файла…');
|
||||
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
||||
const id = asAssetId(this.randomId());
|
||||
const orig = path.basename(input.filePath);
|
||||
@@ -1349,13 +1452,17 @@ export class ZipProjectStore {
|
||||
nextAssetId = id;
|
||||
}
|
||||
|
||||
report(88, 'project', 'Обновление проекта…');
|
||||
await this.updateProject((p) => {
|
||||
const npcs = [...(p.npcs ?? [])];
|
||||
const assets = { ...p.assets };
|
||||
if (stagedAsset) assets[stagedAsset.id] = stagedAsset;
|
||||
|
||||
const groupIds = new Set((p.npcGroups ?? []).map((g) => g.id));
|
||||
const resolveGroup = (raw: NpcGroupId | null | undefined, prev: NpcGroupId | null): NpcGroupId | null => {
|
||||
const resolveGroup = (
|
||||
raw: NpcGroupId | null | undefined,
|
||||
prev: NpcGroupId | null,
|
||||
): NpcGroupId | null => {
|
||||
if (raw === undefined) return prev;
|
||||
if (raw === null) return null;
|
||||
return groupIds.has(raw) ? raw : null;
|
||||
@@ -1369,14 +1476,28 @@ export class ZipProjectStore {
|
||||
...prev,
|
||||
name,
|
||||
avatarAssetId: nextAssetId ?? prev.avatarAssetId,
|
||||
description:
|
||||
typeof input.description === 'string' ? input.description : prev.description,
|
||||
description: typeof input.description === 'string' ? input.description : prev.description,
|
||||
groupId: resolveGroup(input.groupId, prev.groupId),
|
||||
binding: input.binding !== undefined ? input.binding : prev.binding,
|
||||
...(input.ringColor !== undefined
|
||||
? { ringColor: normalizeHexColor(input.ringColor, DEFAULT_PLAYER_RING_COLOR) }
|
||||
: {}),
|
||||
...(input.disposition !== undefined
|
||||
? {
|
||||
disposition: normalizeNpcDisposition(input.disposition),
|
||||
ringColor: npcDispositionRingColor(normalizeNpcDisposition(input.disposition)),
|
||||
}
|
||||
: {}),
|
||||
...(input.imageOffset !== undefined
|
||||
? { imageOffset: clampPlayerImageOffset(input.imageOffset) }
|
||||
: {}),
|
||||
...(input.imageScale !== undefined
|
||||
? { imageScale: clampPlayerImageScale(input.imageScale) }
|
||||
: {}),
|
||||
};
|
||||
} else {
|
||||
if (!nextAssetId) throw new Error('NPC avatar is required');
|
||||
const count = npcs.length;
|
||||
const disposition = normalizeNpcDisposition(input.disposition ?? DEFAULT_NPC_DISPOSITION);
|
||||
npcs.push({
|
||||
id: asNpcId(`npc_${this.randomId()}`),
|
||||
name,
|
||||
@@ -1385,7 +1506,10 @@ export class ZipProjectStore {
|
||||
x: 80 + (count % 4) * 220,
|
||||
y: 80 + Math.floor(count / 4) * 200,
|
||||
groupId: resolveGroup(input.groupId, null),
|
||||
binding: input.binding ?? noneBinding(),
|
||||
disposition,
|
||||
ringColor: npcDispositionRingColor(disposition),
|
||||
imageOffset: clampPlayerImageOffset(input.imageOffset),
|
||||
imageScale: clampPlayerImageScale(input.imageScale),
|
||||
});
|
||||
}
|
||||
return { ...p, assets, npcs };
|
||||
@@ -1393,6 +1517,7 @@ export class ZipProjectStore {
|
||||
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
report(100, 'done', 'Готово');
|
||||
return latest;
|
||||
}
|
||||
|
||||
@@ -1402,21 +1527,19 @@ export class ZipProjectStore {
|
||||
name?: string;
|
||||
description?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
binding?: NpcBinding;
|
||||
ringColor?: string;
|
||||
disposition?: NpcDisposition;
|
||||
imageOffset?: { x: number; y: number };
|
||||
imageScale?: number;
|
||||
},
|
||||
): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
const name =
|
||||
typeof patch.name === 'string' ? patch.name.trim() : undefined;
|
||||
const name = typeof patch.name === 'string' ? patch.name.trim() : undefined;
|
||||
if (name !== undefined) {
|
||||
if (name.length < 1) throw new Error('NPC name is required');
|
||||
const nameKey = name.toLowerCase();
|
||||
if (
|
||||
(open.project.npcs ?? []).some(
|
||||
(n) => n.id !== npcId && n.name.trim().toLowerCase() === nameKey,
|
||||
)
|
||||
) {
|
||||
if ((open.project.npcs ?? []).some((n) => n.id !== npcId && n.name.trim().toLowerCase() === nameKey)) {
|
||||
throw new Error('NPC name already exists');
|
||||
}
|
||||
}
|
||||
@@ -1426,15 +1549,30 @@ export class ZipProjectStore {
|
||||
if (n.id !== npcId) return n;
|
||||
let groupId = n.groupId;
|
||||
if (patch.groupId !== undefined) {
|
||||
groupId =
|
||||
patch.groupId === null ? null : groupIds.has(patch.groupId) ? patch.groupId : null;
|
||||
groupId = patch.groupId === null ? null : groupIds.has(patch.groupId) ? patch.groupId : null;
|
||||
}
|
||||
const disposition =
|
||||
patch.disposition !== undefined
|
||||
? normalizeNpcDisposition(patch.disposition)
|
||||
: normalizeNpcDisposition(n.disposition);
|
||||
return {
|
||||
...n,
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(typeof patch.description === 'string' ? { description: patch.description } : {}),
|
||||
groupId,
|
||||
...(patch.binding !== undefined ? { binding: patch.binding } : {}),
|
||||
disposition,
|
||||
ringColor:
|
||||
patch.disposition !== undefined
|
||||
? npcDispositionRingColor(disposition)
|
||||
: patch.ringColor !== undefined
|
||||
? normalizeHexColor(patch.ringColor, DEFAULT_PLAYER_RING_COLOR)
|
||||
: n.ringColor || npcDispositionRingColor(disposition),
|
||||
...(patch.imageOffset !== undefined
|
||||
? { imageOffset: clampPlayerImageOffset(patch.imageOffset) }
|
||||
: {}),
|
||||
...(patch.imageScale !== undefined
|
||||
? { imageScale: clampPlayerImageScale(patch.imageScale) }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
return { ...p, npcs };
|
||||
@@ -1459,13 +1597,25 @@ export class ZipProjectStore {
|
||||
async deleteNpc(npcId: NpcId): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
await this.updateProject((p) => ({
|
||||
...p,
|
||||
npcs: (p.npcs ?? []).filter((n) => n.id !== npcId),
|
||||
npcRelations: (p.npcRelations ?? []).filter(
|
||||
(r) => r.sourceNpcId !== npcId && r.targetNpcId !== npcId,
|
||||
),
|
||||
}));
|
||||
await this.updateProject((p) => {
|
||||
const scenes: Record<SceneId, Scene> = { ...p.scenes };
|
||||
for (const sid of Object.keys(scenes) as SceneId[]) {
|
||||
const sc = scenes[sid];
|
||||
if (!sc) continue;
|
||||
const npcTokens = (sc.npcTokens ?? []).filter((t) => t.npcId !== npcId);
|
||||
if (npcTokens.length !== (sc.npcTokens ?? []).length) {
|
||||
scenes[sid] = { ...sc, npcTokens };
|
||||
}
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
scenes,
|
||||
npcs: (p.npcs ?? []).filter((n) => n.id !== npcId),
|
||||
npcRelations: (p.npcRelations ?? []).filter(
|
||||
(r) => r.sourceNpcId !== npcId && r.targetNpcId !== npcId,
|
||||
),
|
||||
};
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
@@ -1520,10 +1670,7 @@ export class ZipProjectStore {
|
||||
}
|
||||
const nameKey = name.toLowerCase();
|
||||
const siblingConflict = groups.some(
|
||||
(g) =>
|
||||
g.id !== editingId &&
|
||||
g.parentId === parentId &&
|
||||
g.name.trim().toLowerCase() === nameKey,
|
||||
(g) => g.id !== editingId && g.parentId === parentId && g.name.trim().toLowerCase() === nameKey,
|
||||
);
|
||||
if (siblingConflict) throw new Error('Group name already exists');
|
||||
|
||||
@@ -1556,9 +1703,7 @@ export class ZipProjectStore {
|
||||
const nextGroups = groups
|
||||
.filter((g) => g.id !== groupId)
|
||||
.map((g) => (g.parentId === groupId ? { ...g, parentId: parentOfDeleted } : g));
|
||||
const npcs = (p.npcs ?? []).map((n) =>
|
||||
n.groupId === groupId ? { ...n, groupId: null } : n,
|
||||
);
|
||||
const npcs = (p.npcs ?? []).map((n) => (n.groupId === groupId ? { ...n, groupId: null } : n));
|
||||
return { ...p, npcGroups: nextGroups, npcs };
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
@@ -1599,10 +1744,7 @@ export class ZipProjectStore {
|
||||
if (label.length < 1) throw new Error('Relation label is required');
|
||||
if (input.sourceNpcId === input.targetNpcId) throw new Error('Cannot relate NPC to itself');
|
||||
const npcs = open.project.npcs ?? [];
|
||||
if (
|
||||
!npcs.some((n) => n.id === input.sourceNpcId) ||
|
||||
!npcs.some((n) => n.id === input.targetNpcId)
|
||||
) {
|
||||
if (!npcs.some((n) => n.id === input.sourceNpcId) || !npcs.some((n) => n.id === input.targetNpcId)) {
|
||||
throw new Error('NPC not found');
|
||||
}
|
||||
await this.updateProject((p) => {
|
||||
@@ -1641,14 +1783,18 @@ export class ZipProjectStore {
|
||||
async saveNow(): Promise<void> {
|
||||
const open = this.openProject;
|
||||
if (!open) return;
|
||||
// Let background preview finalizers finish before packing cache → zip.
|
||||
await this.drainProjectMutations();
|
||||
await this.projectWriteChain;
|
||||
await this.enqueuePack(open.cacheDir, open.zipPath);
|
||||
}
|
||||
|
||||
async closeOpenProject(): Promise<void> {
|
||||
return this.enqueueProjectSwitch(async () => {
|
||||
// Invalidate in-flight finalize early; then wait for queue to settle.
|
||||
this.projectSession += 1;
|
||||
if (!this.openProject) return;
|
||||
await this.drainProjectMutations();
|
||||
await this.saveNow();
|
||||
await this.drainSavePipeline();
|
||||
this.saveQueued = false;
|
||||
@@ -1987,10 +2133,13 @@ export class ZipProjectStore {
|
||||
async getProjectStorylines(
|
||||
projectId: ProjectId,
|
||||
labels: StorylineLabels,
|
||||
): Promise<StorylineListItem[]> {
|
||||
): Promise<{ storylines: StorylineListItem[]; npcs: { id: string; name: string }[] }> {
|
||||
const snap = await this.loadProjectSnapshot(projectId);
|
||||
try {
|
||||
return listExportableStorylines(snap.project, labels);
|
||||
return {
|
||||
storylines: listExportableStorylines(snap.project, labels),
|
||||
npcs: (snap.project.npcs ?? []).map((n) => ({ id: n.id, name: n.name })),
|
||||
};
|
||||
} finally {
|
||||
if (snap.ownsCache) {
|
||||
await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
@@ -2001,6 +2150,7 @@ export class ZipProjectStore {
|
||||
async exportStorylinesZipToPath(
|
||||
projectId: ProjectId,
|
||||
selections: StorylineSelection[],
|
||||
npcIds: string[],
|
||||
destinationPath: string,
|
||||
labels: StorylineLabels,
|
||||
onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void,
|
||||
@@ -2017,6 +2167,7 @@ export class ZipProjectStore {
|
||||
newProjectId: newExportBundleProjectId(),
|
||||
exportTitle: entry?.name ?? snap.project.meta.name,
|
||||
labels,
|
||||
npcIds,
|
||||
});
|
||||
await fs.mkdir(path.join(exportCache, 'assets'), { recursive: true });
|
||||
const assetIds = Object.keys(partial.assets) as AssetId[];
|
||||
@@ -2142,16 +2293,14 @@ export class ZipProjectStore {
|
||||
sourceForMerge = remapProjectSceneTokenIds(source, remap);
|
||||
}
|
||||
const offsetX = computeGraphImportOffsetX(this.openProject.project);
|
||||
const { project: merged, report, assetCopies } = mergeStorylinesIntoProject(
|
||||
this.openProject.project,
|
||||
sourceForMerge,
|
||||
selections,
|
||||
sceneResolutions,
|
||||
{
|
||||
graphOffsetX: offsetX,
|
||||
...(npcResolutions ? { npcResolutions } : {}),
|
||||
},
|
||||
);
|
||||
const {
|
||||
project: merged,
|
||||
report,
|
||||
assetCopies,
|
||||
} = mergeStorylinesIntoProject(this.openProject.project, sourceForMerge, selections, sceneResolutions, {
|
||||
graphOffsetX: offsetX,
|
||||
...(npcResolutions ? { npcResolutions } : {}),
|
||||
});
|
||||
|
||||
const targetCache = this.openProject.cacheDir;
|
||||
await fs.mkdir(path.join(targetCache, 'assets'), { recursive: true });
|
||||
@@ -2360,6 +2509,10 @@ function normalizeScene(s: Scene): Scene {
|
||||
const tokens = (Array.isArray(rawTokens) ? rawTokens : [])
|
||||
.map((t) => normalizeSceneToken(t))
|
||||
.filter((t): t is SceneToken => Boolean(t));
|
||||
const rawNpcTokens = (s as unknown as { npcTokens?: unknown[] }).npcTokens;
|
||||
const npcTokens = (Array.isArray(rawNpcTokens) ? rawNpcTokens : [])
|
||||
.map((t) => normalizeSceneNpcToken(t))
|
||||
.filter((t): t is SceneNpcToken => Boolean(t));
|
||||
const grid = normalizeSceneGrid((s as unknown as { grid?: unknown }).grid);
|
||||
|
||||
const rawAudios = Array.isArray(raw.audios) ? raw.audios : [];
|
||||
@@ -2391,6 +2544,7 @@ function normalizeScene(s: Scene): Scene {
|
||||
darkenScene,
|
||||
traps,
|
||||
tokens,
|
||||
npcTokens,
|
||||
grid,
|
||||
layout: layoutIn ?? { x: 0, y: 0 },
|
||||
media: {
|
||||
@@ -2470,11 +2624,6 @@ function normalizeProject(p: Project): Project {
|
||||
);
|
||||
const npcGroups = normalizeNpcGroups((p as unknown as { npcGroups?: unknown }).npcGroups);
|
||||
const groupIdSet = new Set(npcGroups.map((g) => g.id));
|
||||
const sceneIdSet = new Set(Object.keys(scenes) as SceneId[]);
|
||||
const sideStartIds = new Set(
|
||||
sceneGraphNodes.filter((n) => n.isSideStoryStart).map((n) => n.id),
|
||||
);
|
||||
const hasMainStart = sceneGraphNodes.some((n) => n.isStartScene);
|
||||
const rawNpcs = (p as unknown as { npcs?: unknown[] }).npcs;
|
||||
const npcs: ProjectNpc[] = (Array.isArray(rawNpcs) ? rawNpcs : [])
|
||||
.map((n, index) => {
|
||||
@@ -2487,7 +2636,8 @@ function normalizeProject(p: Project): Project {
|
||||
x?: number;
|
||||
y?: number;
|
||||
groupId?: string | null;
|
||||
binding?: unknown;
|
||||
ringColor?: string;
|
||||
imageOffset?: unknown;
|
||||
};
|
||||
if (!obj.id || !obj.avatarAssetId || typeof obj.name !== 'string') return null;
|
||||
const name = obj.name.trim();
|
||||
@@ -2503,11 +2653,12 @@ function normalizeProject(p: Project): Project {
|
||||
x,
|
||||
y,
|
||||
groupId: resolveNpcGroupId(obj.groupId, groupIdSet),
|
||||
binding: normalizeNpcBinding(obj.binding, {
|
||||
sceneIds: sceneIdSet,
|
||||
sideStartIds,
|
||||
hasMainStart,
|
||||
}),
|
||||
disposition: normalizeNpcDisposition((obj as { disposition?: unknown }).disposition),
|
||||
ringColor: npcDispositionRingColor(
|
||||
normalizeNpcDisposition((obj as { disposition?: unknown }).disposition),
|
||||
),
|
||||
imageOffset: clampPlayerImageOffset(obj.imageOffset),
|
||||
imageScale: clampPlayerImageScale((obj as { imageScale?: unknown }).imageScale),
|
||||
};
|
||||
})
|
||||
.filter((x): x is ProjectNpc => Boolean(x));
|
||||
@@ -2545,6 +2696,15 @@ function normalizeProject(p: Project): Project {
|
||||
if (a && a.length > 0) return a;
|
||||
return '0.0.0';
|
||||
})();
|
||||
const scenesPruned: Record<SceneId, Scene> = {};
|
||||
for (const sid of Object.keys(scenes) as SceneId[]) {
|
||||
const sc = scenes[sid];
|
||||
if (!sc) continue;
|
||||
scenesPruned[sid] = {
|
||||
...sc,
|
||||
npcTokens: (sc.npcTokens ?? []).filter((t) => npcIdSet.has(t.npcId)),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
meta: {
|
||||
@@ -2555,7 +2715,7 @@ function normalizeProject(p: Project): Project {
|
||||
createdWithAppVersion,
|
||||
schemaVersion: PROJECT_SCHEMA_VERSION,
|
||||
},
|
||||
scenes,
|
||||
scenes: scenesPruned,
|
||||
campaignAudios,
|
||||
materials,
|
||||
npcs,
|
||||
@@ -2565,7 +2725,7 @@ function normalizeProject(p: Project): Project {
|
||||
sceneGraphEdges,
|
||||
currentGraphNodeId,
|
||||
sceneListOrder: reconcileSceneListOrder(
|
||||
scenes,
|
||||
scenesPruned,
|
||||
(p as { sceneListOrder?: SceneId[] }).sceneListOrder,
|
||||
),
|
||||
};
|
||||
@@ -2726,7 +2886,16 @@ async function zipDir(srcDir: string, outZipPath: string): Promise<void> {
|
||||
const all = await listFilesRecursive(srcDir);
|
||||
for (const abs of all) {
|
||||
const rel = path.relative(srcDir, abs).replace(/\\/gu, '/');
|
||||
zipfile.addFile(abs, rel, zipOptionsForRelativeEntry(rel));
|
||||
// Bufferize: yazl.addFile opens lazily and can ENOENT if reconcile unlinks mid-pack.
|
||||
let buf: Buffer;
|
||||
try {
|
||||
buf = await fs.readFile(abs);
|
||||
} catch (e) {
|
||||
const err = e as NodeJS.ErrnoException;
|
||||
if (err?.code === 'ENOENT') continue;
|
||||
throw e;
|
||||
}
|
||||
zipfile.addBuffer(buf, rel, zipOptionsForRelativeEntry(rel));
|
||||
}
|
||||
await fs.mkdir(path.dirname(outZipPath), { recursive: true });
|
||||
const out = fssync.createWriteStream(outZipPath);
|
||||
|
||||
@@ -2,7 +2,8 @@ import fs from 'node:fs/promises';
|
||||
|
||||
import { session } from 'electron';
|
||||
|
||||
import { asAssetId, asTokenId } from '../../shared/types/ids';
|
||||
import { asAssetId, asPlayerId, asTokenId } from '../../shared/types/ids';
|
||||
import type { PlayersStore } from '../players/playersStore';
|
||||
import type { ZipProjectStore } from '../project/zipStore';
|
||||
import type { TokensStore } from '../tokens/tokensStore';
|
||||
|
||||
@@ -75,11 +76,12 @@ async function serveFile(info: ReadInfo, request: Request): Promise<Response> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Обслуживает `dnd://asset?...` и `dnd://token?...`.
|
||||
* Обслуживает `dnd://asset?...`, `dnd://token?...` и `dnd://player?...`.
|
||||
*/
|
||||
export function registerDndAssetProtocol(
|
||||
projectStore: ZipProjectStore,
|
||||
tokensStore: TokensStore,
|
||||
playersStore: PlayersStore | null,
|
||||
): void {
|
||||
session.defaultSession.protocol.handle('dnd', async (request) => {
|
||||
const url = new URL(request.url);
|
||||
@@ -92,6 +94,8 @@ export function registerDndAssetProtocol(
|
||||
info = projectStore.getAssetReadInfo(asAssetId(id));
|
||||
} else if (url.hostname === 'token') {
|
||||
info = tokensStore.getImageReadInfo(asTokenId(id));
|
||||
} else if (url.hostname === 'player') {
|
||||
info = playersStore?.getImageReadInfo(asPlayerId(id)) ?? null;
|
||||
}
|
||||
if (!info) {
|
||||
return new Response(null, { status: 404 });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SceneTokensSessionEvent, SceneTokensSessionState } from '../../shared/types';
|
||||
import { rotateMapNormPointByCwSteps } from '../../shared/types/scenePreviewRotation';
|
||||
|
||||
function emptyState(): SceneTokensSessionState {
|
||||
return {
|
||||
@@ -23,6 +24,26 @@ export class SceneTokensSessionStore {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Rotate session position overrides with the scene preview (CSS rotate steps). */
|
||||
rotateMapCwSteps(steps: number): SceneTokensSessionState {
|
||||
const n = ((steps % 4) + 4) % 4;
|
||||
if (n === 0) return this.state;
|
||||
const ids = Object.keys(this.state.byPlacementId);
|
||||
if (ids.length === 0) return this.state;
|
||||
const byPlacementId: SceneTokensSessionState['byPlacementId'] = {};
|
||||
for (const id of ids) {
|
||||
const prev = this.state.byPlacementId[id];
|
||||
if (!prev) continue;
|
||||
const next = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n);
|
||||
byPlacementId[id] = { nx: next.nx, ny: next.ny };
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: SceneTokensSessionEvent): SceneTokensSessionState {
|
||||
switch (event.kind) {
|
||||
case 'clear':
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { TokenGridSnapSessionStore } from './tokenGridSnapSessionStore';
|
||||
|
||||
void test('TokenGridSnapSessionStore: set and reset', () => {
|
||||
const store = new TokenGridSnapSessionStore();
|
||||
assert.equal(store.getState().enabled, false);
|
||||
assert.equal(store.setEnabled(true).enabled, true);
|
||||
assert.equal(store.getState().enabled, true);
|
||||
assert.equal(store.reset().enabled, false);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
export type TokenGridSnapSessionState = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export class TokenGridSnapSessionStore {
|
||||
private enabled = false;
|
||||
|
||||
getState(): TokenGridSnapSessionState {
|
||||
return { enabled: this.enabled };
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): TokenGridSnapSessionState {
|
||||
this.enabled = Boolean(enabled);
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
reset(): TokenGridSnapSessionState {
|
||||
this.enabled = false;
|
||||
return this.getState();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
emptyTokenPathSessionState,
|
||||
tokenPathKey,
|
||||
type TokenPathPlaybackEntry,
|
||||
type TokenPathSessionEvent,
|
||||
type TokenPathSessionState,
|
||||
type TokenPathTargetRef,
|
||||
} from '../../shared/types/tokenPathSession';
|
||||
|
||||
function bump(
|
||||
state: TokenPathSessionState,
|
||||
patch: Partial<Omit<TokenPathSessionState, 'revision' | 'serverNowMs'>>,
|
||||
): TokenPathSessionState {
|
||||
return {
|
||||
...state,
|
||||
...patch,
|
||||
revision: state.revision + 1,
|
||||
serverNowMs: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export class TokenPathSessionStore {
|
||||
private state: TokenPathSessionState = emptyTokenPathSessionState();
|
||||
|
||||
getState(): TokenPathSessionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Refresh clock without logical change (optional heartbeat). */
|
||||
touchClock(): TokenPathSessionState {
|
||||
this.state = { ...this.state, serverNowMs: Date.now() };
|
||||
return this.state;
|
||||
}
|
||||
|
||||
reset(): TokenPathSessionState {
|
||||
if (
|
||||
Object.keys(this.state.playback).length === 0 &&
|
||||
Object.keys(this.state.presentationVisible).length === 0
|
||||
) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = emptyTokenPathSessionState(this.state.revision + 1);
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: TokenPathSessionEvent): TokenPathSessionState {
|
||||
switch (event.kind) {
|
||||
case 'clear':
|
||||
return this.reset();
|
||||
case 'showPresentation': {
|
||||
const key = tokenPathKey(event.target.kind, event.target.placementId);
|
||||
if (this.state.presentationVisible[key]) return this.state;
|
||||
this.state = bump(this.state, {
|
||||
presentationVisible: { ...this.state.presentationVisible, [key]: true },
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
case 'hidePresentation': {
|
||||
const key = tokenPathKey(event.target.kind, event.target.placementId);
|
||||
if (!this.state.presentationVisible[key]) return this.state;
|
||||
const { [key]: _removed, ...rest } = this.state.presentationVisible;
|
||||
this.state = bump(this.state, { presentationVisible: rest });
|
||||
return this.state;
|
||||
}
|
||||
case 'stop': {
|
||||
const key = tokenPathKey(event.target.kind, event.target.placementId);
|
||||
const prev = this.state.playback[key];
|
||||
if (!prev || prev.phase === 'stopped') return this.state;
|
||||
const atDist =
|
||||
typeof event.atDist === 'number' && Number.isFinite(event.atDist)
|
||||
? Math.max(0, event.atDist)
|
||||
: prev.baseDist;
|
||||
this.state = bump(this.state, {
|
||||
playback: {
|
||||
...this.state.playback,
|
||||
[key]: {
|
||||
...prev,
|
||||
phase: 'stopped',
|
||||
baseDist: atDist,
|
||||
rejoinDist: null,
|
||||
segmentStartedAtMs: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
case 'resume': {
|
||||
const key = tokenPathKey(event.target.kind, event.target.placementId);
|
||||
const prev = this.state.playback[key];
|
||||
if (!prev) return this.state;
|
||||
if (prev.phase !== 'stopped' && prev.phase !== 'done') return this.state;
|
||||
const entry: TokenPathPlaybackEntry = {
|
||||
...prev,
|
||||
phase: 'moving',
|
||||
segmentStartedAtMs: Date.now(),
|
||||
// Jump to nearest-ahead on path, then continue (v1: no off-path lerp).
|
||||
baseDist: Math.max(0, event.fromDist),
|
||||
rejoinDist: null,
|
||||
direction: 1,
|
||||
};
|
||||
this.state = bump(this.state, {
|
||||
playback: { ...this.state.playback, [key]: entry },
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
case 'resetToStart': {
|
||||
const key = tokenPathKey(event.target.kind, event.target.placementId);
|
||||
const prev = this.state.playback[key];
|
||||
const entry: TokenPathPlaybackEntry = {
|
||||
kind: event.target.kind,
|
||||
placementId: event.target.placementId,
|
||||
phase: 'moving',
|
||||
segmentStartedAtMs: Date.now(),
|
||||
baseDist: 0,
|
||||
direction: 1,
|
||||
rejoinDist: null,
|
||||
durationSec: prev?.durationSec ?? 8,
|
||||
pathLength: prev?.pathLength ?? 1,
|
||||
};
|
||||
this.state = bump(this.state, {
|
||||
playback: { ...this.state.playback, [key]: entry },
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
case 'seedPlayback': {
|
||||
const e = event.entry;
|
||||
const key = tokenPathKey(e.kind, e.placementId);
|
||||
const entry: TokenPathPlaybackEntry = {
|
||||
kind: e.kind,
|
||||
placementId: e.placementId,
|
||||
phase: e.phase,
|
||||
segmentStartedAtMs: e.segmentStartedAtMs ?? Date.now(),
|
||||
baseDist: e.baseDist,
|
||||
direction: e.direction,
|
||||
rejoinDist: e.rejoinDist,
|
||||
durationSec: e.durationSec,
|
||||
pathLength: e.pathLength,
|
||||
};
|
||||
this.state = bump(this.state, {
|
||||
playback: { ...this.state.playback, [key]: entry },
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
case 'markDone': {
|
||||
const key = tokenPathKey(event.target.kind, event.target.placementId);
|
||||
const prev = this.state.playback[key];
|
||||
if (!prev || prev.phase === 'done') return this.state;
|
||||
this.state = bump(this.state, {
|
||||
playback: {
|
||||
...this.state.playback,
|
||||
[key]: {
|
||||
...prev,
|
||||
phase: 'done',
|
||||
baseDist: prev.pathLength,
|
||||
rejoinDist: null,
|
||||
segmentStartedAtMs: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
default: {
|
||||
const _exhaustive: never = event;
|
||||
void _exhaustive;
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type { TokenPathTargetRef };
|
||||
@@ -92,11 +92,7 @@ export class TokensStore {
|
||||
return path.join(this.rootDir, relPath);
|
||||
}
|
||||
|
||||
async upsert(input: {
|
||||
id?: TokenId | null;
|
||||
name: string;
|
||||
filePath?: string | null;
|
||||
}): Promise<AppToken> {
|
||||
async upsert(input: { id?: TokenId | null; name: string; filePath?: string | null }): Promise<AppToken> {
|
||||
await this.ensureLoaded();
|
||||
const name = input.name.trim();
|
||||
if (!name) throw new Error('Token name is required');
|
||||
@@ -166,8 +162,7 @@ export class TokensStore {
|
||||
}): Promise<{ token: AppToken; remappedFrom: TokenId }> {
|
||||
await this.ensureLoaded();
|
||||
let buf = await fs.readFile(input.absFilePath);
|
||||
const sha256 =
|
||||
input.sha256 ?? crypto.createHash('sha256').update(buf).digest('hex');
|
||||
const sha256 = input.sha256 ?? crypto.createHash('sha256').update(buf).digest('hex');
|
||||
const existingByHash = this.findBySha256(sha256);
|
||||
if (existingByHash) {
|
||||
return { token: existingByHash, remappedFrom: input.preferredId };
|
||||
@@ -262,7 +257,7 @@ export class TokensStore {
|
||||
preferredId: asTokenId(t.id),
|
||||
name: typeof t.name === 'string' ? t.name : 'Token',
|
||||
absFilePath: abs,
|
||||
sha256: typeof t.sha256 === 'string' ? t.sha256 : undefined,
|
||||
...(typeof t.sha256 === 'string' ? { sha256: t.sha256 } : {}),
|
||||
});
|
||||
remap.set(remappedFrom, token.id);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ void test('createWindows: окно описания сцены закрывае
|
||||
assert.ok(src.includes('closeSceneDescriptionWindow'));
|
||||
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/);
|
||||
assert.match(src, /kind !== 'presentation'[\s\S]*closeSceneDescriptionWindow/);
|
||||
});
|
||||
|
||||
void test('createWindows: окно материалов закрывается с multi-window', () => {
|
||||
@@ -57,11 +57,18 @@ void test('createWindows: окно НПС закрывается с multi-window
|
||||
assert.ok(src.includes('openNpcsWindow'));
|
||||
assert.ok(src.includes('closeNpcsWindow'));
|
||||
assert.ok(src.includes('openNpcsEditorWindow'));
|
||||
assert.ok(src.includes('warmNpcsEditorWindow'));
|
||||
assert.ok(src.includes("createWindow('npcs'"));
|
||||
assert.ok(src.includes("createWindow('npcsEditor'"));
|
||||
assert.match(src, /export function closeMultiWindow[\s\S]*closeNpcsWindow/);
|
||||
});
|
||||
|
||||
void test('createWindows: закрытие пульта закрывает сессионные окна и презентацию', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.match(src, /kind === 'control'/);
|
||||
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closePresentationWindow/);
|
||||
});
|
||||
|
||||
void test('createWindows: production — loadFile для HTML (не только file://)', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('loadFile'));
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from 'node:path';
|
||||
|
||||
import { app, BrowserWindow, screen } from 'electron';
|
||||
|
||||
import { windowChromeTitle } from '../../shared/appBranding';
|
||||
import { windowChromeTitle, type AppWindowKind } from '../../shared/appBranding';
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
|
||||
import { safeConsoleError } from '../safeConsole';
|
||||
@@ -18,6 +18,7 @@ export type WindowKind =
|
||||
| 'materials'
|
||||
| 'npcsEditor'
|
||||
| 'sceneEditor'
|
||||
| 'tokenPathEditor'
|
||||
| 'npcs';
|
||||
|
||||
/** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */
|
||||
@@ -28,11 +29,21 @@ export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [
|
||||
'npcs',
|
||||
'npcsEditor',
|
||||
'sceneEditor',
|
||||
'tokenPathEditor',
|
||||
] as const;
|
||||
|
||||
const windows = new Map<WindowKind, BrowserWindow>();
|
||||
|
||||
/** Язык заголовков окон (из редактора); иначе `app.getLocale()`. */
|
||||
let chromeLocaleTagOverride: string | null = null;
|
||||
|
||||
function resolveChromeLocaleTag(): string {
|
||||
return chromeLocaleTagOverride ?? app.getLocale();
|
||||
}
|
||||
|
||||
let appQuitting = false;
|
||||
/** Защита от каскада close(control) ↔ close(presentation). */
|
||||
let closingPlaySession = false;
|
||||
let pendingSceneDescriptionHtml = '';
|
||||
|
||||
/** Окно материалов — только колонка списка. */
|
||||
@@ -60,6 +71,35 @@ function broadcastMultiWindowStateChanged(open: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function getPresentationContentSize(): { width: number; height: number } | null {
|
||||
const pres = windows.get('presentation');
|
||||
if (!pres || pres.isDestroyed()) return null;
|
||||
const [width, height] = pres.getContentSize();
|
||||
if (width <= 0 || height <= 0) return null;
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function broadcastPresentationContentSize(): void {
|
||||
const size = getPresentationContentSize();
|
||||
if (!size) return;
|
||||
for (const w of BrowserWindow.getAllWindows()) {
|
||||
if (w.isDestroyed() || w.webContents.isDestroyed()) continue;
|
||||
try {
|
||||
w.webContents.send(ipcChannels.windows.presentationContentSizeChanged, size);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bindPresentationContentSizeTracking(win: BrowserWindow): void {
|
||||
const emit = () => broadcastPresentationContentSize();
|
||||
win.on('resize', emit);
|
||||
win.on('enter-full-screen', emit);
|
||||
win.on('leave-full-screen', emit);
|
||||
win.webContents.once('did-finish-load', emit);
|
||||
}
|
||||
|
||||
function sendSceneDescriptionContent(win: BrowserWindow, html: string): void {
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) return;
|
||||
try {
|
||||
@@ -91,6 +131,51 @@ export function sendToAppWindows(
|
||||
}
|
||||
}
|
||||
|
||||
function applyWindowChromeTitle(win: BrowserWindow, kind: WindowKind): void {
|
||||
if (win.isDestroyed()) return;
|
||||
win.setTitle(windowChromeTitle(kind as AppWindowKind, resolveChromeLocaleTag()));
|
||||
}
|
||||
|
||||
export function syncAllWindowChromeTitles(localeTag: string): void {
|
||||
chromeLocaleTagOverride = localeTag.trim() || null;
|
||||
for (const [kind, win] of windows.entries()) {
|
||||
applyWindowChromeTitle(win, kind);
|
||||
}
|
||||
}
|
||||
|
||||
function bindWindowChromeTitle(win: BrowserWindow, kind: WindowKind): void {
|
||||
const apply = () => applyWindowChromeTitle(win, kind);
|
||||
apply();
|
||||
win.webContents.on('page-title-updated', (event) => {
|
||||
event.preventDefault();
|
||||
apply();
|
||||
});
|
||||
win.webContents.on('did-finish-load', () => {
|
||||
apply();
|
||||
});
|
||||
}
|
||||
|
||||
/** Закрыть окна сессии, кроме редактора и его дочерних окон. */
|
||||
function closePlaySessionAuxiliaryWindows(): void {
|
||||
closeSceneDescriptionWindow();
|
||||
closeMaterialsWindow();
|
||||
closeNpcsWindow();
|
||||
}
|
||||
|
||||
function closePresentationWindow(): void {
|
||||
const pres = windows.get('presentation');
|
||||
if (pres && !pres.isDestroyed()) {
|
||||
pres.close();
|
||||
}
|
||||
}
|
||||
|
||||
function closeControlWindow(): void {
|
||||
const ctrl = windows.get('control');
|
||||
if (ctrl && !ctrl.isDestroyed()) {
|
||||
ctrl.close();
|
||||
}
|
||||
}
|
||||
|
||||
function quitAppFromEditorClose(): void {
|
||||
markAppQuitting();
|
||||
app.quit();
|
||||
@@ -126,6 +211,8 @@ function pageNameForKind(kind: WindowKind): string {
|
||||
return 'npcsEditor.html';
|
||||
case 'sceneEditor':
|
||||
return 'sceneEditor.html';
|
||||
case 'tokenPathEditor':
|
||||
return 'tokenPathEditor.html';
|
||||
case 'npcs':
|
||||
return 'npcs.html';
|
||||
}
|
||||
@@ -163,7 +250,7 @@ export function applyDockIconIfNeeded(): void {
|
||||
type CreateWindowOpts = {
|
||||
/** Дочернее окно (например пульт) держится над родителем (экран просмотра). */
|
||||
parent?: BrowserWindow;
|
||||
/** Только редактор: не показывать окно до `show()` (экран загрузки). */
|
||||
/** Не показывать окно до явного `show()` (экран загрузки / прогрев НПС). */
|
||||
deferVisibility?: boolean;
|
||||
};
|
||||
|
||||
@@ -197,22 +284,23 @@ function windowSizeForKind(kind: WindowKind): { width: number; height: number }
|
||||
if (kind === 'materials') return { width: MATERIALS_WINDOW_WIDTH, height: MATERIALS_WINDOW_HEIGHT };
|
||||
if (kind === 'npcsEditor') return { width: NPCS_EDITOR_WINDOW_WIDTH, height: NPCS_EDITOR_WINDOW_HEIGHT };
|
||||
if (kind === 'sceneEditor') return { width: 1280, height: 800 };
|
||||
if (kind === 'tokenPathEditor') return { width: 1100, height: 760 };
|
||||
if (kind === 'npcs') return { width: NPCS_WINDOW_WIDTH, height: NPCS_WINDOW_HEIGHT };
|
||||
return { width: 1280, height: 800 };
|
||||
}
|
||||
|
||||
function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow {
|
||||
const deferEditor = kind === 'editor' && opts?.deferVisibility === true;
|
||||
const deferShow = opts?.deferVisibility === true;
|
||||
const icon = loadBrandingWindowIcon();
|
||||
const size = windowSizeForKind(kind);
|
||||
const win = new BrowserWindow({
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
autoHideMenuBar: true,
|
||||
...(kind === 'sceneDescription'
|
||||
? {
|
||||
minWidth: 520,
|
||||
minHeight: 420,
|
||||
autoHideMenuBar: true,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'materials'
|
||||
@@ -222,7 +310,6 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
minWidth: 260,
|
||||
maxWidth: 360,
|
||||
minHeight: 480,
|
||||
autoHideMenuBar: true,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'npcsEditor'
|
||||
@@ -231,7 +318,6 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
height: NPCS_EDITOR_WINDOW_HEIGHT,
|
||||
minWidth: 1100,
|
||||
minHeight: 640,
|
||||
autoHideMenuBar: true,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'sceneEditor'
|
||||
@@ -240,7 +326,14 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
height: 800,
|
||||
minWidth: 960,
|
||||
minHeight: 600,
|
||||
autoHideMenuBar: true,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'tokenPathEditor'
|
||||
? {
|
||||
width: 1100,
|
||||
height: 760,
|
||||
minWidth: 900,
|
||||
minHeight: 560,
|
||||
}
|
||||
: {}),
|
||||
...(kind === 'npcs'
|
||||
@@ -250,7 +343,6 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
minWidth: 560,
|
||||
maxWidth: 900,
|
||||
minHeight: 480,
|
||||
autoHideMenuBar: true,
|
||||
}
|
||||
: {}),
|
||||
show: false,
|
||||
@@ -276,12 +368,13 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
}
|
||||
}
|
||||
|
||||
win.setTitle(windowChromeTitle(kind, app.getLocale()));
|
||||
bindWindowChromeTitle(win, kind);
|
||||
if (
|
||||
kind === 'sceneDescription' ||
|
||||
kind === 'materials' ||
|
||||
kind === 'npcsEditor' ||
|
||||
kind === 'sceneEditor' ||
|
||||
kind === 'tokenPathEditor' ||
|
||||
kind === 'npcs'
|
||||
) {
|
||||
win.setMenuBarVisibility(false);
|
||||
@@ -300,7 +393,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
safeConsoleError('[render-process-gone]', details.reason, details.exitCode);
|
||||
});
|
||||
|
||||
if (!deferEditor) {
|
||||
if (!deferShow) {
|
||||
ensureWindowBecomesVisible(win);
|
||||
}
|
||||
loadWindowPage(win, kind);
|
||||
@@ -311,14 +404,35 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
||||
quitAppFromEditorClose();
|
||||
});
|
||||
}
|
||||
if (kind === 'control') {
|
||||
win.on('close', () => {
|
||||
if (appQuitting || closingPlaySession) return;
|
||||
closingPlaySession = true;
|
||||
closePlaySessionAuxiliaryWindows();
|
||||
closePresentationWindow();
|
||||
});
|
||||
}
|
||||
if (kind === 'presentation') {
|
||||
bindPresentationContentSizeTracking(win);
|
||||
win.on('close', () => {
|
||||
if (appQuitting || closingPlaySession) return;
|
||||
closingPlaySession = true;
|
||||
closePlaySessionAuxiliaryWindows();
|
||||
closeControlWindow();
|
||||
});
|
||||
}
|
||||
win.on('closed', () => windows.delete(kind));
|
||||
if (kind === 'sceneEditor') {
|
||||
win.on('closed', () => {
|
||||
closeTokenPathEditorWindow();
|
||||
});
|
||||
}
|
||||
win.on('closed', () => {
|
||||
if (kind !== 'presentation' && kind !== 'control') return;
|
||||
const open = windows.has('presentation') || windows.has('control');
|
||||
if (!open) {
|
||||
closeSceneDescriptionWindow();
|
||||
closeMaterialsWindow();
|
||||
closeNpcsWindow();
|
||||
closingPlaySession = false;
|
||||
closePlaySessionAuxiliaryWindows();
|
||||
}
|
||||
broadcastMultiWindowStateChanged(open);
|
||||
});
|
||||
@@ -399,16 +513,19 @@ export function openMultiWindow() {
|
||||
createWindow('control', process.platform === 'darwin' ? undefined : { parent: presentation });
|
||||
}
|
||||
broadcastMultiWindowStateChanged(true);
|
||||
broadcastPresentationContentSize();
|
||||
}
|
||||
|
||||
export function closeMultiWindow(): void {
|
||||
closeSceneDescriptionWindow();
|
||||
closeMaterialsWindow();
|
||||
closeNpcsWindow();
|
||||
closingPlaySession = true;
|
||||
closePlaySessionAuxiliaryWindows();
|
||||
const pres = windows.get('presentation');
|
||||
const ctrl = windows.get('control');
|
||||
if (pres) pres.close();
|
||||
if (ctrl) ctrl.close();
|
||||
if (pres && !pres.isDestroyed()) pres.close();
|
||||
if (ctrl && !ctrl.isDestroyed()) ctrl.close();
|
||||
if (!windows.has('presentation') && !windows.has('control')) {
|
||||
closingPlaySession = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isMultiWindowOpen(): boolean {
|
||||
@@ -437,12 +554,73 @@ export function closeNpcsEditorWindow(): void {
|
||||
}
|
||||
|
||||
export function closeSceneEditorWindow(): void {
|
||||
closeTokenPathEditorWindow();
|
||||
const win = windows.get('sceneEditor');
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function closeTokenPathEditorWindow(): void {
|
||||
const win = windows.get('tokenPathEditor');
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
}
|
||||
|
||||
let pendingTokenPathEditorTarget: { kind: 'token' | 'npcToken'; placementId: string } | null = null;
|
||||
|
||||
export function getTokenPathEditorTarget(): { kind: 'token' | 'npcToken'; placementId: string } | null {
|
||||
return pendingTokenPathEditorTarget;
|
||||
}
|
||||
|
||||
function broadcastTokenPathEditorTarget(): void {
|
||||
const win = windows.get('tokenPathEditor');
|
||||
if (!win || win.isDestroyed() || win.webContents.isDestroyed()) return;
|
||||
try {
|
||||
win.webContents.send(ipcChannels.windows.tokenPathEditorTargetChanged, pendingTokenPathEditorTarget);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Одно окно пути: смена токена = фокус + targetChanged, без второго окна. */
|
||||
export function openTokenPathEditorWindow(kind: 'token' | 'npcToken', placementId: string): void {
|
||||
pendingTokenPathEditorTarget = { kind, placementId };
|
||||
const existing = windows.get('tokenPathEditor');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
broadcastTokenPathEditorTarget();
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = windows.get('sceneEditor') ?? windows.get('editor');
|
||||
const win = createWindow('tokenPathEditor', 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) / 2)),
|
||||
y: Math.round(y + Math.max(0, (dh - height) / 2)),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
broadcastTokenPathEditorTarget();
|
||||
}
|
||||
});
|
||||
win.on('closed', () => {
|
||||
pendingTokenPathEditorTarget = null;
|
||||
});
|
||||
}
|
||||
|
||||
export function closeNpcsWindow(): void {
|
||||
const win = windows.get('npcs');
|
||||
if (win && !win.isDestroyed()) {
|
||||
@@ -527,19 +705,8 @@ export function openMaterialsWindow(): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** Редактор НПС: отдельное окно с графом и инспектором. */
|
||||
export function openNpcsEditorWindow(): void {
|
||||
const existing = windows.get('npcsEditor');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
return;
|
||||
}
|
||||
|
||||
function positionNpcsEditorWindow(win: BrowserWindow): void {
|
||||
const parent = windows.get('editor');
|
||||
const win = createWindow('npcsEditor', 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;
|
||||
@@ -549,9 +716,43 @@ export function openNpcsEditorWindow(): void {
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
|
||||
/** Прогрев окна НПС в фоне после открытия проекта — клик «НПС» не ждёт холодной загрузки. */
|
||||
export function warmNpcsEditorWindow(): void {
|
||||
const existing = windows.get('npcsEditor');
|
||||
if (existing && !existing.isDestroyed()) return;
|
||||
const parent = windows.get('editor');
|
||||
createWindow('npcsEditor', {
|
||||
...(parent ? { parent } : {}),
|
||||
deferVisibility: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Редактор НПС: отдельное окно с графом и инспектором. */
|
||||
export function openNpcsEditorWindow(): void {
|
||||
const existing = windows.get('npcsEditor');
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
if (existing.isMinimized()) existing.restore();
|
||||
positionNpcsEditorWindow(existing);
|
||||
existing.show();
|
||||
existing.focus();
|
||||
existing.moveTop();
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = windows.get('editor');
|
||||
const win = createWindow('npcsEditor', {
|
||||
...(parent ? { parent } : {}),
|
||||
deferVisibility: true,
|
||||
});
|
||||
positionNpcsEditorWindow(win);
|
||||
// Показываем сразу (тёмный фон), не дожидаясь полной загрузки React/ReactFlow.
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.show();
|
||||
win.focus();
|
||||
win.moveTop();
|
||||
}
|
||||
|
||||
@@ -271,6 +271,7 @@
|
||||
|
||||
.historyTitle {
|
||||
font-weight: 800;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.emptyStory {
|
||||
@@ -302,9 +303,45 @@
|
||||
|
||||
.previewActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.npcTokenScale {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.snapToGrid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.snapToGridLabel {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.npcTokenScaleLabel {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.npcTokenScaleRange {
|
||||
width: min(140px, 22vw);
|
||||
accent-color: var(--color-accent, #c9a227);
|
||||
}
|
||||
|
||||
.videoHint {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
@@ -356,7 +393,7 @@
|
||||
|
||||
.branchGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -367,6 +404,8 @@
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.branchCardHeader {
|
||||
@@ -383,6 +422,7 @@
|
||||
|
||||
.branchName {
|
||||
font-weight: 900;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.branchCardReturn {
|
||||
@@ -575,3 +615,43 @@
|
||||
.audioScrubDefault {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ctxMenuBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ctxMenu {
|
||||
position: fixed;
|
||||
z-index: 41;
|
||||
min-width: 200px;
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--stroke, #2a2f3a);
|
||||
background: var(--color-surface-menu, #1a1e28);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ctxItem {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text1, #e8eaef);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ctxItem:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
+933
-111
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,8 @@
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
pointer-events: auto;
|
||||
/* Above brush / traps layers so transport stays usable on video scenes. */
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.scrub {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
|
||||
import type { SessionState } from '../../shared/ipc/contracts';
|
||||
import type { SceneViewCamera } from '../../shared/types';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { ContainedVideo } from '../shared/ContainedVideo';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
import { useVideoPlaybackState } from '../shared/video/useVideoPlaybackState';
|
||||
@@ -35,6 +36,7 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
|
||||
const isVideo = scene?.previewAssetType === 'video';
|
||||
const assetId = scene?.previewAssetType === 'video' ? scene.previewAssetId : null;
|
||||
const autostart = scene?.previewVideoAutostart ?? false;
|
||||
const lastTargetRef = useRef<{ sceneKey: string; assetId: string; autostart: boolean } | null>(null);
|
||||
|
||||
const [tick, setTick] = useState(0);
|
||||
const dur = useMemo(
|
||||
@@ -61,14 +63,18 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
|
||||
useEffect(() => {
|
||||
if (!isVideo) return;
|
||||
if (!assetId) return;
|
||||
// `target.set` bumps revision and resets anchors; avoid firing on every render.
|
||||
if (vp?.targetAssetId === assetId) return;
|
||||
const sceneKey = session?.project?.currentGraphNodeId ?? session?.currentSceneId ?? '';
|
||||
const prev = lastTargetRef.current;
|
||||
if (prev && prev.sceneKey === sceneKey && prev.assetId === assetId && prev.autostart === autostart) {
|
||||
return;
|
||||
}
|
||||
lastTargetRef.current = { sceneKey, assetId, autostart };
|
||||
void video.dispatch({
|
||||
kind: 'target.set',
|
||||
assetId,
|
||||
autostart,
|
||||
});
|
||||
}, [assetId, isVideo, autostart, vp?.targetAssetId, video]);
|
||||
}, [assetId, isVideo, autostart, session?.currentSceneId, session?.project?.currentGraphNodeId, video]);
|
||||
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
@@ -101,19 +107,20 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
|
||||
onContentRectChange={onContentRectChange}
|
||||
/>
|
||||
) : url && isVideo ? (
|
||||
<video
|
||||
ref={(el) => {
|
||||
(videoRef as unknown as { current: HTMLVideoElement | null }).current = el;
|
||||
}}
|
||||
className={styles.video}
|
||||
src={url}
|
||||
<ContainedVideo
|
||||
url={url}
|
||||
rotationDeg={rot}
|
||||
videoRef={videoRef}
|
||||
playsInline
|
||||
loop={Boolean(scene?.settings?.loopVideo)}
|
||||
preload="auto"
|
||||
viewCamera={viewCamera}
|
||||
onContentRectChange={onContentRectChange}
|
||||
onTimeUpdate={() => setTick((x) => x + 1)}
|
||||
onLoadedMetadata={() => setTick((x) => x + 1)}
|
||||
>
|
||||
<track kind="captions" srcLang="ru" label={t('control.previewTrackLabel')} />
|
||||
</video>
|
||||
</ContainedVideo>
|
||||
) : (
|
||||
<div className={styles.placeholder} />
|
||||
)}
|
||||
|
||||
@@ -58,7 +58,7 @@ void test('ControlApp: эффект «взрыв» + ловушка исполь
|
||||
const appSrc = readControlApp();
|
||||
const sfxSrc = fs.readFileSync(path.join(here, 'explosionSfx.ts'), 'utf8');
|
||||
assert.ok(appSrc.includes("title={t('control.explosion')}"));
|
||||
assert.ok(appSrc.includes("tool: 'explosion'"));
|
||||
assert.ok(appSrc.includes("selectEffectTool('explosion')"));
|
||||
assert.ok(appSrc.includes("type: 'explosion'"));
|
||||
assert.ok(appSrc.includes('getExplosionEffectLifeMs'));
|
||||
assert.ok(appSrc.includes('playExplosionEffectSound'));
|
||||
@@ -87,6 +87,9 @@ void test('ControlApp: эффекты в пульте, иконки с тулт
|
||||
assert.ok(src.includes("t('control.actionEffects')"));
|
||||
assert.ok(src.includes("t('control.darknessControl')"));
|
||||
assert.ok(src.includes("t('control.explorerBrush')"));
|
||||
assert.ok(src.includes("t('control.closerBrush')"));
|
||||
assert.ok(src.includes("tool: 'closeBrush'") || src.includes("selectEffectTool('closeBrush')"));
|
||||
assert.ok(src.includes("mode: b.tool === 'closeBrush' ? 'cover' : 'reveal'") || src.includes("'cover'"));
|
||||
assert.ok(src.includes('SceneDarknessOverlay'));
|
||||
assert.ok(src.includes('useSceneDarknessState'));
|
||||
assert.ok(src.includes("t('control.sunbeam')"));
|
||||
@@ -113,6 +116,18 @@ void test('ControlApp: эффекты в пульте, иконки с тулт
|
||||
assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше сюжетной линии');
|
||||
});
|
||||
|
||||
void test('ControlApp: чекбокс привязки токенов к сетке', () => {
|
||||
const src = readControlApp();
|
||||
const css = readControlAppCss();
|
||||
assert.ok(src.includes('useTokenGridSnapSession'));
|
||||
assert.ok(src.includes('snapNormToGridCell'));
|
||||
assert.ok(src.includes('data-testid="token-grid-snap"'));
|
||||
assert.ok(src.includes("t('control.snapTokensToGrid')"));
|
||||
assert.ok(src.includes('snapAllTokensToGrid'));
|
||||
assert.ok(src.includes('snapNorm={snapNormActive}'));
|
||||
assert.ok(css.includes('.snapToGrid'));
|
||||
});
|
||||
|
||||
void test('ControlApp: сюжетная линия — колонка сверху вниз и фон как у карточек ветвления', () => {
|
||||
const src = readControlApp();
|
||||
const css = readControlAppCss();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||
|
||||
import { ControlApp } from './ControlApp';
|
||||
|
||||
@@ -13,8 +14,10 @@ if (!rootEl) {
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<EditorI18nProvider>
|
||||
<ControlApp />
|
||||
</EditorI18nProvider>
|
||||
<WindowErrorBoundary title="Пульт">
|
||||
<EditorI18nProvider>
|
||||
<ControlApp />
|
||||
</EditorI18nProvider>
|
||||
</WindowErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -59,6 +59,68 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.splitRun {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
height: 34px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--accent-border);
|
||||
background: var(--accent-fill-solid);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.splitRun:disabled,
|
||||
.splitRun[aria-disabled='true'] {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.splitRunMain,
|
||||
.splitRunChevron {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.splitRunMain {
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.splitRunMain:hover:not(:disabled),
|
||||
.splitRunChevron:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--accent-fill-solid) 72%, white);
|
||||
}
|
||||
|
||||
.splitRunMain:active:not(:disabled),
|
||||
.splitRunChevron:active:not(:disabled),
|
||||
.splitRunChevronOpen {
|
||||
background: color-mix(in srgb, var(--accent-fill-solid) 78%, black);
|
||||
}
|
||||
|
||||
.splitRunDivider {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.splitRunChevron {
|
||||
width: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.splitRunChevronSvg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.editorSidebar {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
@@ -265,6 +327,18 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.projectNameLabel {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 10px;
|
||||
color: var(--text1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inspectorScroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -934,6 +1008,33 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.actionsRowHalf {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actionsRowHalf > span {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.actionsRowHalf > span > button {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actionsRowVideoChecks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.checkboxLabel {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -1076,6 +1177,7 @@
|
||||
border: 1px solid transparent;
|
||||
box-sizing: border-box;
|
||||
background: transparent;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sceneCard:not(.sceneCardActive):hover {
|
||||
@@ -1163,21 +1265,23 @@
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sceneCardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.badgeCurrent {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--accent2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sceneMenuBtn {
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
background: var(--panel2);
|
||||
border-radius: var(--radius-xs);
|
||||
@@ -1191,6 +1295,11 @@
|
||||
|
||||
.sceneCardTitle {
|
||||
font-weight: 750;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menuBackdrop {
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { startTransition, useCallback, useEffect, useMemo, useRef, useSta
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { moveSceneInListOrder, reconcileSceneListOrder } from '../../shared/graph/sceneListOrder';
|
||||
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
|
||||
import type {
|
||||
NpcImportResolution,
|
||||
SceneImportResolution,
|
||||
@@ -28,6 +29,7 @@ import type {
|
||||
SceneId,
|
||||
} from '../../shared/types';
|
||||
import { AppLogo } from '../shared/branding/AppLogo';
|
||||
import { ContainedVideo } from '../shared/ContainedVideo';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
@@ -55,6 +57,8 @@ import type { HelpSectionId } from './help/helpSections';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals';
|
||||
import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals';
|
||||
import { PlayersManagerModal } from './PlayersModals';
|
||||
import { LaunchPlayersModal } from './LaunchPlayersModal';
|
||||
import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml';
|
||||
import { SceneDescriptionModal } from './SceneDescriptionModal';
|
||||
import type { ProjectNoticeCode } from './state/projectState';
|
||||
@@ -134,9 +138,9 @@ export function EditorApp() {
|
||||
const [importConflictsOpen, setImportConflictsOpen] = useState(false);
|
||||
const [importConflicts, setImportConflicts] = useState<ReturnType<typeof computeImportConflicts>>([]);
|
||||
const [importNpcConflictsOpen, setImportNpcConflictsOpen] = useState(false);
|
||||
const [importNpcConflicts, setImportNpcConflicts] = useState<
|
||||
ReturnType<typeof computeNpcImportConflicts>
|
||||
>([]);
|
||||
const [importNpcConflicts, setImportNpcConflicts] = useState<ReturnType<typeof computeNpcImportConflicts>>(
|
||||
[],
|
||||
);
|
||||
const [pendingImportSelections, setPendingImportSelections] = useState<StorylineSelection[]>([]);
|
||||
const [pendingSceneResolutions, setPendingSceneResolutions] = useState<SceneImportResolution[]>([]);
|
||||
const [importReportOpen, setImportReportOpen] = useState(false);
|
||||
@@ -154,6 +158,11 @@ export function EditorApp() {
|
||||
const licenseActive = licenseSnap?.active === true;
|
||||
const [appNotice, setAppNotice] = useState<{ title?: string; message: string } | null>(null);
|
||||
const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false);
|
||||
const [playersManagerOpen, setPlayersManagerOpen] = useState(false);
|
||||
const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false);
|
||||
const [runMenuOpen, setRunMenuOpen] = useState(false);
|
||||
const [runMenuPos, setRunMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||||
const runSplitRef = useRef<HTMLDivElement | null>(null);
|
||||
const [materialEdit, setMaterialEdit] = useState<ProjectMaterial | null | 'new'>(null);
|
||||
const onProjectNotice = useCallback(
|
||||
(code: ProjectNoticeCode) => {
|
||||
@@ -351,13 +360,17 @@ export function EditorApp() {
|
||||
const runHelpTooltip = !licenseActive ? t('top.afterLicense') : t('top.setStartScene');
|
||||
|
||||
const launchFromGraphNode = useCallback(
|
||||
(graphNodeId: GraphNodeId) => {
|
||||
(graphNodeId: GraphNodeId, playerIds?: string[]) => {
|
||||
if (!licenseActive || launching) return;
|
||||
setLaunching(true);
|
||||
setRunMenuOpen(false);
|
||||
setLaunchPlayersOpen(false);
|
||||
void (async () => {
|
||||
try {
|
||||
await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId });
|
||||
await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {});
|
||||
await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {
|
||||
...(playerIds && playerIds.length > 0 ? { playerIds } : {}),
|
||||
});
|
||||
} catch {
|
||||
setLaunching(false);
|
||||
}
|
||||
@@ -461,6 +474,23 @@ export function EditorApp() {
|
||||
return () => window.removeEventListener('mousedown', onDown);
|
||||
}, [aboutMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runMenuOpen) return;
|
||||
const r = runSplitRef.current?.getBoundingClientRect() ?? null;
|
||||
queueMicrotask(() => {
|
||||
if (r) setRunMenuPos({ left: r.right, top: r.bottom + 8 });
|
||||
else setRunMenuPos(null);
|
||||
});
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (!t) return;
|
||||
if (t.closest('[data-runmenu-root="1"]')) return;
|
||||
setRunMenuOpen(false);
|
||||
};
|
||||
window.addEventListener('mousedown', onDown);
|
||||
return () => window.removeEventListener('mousedown', onDown);
|
||||
}, [runMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
let off: (() => void) | null = null;
|
||||
void (async () => {
|
||||
@@ -546,12 +576,7 @@ export function EditorApp() {
|
||||
sceneResolutions,
|
||||
npcResolutions,
|
||||
)
|
||||
: await actions.mergeImportZip(
|
||||
importPeek.filePath!,
|
||||
selections,
|
||||
sceneResolutions,
|
||||
npcResolutions,
|
||||
);
|
||||
: await actions.mergeImportZip(importPeek.filePath!, selections, sceneResolutions, npcResolutions);
|
||||
setImportReport(report);
|
||||
setImportReportOpen(true);
|
||||
clearImportFlow();
|
||||
@@ -562,11 +587,7 @@ export function EditorApp() {
|
||||
const continueImportAfterScenes = useCallback(
|
||||
(selections: StorylineSelection[], sceneResolutions: SceneImportResolution[]) => {
|
||||
if (!importPeek || !state.project) return;
|
||||
const npcConflicts = computeNpcImportConflicts(
|
||||
state.project,
|
||||
importPeek.sourceProject,
|
||||
selections,
|
||||
);
|
||||
const npcConflicts = computeNpcImportConflicts(state.project, importPeek.sourceProject);
|
||||
setPendingImportSelections(selections);
|
||||
setPendingSceneResolutions(sceneResolutions);
|
||||
setImportConflictsOpen(false);
|
||||
@@ -575,13 +596,7 @@ export function EditorApp() {
|
||||
setImportNpcConflictsOpen(true);
|
||||
return;
|
||||
}
|
||||
const npcResolutions = buildNpcResolutionsForImport(
|
||||
state.project,
|
||||
importPeek.sourceProject,
|
||||
selections,
|
||||
[],
|
||||
[],
|
||||
);
|
||||
const npcResolutions = buildNpcResolutionsForImport(state.project, importPeek.sourceProject, [], []);
|
||||
void runStorylineMerge(selections, sceneResolutions, npcResolutions);
|
||||
},
|
||||
[importPeek, runStorylineMerge, state.project],
|
||||
@@ -729,9 +744,7 @@ export function EditorApp() {
|
||||
{t('scenes.batchProgress')
|
||||
.replace('{current}', String(state.sceneBatchImport.current))
|
||||
.replace('{total}', String(state.sceneBatchImport.total))}
|
||||
{state.sceneBatchImport.fileName
|
||||
? `: ${state.sceneBatchImport.fileName}`
|
||||
: ''}
|
||||
{state.sceneBatchImport.fileName ? `: ${state.sceneBatchImport.fileName}` : ''}
|
||||
</div>
|
||||
<div>
|
||||
{Math.round(
|
||||
@@ -830,6 +843,25 @@ export function EditorApp() {
|
||||
{t('top.file')}
|
||||
</button>
|
||||
) : null}
|
||||
{USERS_BRANCH_FEATURES_ENABLED ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="players-header-btn"
|
||||
className={styles.fileMenuTrigger}
|
||||
disabled={!licenseActive}
|
||||
title={!licenseActive ? t('top.afterLicense') : undefined}
|
||||
onClick={() => {
|
||||
if (!licenseActive) return;
|
||||
setProjectMenuOpen(false);
|
||||
setSettingsMenuOpen(false);
|
||||
setAboutMenuOpen(false);
|
||||
setFileMenuOpen(false);
|
||||
setPlayersManagerOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('top.players')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.flex1} />
|
||||
{appVersionText ? (
|
||||
@@ -840,16 +872,69 @@ export function EditorApp() {
|
||||
<div className={styles.headerActions}>
|
||||
{state.project ? (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={runDisabled || launching}
|
||||
onClick={() => {
|
||||
if (!licenseActive || !graphStartGraphNodeId || launching) return;
|
||||
launchFromGraphNode(graphStartGraphNodeId);
|
||||
}}
|
||||
{USERS_BRANCH_FEATURES_ENABLED ? (
|
||||
<div
|
||||
ref={runSplitRef}
|
||||
className={styles.splitRun}
|
||||
data-runmenu-root="1"
|
||||
aria-disabled={runDisabled || launching ? true : undefined}
|
||||
>
|
||||
{t('top.run')}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.splitRunMain}
|
||||
disabled={runDisabled || launching}
|
||||
data-testid="run-main-btn"
|
||||
onClick={() => {
|
||||
if (!licenseActive || !graphStartGraphNodeId || launching) return;
|
||||
launchFromGraphNode(graphStartGraphNodeId);
|
||||
}}
|
||||
>
|
||||
{t('top.run')}
|
||||
</button>
|
||||
<span className={styles.splitRunDivider} aria-hidden />
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
styles.splitRunChevron,
|
||||
runMenuOpen ? styles.splitRunChevronOpen : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
disabled={runDisabled || launching}
|
||||
aria-label={t('top.runMenuAria')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={runMenuOpen}
|
||||
data-testid="run-menu-btn"
|
||||
onClick={() => {
|
||||
if (runDisabled || launching) return;
|
||||
setRunMenuOpen((v) => !v);
|
||||
}}
|
||||
>
|
||||
<svg className={styles.splitRunChevronSvg} viewBox="0 0 12 12" aria-hidden>
|
||||
<path
|
||||
d="M2.5 4.25 L6 7.75 L9.5 4.25"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={runDisabled || launching}
|
||||
data-testid="run-main-btn"
|
||||
onClick={() => {
|
||||
if (!licenseActive || !graphStartGraphNodeId || launching) return;
|
||||
launchFromGraphNode(graphStartGraphNodeId);
|
||||
}}
|
||||
>
|
||||
{t('top.run')}
|
||||
</Button>
|
||||
)}
|
||||
{runDisabled ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -905,9 +990,7 @@ export function EditorApp() {
|
||||
scene={s}
|
||||
reorderEnabled={sceneListReorderEnabled}
|
||||
listDragActive={draggingListSceneId !== null}
|
||||
dropPlace={
|
||||
sceneListDrop?.targetId === s.id ? sceneListDrop.place : null
|
||||
}
|
||||
dropPlace={sceneListDrop?.targetId === s.id ? sceneListDrop.place : null}
|
||||
isDragging={draggingListSceneId === s.id}
|
||||
onSelect={() => {
|
||||
setSelectedGraphNodeId(null);
|
||||
@@ -930,9 +1013,7 @@ export function EditorApp() {
|
||||
return;
|
||||
}
|
||||
setSceneListDrop((cur) =>
|
||||
cur?.targetId === targetId && cur.place === place
|
||||
? cur
|
||||
: { targetId, place },
|
||||
cur?.targetId === targetId && cur.place === place ? cur : { targetId, place },
|
||||
);
|
||||
}}
|
||||
onDropListReorder={(draggedId, targetId, place) => {
|
||||
@@ -1023,6 +1104,9 @@ export function EditorApp() {
|
||||
<div className={styles.inspectorScroll}>
|
||||
{state.project ? (
|
||||
<>
|
||||
<div className={styles.projectNameLabel} title={state.project.meta.name}>
|
||||
{t('scenes.projectLabel', { name: state.project.meta.name })}
|
||||
</div>
|
||||
<div className={styles.inspectorTitle}>{t('scenes.inspectorGame')}</div>
|
||||
<CampaignInspector
|
||||
audioRefs={campaignAudioRefs}
|
||||
@@ -1098,6 +1182,7 @@ export function EditorApp() {
|
||||
previewAssetId={sc?.previewAssetId ?? null}
|
||||
previewAssetType={sc?.previewAssetType ?? null}
|
||||
previewVideoAutostart={sc?.previewVideoAutostart ?? false}
|
||||
previewVideoLoop={sc?.settings.loopVideo ?? false}
|
||||
previewRotationDeg={sc?.previewRotationDeg ?? 0}
|
||||
darkenScene={sc?.darkenScene ?? false}
|
||||
previewBusy={previewBusy}
|
||||
@@ -1110,6 +1195,9 @@ export function EditorApp() {
|
||||
onPreviewVideoAutostartChange={(next) =>
|
||||
void actions.updateScene(sid, { previewVideoAutostart: next })
|
||||
}
|
||||
onPreviewVideoLoopChange={(next) =>
|
||||
void actions.updateScene(sid, { settings: { loopVideo: next } })
|
||||
}
|
||||
onDarkenSceneChange={(next) => void actions.updateScene(sid, { darkenScene: next })}
|
||||
onTitleChange={(title) => void actions.updateScene(sid, { title })}
|
||||
onDescriptionChange={(description) =>
|
||||
@@ -1459,8 +1547,8 @@ export function EditorApp() {
|
||||
storylineLabels={storylineLabels}
|
||||
loadStorylines={loadProjectStorylines}
|
||||
onClose={() => setExportModalOpen(false)}
|
||||
onExport={async (projectId, selections) => {
|
||||
await actions.exportProject(projectId, selections, storylineLabels);
|
||||
onExport={async (projectId, selections, npcIds) => {
|
||||
await actions.exportProject(projectId, selections, npcIds, storylineLabels);
|
||||
}}
|
||||
/>
|
||||
<ImportSourceModal
|
||||
@@ -1535,7 +1623,6 @@ export function EditorApp() {
|
||||
const npcResolutions = buildNpcResolutionsForImport(
|
||||
state.project,
|
||||
importPeek.sourceProject,
|
||||
pendingImportSelections,
|
||||
importNpcConflicts,
|
||||
userNpcResolutions,
|
||||
);
|
||||
@@ -1551,6 +1638,43 @@ export function EditorApp() {
|
||||
}}
|
||||
/>
|
||||
<CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(false)} />
|
||||
{USERS_BRANCH_FEATURES_ENABLED ? (
|
||||
<>
|
||||
<PlayersManagerModal open={playersManagerOpen} onClose={() => setPlayersManagerOpen(false)} />
|
||||
<LaunchPlayersModal
|
||||
open={launchPlayersOpen}
|
||||
onClose={() => setLaunchPlayersOpen(false)}
|
||||
onConfirm={(playerIds) => {
|
||||
if (!graphStartGraphNodeId) return;
|
||||
launchFromGraphNode(graphStartGraphNodeId, playerIds);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{USERS_BRANCH_FEATURES_ENABLED && runMenuOpen && runMenuPos
|
||||
? createPortal(
|
||||
<div
|
||||
data-runmenu-root="1"
|
||||
className={styles.fileMenu}
|
||||
style={{ left: runMenuPos.left, top: runMenuPos.top, transform: 'translateX(-100%)' }}
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.fileMenuItem}
|
||||
role="menuitem"
|
||||
data-testid="run-with-players-item"
|
||||
onClick={() => {
|
||||
setRunMenuOpen(false);
|
||||
setLaunchPlayersOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('top.runWithPlayers')}
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
<MaterialsManagerModal
|
||||
open={materialsManagerOpen}
|
||||
materials={state.project?.materials ?? []}
|
||||
@@ -2325,6 +2449,7 @@ type SceneInspectorProps = {
|
||||
previewAssetId: AssetId | null;
|
||||
previewAssetType: 'image' | 'video' | null;
|
||||
previewVideoAutostart: boolean;
|
||||
previewVideoLoop: boolean;
|
||||
previewRotationDeg: 0 | 90 | 180 | 270;
|
||||
darkenScene: boolean;
|
||||
previewBusy: boolean;
|
||||
@@ -2333,6 +2458,7 @@ type SceneInspectorProps = {
|
||||
audioRefs: SceneAudioRef[];
|
||||
onAudioRefsChange: (next: SceneAudioRef[]) => void;
|
||||
onPreviewVideoAutostartChange: (next: boolean) => void;
|
||||
onPreviewVideoLoopChange: (next: boolean) => void;
|
||||
onDarkenSceneChange: (next: boolean) => void;
|
||||
onTitleChange: (v: string) => void;
|
||||
onDescriptionChange: (v: string) => void;
|
||||
@@ -2376,9 +2502,7 @@ function CampaignInspector({
|
||||
onDragOver={audioDrop.onDragOver}
|
||||
onDrop={audioDrop.onDrop}
|
||||
>
|
||||
{audioDrop.dragOver ? (
|
||||
<div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div>
|
||||
) : null}
|
||||
{audioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
|
||||
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
|
||||
<div className={styles.audioDropEmpty}>
|
||||
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
|
||||
@@ -2459,6 +2583,7 @@ function SceneInspector({
|
||||
previewAssetId,
|
||||
previewAssetType,
|
||||
previewVideoAutostart,
|
||||
previewVideoLoop,
|
||||
previewRotationDeg,
|
||||
darkenScene,
|
||||
previewBusy,
|
||||
@@ -2467,6 +2592,7 @@ function SceneInspector({
|
||||
audioRefs,
|
||||
onAudioRefsChange,
|
||||
onPreviewVideoAutostartChange,
|
||||
onPreviewVideoLoopChange,
|
||||
onDarkenSceneChange,
|
||||
onTitleChange,
|
||||
onDescriptionChange,
|
||||
@@ -2543,10 +2669,7 @@ function SceneInspector({
|
||||
{sideStoryStartNodes.map((gn) => (
|
||||
<div key={gn.id}>
|
||||
<div className={styles.labelSm}>{t('scene.sideStoryLineTitle')}</div>
|
||||
<Input
|
||||
value={gn.sideStoryLineTitle}
|
||||
onChange={(v) => onSideStoryLineTitleChange(gn.id, v)}
|
||||
/>
|
||||
<Input value={gn.sideStoryLineTitle} onChange={(v) => onSideStoryLineTitleChange(gn.id, v)} />
|
||||
<div className={styles.spacer8} />
|
||||
</div>
|
||||
))}
|
||||
@@ -2562,9 +2685,7 @@ function SceneInspector({
|
||||
onDragOver={previewDrop.onDragOver}
|
||||
onDrop={previewDrop.onDrop}
|
||||
>
|
||||
{previewDrop.dragOver ? (
|
||||
<div className={styles.dropHintOverlay}>{t('drop.hintPreview')}</div>
|
||||
) : null}
|
||||
{previewDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintPreview')}</div> : null}
|
||||
{previewUrl && previewAssetType === 'image' ? (
|
||||
<div className={styles.previewFill}>
|
||||
<RotatedImage
|
||||
@@ -2576,14 +2697,16 @@ function SceneInspector({
|
||||
</div>
|
||||
) : previewUrl && previewAssetType === 'video' ? (
|
||||
<div className={styles.previewFill}>
|
||||
<video
|
||||
src={previewUrl}
|
||||
<ContainedVideo
|
||||
url={previewUrl}
|
||||
rotationDeg={previewRotationDeg}
|
||||
mode="cover"
|
||||
muted
|
||||
playsInline
|
||||
autoPlay={previewVideoAutostart}
|
||||
loop
|
||||
loop={previewVideoLoop}
|
||||
preload="metadata"
|
||||
className={styles.videoCover}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -2598,12 +2721,14 @@ function SceneInspector({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.actionsRow}>
|
||||
<div className={styles.actionsRowHalf}>
|
||||
<Button variant="primary" disabled={previewBusy} onClick={onImportPreview}>
|
||||
{previewAssetId ? t('scene.change') : t('campaign.upload')}
|
||||
</Button>
|
||||
{previewAssetId ? <Button onClick={onClearPreview}>{t('scene.clear')}</Button> : null}
|
||||
{previewAssetId && previewAssetType === 'video' ? (
|
||||
{previewAssetId ? <Button onClick={onClearPreview}>{t('scene.clear')}</Button> : <span aria-hidden />}
|
||||
</div>
|
||||
{previewAssetId && previewAssetType === 'video' ? (
|
||||
<div className={styles.actionsRowVideoChecks}>
|
||||
<label className={styles.checkboxLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -2612,8 +2737,19 @@ function SceneInspector({
|
||||
/>
|
||||
<span className={styles.spanSm}>{t('scene.autostart')}</span>
|
||||
</label>
|
||||
) : null}
|
||||
{previewAssetId && previewAssetType === 'image' ? (
|
||||
<label className={styles.checkboxLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={previewVideoLoop}
|
||||
onChange={(e) => onPreviewVideoLoopChange(e.target.checked)}
|
||||
/>
|
||||
<span className={styles.spanSm}>{t('campaign.loop')}</span>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
{previewAssetId && (previewAssetType === 'image' || previewAssetType === 'video') ? (
|
||||
<>
|
||||
<div className={styles.spacer6} />
|
||||
<Button
|
||||
onClick={() => {
|
||||
const next = ((previewRotationDeg + 90) % 360) as 0 | 90 | 180 | 270;
|
||||
@@ -2622,9 +2758,9 @@ function SceneInspector({
|
||||
>
|
||||
{t('scene.rotate')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{previewAssetId && previewAssetType === 'image' ? (
|
||||
</>
|
||||
) : null}
|
||||
{previewAssetId && (previewAssetType === 'image' || previewAssetType === 'video') ? (
|
||||
<>
|
||||
<div className={styles.spacer6} />
|
||||
<label className={styles.checkboxLabel}>
|
||||
@@ -2654,9 +2790,7 @@ function SceneInspector({
|
||||
onDragOver={sceneAudioDrop.onDragOver}
|
||||
onDrop={sceneAudioDrop.onDrop}
|
||||
>
|
||||
{sceneAudioDrop.dragOver ? (
|
||||
<div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div>
|
||||
) : null}
|
||||
{sceneAudioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
|
||||
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
|
||||
<div className={styles.audioDropEmpty}>
|
||||
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
|
||||
@@ -2765,6 +2899,8 @@ function SceneListCard({
|
||||
const previewUrl = useAssetUrl(scene.previewAssetId);
|
||||
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState(false);
|
||||
const titleRef = useRef<HTMLDivElement | null>(null);
|
||||
const [titleTooltip, setTitleTooltip] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
@@ -2856,23 +2992,44 @@ function SceneListCard({
|
||||
</div>
|
||||
) : previewUrl && scene.previewAssetType === 'video' ? (
|
||||
<div className={styles.sceneThumbInner}>
|
||||
<video
|
||||
src={previewUrl}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
draggable={false}
|
||||
className={styles.sceneThumbVideo}
|
||||
onLoadedData={(e) => {
|
||||
const v = e.currentTarget;
|
||||
try {
|
||||
v.currentTime = 0;
|
||||
v.pause();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{scene.previewRotationDeg === 0 ? (
|
||||
<video
|
||||
src={previewUrl}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
draggable={false}
|
||||
className={styles.sceneThumbVideo}
|
||||
onLoadedData={(e) => {
|
||||
const v = e.currentTarget;
|
||||
try {
|
||||
v.currentTime = 0;
|
||||
v.pause();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ContainedVideo
|
||||
url={previewUrl}
|
||||
rotationDeg={scene.previewRotationDeg}
|
||||
mode="cover"
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
onLoadedData={(e) => {
|
||||
const v = e.currentTarget;
|
||||
try {
|
||||
v.currentTime = 0;
|
||||
v.pause();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.sceneThumbEmptyInner} aria-hidden />
|
||||
@@ -2880,7 +3037,22 @@ function SceneListCard({
|
||||
</div>
|
||||
<div className={styles.sceneCardBody}>
|
||||
<div className={styles.sceneCardHeader}>
|
||||
{scene.active ? <div className={styles.badgeCurrent}>{t('sceneCard.current')}</div> : null}
|
||||
<div
|
||||
ref={titleRef}
|
||||
className={styles.sceneCardTitle}
|
||||
title={titleTooltip}
|
||||
onMouseEnter={() => {
|
||||
const el = titleRef.current;
|
||||
if (!el) {
|
||||
setTitleTooltip(undefined);
|
||||
return;
|
||||
}
|
||||
setTitleTooltip(el.scrollWidth > el.clientWidth + 1 ? scene.title : undefined);
|
||||
}}
|
||||
onMouseLeave={() => setTitleTooltip(undefined)}
|
||||
>
|
||||
{scene.title}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('sceneCard.menu')}
|
||||
@@ -2894,7 +3066,6 @@ function SceneListCard({
|
||||
⋮
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.sceneCardTitle}>{scene.title}</div>
|
||||
</div>
|
||||
{menu && menuPos
|
||||
? createPortal(
|
||||
@@ -2953,9 +3124,7 @@ function SceneListCard({
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.muted}>
|
||||
{t('confirmDeleteScene.body', { name: scene.title })}
|
||||
</div>
|
||||
<div className={styles.muted}>{t('confirmDeleteScene.body', { name: scene.title })}</div>
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={() => setPendingDelete(false)}>{t('common.cancel')}</Button>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
.dialog {
|
||||
width: min(440px, calc(100vw - 48px));
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 12px;
|
||||
color: var(--text2);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-overlay-dark-3);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--accent-fill-soft);
|
||||
}
|
||||
|
||||
.teamDot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 10px 8px;
|
||||
color: var(--text2);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { AppPlayer, AppPlayerTeam, PlayerId, PlayerTeamId } from '../../shared/types';
|
||||
import { resolveLaunchPlayerIds } from '../../shared/players/resolveLaunchPlayerIds';
|
||||
import { useAppPlayers } from '../shared/playerToken/useAppPlayers';
|
||||
import { Button } from '../shared/ui/controls';
|
||||
|
||||
import styles from './EditorApp.module.css';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
import launchStyles from './LaunchPlayersModal.module.css';
|
||||
|
||||
export { resolveLaunchPlayerIds } from '../../shared/players/resolveLaunchPlayerIds';
|
||||
|
||||
export function LaunchPlayersModal({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (playerIds: string[]) => void;
|
||||
}) {
|
||||
const { t } = useEditorI18n();
|
||||
const { players, teams } = useAppPlayers();
|
||||
const [selectedPlayers, setSelectedPlayers] = useState<Set<string>>(() => new Set());
|
||||
const [selectedTeams, setSelectedTeams] = useState<Set<string>>(() => new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSelectedPlayers(new Set());
|
||||
setSelectedTeams(new Set());
|
||||
}, [open]);
|
||||
|
||||
const resolvedIds = useMemo(
|
||||
() => resolveLaunchPlayerIds(players, selectedPlayers, selectedTeams),
|
||||
[players, selectedPlayers, selectedTeams],
|
||||
);
|
||||
|
||||
const standalonePlayers = useMemo(
|
||||
() =>
|
||||
players.filter((p) => !(p.teamId && selectedTeams.has(String(p.teamId)))),
|
||||
[players, selectedTeams],
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const togglePlayer = (id: PlayerId) => {
|
||||
setSelectedPlayers((prev) => {
|
||||
const next = new Set(prev);
|
||||
const key = String(id);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleTeam = (id: PlayerTeamId) => {
|
||||
const key = String(id);
|
||||
setSelectedTeams((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
// Участники выбранной команды не показываются в «Игроки» — снимаем их индивидуальный выбор.
|
||||
setSelectedPlayers((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const next = new Set(prev);
|
||||
let changed = false;
|
||||
for (const p of players) {
|
||||
if (String(p.teamId) !== key) continue;
|
||||
if (next.delete(String(p.id))) changed = true;
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className={styles.modalBackdrop} aria-hidden onClick={onClose} />
|
||||
<div
|
||||
className={[styles.modalDialog, launchStyles.dialog].join(' ')}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
data-testid="launch-players-modal"
|
||||
>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('top.runWithPlayersTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className={launchStyles.hint}>{t('top.runWithPlayersHint')}</p>
|
||||
{teams.length > 0 ? (
|
||||
<section className={launchStyles.section}>
|
||||
<div className={launchStyles.sectionTitle}>{t('top.runWithPlayersTeams')}</div>
|
||||
<div className={launchStyles.list}>
|
||||
{teams.map((team: AppPlayerTeam) => {
|
||||
const count = players.filter((p) => p.teamId === team.id).length;
|
||||
return (
|
||||
<label key={team.id} className={launchStyles.row}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedTeams.has(String(team.id))}
|
||||
onChange={() => toggleTeam(team.id)}
|
||||
data-testid={`launch-team-${team.id}`}
|
||||
/>
|
||||
<span className={launchStyles.teamDot} style={{ background: team.color }} />
|
||||
<span>
|
||||
{team.name} ({count})
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{standalonePlayers.length > 0 ? (
|
||||
<section className={launchStyles.section}>
|
||||
<div className={launchStyles.sectionTitle}>{t('top.runWithPlayersPlayers')}</div>
|
||||
<div className={launchStyles.list}>
|
||||
{standalonePlayers.map((player: AppPlayer) => (
|
||||
<label key={player.id} className={launchStyles.row}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPlayers.has(String(player.id))}
|
||||
onChange={() => togglePlayer(player.id)}
|
||||
data-testid={`launch-player-${player.id}`}
|
||||
/>
|
||||
<span>{player.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : players.length === 0 ? (
|
||||
<section className={launchStyles.section}>
|
||||
<div className={launchStyles.empty}>{t('top.runWithPlayersEmpty')}</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={resolvedIds.length === 0}
|
||||
data-testid="launch-players-confirm"
|
||||
onClick={() => onConfirm(resolvedIds)}
|
||||
>
|
||||
{t('top.runWithPlayersConfirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,17 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.legendHeadRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px 14px;
|
||||
}
|
||||
|
||||
.legendHeadRow .row {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
|
||||
@@ -27,6 +27,8 @@ type Props = {
|
||||
/** Крупная карта (окно «Материалы»). */
|
||||
largeMap?: boolean;
|
||||
onChange: (legend: MaterialLegend) => void;
|
||||
onRotate?: () => void;
|
||||
rotateLabel?: string;
|
||||
};
|
||||
|
||||
type DragMode =
|
||||
@@ -55,6 +57,8 @@ export function MaterialLegendEditor({
|
||||
rotationDeg = 0,
|
||||
largeMap = false,
|
||||
onChange,
|
||||
onRotate,
|
||||
rotateLabel = 'Повернуть',
|
||||
}: Props) {
|
||||
const [draft, setDraft] = useState<MaterialLegend>(() => cloneLegend(legend));
|
||||
const [activeItemId, setActiveItemId] = useState<string | null>(null);
|
||||
@@ -404,10 +408,15 @@ export function MaterialLegendEditor({
|
||||
<div className={[styles.legendBlock, largeMap ? styles.legendBlockLarge : ''].filter(Boolean).join(' ')}>
|
||||
{largeMap ? mapBlock : null}
|
||||
|
||||
<label className={styles.row}>
|
||||
<input type="checkbox" checked={draft.enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
<span>Легенда</span>
|
||||
</label>
|
||||
<div className={styles.legendHeadRow}>
|
||||
{onRotate ? (
|
||||
<Button onClick={onRotate}>{rotateLabel}</Button>
|
||||
) : null}
|
||||
<label className={styles.row}>
|
||||
<input type="checkbox" checked={draft.enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
<span>Легенда</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{draft.enabled ? (
|
||||
<>
|
||||
|
||||
@@ -19,7 +19,7 @@ export type MaterialsBrowserProps = {
|
||||
mode: 'editor' | 'runtime';
|
||||
selectedId: MaterialId | null;
|
||||
onSelect: (id: MaterialId | null) => void;
|
||||
activeMaterialId?: MaterialId | null;
|
||||
activeMaterialIds?: readonly MaterialId[];
|
||||
onAdd?: () => void;
|
||||
onEdit?: (material: ProjectMaterial) => void;
|
||||
onDelete?: (materialId: MaterialId) => Promise<void>;
|
||||
@@ -40,7 +40,7 @@ export function MaterialsBrowser({
|
||||
mode,
|
||||
selectedId,
|
||||
onSelect,
|
||||
activeMaterialId = null,
|
||||
activeMaterialIds = [],
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -74,6 +74,7 @@ export function MaterialsBrowser({
|
||||
return () => window.removeEventListener('mousedown', onDown);
|
||||
}, [menuFor]);
|
||||
|
||||
const activeSet = useMemo(() => new Set(activeMaterialIds), [activeMaterialIds]);
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return materials;
|
||||
@@ -126,7 +127,7 @@ export function MaterialsBrowser({
|
||||
key={m.id}
|
||||
material={m}
|
||||
selected={m.id === selectedId}
|
||||
active={m.id === activeMaterialId}
|
||||
active={activeSet.has(m.id)}
|
||||
showMenu={mode === 'editor'}
|
||||
dragging={dragId === m.id}
|
||||
dropPlace={dropPlace?.id === m.id ? dropPlace.place : null}
|
||||
@@ -200,6 +201,16 @@ export function MaterialsBrowser({
|
||||
legend={selected.legend}
|
||||
previewUrl={selectedUrl}
|
||||
rotationDeg={selected.rotationDeg ?? 0}
|
||||
rotateLabel={t('scene.rotate')}
|
||||
onRotate={
|
||||
onRotate
|
||||
? () => {
|
||||
const cur = selected.rotationDeg ?? 0;
|
||||
const next = ((cur + 90) % 360) as 0 | 90 | 180 | 270;
|
||||
onRotate(selected.id, next);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onChange={(next) => {
|
||||
void onLegendChange(selected.id, next);
|
||||
}}
|
||||
@@ -220,7 +231,7 @@ export function MaterialsBrowser({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{selected && onRotate ? (
|
||||
{selected && onRotate && !onLegendChange ? (
|
||||
<div className={matStyles.previewActions}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
|
||||
@@ -33,6 +33,28 @@
|
||||
.browserToolbarRow > * {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.browserToolbarRow > * > button {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.browserToolbarZoomRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.browserToolbarFullBtn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.browserToolbarFullBtn > button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.browserToolbarHint {
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
.dialog {
|
||||
width: min(1080px, calc(100vw - 48px));
|
||||
max-width: 1080px;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
grid-template-columns: 330px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
min-height: 560px;
|
||||
max-height: min(78vh, 760px);
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.editor {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidebarActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidebarActions > * {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebarActions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.teamForm {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 10px;
|
||||
background: var(--color-overlay-dark-3);
|
||||
}
|
||||
|
||||
.teamFormField {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
color: var(--text2);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.teamFormField input[type='color'] {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.teamFormActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.teamFormActions > * {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.teamFormActions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
overflow: auto;
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
.team {
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 10px;
|
||||
background: var(--color-overlay-dark-2);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.teamHeader {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-height: 34px;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--stroke);
|
||||
}
|
||||
|
||||
.teamDot {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.teamName {
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.teamMenu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.teamMenu summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
padding: 4px 7px;
|
||||
}
|
||||
|
||||
.teamMenuPopup {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
min-width: 130px;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 8px;
|
||||
background: var(--bg1, #1a1d24);
|
||||
box-shadow: 0 10px 24px rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
.teamMenuPopup button {
|
||||
width: 100%;
|
||||
padding: 7px 9px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.teamMenuPopup button:hover {
|
||||
background: rgb(255 255 255 / 8%);
|
||||
}
|
||||
|
||||
.teamBody {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-height: 28px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.playerRow {
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.playerRow:hover,
|
||||
.playerRowSelected {
|
||||
border-color: var(--color-accent, #c9a227);
|
||||
background: rgb(255 255 255 / 5%);
|
||||
}
|
||||
|
||||
.playerRow > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dropTarget {
|
||||
padding: 8px;
|
||||
color: var(--text2);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.editor {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
overflow: auto;
|
||||
padding: 22px;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 12px;
|
||||
background: var(--color-overlay-dark-3);
|
||||
}
|
||||
|
||||
.editorDropOver {
|
||||
border-color: var(--color-accent, #c9a227);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
width: min(420px, 100%);
|
||||
color: var(--text2);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.field input[type='color'] {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal, flushSync } from 'react-dom';
|
||||
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import type { AppPlayer, AppPlayerTeam, PlayerId, PlayerTeamId } from '../../shared/types';
|
||||
import {
|
||||
DEFAULT_PLAYER_IMAGE_OFFSET,
|
||||
DEFAULT_PLAYER_IMAGE_SCALE,
|
||||
DEFAULT_PLAYER_RING_COLOR,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
|
||||
import { useAppPlayers } from '../shared/playerToken/useAppPlayers';
|
||||
import { usePlayerImageUrl } from '../shared/playerToken/usePlayerImageUrl';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
|
||||
import styles from './EditorApp.module.css';
|
||||
import {
|
||||
filterMaterialImagePaths,
|
||||
getDroppedFileEntries,
|
||||
pickFirstMaterialImagePath,
|
||||
useFileDropZone,
|
||||
} from './fileDrop';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
import playerStyles from './PlayersModals.module.css';
|
||||
|
||||
const PLAYER_DND_MIME = 'application/x-dnd-player-id';
|
||||
|
||||
function PlayerRow({
|
||||
player,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
player: AppPlayer;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const url = usePlayerImageUrl(player.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`player-row-${player.id}`}
|
||||
className={[playerStyles.playerRow, selected ? playerStyles.playerRowSelected : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData(PLAYER_DND_MIME, player.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<PlayerTokenView
|
||||
name={player.name}
|
||||
imageUrl={url}
|
||||
ringColor={player.ringColor}
|
||||
imageOffset={player.imageOffset}
|
||||
imageScale={player.imageScale}
|
||||
sizePx={48}
|
||||
hideName
|
||||
/>
|
||||
<span>{player.name}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamSection({
|
||||
team,
|
||||
players,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onAssign,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
team: AppPlayerTeam | null;
|
||||
players: AppPlayer[];
|
||||
selectedId: PlayerId | null;
|
||||
onSelect: (id: PlayerId) => void;
|
||||
onAssign: (id: PlayerId, teamId: PlayerTeamId | null) => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
const { t } = useEditorI18n();
|
||||
return (
|
||||
<section
|
||||
className={playerStyles.team}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer.types.includes(PLAYER_DND_MIME)) e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const id = e.dataTransfer.getData(PLAYER_DND_MIME);
|
||||
if (id) onAssign(id as PlayerId, team?.id ?? null);
|
||||
}}
|
||||
>
|
||||
<div className={playerStyles.teamHeader}>
|
||||
{team ? <span className={playerStyles.teamDot} style={{ background: team.color }} /> : null}
|
||||
<span className={playerStyles.teamName}>{team?.name ?? t('players.ungrouped')}</span>
|
||||
{team ? (
|
||||
<details className={playerStyles.teamMenu}>
|
||||
<summary aria-label={t('players.teamMenu')}>⋮</summary>
|
||||
<div className={playerStyles.teamMenuPopup}>
|
||||
<button type="button" onClick={onEdit}>
|
||||
{t('common.edit')}
|
||||
</button>
|
||||
<button type="button" onClick={onDelete}>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={playerStyles.teamBody}>
|
||||
{players.map((player) => (
|
||||
<PlayerRow
|
||||
key={player.id}
|
||||
player={player}
|
||||
selected={player.id === selectedId}
|
||||
onSelect={() => onSelect(player.id)}
|
||||
/>
|
||||
))}
|
||||
{players.length === 0 ? <div className={playerStyles.dropTarget}>{t('players.dropHere')}</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlayersManagerModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { t } = useEditorI18n();
|
||||
const api = getDndApi();
|
||||
const { players, teams } = useAppPlayers();
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedId, setSelectedId] = useState<PlayerId | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [ringColor, setRingColor] = useState(DEFAULT_PLAYER_RING_COLOR);
|
||||
const [imageOffset, setImageOffset] = useState(DEFAULT_PLAYER_IMAGE_OFFSET);
|
||||
const [imageScale, setImageScale] = useState(DEFAULT_PLAYER_IMAGE_SCALE);
|
||||
const [filePath, setFilePath] = useState<string | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [progress, setProgress] = useState({ percent: 0, detail: '' });
|
||||
/** Electron не поддерживает window.prompt — форма команды в сайдбаре. */
|
||||
const [teamForm, setTeamForm] = useState<{
|
||||
id: PlayerTeamId | null;
|
||||
name: string;
|
||||
color: string;
|
||||
} | null>(null);
|
||||
const [pendingDeletePlayer, setPendingDeletePlayer] = useState<AppPlayer | null>(null);
|
||||
const [pendingDeleteTeam, setPendingDeleteTeam] = useState<AppPlayerTeam | null>(null);
|
||||
const appearanceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const selected = players.find((p) => p.id === selectedId) ?? null;
|
||||
const selectedUrl = usePlayerImageUrl(selected?.id);
|
||||
|
||||
const resetModalState = useCallback(() => {
|
||||
if (appearanceTimerRef.current) {
|
||||
clearTimeout(appearanceTimerRef.current);
|
||||
appearanceTimerRef.current = null;
|
||||
}
|
||||
setQuery('');
|
||||
setSelectedId(null);
|
||||
setName('');
|
||||
setRingColor(DEFAULT_PLAYER_RING_COLOR);
|
||||
setImageOffset({ ...DEFAULT_PLAYER_IMAGE_OFFSET });
|
||||
setImageScale(DEFAULT_PLAYER_IMAGE_SCALE);
|
||||
setFilePath(null);
|
||||
setPreviewUrl((prev) => {
|
||||
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
setAdding(false);
|
||||
setSaving(false);
|
||||
setProgress({ percent: 0, detail: '' });
|
||||
setTeamForm(null);
|
||||
setPendingDeletePlayer(null);
|
||||
setPendingDeleteTeam(null);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (saving) return;
|
||||
resetModalState();
|
||||
onClose();
|
||||
}, [onClose, resetModalState, saving]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSelectedId((current) =>
|
||||
current && players.some((p) => p.id === current) ? current : (players[0]?.id ?? null),
|
||||
);
|
||||
}, [open, players]);
|
||||
|
||||
// Синхронизировать форму только при смене выбранного игрока — не при каждом
|
||||
// players.stateChanged (иначе сбрасывается несохранённый zoom/pan).
|
||||
useEffect(() => {
|
||||
if (adding) return;
|
||||
if (!selectedId) return;
|
||||
const p = players.find((item) => item.id === selectedId);
|
||||
if (!p) return;
|
||||
setName(p.name);
|
||||
setRingColor(p.ringColor);
|
||||
setImageOffset(p.imageOffset);
|
||||
setImageScale(p.imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE);
|
||||
setFilePath(null);
|
||||
setPreviewUrl(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- только selectedId / adding
|
||||
}, [adding, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
return api.on(ipcChannels.players.upsertProgress, (event) => {
|
||||
setProgress({
|
||||
percent: Math.max(0, Math.min(100, Math.round(event.percent))),
|
||||
detail: event.detail?.trim() ?? t('players.savingWait'),
|
||||
});
|
||||
});
|
||||
}, [api, open, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape' || saving) return;
|
||||
if (pendingDeletePlayer) {
|
||||
setPendingDeletePlayer(null);
|
||||
return;
|
||||
}
|
||||
if (pendingDeleteTeam) {
|
||||
setPendingDeleteTeam(null);
|
||||
return;
|
||||
}
|
||||
if (teamForm) {
|
||||
setTeamForm(null);
|
||||
return;
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [handleClose, open, pendingDeletePlayer, pendingDeleteTeam, saving, teamForm]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return q ? players.filter((p) => p.name.toLowerCase().includes(q)) : players;
|
||||
}, [players, query]);
|
||||
|
||||
const beginAdd = () => {
|
||||
setAdding(true);
|
||||
setSelectedId(null);
|
||||
setName('');
|
||||
setRingColor(DEFAULT_PLAYER_RING_COLOR);
|
||||
setImageOffset({ ...DEFAULT_PLAYER_IMAGE_OFFSET });
|
||||
setImageScale(DEFAULT_PLAYER_IMAGE_SCALE);
|
||||
setFilePath(null);
|
||||
setPreviewUrl(null);
|
||||
};
|
||||
|
||||
const setPreviewFromPathAndUrl = (path: string, url: string) => {
|
||||
setFilePath(path);
|
||||
setPreviewUrl((prev) => {
|
||||
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
|
||||
return url || null;
|
||||
});
|
||||
};
|
||||
|
||||
const pickImage = async () => {
|
||||
const result = await api.invoke(ipcChannels.players.pickImage, {});
|
||||
if (result.canceled) return;
|
||||
setPreviewFromPathAndUrl(result.filePath, result.previewDataUrl);
|
||||
};
|
||||
|
||||
const applyDroppedImage = (path: string, previewFromFile: string) => {
|
||||
if (!adding && !selected) beginAdd();
|
||||
setPreviewFromPathAndUrl(path, previewFromFile);
|
||||
};
|
||||
|
||||
const drop = useFileDropZone({
|
||||
disabled: saving,
|
||||
filterPaths: filterMaterialImagePaths,
|
||||
onDropPaths: (paths) => {
|
||||
const picked = pickFirstMaterialImagePath(paths);
|
||||
if (!picked) return;
|
||||
applyDroppedImage(picked, '');
|
||||
},
|
||||
});
|
||||
|
||||
const appearanceLatestRef = useRef({
|
||||
selected,
|
||||
adding,
|
||||
saving,
|
||||
ringColor,
|
||||
imageOffset,
|
||||
imageScale,
|
||||
});
|
||||
appearanceLatestRef.current = { selected, adding, saving, ringColor, imageOffset, imageScale };
|
||||
|
||||
const persistAppearance = (patch: {
|
||||
ringColor?: string;
|
||||
imageOffset?: typeof imageOffset;
|
||||
imageScale?: number;
|
||||
}) => {
|
||||
const snap = appearanceLatestRef.current;
|
||||
if (!snap.selected || snap.adding || snap.saving) return;
|
||||
const payload = {
|
||||
id: snap.selected.id,
|
||||
teamId: snap.selected.teamId,
|
||||
name: snap.selected.name,
|
||||
ringColor: patch.ringColor ?? snap.ringColor,
|
||||
imageOffset: patch.imageOffset ?? snap.imageOffset,
|
||||
imageScale: patch.imageScale ?? snap.imageScale,
|
||||
};
|
||||
if (appearanceTimerRef.current) clearTimeout(appearanceTimerRef.current);
|
||||
appearanceTimerRef.current = setTimeout(() => {
|
||||
appearanceTimerRef.current = null;
|
||||
void api.invoke(ipcChannels.players.upsert, payload);
|
||||
}, 180);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (appearanceTimerRef.current) clearTimeout(appearanceTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || (selected === null && filePath === null)) return;
|
||||
if (appearanceTimerRef.current) {
|
||||
clearTimeout(appearanceTimerRef.current);
|
||||
appearanceTimerRef.current = null;
|
||||
}
|
||||
flushSync(() => {
|
||||
setSaving(true);
|
||||
setProgress({ percent: 0, detail: t('players.savingWait') });
|
||||
});
|
||||
try {
|
||||
const result = await api.invoke(ipcChannels.players.upsert, {
|
||||
...(selected ? { id: selected.id, teamId: selected.teamId } : {}),
|
||||
name: trimmed,
|
||||
...(filePath ? { filePath } : {}),
|
||||
ringColor,
|
||||
imageOffset,
|
||||
imageScale,
|
||||
});
|
||||
setAdding(false);
|
||||
setSelectedId(result.player.id);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
const activeUrl = previewUrl ?? selectedUrl;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className={styles.modalBackdrop} aria-hidden />
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
data-testid="players-modal"
|
||||
className={[styles.modalDialog, playerStyles.dialog].join(' ')}
|
||||
>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('players.managerTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={handleClose}
|
||||
className={styles.modalClose}
|
||||
disabled={saving}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={playerStyles.body}>
|
||||
<aside className={playerStyles.sidebar}>
|
||||
<Input value={query} onChange={setQuery} placeholder={t('players.search')} />
|
||||
<div className={playerStyles.sidebarActions}>
|
||||
<Button variant="primary" data-testid="players-add" onClick={beginAdd}>
|
||||
{t('players.add')}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="players-add-team"
|
||||
onClick={() =>
|
||||
setTeamForm({
|
||||
id: null,
|
||||
name: '',
|
||||
color: DEFAULT_PLAYER_RING_COLOR,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('players.addTeam')}
|
||||
</Button>
|
||||
</div>
|
||||
{teamForm ? (
|
||||
<div className={playerStyles.teamForm} data-testid="players-team-form">
|
||||
<label className={playerStyles.teamFormField}>
|
||||
<span>{t('players.teamName')}</span>
|
||||
<Input
|
||||
value={teamForm.name}
|
||||
onChange={(v) => setTeamForm((prev) => (prev ? { ...prev, name: v } : prev))}
|
||||
placeholder={t('players.teamName')}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className={playerStyles.teamFormField}>
|
||||
<span>{t('players.teamColor')}</span>
|
||||
<input
|
||||
type="color"
|
||||
value={teamForm.color}
|
||||
onChange={(e) =>
|
||||
setTeamForm((prev) =>
|
||||
prev ? { ...prev, color: e.currentTarget.value } : prev,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className={playerStyles.teamFormActions}>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!teamForm.name.trim()}
|
||||
onClick={() => {
|
||||
const trimmed = teamForm.name.trim();
|
||||
if (!trimmed) return;
|
||||
void api
|
||||
.invoke(ipcChannels.players.upsertTeam, {
|
||||
...(teamForm.id ? { id: teamForm.id } : {}),
|
||||
name: trimmed,
|
||||
color: teamForm.color,
|
||||
})
|
||||
.then(() => setTeamForm(null));
|
||||
}}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button onClick={() => setTeamForm(null)}>{t('common.cancel')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={playerStyles.list}>
|
||||
{teams.map((team) => (
|
||||
<TeamSection
|
||||
key={team.id}
|
||||
team={team}
|
||||
players={filtered.filter((p) => p.teamId === team.id)}
|
||||
selectedId={selectedId}
|
||||
onSelect={(id) => {
|
||||
setAdding(false);
|
||||
setSelectedId(id);
|
||||
}}
|
||||
onAssign={(playerId, teamId) =>
|
||||
void api.invoke(ipcChannels.players.assignTeam, { playerId, teamId })
|
||||
}
|
||||
onEdit={() =>
|
||||
setTeamForm({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
color: team.color,
|
||||
})
|
||||
}
|
||||
onDelete={() => setPendingDeleteTeam(team)}
|
||||
/>
|
||||
))}
|
||||
<TeamSection
|
||||
team={null}
|
||||
players={filtered.filter((p) => p.teamId === null)}
|
||||
selectedId={selectedId}
|
||||
onSelect={(id) => {
|
||||
setAdding(false);
|
||||
setSelectedId(id);
|
||||
}}
|
||||
onAssign={(playerId, teamId) =>
|
||||
void api.invoke(ipcChannels.players.assignTeam, { playerId, teamId })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
<main
|
||||
className={[playerStyles.editor, drop.dragOver ? playerStyles.editorDropOver : '']
|
||||
.filter(Boolean)
|
||||
.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];
|
||||
applyDroppedImage(entry.path, file ? URL.createObjectURL(file) : '');
|
||||
return;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{drop.dragOver ? <div className={styles.dropHintOverlay}>{t('players.dropHint')}</div> : null}
|
||||
{adding || selected ? (
|
||||
<>
|
||||
<PlayerTokenView
|
||||
data-testid="player-token-preview"
|
||||
name={name}
|
||||
imageUrl={activeUrl}
|
||||
ringColor={ringColor}
|
||||
imageOffset={imageOffset}
|
||||
imageScale={imageScale}
|
||||
panEnabled
|
||||
onImageOffsetChange={(next) => {
|
||||
setImageOffset(next);
|
||||
persistAppearance({ imageOffset: next });
|
||||
}}
|
||||
onImageScaleChange={(next) => {
|
||||
setImageScale(next);
|
||||
persistAppearance({ imageScale: next });
|
||||
}}
|
||||
sizePx={220}
|
||||
/>
|
||||
<label className={playerStyles.field}>
|
||||
<span>{t('players.name')}</span>
|
||||
<Input value={name} onChange={setName} placeholder={t('players.namePlaceholder')} />
|
||||
</label>
|
||||
<label className={playerStyles.field}>
|
||||
<span>{t('players.ringColor')}</span>
|
||||
<input
|
||||
type="color"
|
||||
value={ringColor}
|
||||
onChange={(e) => {
|
||||
const next = e.currentTarget.value;
|
||||
setRingColor(next);
|
||||
persistAppearance({ ringColor: next });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className={playerStyles.actions}>
|
||||
<Button data-testid="players-choose-image" onClick={() => void pickImage()}>
|
||||
{t('players.chooseImage')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
data-testid="players-save"
|
||||
disabled={!name.trim() || (!selected && !filePath) || saving}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
{selected ? (
|
||||
<Button onClick={() => setPendingDeletePlayer(selected)}>{t('common.delete')}</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.muted}>{t('players.selectPrompt')}</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{pendingDeletePlayer
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={styles.modalBackdrop}
|
||||
onClick={() => setPendingDeletePlayer(null)}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
data-testid="players-delete-confirm"
|
||||
className={styles.modalDialog}
|
||||
>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('players.deleteTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={styles.modalClose}
|
||||
onClick={() => setPendingDeletePlayer(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.muted}>
|
||||
{t('players.deleteConfirm', { name: pendingDeletePlayer.name })}
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={() => setPendingDeletePlayer(null)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const id = pendingDeletePlayer.id;
|
||||
setPendingDeletePlayer(null);
|
||||
void api.invoke(ipcChannels.players.delete, { id });
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
{pendingDeleteTeam
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={styles.modalBackdrop}
|
||||
onClick={() => setPendingDeleteTeam(null)}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('players.deleteTeamTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={styles.modalClose}
|
||||
onClick={() => setPendingDeleteTeam(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.muted}>
|
||||
{t('players.deleteTeamConfirm', { name: pendingDeleteTeam.name })}
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={() => setPendingDeleteTeam(null)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const id = pendingDeleteTeam.id;
|
||||
setPendingDeleteTeam(null);
|
||||
void api.invoke(ipcChannels.players.deleteTeam, { id });
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
{saving ? (
|
||||
<div className={styles.progressOverlay} role="dialog" aria-busy data-testid="players-save-progress">
|
||||
<div className={styles.progressModal}>
|
||||
<div className={styles.progressTitle}>{t('players.savingTitle')}</div>
|
||||
<div className={styles.previewSpinner} aria-hidden />
|
||||
<div className={styles.progressBar}>
|
||||
<div className={styles.progressFill} style={{ width: `${String(progress.percent)}%` }} />
|
||||
</div>
|
||||
<div className={styles.progressMeta}>
|
||||
<div>{progress.detail}</div>
|
||||
<div>{progress.percent}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -73,7 +73,7 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
|
||||
const editor = useEditor({
|
||||
extensions,
|
||||
content: initialHtml || '',
|
||||
immediatelyRender: true,
|
||||
immediatelyRender: false,
|
||||
shouldRerenderOnTransaction: true,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
@@ -94,21 +94,56 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
|
||||
const toolbarState = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor: ed }) => ({
|
||||
bold: ed.isActive('bold'),
|
||||
italic: ed.isActive('italic'),
|
||||
underline: ed.isActive('underline'),
|
||||
bulletList: ed.isActive('bulletList'),
|
||||
orderedList: ed.isActive('orderedList'),
|
||||
h2: ed.isActive('heading', { level: 2 }),
|
||||
h3: ed.isActive('heading', { level: 3 }),
|
||||
blockquote: ed.isActive('blockquote'),
|
||||
bold: Boolean(ed && !ed.isDestroyed && ed.isActive('bold')),
|
||||
italic: Boolean(ed && !ed.isDestroyed && ed.isActive('italic')),
|
||||
underline: Boolean(ed && !ed.isDestroyed && ed.isActive('underline')),
|
||||
bulletList: Boolean(ed && !ed.isDestroyed && ed.isActive('bulletList')),
|
||||
orderedList: Boolean(ed && !ed.isDestroyed && ed.isActive('orderedList')),
|
||||
h2: Boolean(ed && !ed.isDestroyed && ed.isActive('heading', { level: 2 })),
|
||||
h3: Boolean(ed && !ed.isDestroyed && ed.isActive('heading', { level: 3 })),
|
||||
blockquote: Boolean(ed && !ed.isDestroyed && ed.isActive('blockquote')),
|
||||
}),
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(normalizeSceneDescriptionHtml(editor.getHTML()));
|
||||
if (!editor || editor.isDestroyed) return;
|
||||
let raw = '';
|
||||
try {
|
||||
raw = editor.getHTML();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
onSave(normalizeSceneDescriptionHtml(raw));
|
||||
};
|
||||
|
||||
if (!editor || editor.isDestroyed) {
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={[styles.modalDialog, modalStyles.dialog].join(' ')}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('scene.descriptionModalTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={modalStyles.editorShell} />
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
@@ -135,21 +170,21 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
|
||||
<div className={modalStyles.toolbarGroup}>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionBold')}
|
||||
active={toolbarState.bold}
|
||||
active={toolbarState?.bold ?? false}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
>
|
||||
<strong>B</strong>
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionItalic')}
|
||||
active={toolbarState.italic}
|
||||
active={toolbarState?.italic ?? false}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
>
|
||||
<em>I</em>
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionUnderline')}
|
||||
active={toolbarState.underline}
|
||||
active={toolbarState?.underline ?? false}
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
>
|
||||
<span style={{ textDecoration: 'underline' }}>U</span>
|
||||
@@ -159,21 +194,21 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
|
||||
<div className={modalStyles.toolbarGroup}>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionHeading2')}
|
||||
active={toolbarState.h2}
|
||||
active={toolbarState?.h2 ?? false}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
>
|
||||
H2
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionHeading3')}
|
||||
active={toolbarState.h3}
|
||||
active={toolbarState?.h3 ?? false}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
>
|
||||
H3
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionQuote')}
|
||||
active={toolbarState.blockquote}
|
||||
active={toolbarState?.blockquote ?? false}
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
>
|
||||
<ToolbarIcon path="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" />
|
||||
@@ -183,14 +218,14 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
|
||||
<div className={modalStyles.toolbarGroup}>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionBulletList')}
|
||||
active={toolbarState.bulletList}
|
||||
active={toolbarState?.bulletList ?? false}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
>
|
||||
<ToolbarIcon path="M4 6h2v2H4V6zm0 5h2v2H4v-2zm0 5h2v2H4v-2zm4-10h12v2H8V6zm0 5h12v2H8v-2zm0 5h12v2H8v-2z" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
title={t('scene.descriptionOrderedList')}
|
||||
active={toolbarState.orderedList}
|
||||
active={toolbarState?.orderedList ?? false}
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
>
|
||||
<ToolbarIcon path="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1V14h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z" />
|
||||
|
||||
@@ -3,9 +3,9 @@ import { createPortal } from 'react-dom';
|
||||
|
||||
import {
|
||||
collectSceneIdsForSelections,
|
||||
filterNpcsForStorylineExport,
|
||||
findNpcNameConflicts,
|
||||
findSceneTitleConflicts,
|
||||
listExportedNpcsFromBundle,
|
||||
storylineSelectionKey,
|
||||
type NpcImportResolution,
|
||||
type NpcNameConflict,
|
||||
@@ -22,29 +22,40 @@ import { Button, Select } from '../shared/ui/controls';
|
||||
import styles from './EditorApp.module.css';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
|
||||
export type ExportNpcOption = { id: string; name: string };
|
||||
|
||||
type ExportProjectModalProps = {
|
||||
open: boolean;
|
||||
projects: { id: ProjectId; name: string; fileName: string }[];
|
||||
initialProjectId: ProjectId | null;
|
||||
storylineLabels: StorylineLabels;
|
||||
loadStorylines: (projectId: ProjectId) => Promise<StorylineListItem[]>;
|
||||
loadStorylines: (
|
||||
projectId: ProjectId,
|
||||
) => Promise<{ storylines: StorylineListItem[]; npcs: ExportNpcOption[] }>;
|
||||
onClose: () => void;
|
||||
onExport: (projectId: ProjectId, selections: StorylineSelection[]) => Promise<void>;
|
||||
onExport: (
|
||||
projectId: ProjectId,
|
||||
selections: StorylineSelection[],
|
||||
npcIds: string[],
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export function ExportProjectModal({
|
||||
open,
|
||||
projects,
|
||||
initialProjectId,
|
||||
storylineLabels,
|
||||
storylineLabels: _storylineLabels,
|
||||
loadStorylines,
|
||||
onClose,
|
||||
onExport,
|
||||
}: ExportProjectModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const [step, setStep] = useState<'storylines' | 'npcs'>('storylines');
|
||||
const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId);
|
||||
const [storylines, setStorylines] = useState<StorylineListItem[]>([]);
|
||||
const [npcs, setNpcs] = useState<ExportNpcOption[]>([]);
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||
const [selectedNpcIds, setSelectedNpcIds] = useState<Set<string>>(new Set());
|
||||
const [loadingStorylines, setLoadingStorylines] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -55,23 +66,38 @@ export function ExportProjectModal({
|
||||
setSaving(false);
|
||||
setError(null);
|
||||
setSelectedKeys(new Set());
|
||||
setSelectedNpcIds(new Set());
|
||||
setStep('storylines');
|
||||
setNpcs([]);
|
||||
}, [initialProjectId, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !projectId) {
|
||||
setStorylines([]);
|
||||
setNpcs([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoadingStorylines(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const list = await loadStorylines(projectId);
|
||||
const res = await loadStorylines(projectId);
|
||||
if (cancelled) return;
|
||||
const list = Array.isArray(res?.storylines) ? res.storylines : [];
|
||||
const npcList = Array.isArray(res?.npcs) ? res.npcs : [];
|
||||
setStorylines(list);
|
||||
setNpcs(npcList);
|
||||
setSelectedKeys(new Set(list.map((item) => storylineSelectionKey(item.selection))));
|
||||
setSelectedNpcIds(new Set(npcList.map((n) => n.id)));
|
||||
setStep('storylines');
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
||||
if (!cancelled) {
|
||||
setStorylines([]);
|
||||
setNpcs([]);
|
||||
setSelectedKeys(new Set());
|
||||
setSelectedNpcIds(new Set());
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoadingStorylines(false);
|
||||
}
|
||||
@@ -84,15 +110,18 @@ export function ExportProjectModal({
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Escape') {
|
||||
if (step === 'npcs') setStep('storylines');
|
||||
else onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, open]);
|
||||
}, [onClose, open, step]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const canExport =
|
||||
const canContinueStorylines =
|
||||
projectId !== null &&
|
||||
projects.some((p) => p.id === projectId) &&
|
||||
selectedKeys.size > 0 &&
|
||||
@@ -108,10 +137,44 @@ export function ExportProjectModal({
|
||||
});
|
||||
};
|
||||
|
||||
const toggleNpc = (id: string) => {
|
||||
setSelectedNpcIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectedSelections = storylines
|
||||
.filter((item) => selectedKeys.has(storylineSelectionKey(item.selection)))
|
||||
.map((item) => item.selection);
|
||||
|
||||
const runExport = (npcIds: string[]) => {
|
||||
if (!projectId || !canContinueStorylines) return;
|
||||
void (async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onExport(projectId, selectedSelections, npcIds);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const goNextFromStorylines = () => {
|
||||
if (!canContinueStorylines) return;
|
||||
if (npcs.length === 0) {
|
||||
runExport([]);
|
||||
return;
|
||||
}
|
||||
setStep('npcs');
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
@@ -122,7 +185,9 @@ export function ExportProjectModal({
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('export.title')}</div>
|
||||
<div className={styles.modalTitle}>
|
||||
{step === 'storylines' ? t('export.title') : t('export.npcsTitle')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
@@ -133,81 +198,108 @@ export function ExportProjectModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.fieldLabel}>{t('export.project')}</div>
|
||||
<Select
|
||||
value={projectId ?? ''}
|
||||
onChange={(next) => setProjectId((next as ProjectId) || null)}
|
||||
disabled={projects.length === 0}
|
||||
ariaLabel={t('export.project')}
|
||||
options={projects.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.name} (${p.fileName})`,
|
||||
}))}
|
||||
/>
|
||||
{step === 'storylines' ? (
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.fieldLabel}>{t('export.project')}</div>
|
||||
<Select
|
||||
value={projectId ?? ''}
|
||||
onChange={(next) => setProjectId((next as ProjectId) || null)}
|
||||
disabled={projects.length === 0 || saving}
|
||||
ariaLabel={t('export.project')}
|
||||
options={projects.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.name} (${p.fileName})`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<div className={styles.fieldLabel}>{t('storyline.section')}</div>
|
||||
{loadingStorylines ? (
|
||||
<div className={styles.muted}>{t('storyline.loading')}</div>
|
||||
) : storylines.length === 0 ? (
|
||||
<div className={styles.muted}>{t('storyline.empty')}</div>
|
||||
) : (
|
||||
<div className={styles.fieldLabel}>{t('storyline.section')}</div>
|
||||
{loadingStorylines ? (
|
||||
<div className={styles.muted}>{t('storyline.loading')}</div>
|
||||
) : storylines.length === 0 ? (
|
||||
<div className={styles.muted}>{t('storyline.empty')}</div>
|
||||
) : (
|
||||
<div className={styles.storylineChecklist}>
|
||||
{storylines.map((item) => {
|
||||
const key = storylineSelectionKey(item.selection);
|
||||
const checked = selectedKeys.has(key);
|
||||
const disabled = item.disabled === true;
|
||||
return (
|
||||
<label
|
||||
key={key}
|
||||
className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled || saving}
|
||||
onChange={() => toggleKey(key, disabled)}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
{disabled && item.disabledReason === 'main_exists' ? (
|
||||
<span className={styles.muted}> — {t('storyline.mainExistsHint')}</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.muted}>{t('export.hint')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.muted}>{t('export.npcsHint')}</div>
|
||||
<div className={styles.storylineChecklist}>
|
||||
{storylines.map((item) => {
|
||||
const key = storylineSelectionKey(item.selection);
|
||||
const checked = selectedKeys.has(key);
|
||||
const disabled = item.disabled === true;
|
||||
return (
|
||||
<label
|
||||
key={key}
|
||||
className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={() => toggleKey(key, disabled)}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
{disabled && item.disabledReason === 'main_exists' ? (
|
||||
<span className={styles.muted}> — {t('storyline.mainExistsHint')}</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{npcs.map((n) => (
|
||||
<label key={n.id} className={styles.storylineCheck}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedNpcIds.has(n.id)}
|
||||
disabled={saving}
|
||||
onChange={() => toggleNpc(n.id)}
|
||||
/>
|
||||
<span>{n.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.muted}>{t('export.hint')}</div>
|
||||
</div>
|
||||
<Button
|
||||
disabled={saving || npcs.length === 0}
|
||||
onClick={() => setSelectedNpcIds(new Set(npcs.map((n) => n.id)))}
|
||||
>
|
||||
{t('export.selectAllNpcs')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? <div className={styles.fieldError}>{error}</div> : null}
|
||||
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={onClose} disabled={saving} title={saving ? t('export.exporting') : undefined}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!canExport || saving}
|
||||
onClick={() => {
|
||||
if (!projectId || !canExport) return;
|
||||
void (async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onExport(projectId, selectedSelections);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('export.saveAs')}
|
||||
</Button>
|
||||
{step === 'npcs' ? (
|
||||
<Button onClick={() => setStep('storylines')} disabled={saving}>
|
||||
{t('export.back')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onClose} disabled={saving} title={saving ? t('export.exporting') : undefined}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
{step === 'storylines' ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!canContinueStorylines || saving}
|
||||
onClick={goNextFromStorylines}
|
||||
>
|
||||
{npcs.length > 0 ? t('export.next') : t('export.saveAs')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={saving}
|
||||
onClick={() => runExport([...selectedNpcIds])}
|
||||
>
|
||||
{t('export.saveAs')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
@@ -831,11 +923,10 @@ export function computeImportConflicts(
|
||||
export function buildNpcResolutionsForImport(
|
||||
_targetProject: Project,
|
||||
sourceProject: Project,
|
||||
selections: StorylineSelection[],
|
||||
conflicts: NpcNameConflict[],
|
||||
userResolutions: NpcImportResolution[],
|
||||
): NpcImportResolution[] {
|
||||
const exported = filterNpcsForStorylineExport(sourceProject, selections);
|
||||
const exported = listExportedNpcsFromBundle(sourceProject);
|
||||
const conflictIds = new Set(conflicts.map((c) => c.sourceNpcId));
|
||||
const bySource = new Map(userResolutions.map((r) => [r.sourceNpcId, r]));
|
||||
const out: NpcImportResolution[] = [];
|
||||
@@ -853,9 +944,8 @@ export function buildNpcResolutionsForImport(
|
||||
export function computeNpcImportConflicts(
|
||||
targetProject: Project,
|
||||
sourceProject: Project,
|
||||
selections: StorylineSelection[],
|
||||
): NpcNameConflict[] {
|
||||
const exported = filterNpcsForStorylineExport(sourceProject, selections);
|
||||
const exported = listExportedNpcsFromBundle(sourceProject);
|
||||
return findNpcNameConflicts(
|
||||
targetProject,
|
||||
sourceProject,
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.musicParams {
|
||||
|
||||
@@ -26,7 +26,10 @@ import {
|
||||
isSideStoryEdge,
|
||||
} from '../../../shared/graph/sceneGraphLineage';
|
||||
import type { AssetId, GraphNodeId, SceneGraphEdge, SceneGraphNode, SceneId } from '../../../shared/types';
|
||||
import { ContainedVideo } from '../../shared/ContainedVideo';
|
||||
import { RotatedImage } from '../../shared/RotatedImage';
|
||||
import { EllipsisText } from '../../shared/ui/EllipsisText';
|
||||
import ellipsisStyles from '../../shared/ui/ellipsisText.module.css';
|
||||
import { useAssetUrl } from '../../shared/useAssetImageUrl';
|
||||
|
||||
import styles from './SceneGraph.module.css';
|
||||
@@ -258,22 +261,45 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
)}
|
||||
</div>
|
||||
) : previewUrl && data.previewAssetType === 'video' ? (
|
||||
<video
|
||||
src={previewUrl}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className={styles.videoCover}
|
||||
onLoadedData={(e) => {
|
||||
const v = e.currentTarget;
|
||||
try {
|
||||
v.currentTime = 0;
|
||||
v.pause();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className={styles.previewFill}>
|
||||
{data.previewRotationDeg === 0 ? (
|
||||
<video
|
||||
src={previewUrl}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className={styles.videoCover}
|
||||
onLoadedData={(e) => {
|
||||
const v = e.currentTarget;
|
||||
try {
|
||||
v.currentTime = 0;
|
||||
v.pause();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ContainedVideo
|
||||
url={previewUrl}
|
||||
rotationDeg={data.previewRotationDeg}
|
||||
mode="cover"
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
onLoadedData={(e) => {
|
||||
const v = e.currentTarget;
|
||||
try {
|
||||
v.currentTime = 0;
|
||||
v.pause();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.previewPlaceholder} aria-hidden />
|
||||
)}
|
||||
@@ -293,7 +319,7 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.nodeBody}>
|
||||
<div className={styles.title}>{data.title || ui.untitled}</div>
|
||||
<EllipsisText text={data.title || ui.untitled} className={[styles.title, ellipsisStyles.root].join(' ')} />
|
||||
{data.hasAnyAudioLoop || data.hasAnyAudioAutoplay ? (
|
||||
<div className={styles.musicParams}>
|
||||
{data.hasAnyAudioLoop ? (
|
||||
|
||||
@@ -18,6 +18,7 @@ const RU_TITLES: Record<HelpSectionId, string> = {
|
||||
tokens: 'Неигровые токены',
|
||||
campaignAudio: 'Аудио игры',
|
||||
materials: 'Материалы',
|
||||
players: 'Игроки',
|
||||
npcs: 'НПС',
|
||||
session: 'Запуск сессии',
|
||||
controlPanel: 'Пульт управления',
|
||||
@@ -31,8 +32,7 @@ const RU_TITLES: Record<HelpSectionId, string> = {
|
||||
|
||||
void test('findHelpLinkRanges: «Ловушки» и алиас «Эффекты»', () => {
|
||||
const catalog = buildHelpLinkCatalog((id) => RU_TITLES[id]);
|
||||
const text =
|
||||
'см. «Ловушки» и кистью (см. «Эффекты»). Также разделы «Генератор сетки», «Неигровые токены».';
|
||||
const text = 'см. «Ловушки» и кистью (см. «Эффекты»). Также разделы «Генератор сетки», «Неигровые токены».';
|
||||
const ranges = findHelpLinkRanges(text, catalog);
|
||||
assert.deepEqual(
|
||||
ranges.map((r) => r.id),
|
||||
|
||||
@@ -9,6 +9,7 @@ export const HELP_SECTION_LINK_ALIASES: Partial<Record<HelpSectionId, readonly s
|
||||
presentation: ['Презентация', 'Presentation'],
|
||||
grid: ['Сетка', 'Grid'],
|
||||
controlPanel: ['Пульт'],
|
||||
players: ['Игроки', 'Players'],
|
||||
};
|
||||
|
||||
export type HelpLinkCatalogEntry = {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Порядок разделов в окне «Инструкция». */
|
||||
export const HELP_SECTION_IDS = [
|
||||
import { USERS_BRANCH_FEATURES_ENABLED } from '../../../shared/features/usersBranchFeatures';
|
||||
|
||||
const HELP_SECTION_IDS_ALL = [
|
||||
'overview',
|
||||
'license',
|
||||
'projects',
|
||||
@@ -13,6 +15,7 @@ export const HELP_SECTION_IDS = [
|
||||
'tokens',
|
||||
'campaignAudio',
|
||||
'materials',
|
||||
'players',
|
||||
'npcs',
|
||||
'session',
|
||||
'controlPanel',
|
||||
@@ -24,7 +27,11 @@ export const HELP_SECTION_IDS = [
|
||||
'settings',
|
||||
] as const;
|
||||
|
||||
export type HelpSectionId = (typeof HELP_SECTION_IDS)[number];
|
||||
export type HelpSectionId = (typeof HELP_SECTION_IDS_ALL)[number];
|
||||
|
||||
export const HELP_SECTION_IDS: readonly HelpSectionId[] = USERS_BRANCH_FEATURES_ENABLED
|
||||
? HELP_SECTION_IDS_ALL
|
||||
: HELP_SECTION_IDS_ALL.filter((id) => id !== 'players');
|
||||
|
||||
export function helpSectionTitleKey(id: HelpSectionId): string {
|
||||
return `help.section.${id}.title`;
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
translateEditorMessage,
|
||||
type EditorLocale,
|
||||
} from './editorMessages';
|
||||
import { getDndApi } from '../../shared/dndApi';
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
|
||||
type EditorI18nContextValue = {
|
||||
locale: EditorLocale;
|
||||
@@ -36,6 +38,15 @@ export function EditorI18nProvider({ children }: { children: React.ReactNode })
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const tag = locale === 'ru' ? 'ru-RU' : 'en-US';
|
||||
try {
|
||||
void getDndApi().invoke(ipcChannels.windows.syncChromeTitles, { localeTag: tag });
|
||||
} catch {
|
||||
// preload ещё не готов (редко при первом кадре)
|
||||
}
|
||||
}, [locale]);
|
||||
|
||||
// Другие окна Electron (пульт, материалы) подхватывают смену языка из редактора.
|
||||
useEffect(() => {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
|
||||
@@ -110,9 +110,18 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'top.settings': 'Настройки',
|
||||
'top.project': 'Проект',
|
||||
'top.file': 'Файл',
|
||||
'top.players': 'Игроки',
|
||||
'top.backToProjects': 'К списку проектов',
|
||||
'top.appVersion': 'Версия приложения',
|
||||
'top.run': 'Запустить',
|
||||
'top.runMenuAria': 'Дополнительные варианты запуска',
|
||||
'top.runWithPlayers': 'Запустить с игроками',
|
||||
'top.runWithPlayersTitle': 'Запуск с игроками',
|
||||
'top.runWithPlayersHint': 'Выберите игроков и/или команды. Выбор сохранится на время сессии.',
|
||||
'top.runWithPlayersTeams': 'Команды',
|
||||
'top.runWithPlayersPlayers': 'Игроки',
|
||||
'top.runWithPlayersEmpty': 'Нет игроков в библиотеке.',
|
||||
'top.runWithPlayersConfirm': 'Запустить',
|
||||
'top.launching': 'Запуск…',
|
||||
'top.afterLicense': 'Доступно после активации лицензии',
|
||||
'top.setStartScene': 'Назначьте начальную сцену на графе (ПКМ по узлу)',
|
||||
@@ -171,23 +180,23 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.sceneProps.title': 'Свойства сцены',
|
||||
'help.section.sceneProps.body':
|
||||
'Выберите сцену в списке слева — справа откроются её свойства.\n\n«Название сцены» — для мастера. «Описание» — заметки мастера с форматированием: рядом с подписью нажмите карандаш, откроется редактор (жирный, курсив, заголовки, списки, ссылки). Под подписью видно фрагмент текста или «описание отсутствует», если поле пустое. Во время сессии описание открывается с пульта в отдельном окне (см. «Пульт управления»), а не в блоке «Сюжетная линия».\n\nЕсли у сцены на карте есть карточка с синей меткой «ПОБОЧНАЯ», появится поле «Название побочной линии».\n\nКартинка или видео для игроков:\n\n1) В «Превью сцены» нажмите «Загрузить» или «Изменить».\n\n2) Выберите файл (PNG, JPG, WebP, GIF и др.).\n\n3) Для картинки можно «Повернуть» (шаг 90°). «Очистить» — убрать превью.\n\n4) Для картинки можно включить «Затемнить сцену»: при показе игроки сначала увидят карту в полной темноте, а вы снимете её с пульта «Кистью Открытия» (см. «Эффекты»).\n\n5) Для картинки доступна кнопка «Редактор сцены» — сетка, ловушки и неигровые токены на карте (см. «Редактор сцены», «Генератор сетки», «Ловушки», «Неигровые токены»).\n\nДля видео включите «Автостарт», если ролик должен сам начаться на экране игроков. На видео-сценах эффекты кистью и редактор сцены недоступны.\n\n«Аудио сцены» — музыка и звуки этого эпизода. «Загрузить» добавляет файлы. У каждого трека: «Авто» (играть при входе в сцену) и «Цикл». Корзина удаляет трек.\n\nСвязи между сценами задаются на карте, а не здесь — см. «Граф сцен».',
|
||||
'Выберите сцену в списке слева — справа откроются её свойства.\n\n«Название сцены» — для мастера. «Описание» — заметки мастера с форматированием: рядом с подписью нажмите карандаш, откроется редактор (жирный, курсив, заголовки, списки, ссылки). Под подписью видно фрагмент текста или «описание отсутствует», если поле пустое. Во время сессии описание открывается с пульта в отдельном окне (см. «Пульт управления»), а не в блоке «Сюжетная линия».\n\nЕсли у сцены на карте есть карточка с синей меткой «ПОБОЧНАЯ», появится поле «Название побочной линии».\n\nКартинка или видео для игроков:\n\n1) В «Превью сцены» нажмите «Загрузить» или «Изменить».\n\n2) Выберите файл (PNG, JPG, WebP, GIF, видео и др.).\n\n3) Для картинки и видео можно «Повернуть» (шаг 90°). «Очистить» — убрать превью.\n\n4) Для картинки и видео можно включить «Затемнить сцену»: при показе игроки сначала увидят кадр в полной темноте, а вы снимете её с пульта «Кистью Открытия» (см. «Эффекты»).\n\n5) Кнопка «Редактор сцены» доступна и для картинки, и для видео — сетка, ловушки и неигровые токены поверх превью (см. «Редактор сцены», «Генератор сетки», «Ловушки», «Неигровые токены»).\n\nДля видео включите «Автостарт», если ролик должен сам начаться на экране игроков, и при необходимости «Цикл».\n\n«Аудио сцены» — музыка и звуки этого эпизода. «Загрузить» добавляет файлы. У каждого трека: «Авто» (играть при входе в сцену) и «Цикл». Корзина удаляет трек.\n\nСвязи между сценами задаются на карте, а не здесь — см. «Граф сцен».',
|
||||
|
||||
'help.section.sceneEditor.title': 'Редактор сцены',
|
||||
'help.section.sceneEditor.body':
|
||||
'«Редактор сцены» — отдельное окно для подготовки карты: сетка боя, ловушки и неигровые токены. Доступен только для сцен с изображением (не с видео).\n\nОткрыть:\n\n1) Выберите сцену в списке слева.\n\n2) В «Свойствах сцены» загрузите картинку, если её ещё нет.\n\n3) Нажмите «Редактор сцены».\n\nСлева — аккордеоны «Сетка», «Неигровые токены» и «Ловушки»; справа — карта сцены.\n\nНавигация по карте: колесо мыши — зум; средняя кнопка мыши или Space+ЛКМ — сдвиг вида. Delete / Backspace убирает выделенный маркер на карте.\n\nПод аккордеонами кнопка «Очистить сцену» убирает с текущей карты все ловушки и токены (пул токенов приложения не трогает).\n\nПодробнее: разделы «Генератор сетки», «Ловушки» и «Неигровые токены».',
|
||||
'«Редактор сцены» — отдельное окно для подготовки карты: сетка боя, ловушки, неигровые токены и круглые токены НПС. Работает для сцен с изображением и с видео (оверлеи поверх ролика).\n\nОткрыть:\n\n1) Выберите сцену в списке слева.\n\n2) В «Свойствах сцены» загрузите картинку или видео, если превью ещё нет.\n\n3) Нажмите «Редактор сцены».\n\nСлева — аккордеоны «Сетка», «Неигровые токены», «НПС» и «Ловушки»; справа — карта сцены. Перетащите НПС на карту, чтобы добавить его круглый токен.\n\nНавигация по карте: колесо мыши — зум; средняя кнопка мыши или Space+ЛКМ — сдвиг вида. Delete / Backspace убирает выделенный маркер на карте.\n\nПод аккордеонами кнопка «Очистить сцену» убирает с текущей карты все ловушки и токены (пул токенов приложения не трогает).\n\nПодробнее: разделы «Генератор сетки», «Ловушки» и «Неигровые токены».',
|
||||
|
||||
'help.section.grid.title': 'Генератор сетки',
|
||||
'help.section.grid.body':
|
||||
'Генератор сетки накладывает на картинку сцены боевую сетку — квадратную или гексагональную. Сетка помогает ориентироваться по клеткам во время боя и видна и вам на пульте, и игрокам на презентации.\n\nНастроить:\n\n1) Откройте «Редактор сцены» для сцены с изображением (см. «Редактор сцены»).\n\n2) Раскройте аккордеон «Сетка» слева.\n\n3) Включите «Наложить сетку» — линии появятся поверх карты.\n\n4) «Тип» — «Квадратная» или «Гексогональная».\n\n5) «Цвет» — оттенок линий (удобно подобрать контраст к карте).\n\n6) «Размер» — ползунок ячейки: чем больше значение, тем крупнее клетки.\n\nПока сетка выключена, тип, цвет и размер недоступны для изменения, но запомненные значения сохраняются и вернутся при повторном включении.\n\nНастройки сетки хранятся в проекте вместе со сценой. На видео-сценах генератор недоступен — только на картинках. Сетка рисуется под маркерами ловушек и токенов и не мешает их расставлять.',
|
||||
'Генератор сетки накладывает на превью сцены (картинку или видео) боевую сетку — квадратную или гексагональную. Сетка помогает ориентироваться по клеткам во время боя и видна и вам на пульте, и игрокам на презентации.\n\nНастроить:\n\n1) Откройте «Редактор сцены» (см. «Редактор сцены»).\n\n2) Раскройте аккордеон «Сетка» слева.\n\n3) Включите «Наложить сетку» — линии появятся поверх карты.\n\n4) «Тип» — «Квадратная» или «Гексогональная».\n\n5) «Цвет» — оттенок линий (удобно подобрать контраст к карте).\n\n6) «Размер» — ползунок ячейки: чем больше значение, тем крупнее клетки.\n\nПока сетка выключена, тип, цвет и размер недоступны для изменения, но запомненные значения сохраняются и вернутся при повторном включении.\n\nНастройки сетки хранятся в проекте вместе со сценой. Сетка рисуется под маркерами ловушек и токенов и не мешает их расставлять.\n\nВо время сессии на пульте можно включить «Привязка токенов к сетке»: при перетаскивании неигровые токены, токены НПС и игроков «прилипают» к клеткам (квадрат или гекс — по типу сетки). Если сетка выключена, галочка не действует.',
|
||||
|
||||
'help.section.traps.title': 'Ловушки',
|
||||
'help.section.traps.body':
|
||||
'Ловушки — маркеры на карте сцены для скрытых угроз и сюрпризов. Расстановка хранится в проекте вместе со сценой; во время игры вы решаете, когда их показать игрокам.\n\nВ редакторе сцены:\n\n1) Откройте «Редактор сцены» для сцены с картинкой (см. «Редактор сцены»).\n\n2) Раскройте аккордеон «Ловушки» слева.\n\n3) Перетащите тип из палитры на нужное место карты.\n\n4) Перетащите маркер, чтобы сдвинуть его; потяните за уголок выделенного маркера — изменить размер.\n\n5) Delete / Backspace — убрать выделенную ловушку. «Очистить сцену» снимает все маркеры сразу.\n\nТипы: Мимик, Взрыв, Яд, Пропасть, Стрела, Лазер и Метка (универсальный маркер без особого эффекта).\n\nВо время сессии:\n\n1) На пульте в «Предпросмотр экрана» видны все ловушки текущей сцены. Пока они скрыты от игроков, маркеры у вас слегка приглушены.\n\n2) На презентации маркеры появляются только после проявления или срабатывания.\n\n3) Правый клик по маркеру на пульте:\n• «Проявить» — показать игрокам без срабатывания.\n• «Активировать» — проявить и запустить эффект: у Мимика, Пропасти, Стрелы и Лазера — анимация и звук; у Яда и Взрыва — как соответствующие эффекты с пульта (облако яда / взрыв); у Метки — короткая вспышка.\n• «Обезвредить» — показать как обезвреженную.\n\nСостояние ловушек (проявлены / сработали / обезврежены) сбрасывается при новом запуске сессии. Сами маркеры на карте остаются.',
|
||||
'Ловушки — маркеры на карте сцены для скрытых угроз и сюрпризов. Расстановка хранится в проекте вместе со сценой; во время игры вы решаете, когда их показать игрокам.\n\nВ редакторе сцены:\n\n1) Откройте «Редактор сцены» для сцены с картинкой или видео (см. «Редактор сцены»).\n\n2) Раскройте аккордеон «Ловушки» слева.\n\n3) Перетащите тип из палитры на нужное место карты.\n\n4) Перетащите маркер, чтобы сдвинуть его; потяните за уголок выделенного маркера — изменить размер.\n\n5) Delete / Backspace — убрать выделенную ловушку. «Очистить сцену» снимает все маркеры сразу.\n\nТипы: Мимик, Взрыв, Яд, Пропасть, Стрела, Лазер и Метка (универсальный маркер без особого эффекта).\n\nВо время сессии:\n\n1) На пульте в «Предпросмотр экрана» видны все ловушки текущей сцены. Пока они скрыты от игроков, у вас маркеры остаются хорошо читаемыми (пунктирная рамка), чтобы их было удобно найти на карте.\n\n2) На презентации маркеры появляются только после проявления или срабатывания.\n\n3) Правый клик по маркеру на пульте:\n• «Проявить» — показать игрокам без срабатывания.\n• «Активировать» — проявить и запустить эффект: у Мимика, Пропасти, Стрелы и Лазера — анимация и звук; у Яда и Взрыва — как соответствующие эффекты с пульта (облако яда / взрыв); у Метки — короткая вспышка.\n• «Обезвредить» — показать как обезвреженную.\n\nСостояние ловушек (проявлены / сработали / обезврежены) сбрасывается при новом запуске сессии. Сами маркеры на карте остаются.',
|
||||
|
||||
'help.section.tokens.title': 'Неигровые токены',
|
||||
'help.section.tokens.body':
|
||||
'Неигровые токены — картинки существ, предметов и маркеров, которые вы ставите на карту сцены. Библиотека токенов хранится в приложении на этом компьютере (не внутри файла проекта). На сцене сохраняется только расстановка: какой токен, где стоит, размер и поворот.\n\nВ редакторе сцены:\n\n1) Откройте «Редактор сцены» для сцены с картинкой (см. «Редактор сцены»).\n\n2) Раскройте «Неигровые токены».\n\n3) «Добавить» — задайте уникальное название и изображение (кнопка выбора или перетаскивание файла).\n\n4) В поиске можно быстро найти токен по имени.\n\n5) Меню «⋮» у плитки — «Изменить» или «Удалить» (с подтверждением). Удаление из пула также убирает этот токен с текущей сцены.\n\n6) Перетащите плитку на карту, чтобы поставить токен. Выделите маркер: перетаскивание — сдвиг, уголок — размер, ручка поворота — угол. Delete / Backspace или ПКМ по маркеру — убрать с карты. «Очистить сцену» снимает все токены и ловушки со сцены.\n\nВо время сессии:\n\n1) На пульте в «Предпросмотр экрана» токены видны сразу (в отличие от ловушек их не нужно проявлять).\n\n2) Перетаскивайте токены левой кнопкой — новая позиция запоминается до конца текущей сессии, в том числе если вы возвращаетесь к сцене через «Сюжетную линию». При новом «Запустить» позиции снова берутся из редактора.\n\n3) На экране презентации токены только отображаются: клики и перетаскивание для игроков недоступны.\n\nПри экспорте и импорте сюжетных линий нужные файлы токенов упаковываются вместе с линией, чтобы на другом компьютере расстановка не «теряла» картинки.',
|
||||
'Неигровые токены — картинки существ, предметов и маркеров, которые вы ставите на карту сцены. Библиотека токенов хранится в приложении на этом компьютере (не внутри файла проекта). На сцене сохраняется только расстановка: какой токен, где стоит, размер и поворот.\n\nВ редакторе сцены:\n\n1) Откройте «Редактор сцены» для сцены с картинкой или видео (см. «Редактор сцены»).\n\n2) Раскройте «Неигровые токены».\n\n3) «Добавить» — задайте уникальное название и изображение (кнопка выбора или перетаскивание файла).\n\n4) В поиске можно быстро найти токен по имени.\n\n5) Меню «⋮» у плитки — «Изменить» или «Удалить» (с подтверждением). Удаление из пула также убирает этот токен с текущей сцены.\n\n6) Перетащите плитку на карту, чтобы поставить токен. Выделите маркер: перетаскивание — сдвиг, уголок — размер, ручка поворота — угол. Delete / Backspace — убрать с карты. ПКМ по маркеру — меню: «Указать движение» / «Удалить» / «Сбросить на старт пути». «Очистить сцену» снимает все токены и ловушки со сцены.\n\nВо время сессии:\n\n1) На пульте в «Предпросмотр экрана» токены видны сразу (в отличие от ловушек их не нужно проявлять).\n\n2) Перетаскивайте токены левой кнопкой — новая позиция запоминается до конца текущей сессии, в том числе если вы возвращаетесь к сцене через «Сюжетную линию». При новом «Запустить» позиции снова берутся из редактора.\n\n3) Если на сцене включена сетка, на пульте можно отметить «Привязка токенов к сетке» — при перетаскивании токены встают по клеткам (см. «Генератор сетки»).\n\n4) На экране презентации токены только отображаются: клики и перетаскивание для игроков недоступны.\n\nПри экспорте и импорте сюжетных линий нужные файлы токенов упаковываются вместе с линией, чтобы на другом компьютере расстановка не «теряла» картинки.',
|
||||
|
||||
'help.section.campaignAudio.title': 'Аудио игры',
|
||||
'help.section.campaignAudio.body':
|
||||
@@ -195,19 +204,22 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'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При смене сцены показ материала сбрасывается. Описание сцены и эффекты поля с материалами не связаны.',
|
||||
'Материалы — изображения кампании (карты, записки, чертежи), которые можно показать игрокам поверх сцены во время игры. Они общие для проекта и не привязаны к одной сцене.\n\nВ редакторе:\n\n1) В блоке «Свойства игры» нажмите «Материалы».\n\n2) «Добавить» — укажите уникальное название и изображение (PNG, JPG или WebP): кнопка выбора или перетаскивание файла.\n\n3) В списке можно искать, менять порядок перетаскиванием, править или удалять через меню «⋮» (перед удалением будет подтверждение).\n\n4) Под большим превью — «Повернуть»: поворот на 90° (учитывается и в плитке, и при показе на экране).\n\n5) Блок «Легенда» у выбранного материала: можно включить легенду, разместить нумерованные маркеры на картинке и подписать пункты списка. При показе на пульте и презентации легенда идёт вместе с материалом.\n\nВо время сессии:\n\n1) На пульте в «Инструменты» нажмите кнопку материалов (иконка карты сокровищ) — откроется отдельное окно со списком.\n\n2) Клик по плитке показывает материал поверх сцены на пульте и на презентации; повторный клик по той же плитке скрывает его. Можно открыть несколько материалов сразу — каждый кликом по своей плитке.\n\n3) На предпросмотре пульта материал можно перетаскивать, менять размер за углы и поворачивать; крестик закрывает показ этого материала (остальные остаются).\n\n4) В окне материалов лупы «+» / «−» — инструменты масштаба: выберите лупу, затем кликните по материалу в предпросмотре пульта.\n\nПри смене сцены показ материалов сбрасывается. Описание сцены и эффекты поля с материалами не связаны.',
|
||||
'help.section.players.title': 'Игроки',
|
||||
'help.section.players.body':
|
||||
'Раздел «Игроки» в шапке хранит локальную библиотеку живых игроков на этом компьютере (не внутри файла проекта).\n\n1) Откройте «Игроки» в шапке.\n\n2) «Добавить игрока» — имя и изображение обязательны. Пока идёт сохранение, видно окно с прогрессом.\n\n3) Справа — превью игрового токена: круг с цветной рамкой, аватар внутри (перетащите мышью, чтобы отцентровать; колесо мыши — увеличить/уменьшить), имя на тёмной подложке и выбор цвета рамки.\n\n4) Команды — плоские группы без вложенности: создайте команду, перетащите игрока в неё или в «Без команды».\n\nКампанийных НПС на сцену ставят отдельно: в «Редактор сцены» аккордеон «НПС» — плитки персонажей проекта, на карте они выглядят как такой же круглый токен.\n\nВо время сессии размер круглых токенов игроков и НПС на пульте можно менять ползунком «Размеры игр. токенов» (см. «Пульт управления»).',
|
||||
|
||||
'help.section.npcs.title': 'НПС',
|
||||
'help.section.npcs.body':
|
||||
'НПС — персонажи кампании с аватаром, описанием и однонаправленными связями между собой. Они общие для проекта и не привязаны к одной сцене.\n\nВ редакторе:\n\n1) В блоке «Свойства игры» нажмите «НПС» — откроется отдельное окно редактора персонажей.\n\n2) «Добавить» — укажите уникальное имя и обязательный аватар (PNG, JPG или WebP): кнопка выбора или перетаскивание файла. При необходимости сразу заполните описание.\n\n3) Слева — список персонажей: поиск, порядок перетаскиванием; меню «⋮» — только удаление (с подтверждением; все связи с этим персонажем тоже удаляются).\n\n4) В центре — граф связей: протяните стрелку от одного персонажа к другому и укажите обязательное название связи. Связь однонаправленная (А → Б и Б → А — разные). Несколько связей в одном направлении рисуются параллельными дугами. Клик по связи или её подписи выбирает исходного персонажа и подсвечивает его исходящие связи.\n\n5) Справа — карточка выбранного персонажа: аватар, имя, описание (форматированный текст) и список «Отношения» — только исходящие связи («название» + имя цели).\n\n6) Правый клик по связи на графе — «Редактировать» название или «Удалить» (с подтверждением).\n\nВо время сессии:\n\n1) На пульте в «Инструменты» рядом с материалами нажмите кнопку НПС (цветная иконка человека) — откроется отдельное окно.\n\n2) Справа — список персонажей; клик по плитке показывает аватар поверх сцены на пульте и на презентации; повторный клик по той же плитке скрывает его. Слева — описание и исходящие отношения выбранного персонажа (их видите только вы).\n\n3) На предпросмотре пульта аватар можно перетаскивать и менять размер за углы; крестик закрывает показ.\n\n4) В окне НПС лупы «+» / «−» — инструменты масштаба: выберите лупу, затем кликните по аватару в предпросмотре пульта.\n\nПри смене сцены показ НПС сбрасывается. Игроки на презентации видят только аватар.',
|
||||
'НПС — персонажи кампании с аватаром, описанием и однонаправленными связями между собой. Они общие для проекта.\n\nВ редакторе:\n\n1) В блоке «Свойства игры» нажмите «НПС» — откроется отдельное окно редактора персонажей.\n\n2) «Добавить» — укажите уникальное имя и обязательный аватар (PNG, JPG или WebP): кнопка выбора или перетаскивание файла. При необходимости сразу заполните описание.\n\n3) Слева — список персонажей: поиск, порядок перетаскиванием; меню «⋮» — только удаление (с подтверждением; все связи с этим персонажем тоже удаляются). Персонажей можно объединять в группы (и подгруппы): создайте группу и перетащите НПС в неё или оставьте «Без группы». На графе связей доступен фильтр по группе.\n\n4) В центре — граф связей: протяните стрелку от одного персонажа к другому и укажите обязательное название связи.\n\n5) Справа — карточка выбранного персонажа: настройте круглый токен, цвет рамки, положение аватара (перетаскивание и колесо мыши для масштаба), имя, описание, группу и отношения. Поле «Тип» задаёт отношение: враждебный, нейтральный или дружественный — от этого зависит цвет кольца токена на карте.\n\n6) В «Редакторе сцены» раскройте «НПС» и перетащите персонажа на карту. Круглый токен можно двигать и менять его размер.\n\nВо время сессии на пульте токен НПС можно двигать; правый клик по маркеру:\n• если токен неактивен — «Сделать активным»;\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). Игроки увидят только картинку, видео и эффекты — без ваших кнопок.',
|
||||
'Когда кампания готова, можно начать игру.\n\nОбычный запуск:\n\n1) На карте связей щёлкните правой кнопкой по карточке старта → «Начальная сцена».\n\n2) Нажмите «Запустить» в шапке редактора (левая часть кнопки).\n\nЗапуск с игроками: стрелка справа на кнопке «Запустить» → «Запустить с игроками» — выберите игроков и/или команды. Выбор сохранится на время сессии; на пульте появится «Показать игроков» / «Скрыть игроков». При смене сцены токены игроков скрываются.\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• «Размеры игр. токенов» — общий масштаб круглых токенов игроков и НПС на предпросмотре и презентации;\n• «Показать игроков» / «Скрыть игроков» — после запуска с игроками.\n\n«Сюжетная линия» — цепочка пройденных эпизодов. Текущая сцена помечена «ТЕКУЩАЯ СЦЕНА». Клик по прошлому шагу переносит партию к тому месту на карте и добавляет новый шаг в историю.\n\n«Выключить демонстрацию» — закончить показ и вернуться в редактор.',
|
||||
|
||||
'help.section.transitions.title': 'Переходы между сценами',
|
||||
'help.section.transitions.body':
|
||||
@@ -219,11 +231,11 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.effects.title': 'Эффекты поля и действий',
|
||||
'help.section.effects.body':
|
||||
'Эффекты работают на сценах с картинкой, не на видео. Рисуйте в «Предпросмотр экрана» — игроки увидят то же на презентации.\n\nВыберите инструмент слева:\n• Эффекты поля (туман, дождь, огонь, вода) — зажмите левую кнопку и ведите по карте.\n• Эффекты действий (молния, луч света, заморозка, тьма, облако яда, взрыв) — короткий клик или штрих; у некоторых есть звук.\n\nЕсли у сцены в свойствах включено «Затемнить сцену», появится блок «Управление затемнением» с «Кистью Открытия» 🔦. Водите по предпросмотру — тьма снимается на обоих экранах сразу. У игроков нераскрытое остаётся чёрным, у вас на пульте — полузатемнённым. Уже открытые участки сохраняются, пока идёт показ и вы снова попадаете на ту же карточку сцены на карте. Это не то же самое, что эффект «Тьма» 🌑 в блоке действий.\n\nЛастик 🧹 — для эффектов поля (туман, дождь, огонь, вода) водите кистью, как «Кистью Открытия» для затемнения: стирается только пройденный участок. Эффекты действий (молния, луч и т.д.) убираются целиком при клике или проведении по ним. «Очистить эффекты» — снять всё сразу.\n\n«Радиус кисти» под панелью — чем больше число, тем шире мазок.',
|
||||
'Эффекты работают на сценах с картинкой и с видео. Рисуйте в «Предпросмотр экрана» — игроки увидят то же на презентации.\n\nВыберите инструмент слева:\n• Эффекты поля (туман, дождь, огонь, вода) — зажмите левую кнопку и ведите по карте.\n• Эффекты действий (молния, луч света, заморозка, тьма, облако яда, взрыв) — короткий клик или штрих; у некоторых есть звук.\n\nЕсли у сцены в свойствах включено «Затемнить сцену», появится блок «Управление затемнением» с «Кистью Открытия» 🔦 и «Кистью Закрытия» ⬛. «Кисть Открытия» снимает тьму на обоих экранах сразу; «Кисть Закрытия» снова накрывает уже открытые участки. У игроков нераскрытое остаётся чёрным, у вас на пульте — полузатемнённым. Состояние сохраняется, пока идёт показ и вы снова попадаете на ту же карточку сцены на карте. Это не то же самое, что эффект «Тьма» 🌑 в блоке действий.\n\nЛастик 🧹 — для эффектов поля (туман, дождь, огонь, вода) водите кистью, как «Кистью Открытия» для затемнения: стирается только пройденный участок. Эффекты действий (молния, луч и т.д.) убираются целиком при клике или проведении по ним. «Очистить эффекты» — снять всё сразу.\n\n«Радиус кисти» под панелью — чем больше число, тем шире мазок.',
|
||||
|
||||
'help.section.presentation.title': 'Экран презентации',
|
||||
'help.section.presentation.body':
|
||||
'«Презентация» — то, что видят игроки: картинка сцены (с учётом поворота из редактора) или видео по вашим настройкам.\n\nЭффекты с пульта накладываются поверх. Меню и кнопки мастера здесь не показываются — клики по карте, ловушкам и токенам для игроков недоступны.\n\nЕсли у сцены включено «Затемнить сцену», игроки сначала видят полностью чёрный экран. Мастер открывает карту «Кистью Открытия» на пульте.\n\nПри смене сцены с пульта картинка обновляется сама. Перенесите окно на экран для игроков и при необходимости спрячьте панель задач.\n\nПустой или тёмный экран — скорее всего, у сцены нет превью. Добавьте картинку в свойствах сцены в редакторе.',
|
||||
'«Презентация» — то, что видят игроки: картинка или видео сцены (с учётом поворота из редактора) по вашим настройкам.\n\nЭффекты с пульта накладываются поверх. Меню и кнопки мастера здесь не показываются — клики по карте, ловушкам и токенам для игроков недоступны.\n\nЕсли у сцены включено «Затемнить сцену», игроки сначала видят полностью чёрный экран. Мастер открывает карту «Кистью Открытия» и при необходимости снова закрывает участки «Кистью Закрытия» на пульте.\n\nПри смене сцены с пульта картинка обновляется сама. Перенесите окно на экран для игроков и при необходимости спрячьте панель задач.\n\nПустой или тёмный экран — скорее всего, у сцены нет превью. Добавьте картинку в свойствах сцены в редакторе.',
|
||||
|
||||
'help.section.importExport.title': 'Импорт и экспорт',
|
||||
'help.section.importExport.body':
|
||||
@@ -282,6 +294,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'scenes.dropSkippedNoPath': 'не удалось получить путь к файлу',
|
||||
'scenes.inspectorGame': 'Свойства игры',
|
||||
'scenes.inspectorScene': 'Свойства сцены',
|
||||
'scenes.projectLabel': 'Проект: {name}',
|
||||
'scenes.selectHint': 'Выберите сцену слева, чтобы редактировать её свойства.',
|
||||
'scenes.openProjectHint': 'Откройте проект, чтобы редактировать кампанию и сцены.',
|
||||
|
||||
@@ -298,7 +311,13 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'export.title': 'Экспорт проекта',
|
||||
'export.project': 'ПРОЕКТ',
|
||||
'export.hint':
|
||||
'Выберите сюжетные линии для экспорта. В архив попадут только выбранные линии, их сцены и материалы. Далее откроется окно сохранения .ttrpg.zip.',
|
||||
'Выберите сюжетные линии для экспорта. В архив попадут выбранные линии, их сцены и материалы. Если в проекте есть НПС, на следующем шаге можно отметить, кого включить.',
|
||||
'export.npcsTitle': 'Экспорт НПС',
|
||||
'export.npcsHint':
|
||||
'Отметьте НПС для экспорта. Вместе с ними попадут связи между отмеченными и группы этих персонажей.',
|
||||
'export.selectAllNpcs': 'Отметить всех',
|
||||
'export.next': 'Далее',
|
||||
'export.back': 'Назад',
|
||||
'export.exporting': 'Экспорт…',
|
||||
'export.saveAs': 'Сохранить как…',
|
||||
|
||||
@@ -399,7 +418,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'materials.dropHint': 'Перетащите изображение (PNG, JPG, WebP)',
|
||||
'materials.tileMenu': 'Меню материала',
|
||||
'materials.windowEmpty': 'Добавьте материалы в редакторе.',
|
||||
'materials.closeOverlay': 'Закрыть материал',
|
||||
'materials.closeOverlay': 'Закрыть материалы',
|
||||
'materials.rotateOverlay': 'Повернуть',
|
||||
'materials.deleteTitle': 'Удаление материала',
|
||||
'materials.deleteConfirm': 'Вы уверены, что хотите удалить материал «{name}»?',
|
||||
@@ -409,11 +428,37 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'materials.zoomOutHint': 'Кликните по материалу в предпросмотре пульта, чтобы уменьшить.',
|
||||
'materials.zoomIdleHint': 'Выберите лупу, затем кликните по материалу в предпросмотре пульта.',
|
||||
|
||||
'players.managerTitle': 'Игроки',
|
||||
'players.add': 'Добавить игрока',
|
||||
'players.search': 'Поиск игроков…',
|
||||
'players.selectPrompt': 'Выберите игрока или добавьте нового.',
|
||||
'players.name': 'ИМЯ',
|
||||
'players.namePlaceholder': 'Имя игрока…',
|
||||
'players.ringColor': 'ЦВЕТ РАМКИ',
|
||||
'players.chooseImage': 'Выбрать изображение',
|
||||
'players.dropHint': 'Перетащите изображение (PNG, JPG, WebP)',
|
||||
'players.savingTitle': 'Сохранение игрока',
|
||||
'players.savingWait': 'Подождите…',
|
||||
'players.ungrouped': 'Без команды',
|
||||
'players.addTeam': 'Новая команда',
|
||||
'players.teamName': 'Название команды',
|
||||
'players.teamColor': 'Цвет команды',
|
||||
'players.teamMenu': 'Меню команды',
|
||||
'players.dropHere': 'Перетащите игрока сюда',
|
||||
'players.deleteTitle': 'Удаление игрока',
|
||||
'players.deleteConfirm': 'Вы уверены, что хотите удалить игрока «{name}»?',
|
||||
'players.deleteTeamTitle': 'Удаление команды',
|
||||
'players.deleteTeamConfirm': 'Вы уверены, что хотите удалить команду «{name}»? Игроки останутся без команды.',
|
||||
|
||||
'npcs.open': 'НПС',
|
||||
'npcs.editorTitle': 'НПС',
|
||||
'npcs.add': 'Добавить',
|
||||
'npcs.addTitle': 'Новый НПС',
|
||||
'npcs.editTitle': 'Изменить НПС',
|
||||
'npcs.savingTitle': 'Сохранение НПС',
|
||||
'npcs.savingWait': 'Подождите…',
|
||||
'npcs.savingProgress': 'Прогресс сохранения НПС',
|
||||
'npcs.graphLoading': 'Загрузка графа…',
|
||||
'npcs.edit': 'Изменить',
|
||||
'npcs.tileMenu': 'Меню НПС',
|
||||
'npcs.search': 'Поиск НПС…',
|
||||
@@ -429,6 +474,17 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'npcs.avatarRequired': 'Выберите аватар.',
|
||||
'npcs.chooseAvatar': 'Выбрать аватар',
|
||||
'npcs.dropHint': 'Перетащите изображение (PNG, JPG, WebP)',
|
||||
'npcs.disposition': 'ТИП',
|
||||
'npcs.disposition.hostile': 'Враждебный',
|
||||
'npcs.disposition.neutral': 'Нейтральный',
|
||||
'npcs.disposition.friendly': 'Дружественный',
|
||||
'npcs.makeHostile': 'Сделать враждебным',
|
||||
'npcs.makeNeutral': 'Сделать нейтральным',
|
||||
'npcs.makeFriendly': 'Сделать дружественным',
|
||||
'npcs.inactive': 'Неактивен',
|
||||
'npcs.makeActive': 'Сделать активным',
|
||||
'npcs.openInfo': 'Открыть информацию',
|
||||
'npcs.ringColor': 'ЦВЕТ РАМКИ ТОКЕНА',
|
||||
'npcs.description': 'ОПИСАНИЕ',
|
||||
'npcs.descriptionPlaceholder': 'Описание персонажа…',
|
||||
'npcs.descriptionEmpty': 'Описание отсутствует',
|
||||
@@ -471,12 +527,6 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'Удалить группу «{name}»? НПС станут без группы, вложенные группы будут подняты на уровень выше.',
|
||||
'npcs.addSubgroup': 'Добавить подгруппу',
|
||||
'npcs.group': 'ГРУППА',
|
||||
'npcs.bindingEnable': 'Привязать…',
|
||||
'npcs.bindingKind': 'ТИП ПРИВЯЗКИ',
|
||||
'npcs.bindingStoryline': 'Сюжетная линия',
|
||||
'npcs.bindingScene': 'Сцена',
|
||||
'npcs.bindingMain': 'Основная линия',
|
||||
'npcs.bindingSelect': 'ОБЪЕКТ',
|
||||
'npcs.graphFilterAll': 'Все',
|
||||
'npcs.graphFilterUngrouped': 'Без группы',
|
||||
'npcs.graphFilter': 'Фильтр графа',
|
||||
@@ -559,6 +609,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.darkness': 'Тьма',
|
||||
'control.darknessControl': 'Управление затемнением',
|
||||
'control.explorerBrush': 'Кисть Открытия',
|
||||
'control.closerBrush': 'Кисть Закрытия',
|
||||
'control.lightning': 'Молния',
|
||||
'control.sunbeam': 'Луч света',
|
||||
'control.freeze': 'Заморозка',
|
||||
@@ -572,7 +623,12 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.passed': 'Пройдено',
|
||||
'control.noActiveScene': 'Нет активной сцены.',
|
||||
'control.screenPreview': 'Предпросмотр экрана',
|
||||
'control.stopPresentation': 'Выключить демонстрацию',
|
||||
'control.npcTokenScale': 'Размеры игр. токенов',
|
||||
'control.snapTokensToGrid': 'Привязка токенов к сетке',
|
||||
'control.snapTokensToGridNoGrid': 'Сетка на текущей сцене выключена — привязка не применяется',
|
||||
'control.stopPresentation': 'Выключить',
|
||||
'control.showPlayers': 'Показать игроков',
|
||||
'control.hidePlayers': 'Скрыть игроков',
|
||||
'control.videoBrushHint':
|
||||
'Видео-превью: кисть эффектов отключена (как на экране демонстрации — оверлей только для изображения).',
|
||||
'control.branches': 'Варианты ветвления',
|
||||
@@ -674,9 +730,18 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'top.settings': 'Settings',
|
||||
'top.project': 'Project',
|
||||
'top.file': 'File',
|
||||
'top.players': 'Players',
|
||||
'top.backToProjects': 'Back to projects',
|
||||
'top.appVersion': 'App version',
|
||||
'top.run': 'Run',
|
||||
'top.runMenuAria': 'More launch options',
|
||||
'top.runWithPlayers': 'Run with players',
|
||||
'top.runWithPlayersTitle': 'Launch with players',
|
||||
'top.runWithPlayersHint': 'Select players and/or teams. The selection is kept for this session.',
|
||||
'top.runWithPlayersTeams': 'Teams',
|
||||
'top.runWithPlayersPlayers': 'Players',
|
||||
'top.runWithPlayersEmpty': 'No players in the library.',
|
||||
'top.runWithPlayersConfirm': 'Launch',
|
||||
'top.launching': 'Starting…',
|
||||
'top.afterLicense': 'Available after license activation',
|
||||
'top.setStartScene': 'Set a start scene on the graph (right‑click a node)',
|
||||
@@ -735,23 +800,23 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.sceneProps.title': 'Scene properties',
|
||||
'help.section.sceneProps.body':
|
||||
'Select a scene in the left list — its properties open on the right.\n\nScene title is for the GM. Description is GM notes with formatting: click the pencil next to the label to open the editor (bold, italic, headings, lists, links). Below the label you see a text preview, or “no description” when empty. During a session, open the description from the control panel in a separate window (see Control panel) — it is not shown inside the Storyline list.\n\nIf the scene has a graph card with a blue SIDE badge, a Side storyline title field appears.\n\nImage or video for players:\n\n1) Under Scene preview, click Upload or Change.\n\n2) Pick a file (PNG, JPG, WebP, GIF, etc.).\n\n3) For images, use Rotate (90° steps). Clear removes the preview.\n\n4) For images, enable Darken scene so players start in full darkness and you reveal the map with the Opening brush on the control panel (see Effects).\n\n5) For images, Scene editor opens the battle grid, traps, and non-player tokens on the map (see Scene editor, Grid generator, Traps, and Non-player tokens).\n\nFor video, enable Autostart if the clip should start on its own on the player screen. Brush effects and the scene editor are not available on video scenes.\n\nScene audio — music and sounds for this episode. Upload adds files. Per track: Auto (play when entering the scene) and Loop. The trash icon removes a track.\n\nLinks between scenes are drawn on the map, not here — see Scene graph.',
|
||||
'Select a scene in the left list — its properties open on the right.\n\nScene title is for the GM. Description is GM notes with formatting: click the pencil next to the label to open the editor (bold, italic, headings, lists, links). Below the label you see a text preview, or “no description” when empty. During a session, open the description from the control panel in a separate window (see Control panel) — it is not shown inside the Storyline list.\n\nIf the scene has a graph card with a blue SIDE badge, a Side storyline title field appears.\n\nImage or video for players:\n\n1) Under Scene preview, click Upload or Change.\n\n2) Pick a file (PNG, JPG, WebP, GIF, video, etc.).\n\n3) For images and video, use Rotate (90° steps). Clear removes the preview.\n\n4) For images and video, enable Darken scene so players start in full darkness and you reveal the frame with the Opening brush on the control panel (see Effects).\n\n5) Scene editor works for both images and video — battle grid, traps, and non-player tokens over the preview (see Scene editor, Grid generator, Traps, and Non-player tokens).\n\nFor video, enable Autostart if the clip should start on its own on the player screen, and Loop if needed.\n\nScene audio — music and sounds for this episode. Upload adds files. Per track: Auto (play when entering the scene) and Loop. The trash icon removes a track.\n\nLinks between scenes are drawn on the map, not here — see Scene graph.',
|
||||
|
||||
'help.section.sceneEditor.title': 'Scene editor',
|
||||
'help.section.sceneEditor.body':
|
||||
'Scene editor is a separate window for preparing the map: battle grid, traps, and non-player tokens. It is available only for image scenes (not video).\n\nOpen it:\n\n1) Select a scene in the left list.\n\n2) In Scene properties, upload an image if the scene has none yet.\n\n3) Click Scene editor.\n\nOn the left are the Grid, Non-player tokens, and Traps accordions; on the right is the scene map.\n\nMap navigation: mouse wheel zooms; middle mouse button or Space+left-drag pans the view. Delete / Backspace removes the selected marker on the map.\n\nUnder the accordions, Clear scene removes every trap and token from the current map (it does not delete tokens from the app library).\n\nFor details, see Grid generator, Traps, and Non-player tokens.',
|
||||
'Scene editor is a separate window for preparing the map: battle grid, traps, non-player tokens, and circular NPC tokens. It works for image and video scenes (overlays sit on top of the clip).\n\nOpen it:\n\n1) Select a scene in the left list.\n\n2) In Scene properties, upload an image or video if the scene has none yet.\n\n3) Click Scene editor.\n\nOn the left are the Grid, Non-player tokens, NPCs, and Traps accordions; on the right is the scene map. Drag an NPC onto the map to add its circular token.\n\nMap navigation: mouse wheel zooms; middle mouse button or Space+left-drag pans the view. Delete / Backspace removes the selected marker on the map.\n\nUnder the accordions, Clear scene removes every trap and token from the current map (it does not delete tokens from the app library).\n\nFor details, see Grid generator, Traps, and Non-player tokens.',
|
||||
|
||||
'help.section.grid.title': 'Grid generator',
|
||||
'help.section.grid.body':
|
||||
'The grid generator overlays a battle grid on the scene image — square or hexagonal. It helps track cells in combat and is visible both on your control panel and on the players’ presentation.\n\nSet it up:\n\n1) Open Scene editor for an image scene (see Scene editor).\n\n2) Expand the Grid accordion on the left.\n\n3) Enable Overlay grid — lines appear over the map.\n\n4) Type — Square or Hexagonal.\n\n5) Color — line tint (pick contrast that suits the map).\n\n6) Size — cell size slider: higher values mean larger cells.\n\nWhile the grid is off, type, color, and size stay disabled, but the saved values return when you turn it back on.\n\nGrid settings are stored with the scene in the project. The generator is not available on video scenes — only on images. The grid draws under trap and token markers and does not block placing them.',
|
||||
'The grid generator overlays a battle grid on the scene preview (image or video) — square or hexagonal. It helps track cells in combat and is visible both on your control panel and on the players’ presentation.\n\nSet it up:\n\n1) Open Scene editor (see Scene editor).\n\n2) Expand the Grid accordion on the left.\n\n3) Enable Overlay grid — lines appear over the map.\n\n4) Type — Square or Hexagonal.\n\n5) Color — line tint (pick contrast that suits the map).\n\n6) Size — cell size slider: higher values mean larger cells.\n\nWhile the grid is off, type, color, and size stay disabled, but the saved values return when you turn it back on.\n\nGrid settings are stored with the scene in the project. The grid draws under trap and token markers and does not block placing them.\n\nDuring a session you can enable Snap tokens to grid on the control panel: dragging non-player tokens, NPC tokens, and player tokens snaps them to cells (square or hex, matching the grid type). If the grid is off, the checkbox has no effect.',
|
||||
|
||||
'help.section.traps.title': 'Traps',
|
||||
'help.section.traps.body':
|
||||
'Traps are markers on the scene map for hidden threats and surprises. Placement is stored with the scene in the project; during play you decide when players see them.\n\nIn the scene editor:\n\n1) Open Scene editor for an image scene (see Scene editor).\n\n2) Expand the Traps accordion on the left.\n\n3) Drag a type from the palette onto the map.\n\n4) Drag a marker to move it; drag the corner handle of the selected marker to resize.\n\n5) Delete / Backspace removes the selected trap. Clear scene removes all markers at once.\n\nTypes: Mimic, Explosion, Poison, Pit, Arrow, Laser, and Marker (a generic marker without a special effect).\n\nDuring a session:\n\n1) On the control panel Screen preview you see every trap on the current scene. While still hidden from players, markers look slightly muted on your side.\n\n2) On presentation, markers appear only after reveal or activation.\n\n3) Right-click a marker on the control panel:\n• Reveal — show it to players without triggering.\n• Activate — reveal and play the effect: Mimic, Pit, Arrow, and Laser play animation and sound; Poison and Explosion use the matching control-panel effects (poison cloud / explosion); Marker shows a short flash.\n• Disarm — show it as disarmed.\n\nTrap runtime state (revealed / triggered / disarmed) resets when you start a new session. Markers placed on the map remain.',
|
||||
'Traps are markers on the scene map for hidden threats and surprises. Placement is stored with the scene in the project; during play you decide when players see them.\n\nIn the scene editor:\n\n1) Open Scene editor for an image or video scene (see Scene editor).\n\n2) Expand the Traps accordion on the left.\n\n3) Drag a type from the palette onto the map.\n\n4) Drag a marker to move it; drag the corner handle of the selected marker to resize.\n\n5) Delete / Backspace removes the selected trap. Clear scene removes all markers at once.\n\nTypes: Mimic, Explosion, Poison, Pit, Arrow, Laser, and Marker (a generic marker without a special effect).\n\nDuring a session:\n\n1) On the control panel Screen preview you see every trap on the current scene. While still hidden from players, markers stay clearly readable on your side (dashed outline) so you can find them on the map.\n\n2) On presentation, markers appear only after reveal or activation.\n\n3) Right-click a marker on the control panel:\n• Reveal — show it to players without triggering.\n• Activate — reveal and play the effect: Mimic, Pit, Arrow, and Laser play animation and sound; Poison and Explosion use the matching control-panel effects (poison cloud / explosion); Marker shows a short flash.\n• Disarm — show it as disarmed.\n\nTrap runtime state (revealed / triggered / disarmed) resets when you start a new session. Markers placed on the map remain.',
|
||||
|
||||
'help.section.tokens.title': 'Non-player tokens',
|
||||
'help.section.tokens.body':
|
||||
'Non-player tokens are images of creatures, props, and markers you place on the scene map. The token library lives in the app on this computer (not inside the project file). The scene only stores placements: which token, where it stands, size, and rotation.\n\nIn the scene editor:\n\n1) Open Scene editor for an image scene (see Scene editor).\n\n2) Expand Non-player tokens.\n\n3) Add — enter a unique name and an image (choose file or drop one).\n\n4) Use search to find a token by name.\n\n5) The ⋮ menu on a tile opens Edit or Delete (with confirmation). Deleting from the library also removes that token from the current scene.\n\n6) Drag a tile onto the map to place it. Select a marker: drag to move, corner handle to resize, rotate handle to turn. Delete / Backspace or right-click the marker removes it from the map. Clear scene removes all tokens and traps from the scene.\n\nDuring a session:\n\n1) On the control panel Screen preview, tokens are visible right away (unlike traps, they do not need revealing).\n\n2) Drag tokens with the left button — the new position is kept until the current session ends, including when you return to the scene via Storyline. A new Run resets positions to what you set in the editor.\n\n3) On the presentation screen tokens are display-only: players cannot click or drag them.\n\nWhen you export or import storylines, the needed token files are packed with the line so placements keep their images on another computer.',
|
||||
'Non-player tokens are images of creatures, props, and markers you place on the scene map. The token library lives in the app on this computer (not inside the project file). The scene only stores placements: which token, where it stands, size, and rotation.\n\nIn the scene editor:\n\n1) Open Scene editor for an image or video scene (see Scene editor).\n\n2) Expand Non-player tokens.\n\n3) Add — enter a unique name and an image (choose file or drop one).\n\n4) Use search to find a token by name.\n\n5) The ⋮ menu on a tile opens Edit or Delete (with confirmation). Deleting from the library also removes that token from the current scene.\n\n6) Drag a tile onto the map to place it. Select a marker: drag to move, corner handle to resize, rotate handle to turn. Delete / Backspace removes it from the map. Right-click opens a menu: Set movement / Delete / Reset to path start. Clear scene removes all tokens and traps from the scene.\n\nDuring a session:\n\n1) On the control panel Screen preview, tokens are visible right away (unlike traps, they do not need revealing).\n\n2) Drag tokens with the left button — the new position is kept until the current session ends, including when you return to the scene via Storyline. A new Run resets positions to what you set in the editor.\n\n3) If the scene has a grid, enable Snap tokens to grid on the control panel so dragged tokens snap to cells (see Grid generator).\n\n4) On the presentation screen tokens are display-only: players cannot click or drag them.\n\nWhen you export or import storylines, the needed token files are packed with the line so placements keep their images on another computer.',
|
||||
|
||||
'help.section.campaignAudio.title': 'Game audio',
|
||||
'help.section.campaignAudio.body':
|
||||
@@ -759,19 +824,22 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'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.',
|
||||
'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\n5) The Legend block for the selected material: enable the legend, place numbered markers on the image, and label the list items. When shown on the control panel and presentation, the legend travels with the material.\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. You can show several materials at once — one click per tile.\n\n3) On the control preview you can drag the material, resize it from the corners, and rotate it; the × button closes that material (others stay open).\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 material overlays. Scene description and field effects are separate from materials.',
|
||||
'help.section.players.title': 'Players',
|
||||
'help.section.players.body':
|
||||
'The Players item in the header stores a local library of live players on this computer (not inside the project file).\n\n1) Open Players in the header.\n\n2) Add player — name and image are required. A progress dialog appears while saving.\n\n3) On the right — player token preview: a circle with a colored ring, avatar inside (drag to recenter; mouse wheel to zoom), name on a dark plate, and a ring color picker.\n\n4) Teams are flat groups with no nesting: create a team and drag a player into it or into No team.\n\nCampaign NPCs are placed separately: in Scene editor open the NPCs accordion and drag project characters onto the map — they appear as the same circular token.\n\nDuring a session you can change the size of circular player and NPC tokens with the Play token size slider on the control panel (see Control panel).',
|
||||
|
||||
'help.section.npcs.title': 'NPCs',
|
||||
'help.section.npcs.body':
|
||||
'NPCs are campaign characters with an avatar, description, and one-way relations between them. They belong to the project and are not tied to one scene.\n\nIn the editor:\n\n1) Under Game properties, click NPCs — a separate character editor window opens.\n\n2) Add — enter a unique name and a required avatar (PNG, JPG, or WebP) via Choose avatar or by dropping a file. You can fill in the description right away if you want.\n\n3) Left: character list with search and drag reorder; the ⋮ menu is delete only (with confirmation; all relations involving that character are removed too).\n\n4) Center: relationship graph — drag an arrow from one character to another and enter a required relation name. Relations are one-way (A → B and B → A are different). Multiple relations in the same direction are drawn as parallel curves. Click a relation or its label to select the source character and highlight their outgoing links.\n\n5) Right: the selected character’s card — avatar, name, description (rich text), and a Relations list of outgoing links only (“name” + target name).\n\n6) Right-click a relation on the graph to Edit the name or Delete (with confirmation).\n\nDuring a session:\n\n1) On the control panel under Tools, next to materials, click the NPCs button (colored person icon) to open a separate window.\n\n2) Right: character list; click a tile to show the avatar over the scene on the control preview and presentation; click the same tile again to hide it. Left: description and outgoing relations for the selected character (visible only to you).\n\n3) On the control preview you can drag the avatar and resize it from the corners; the × button closes the overlay.\n\n4) In the NPCs window, the + / − magnifiers are zoom tools: pick one, then click the avatar on the control preview.\n\nChanging scenes clears the NPC overlay. Players on presentation see only the avatar.',
|
||||
'NPCs are campaign characters with an avatar, description, and one-way relations between them. They belong to the project.\n\nIn the editor:\n\n1) Under Game properties, click NPCs to open the character editor window.\n\n2) Add — enter a unique name and a required avatar (PNG, JPG, or WebP) via Choose image or by dropping a file. Fill in the description if needed.\n\n3) On the left — character list: search, reorder by drag-and-drop; the ⋮ menu only deletes (with confirmation; relations to that character are removed too). Characters can be organized into groups (and subgroups): create a group and drag NPCs into it, or leave them Ungrouped. The relation graph has a group filter.\n\n4) In the center — relation graph: drag an arrow from one character to another and enter a required relation title.\n\n5) On the right — selected character card: circular token, ring color, avatar position (drag and mouse wheel to zoom), name, description, group, and relations. Type sets disposition: hostile, neutral, or friendly — it controls the ring color on the map.\n\n6) In Scene editor, expand NPCs and drag a character onto the map. The circular marker can be moved and resized.\n\nDuring a session on the control panel you can move NPC tokens; right-click a marker:\n• if inactive — Make active;\n• if active — Open information (NPC card), change type (hostile / neutral / friendly), and Make inactive.\nOn presentation tokens are display-only. See Control panel for more.',
|
||||
|
||||
'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.',
|
||||
'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 (left part of the button).\n\nRun with players: use the chevron on the right of Run → Run with players — pick players and/or teams. The selection lasts for the session; the control panel shows Show players / Hide players. Changing scenes hides player tokens.\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 Turn off or End presentation (when there is nowhere left to go).\n\n2) Wait until both windows close.\n\nMove the Presentation window to a second monitor, projector, or TV and go fullscreen (F11). Players see only the image, video, and effects — not your buttons.',
|
||||
|
||||
'help.section.controlPanel.title': 'Control panel',
|
||||
'help.section.controlPanel.body':
|
||||
'The control panel is your desk during play. Players watch presentation; you run everything from here.\n\nTop-left: Tools, then effects and storyline. On the right: a mini copy of the player screen, branch options, and music.\n\nUnder Tools, the book button opens a separate window with the current scene’s formatted description. If there is no description, the button is disabled — the tooltip reads “No description”. The materials button (map icon) opens a window with the campaign materials list — see the Materials section for details. The NPCs button (colored person icon) opens the characters window — see the NPCs section.\n\nScreen preview shows what players see. On image scenes, paint effects here — they appear on the big screen right away. If the scene has traps, they appear on the preview: right-click a marker to reveal, activate, or disarm (see Traps). Non-player tokens are also visible on the preview and can be moved until the session ends (see Non-player tokens).\n\nStoryline lists episodes you have visited. The current scene is marked CURRENT SCENE. Click an earlier step to move the party back to that spot and add a new history entry.\n\nStop presentation ends the show and unlocks the editor.',
|
||||
'The control panel is your desk during play. Players watch presentation; you run everything from here.\n\nTop-left: Tools, then effects and storyline. On the right: a mini copy of the player screen, branch options, and music.\n\nUnder Tools, the book button opens a separate window with the current scene’s formatted description. If there is no description, the button is disabled — the tooltip reads “No description”. The materials button (map icon) opens a window with the campaign materials list — see the Materials section for details. The NPCs button (colored person icon) opens the characters window — see the NPCs section.\n\nScreen preview shows what players see. Paint effects here — they appear on the big screen right away for both image and video scenes. If the scene has traps, they appear on the preview: right-click a marker to reveal, activate, or disarm (see Traps). Non-player tokens and NPC tokens are also visible on the preview and can be moved until the session ends; right-click an NPC token to activate / open information / change type / make inactive (see NPCs). On video scenes, transport controls and the scrub bar stay at the bottom of the preview.\n\nAbove the preview:\n• Snap tokens to grid — when the scene grid is on, dragged tokens (non-player, NPC, players) snap to cells;\n• Play token size — shared scale for circular player and NPC tokens on the preview and presentation;\n• Show players / Hide players — after Run with players.\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':
|
||||
@@ -783,11 +851,11 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.effects.title': 'Field and action effects',
|
||||
'help.section.effects.body':
|
||||
'Effects work on image scenes, not video. Paint in Screen preview — players see the same on presentation.\n\nPick a tool on the left:\n• Field effects (fog, rain, fire, water) — hold the left button and brush on the map.\n• Action effects (lightning, sunbeam, freeze, darkness, poison cloud, explosion) — click or short stroke; some include sound.\n\nIf Darken scene is enabled in scene properties, a Darkness control section appears with the Opening brush 🔦. Brush on the preview to clear darkness on both screens at once. Unrevealed areas stay fully black for players and half-dark on your preview. Revealed areas are remembered while the show runs and you return to the same graph card. This is not the same as the Darkness 🌑 action effect.\n\nEraser 🧹 — for field effects (fog, rain, fire, water), brush like the Opening brush for darkness: only the stroke area is erased. Action effects (lightning, sunbeam, etc.) are removed whole when you click or drag over them. Clear effects removes everything at once.\n\nBrush radius under the panel — higher values mean a wider stroke.',
|
||||
'Effects work on image and video scenes. Paint in Screen preview — players see the same on presentation.\n\nPick a tool on the left:\n• Field effects (fog, rain, fire, water) — hold the left button and brush on the map.\n• Action effects (lightning, sunbeam, freeze, darkness, poison cloud, explosion) — click or short stroke; some include sound.\n\nIf Darken scene is enabled in scene properties, a Darkness control section appears with the Opening brush 🔦 and Closing brush ⬛. Opening brush clears darkness on both screens at once; Closing brush covers revealed areas again. Unrevealed areas stay fully black for players and half-dark on your preview. The state is remembered while the show runs and you return to the same graph card. This is not the same as the Darkness 🌑 action effect.\n\nEraser 🧹 — for field effects (fog, rain, fire, water), brush like the Opening brush for darkness: only the stroke area is erased. Action effects (lightning, sunbeam, etc.) are removed whole when you click or drag over them. Clear effects removes everything at once.\n\nBrush radius under the panel — higher values mean a wider stroke.',
|
||||
|
||||
'help.section.presentation.title': 'Presentation screen',
|
||||
'help.section.presentation.body':
|
||||
'Presentation is what players see: the scene image (with rotation from the editor) or video according to your settings.\n\nEffects from the control panel draw on top. There are no GM menus or buttons here — players cannot click the map, traps, or tokens.\n\nIf Darken scene is enabled, players first see a fully black screen. The GM reveals the map with the Opening brush on the control panel.\n\nWhen you switch scenes from the control panel, the image updates automatically. Move the window to the display players watch and hide the taskbar if needed.\n\nA blank or dark screen usually means the scene has no preview — add one in scene properties in the editor.',
|
||||
'Presentation is what players see: the scene image or video (with rotation from the editor) according to your settings.\n\nEffects from the control panel draw on top. There are no GM menus or buttons here — players cannot click the map, traps, or tokens.\n\nIf Darken scene is enabled, players first see a fully black screen. The GM reveals the map with the Opening brush and can cover areas again with the Closing brush on the control panel.\n\nWhen you switch scenes from the control panel, the image updates automatically. Move the window to the display players watch and hide the taskbar if needed.\n\nA blank or dark screen usually means the scene has no preview — add one in scene properties in the editor.',
|
||||
|
||||
'help.section.importExport.title': 'Import and export',
|
||||
'help.section.importExport.body':
|
||||
@@ -846,6 +914,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'scenes.dropSkippedNoPath': 'could not resolve file path',
|
||||
'scenes.inspectorGame': 'Game properties',
|
||||
'scenes.inspectorScene': 'Scene properties',
|
||||
'scenes.projectLabel': 'Project: {name}',
|
||||
'scenes.selectHint': 'Select a scene on the left to edit its properties.',
|
||||
'scenes.openProjectHint': 'Open a project to edit the campaign and scenes.',
|
||||
|
||||
@@ -862,7 +931,13 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'export.title': 'Export project',
|
||||
'export.project': 'PROJECT',
|
||||
'export.hint':
|
||||
'Select storylines to export. The archive will include only the chosen lines, their scenes, and assets. Then choose where to save the .ttrpg.zip file.',
|
||||
'Select storylines to export. The archive will include the chosen lines, their scenes, and assets. If the project has NPCs, the next step lets you choose which ones to include.',
|
||||
'export.npcsTitle': 'Export NPCs',
|
||||
'export.npcsHint':
|
||||
'Select NPCs to export. Relations between selected NPCs and their groups are included.',
|
||||
'export.selectAllNpcs': 'Select all',
|
||||
'export.next': 'Next',
|
||||
'export.back': 'Back',
|
||||
'export.exporting': 'Exporting…',
|
||||
'export.saveAs': 'Save as…',
|
||||
|
||||
@@ -964,7 +1039,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'materials.dropHint': 'Drop an image (PNG, JPG, WebP)',
|
||||
'materials.tileMenu': 'Material menu',
|
||||
'materials.windowEmpty': 'Add materials in the editor.',
|
||||
'materials.closeOverlay': 'Close material',
|
||||
'materials.closeOverlay': 'Close materials',
|
||||
'materials.rotateOverlay': 'Rotate',
|
||||
'materials.deleteTitle': 'Delete material',
|
||||
'materials.deleteConfirm': 'Are you sure you want to delete material “{name}”?',
|
||||
@@ -974,11 +1049,38 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'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.',
|
||||
|
||||
'players.managerTitle': 'Players',
|
||||
'players.add': 'Add player',
|
||||
'players.search': 'Search players…',
|
||||
'players.selectPrompt': 'Select a player or add a new one.',
|
||||
'players.name': 'NAME',
|
||||
'players.namePlaceholder': 'Player name…',
|
||||
'players.ringColor': 'RING COLOR',
|
||||
'players.chooseImage': 'Choose image',
|
||||
'players.dropHint': 'Drop an image (PNG, JPG, WebP)',
|
||||
'players.savingTitle': 'Saving player',
|
||||
'players.savingWait': 'Please wait…',
|
||||
'players.ungrouped': 'No team',
|
||||
'players.addTeam': 'New team',
|
||||
'players.teamName': 'Team name',
|
||||
'players.teamColor': 'Team color',
|
||||
'players.teamMenu': 'Team menu',
|
||||
'players.dropHere': 'Drop a player here',
|
||||
'players.deleteTitle': 'Delete player',
|
||||
'players.deleteConfirm': 'Are you sure you want to delete player “{name}”?',
|
||||
'players.deleteTeamTitle': 'Delete team',
|
||||
'players.deleteTeamConfirm':
|
||||
'Are you sure you want to delete team “{name}”? Players will become ungrouped.',
|
||||
|
||||
'npcs.open': 'NPCs',
|
||||
'npcs.editorTitle': 'NPCs',
|
||||
'npcs.add': 'Add',
|
||||
'npcs.addTitle': 'New NPC',
|
||||
'npcs.editTitle': 'Edit NPC',
|
||||
'npcs.savingTitle': 'Saving NPC',
|
||||
'npcs.savingWait': 'Please wait…',
|
||||
'npcs.savingProgress': 'NPC save progress',
|
||||
'npcs.graphLoading': 'Loading graph…',
|
||||
'npcs.edit': 'Edit',
|
||||
'npcs.tileMenu': 'NPC menu',
|
||||
'npcs.search': 'Search NPCs…',
|
||||
@@ -994,13 +1096,25 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'npcs.avatarRequired': 'Choose an avatar.',
|
||||
'npcs.chooseAvatar': 'Choose avatar',
|
||||
'npcs.dropHint': 'Drop an image (PNG, JPG, WebP)',
|
||||
'npcs.disposition': 'TYPE',
|
||||
'npcs.disposition.hostile': 'Hostile',
|
||||
'npcs.disposition.neutral': 'Neutral',
|
||||
'npcs.disposition.friendly': 'Friendly',
|
||||
'npcs.makeHostile': 'Make Hostile',
|
||||
'npcs.makeNeutral': 'Make Neutral',
|
||||
'npcs.makeFriendly': 'Make Friendly',
|
||||
'npcs.inactive': 'Inactive',
|
||||
'npcs.makeActive': 'Make Active',
|
||||
'npcs.openInfo': 'Open information',
|
||||
'npcs.ringColor': 'TOKEN RING COLOR',
|
||||
'npcs.description': 'DESCRIPTION',
|
||||
'npcs.descriptionPlaceholder': 'Character description…',
|
||||
'npcs.descriptionEmpty': 'No description',
|
||||
'npcs.relations': 'Relations',
|
||||
'npcs.untitled': 'Untitled',
|
||||
'npcs.deleteTitle': 'Delete NPC',
|
||||
'npcs.deleteConfirm': 'Are you sure you want to delete NPC “{name}”? All of their relations will be removed.',
|
||||
'npcs.deleteConfirm':
|
||||
'Are you sure you want to delete NPC “{name}”? All of their relations will be removed.',
|
||||
'npcs.relationCreateTitle': 'Relation name',
|
||||
'npcs.relationEditTitle': 'Relation name',
|
||||
'npcs.relationLabel': 'NAME',
|
||||
@@ -1036,12 +1150,6 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'Delete group “{name}”? NPCs will become ungrouped; child groups will move up one level.',
|
||||
'npcs.addSubgroup': 'Add subgroup',
|
||||
'npcs.group': 'GROUP',
|
||||
'npcs.bindingEnable': 'Bind…',
|
||||
'npcs.bindingKind': 'BINDING TYPE',
|
||||
'npcs.bindingStoryline': 'Storyline',
|
||||
'npcs.bindingScene': 'Scene',
|
||||
'npcs.bindingMain': 'Main storyline',
|
||||
'npcs.bindingSelect': 'TARGET',
|
||||
'npcs.graphFilterAll': 'All',
|
||||
'npcs.graphFilterUngrouped': 'Ungrouped',
|
||||
'npcs.graphFilter': 'Graph filter',
|
||||
@@ -1123,6 +1231,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.darkness': 'Darkness',
|
||||
'control.darknessControl': 'Darkness control',
|
||||
'control.explorerBrush': 'Opening brush',
|
||||
'control.closerBrush': 'Closing brush',
|
||||
'control.lightning': 'Lightning',
|
||||
'control.sunbeam': 'Sunbeam',
|
||||
'control.freeze': 'Freeze',
|
||||
@@ -1136,7 +1245,12 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.passed': 'Visited',
|
||||
'control.noActiveScene': 'No active scene.',
|
||||
'control.screenPreview': 'Screen preview',
|
||||
'control.stopPresentation': 'Stop presentation',
|
||||
'control.npcTokenScale': 'Play token size',
|
||||
'control.snapTokensToGrid': 'Snap tokens to grid',
|
||||
'control.snapTokensToGridNoGrid': 'Grid is off on this scene — snap has no effect',
|
||||
'control.stopPresentation': 'Turn off',
|
||||
'control.showPlayers': 'Show players',
|
||||
'control.hidePlayers': 'Hide players',
|
||||
'control.videoBrushHint':
|
||||
'Video preview: effect brush is disabled (like on the presentation screen — overlay is for images only).',
|
||||
'control.branches': 'Branch options',
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||
import { EditorApp } from './EditorApp';
|
||||
import { EditorI18nProvider } from './i18n/EditorI18nContext';
|
||||
|
||||
@@ -12,8 +13,10 @@ if (!rootEl) {
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<EditorI18nProvider>
|
||||
<EditorApp />
|
||||
</EditorI18nProvider>
|
||||
<WindowErrorBoundary title="Редактор">
|
||||
<EditorI18nProvider>
|
||||
<EditorApp />
|
||||
</EditorI18nProvider>
|
||||
</WindowErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
} from '../../../shared/types';
|
||||
import { getDndApi } from '../../shared/dndApi';
|
||||
import { invalidateAssetUrlCache } from '../../shared/useAssetImageUrl';
|
||||
import { applyPreviewRotationToSceneMarkers } from '../../../shared/types/scenePreviewRotation';
|
||||
|
||||
type ProjectSummary = { id: ProjectId; name: string; updatedAt: string; fileName: string };
|
||||
|
||||
@@ -50,11 +51,7 @@ 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>;
|
||||
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>;
|
||||
@@ -104,7 +101,10 @@ type Actions = {
|
||||
mode: 'folder' | 'archive',
|
||||
) => Promise<{ canceled: true } | { canceled: false; sourcePath: string }>;
|
||||
importFoundryProject: (sourcePath: string) => Promise<void>;
|
||||
peekImportZip: (labels: StorylineLabels, targetHasMainStart: boolean) => Promise<
|
||||
peekImportZip: (
|
||||
labels: StorylineLabels,
|
||||
targetHasMainStart: boolean,
|
||||
) => Promise<
|
||||
| { canceled: true }
|
||||
| {
|
||||
canceled: false;
|
||||
@@ -148,10 +148,14 @@ type Actions = {
|
||||
npcResolutions?: NpcImportResolution[],
|
||||
) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
|
||||
importProjectFromPath: (filePath: string) => Promise<void>;
|
||||
getProjectStorylines: (projectId: ProjectId, labels: StorylineLabels) => Promise<StorylineListItem[]>;
|
||||
getProjectStorylines: (
|
||||
projectId: ProjectId,
|
||||
labels: StorylineLabels,
|
||||
) => Promise<{ storylines: StorylineListItem[]; npcs: { id: string; name: string }[] }>;
|
||||
exportProject: (
|
||||
projectId: ProjectId,
|
||||
storylineSelections: StorylineSelection[],
|
||||
npcIds: string[],
|
||||
labels: StorylineLabels,
|
||||
) => Promise<void>;
|
||||
deleteProject: (projectId: ProjectId) => Promise<void>;
|
||||
@@ -351,6 +355,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
darkenScene: false,
|
||||
traps: [],
|
||||
tokens: [],
|
||||
npcTokens: [],
|
||||
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
|
||||
media: { videos: [], audios: [] },
|
||||
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
|
||||
@@ -505,11 +510,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const upsertMaterial = async (input: {
|
||||
materialId?: MaterialId;
|
||||
name: string;
|
||||
filePath?: string;
|
||||
}) => {
|
||||
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();
|
||||
@@ -527,10 +528,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const setMaterialRotation = async (
|
||||
materialId: MaterialId,
|
||||
rotationDeg: 0 | 90 | 180 | 270,
|
||||
) => {
|
||||
const setMaterialRotation = async (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => {
|
||||
const res = await api.invoke(ipcChannels.project.setMaterialRotation, {
|
||||
materialId,
|
||||
rotationDeg,
|
||||
@@ -566,6 +564,9 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
previewRotationDeg?: 0 | 90 | 180 | 270;
|
||||
darkenScene?: boolean;
|
||||
traps?: import('../../../shared/types').SceneTrap[];
|
||||
tokens?: import('../../../shared/types').SceneToken[];
|
||||
npcTokens?: import('../../../shared/types').SceneNpcToken[];
|
||||
grid?: import('../../../shared/types').SceneGrid;
|
||||
settings?: Partial<Scene['settings']>;
|
||||
media?: Partial<Scene['media']>;
|
||||
layout?: { x: number; y: number };
|
||||
@@ -594,11 +595,31 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
|
||||
...(patch.traps !== undefined ? { traps: patch.traps } : null),
|
||||
...(patch.tokens !== undefined ? { tokens: patch.tokens } : null),
|
||||
...(patch.npcTokens !== undefined ? { npcTokens: patch.npcTokens } : null),
|
||||
...(patch.grid !== undefined ? { grid: patch.grid } : null),
|
||||
...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null),
|
||||
...(patch.media ? { media: { ...scene.media, ...patch.media } } : null),
|
||||
layout: patch.layout ? { ...scene.layout, ...patch.layout } : scene.layout,
|
||||
};
|
||||
if (patch.previewRotationDeg !== undefined) {
|
||||
const remapped = applyPreviewRotationToSceneMarkers(
|
||||
{
|
||||
previewRotationDeg: scene.previewRotationDeg ?? 0,
|
||||
tokens: scene.tokens ?? [],
|
||||
npcTokens: scene.npcTokens ?? [],
|
||||
traps: scene.traps ?? [],
|
||||
},
|
||||
patch.previewRotationDeg,
|
||||
{
|
||||
tokensProvided: patch.tokens !== undefined,
|
||||
npcTokensProvided: patch.npcTokens !== undefined,
|
||||
trapsProvided: patch.traps !== undefined,
|
||||
},
|
||||
);
|
||||
if (patch.tokens === undefined) next.tokens = remapped.tokens;
|
||||
if (patch.npcTokens === undefined) next.npcTokens = remapped.npcTokens;
|
||||
if (patch.traps === undefined) next.traps = remapped.traps;
|
||||
}
|
||||
const scenes = { ...p.scenes, [sceneId]: next };
|
||||
const project: Project = { ...p, scenes };
|
||||
return { ...s, project };
|
||||
@@ -869,18 +890,23 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
|
||||
const getProjectStorylines = async (projectId: ProjectId, labels: StorylineLabels) => {
|
||||
const res = await api.invoke(ipcChannels.project.getProjectStorylines, { projectId, labels });
|
||||
return res.storylines;
|
||||
return {
|
||||
storylines: Array.isArray(res?.storylines) ? res.storylines : [],
|
||||
npcs: Array.isArray(res?.npcs) ? res.npcs : [],
|
||||
};
|
||||
};
|
||||
|
||||
const exportProject = async (
|
||||
projectId: ProjectId,
|
||||
storylineSelections: StorylineSelection[],
|
||||
npcIds: string[],
|
||||
labels: StorylineLabels,
|
||||
) => {
|
||||
try {
|
||||
const res = await api.invoke(ipcChannels.project.exportZip, {
|
||||
projectId,
|
||||
storylineSelections,
|
||||
npcIds,
|
||||
labels,
|
||||
});
|
||||
if (res.canceled) return;
|
||||
|
||||
@@ -59,7 +59,7 @@ export function MaterialsApp() {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (overlay?.activeMaterialId) {
|
||||
if ((overlay?.activeMaterialIds?.length ?? 0) > 0) {
|
||||
void overlayApi.dispatch({ kind: 'hide' });
|
||||
return;
|
||||
}
|
||||
@@ -72,10 +72,10 @@ export function MaterialsApp() {
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [overlay?.activeMaterialId, overlay?.zoomTool, overlayApi]);
|
||||
}, [overlay?.activeMaterialIds, overlay?.zoomTool, overlayApi]);
|
||||
|
||||
const materials = session?.project?.materials ?? [];
|
||||
const activeId = overlay?.activeMaterialId ?? null;
|
||||
const activeIds = overlay?.activeMaterialIds ?? [];
|
||||
const zoomTool = overlay?.zoomTool ?? null;
|
||||
|
||||
return (
|
||||
@@ -88,7 +88,7 @@ export function MaterialsApp() {
|
||||
materials={materials}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
activeMaterialId={activeId}
|
||||
activeMaterialIds={activeIds}
|
||||
onTileActivate={(id) => {
|
||||
const mat = materials.find((m) => m.id === id);
|
||||
void overlayApi.dispatch({
|
||||
@@ -99,7 +99,7 @@ export function MaterialsApp() {
|
||||
}}
|
||||
toolbar={
|
||||
<>
|
||||
<div className={matStyles.browserToolbarRow}>
|
||||
<div className={matStyles.browserToolbarZoomRow}>
|
||||
<Button
|
||||
variant={zoomTool === 'zoomIn' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
@@ -131,17 +131,19 @@ export function MaterialsApp() {
|
||||
<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>
|
||||
{activeIds.length > 0 ? (
|
||||
<div className={matStyles.browserToolbarFullBtn}>
|
||||
<Button
|
||||
title={t('materials.closeOverlay')}
|
||||
ariaLabel={t('materials.closeOverlay')}
|
||||
tooltipPlacement="bottom"
|
||||
onClick={() => {
|
||||
void overlayApi.dispatch({ kind: 'hide' });
|
||||
}}
|
||||
>
|
||||
{t('materials.closeOverlay')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={matStyles.browserToolbarHint}>
|
||||
{zoomTool === 'zoomIn'
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||
|
||||
import { MaterialsApp } from './MaterialsApp';
|
||||
|
||||
@@ -13,8 +14,10 @@ if (!rootEl) {
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<EditorI18nProvider>
|
||||
<MaterialsApp />
|
||||
</EditorI18nProvider>
|
||||
<WindowErrorBoundary title="Материалы">
|
||||
<EditorI18nProvider>
|
||||
<MaterialsApp />
|
||||
</EditorI18nProvider>
|
||||
</WindowErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { isNpcBindingNone, listStorylineOptionsForBinding, noneBinding } from '../../shared/npcs/npcBinding';
|
||||
import type { GraphNodeId, NpcBinding, Project, SceneId } from '../../shared/types';
|
||||
import editorStyles from '../editor/EditorApp.module.css';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { Select } from '../shared/ui/controls';
|
||||
|
||||
type NpcBindingFieldsProps = {
|
||||
project: Project;
|
||||
binding: NpcBinding;
|
||||
onChange: (binding: NpcBinding) => void;
|
||||
};
|
||||
|
||||
function defaultBinding(project: Project): NpcBinding {
|
||||
const opts = listStorylineOptionsForBinding(project);
|
||||
if (opts.main) return { kind: 'storyline', storyline: { kind: 'main' } };
|
||||
if (opts.sides[0]) {
|
||||
return {
|
||||
kind: 'storyline',
|
||||
storyline: { kind: 'side', startGraphNodeId: opts.sides[0].startGraphNodeId },
|
||||
};
|
||||
}
|
||||
const firstScene = Object.keys(project.scenes)[0] as SceneId | undefined;
|
||||
if (firstScene) return { kind: 'scene', sceneId: firstScene };
|
||||
return noneBinding();
|
||||
}
|
||||
|
||||
export function NpcBindingFields({ project, binding, onChange }: NpcBindingFieldsProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const enabled = !isNpcBindingNone(binding);
|
||||
const storylineOpts = useMemo(() => listStorylineOptionsForBinding(project), [project]);
|
||||
const sceneOptions = useMemo(
|
||||
() =>
|
||||
Object.entries(project.scenes)
|
||||
.map(([id, scene]) => ({ id: id as SceneId, title: scene.title.trim() || id }))
|
||||
.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })),
|
||||
[project.scenes],
|
||||
);
|
||||
|
||||
const kind = binding.kind === 'none' ? 'storyline' : binding.kind;
|
||||
|
||||
const bindingTargetValue = useMemo(() => {
|
||||
if (binding.kind === 'scene') return binding.sceneId;
|
||||
if (binding.kind === 'storyline') {
|
||||
if (binding.storyline.kind === 'main') return 'main';
|
||||
return `side:${binding.storyline.startGraphNodeId}`;
|
||||
}
|
||||
return '';
|
||||
}, [binding]);
|
||||
|
||||
return (
|
||||
<div className={editorStyles.fieldGrid}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.checked ? defaultBinding(project) : noneBinding());
|
||||
}}
|
||||
/>
|
||||
<span>{t('npcs.bindingEnable')}</span>
|
||||
</label>
|
||||
|
||||
{enabled ? (
|
||||
<>
|
||||
<div className={editorStyles.fieldLabel}>{t('npcs.bindingKind')}</div>
|
||||
<Select
|
||||
value={kind}
|
||||
ariaLabel={t('npcs.bindingKind')}
|
||||
options={[
|
||||
{ value: 'storyline', label: t('npcs.bindingStoryline') },
|
||||
{ value: 'scene', label: t('npcs.bindingScene') },
|
||||
]}
|
||||
onChange={(nextKind) => {
|
||||
if (nextKind === 'scene') {
|
||||
const first = sceneOptions[0];
|
||||
onChange(first ? { kind: 'scene', sceneId: first.id } : noneBinding());
|
||||
return;
|
||||
}
|
||||
if (storylineOpts.main) {
|
||||
onChange({ kind: 'storyline', storyline: { kind: 'main' } });
|
||||
} else if (storylineOpts.sides[0]) {
|
||||
onChange({
|
||||
kind: 'storyline',
|
||||
storyline: { kind: 'side', startGraphNodeId: storylineOpts.sides[0].startGraphNodeId },
|
||||
});
|
||||
} else {
|
||||
onChange(noneBinding());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={editorStyles.fieldLabel}>{t('npcs.bindingSelect')}</div>
|
||||
<Select
|
||||
value={bindingTargetValue}
|
||||
ariaLabel={t('npcs.bindingSelect')}
|
||||
options={
|
||||
kind === 'storyline'
|
||||
? [
|
||||
...(storylineOpts.main
|
||||
? [{ value: 'main', label: t('npcs.bindingMain') }]
|
||||
: []),
|
||||
...storylineOpts.sides.map((s) => ({
|
||||
value: `side:${s.startGraphNodeId}`,
|
||||
label: s.label,
|
||||
})),
|
||||
]
|
||||
: sceneOptions.map((s) => ({ value: s.id, label: s.title }))
|
||||
}
|
||||
onChange={(v) => {
|
||||
if (kind === 'scene') {
|
||||
onChange({ kind: 'scene', sceneId: v as SceneId });
|
||||
return;
|
||||
}
|
||||
if (v === 'main') {
|
||||
onChange({ kind: 'storyline', storyline: { kind: 'main' } });
|
||||
return;
|
||||
}
|
||||
if (v.startsWith('side:')) {
|
||||
onChange({
|
||||
kind: 'storyline',
|
||||
storyline: {
|
||||
kind: 'side',
|
||||
startGraphNodeId: v.slice(5) as GraphNodeId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import modalStyles from '../editor/SceneDescriptionModal.module.css';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
|
||||
import styles from './NpcsEditorApp.module.css';
|
||||
import { readTipTapHtmlSafe } from './tiptapEditorSafe';
|
||||
|
||||
type NpcDescriptionFieldProps = {
|
||||
html: string;
|
||||
@@ -59,7 +60,8 @@ export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps
|
||||
const editor = useEditor({
|
||||
extensions,
|
||||
content: html || '',
|
||||
immediatelyRender: true,
|
||||
// StrictMode + true даёт destroy/recreate с null schema → падение getHTML (чёрный экран окна НПС).
|
||||
immediatelyRender: false,
|
||||
shouldRerenderOnTransaction: true,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
@@ -68,13 +70,17 @@ export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps
|
||||
},
|
||||
},
|
||||
onBlur: ({ editor: ed }) => {
|
||||
onCommit(normalizeSceneDescriptionHtml(ed.getHTML()));
|
||||
const raw = readTipTapHtmlSafe(ed);
|
||||
if (raw == null) return;
|
||||
onCommit(normalizeSceneDescriptionHtml(raw));
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const current = normalizeSceneDescriptionHtml(editor.getHTML());
|
||||
if (!editor || editor.isDestroyed) return;
|
||||
const raw = readTipTapHtmlSafe(editor);
|
||||
if (raw == null) return;
|
||||
const current = normalizeSceneDescriptionHtml(raw);
|
||||
const next = normalizeSceneDescriptionHtml(html);
|
||||
if (current !== next) {
|
||||
editor.commands.setContent(html || '', { emitUpdate: false });
|
||||
@@ -84,30 +90,30 @@ export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps
|
||||
const toolbarState = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor: ed }) => ({
|
||||
bold: ed.isActive('bold'),
|
||||
italic: ed.isActive('italic'),
|
||||
bulletList: ed.isActive('bulletList'),
|
||||
orderedList: ed.isActive('orderedList'),
|
||||
h2: ed.isActive('heading', { level: 2 }),
|
||||
h3: ed.isActive('heading', { level: 3 }),
|
||||
bold: Boolean(ed && !ed.isDestroyed && ed.isActive('bold')),
|
||||
italic: Boolean(ed && !ed.isDestroyed && ed.isActive('italic')),
|
||||
bulletList: Boolean(ed && !ed.isDestroyed && ed.isActive('bulletList')),
|
||||
orderedList: Boolean(ed && !ed.isDestroyed && ed.isActive('orderedList')),
|
||||
h2: Boolean(ed && !ed.isDestroyed && ed.isActive('heading', { level: 2 })),
|
||||
h3: Boolean(ed && !ed.isDestroyed && ed.isActive('heading', { level: 3 })),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!editor) return null;
|
||||
if (!editor || editor.isDestroyed) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.descShell}>
|
||||
<div className={modalStyles.toolbar}>
|
||||
<div className={modalStyles.toolbarGroup}>
|
||||
<ToolButton
|
||||
active={toolbarState.bold}
|
||||
active={toolbarState?.bold ?? false}
|
||||
title={t('scene.descriptionBold')}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
>
|
||||
B
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
active={toolbarState.italic}
|
||||
active={toolbarState?.italic ?? false}
|
||||
title={t('scene.descriptionItalic')}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
>
|
||||
@@ -117,14 +123,14 @@ export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps
|
||||
<div className={modalStyles.toolbarSep} />
|
||||
<div className={modalStyles.toolbarGroup}>
|
||||
<ToolButton
|
||||
active={toolbarState.h2}
|
||||
active={toolbarState?.h2 ?? false}
|
||||
title={t('scene.descriptionHeading2')}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
>
|
||||
H2
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
active={toolbarState.h3}
|
||||
active={toolbarState?.h3 ?? false}
|
||||
title={t('scene.descriptionHeading3')}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
>
|
||||
@@ -134,14 +140,14 @@ export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps
|
||||
<div className={modalStyles.toolbarSep} />
|
||||
<div className={modalStyles.toolbarGroup}>
|
||||
<ToolButton
|
||||
active={toolbarState.bulletList}
|
||||
active={toolbarState?.bulletList ?? false}
|
||||
title={t('scene.descriptionBulletList')}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
>
|
||||
•
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
active={toolbarState.orderedList}
|
||||
active={toolbarState?.orderedList ?? false}
|
||||
title={t('scene.descriptionOrderedList')}
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { createPortal, flushSync } from 'react-dom';
|
||||
|
||||
import { noneBinding } from '../../shared/npcs/npcBinding';
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import { buildNpcGroupForest } from '../../shared/npcs/npcGroups';
|
||||
import type { NpcBinding, NpcGroupId, Project, ProjectNpc, ProjectNpcGroup } from '../../shared/types';
|
||||
import type { NpcGroupId, ProjectNpc, ProjectNpcGroup } from '../../shared/types';
|
||||
import editorStyles from '../editor/EditorApp.module.css';
|
||||
import {
|
||||
filterMaterialImagePaths,
|
||||
@@ -13,11 +13,10 @@ import {
|
||||
} from '../editor/fileDrop';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import matStyles from '../editor/MaterialsModals.module.css';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { Button, Input, Select } from '../shared/ui/controls';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import { NpcBindingFields } from './NpcBindingFields';
|
||||
|
||||
function normalizeName(input: string): string {
|
||||
return input.trim().toLowerCase();
|
||||
}
|
||||
@@ -39,7 +38,6 @@ type NpcEditModalProps = {
|
||||
open: boolean;
|
||||
initial: ProjectNpc | null;
|
||||
existingNames: string[];
|
||||
project: Project | null;
|
||||
npcGroups: ProjectNpcGroup[];
|
||||
onClose: () => void;
|
||||
onPickImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>;
|
||||
@@ -47,7 +45,6 @@ type NpcEditModalProps = {
|
||||
name: string;
|
||||
filePath?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
binding?: NpcBinding;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -55,19 +52,19 @@ export function NpcEditModal({
|
||||
open,
|
||||
initial,
|
||||
existingNames,
|
||||
project,
|
||||
npcGroups,
|
||||
onClose,
|
||||
onPickImage,
|
||||
onSave,
|
||||
}: NpcEditModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const api = getDndApi();
|
||||
const [name, setName] = useState('');
|
||||
const [filePath, setFilePath] = useState<string | null>(null);
|
||||
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
|
||||
const [groupId, setGroupId] = useState<NpcGroupId | ''>('');
|
||||
const [binding, setBinding] = useState<NpcBinding>(noneBinding());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveProgress, setSaveProgress] = useState<{ percent: number; detail: string } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const existingUrl = useAssetUrl(initial?.avatarAssetId ?? null);
|
||||
|
||||
@@ -82,8 +79,8 @@ export function NpcEditModal({
|
||||
setFilePath(null);
|
||||
setLocalPreviewUrl(null);
|
||||
setGroupId(initial?.groupId ?? '');
|
||||
setBinding(initial?.binding ?? noneBinding());
|
||||
setSaving(false);
|
||||
setSaveProgress(null);
|
||||
setError(null);
|
||||
}, [initial, open]);
|
||||
|
||||
@@ -93,14 +90,24 @@ export function NpcEditModal({
|
||||
};
|
||||
}, [localPreviewUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
return api.on(ipcChannels.project.npcUpsertProgress, (evt) => {
|
||||
setSaveProgress({
|
||||
percent: Math.max(0, Math.min(100, Math.round(evt.percent))),
|
||||
detail: evt.detail?.trim() || t('npcs.savingWait'),
|
||||
});
|
||||
});
|
||||
}, [api, open, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Escape' && !saving) onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, open]);
|
||||
}, [onClose, open, saving]);
|
||||
|
||||
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
|
||||
setFilePath(path);
|
||||
@@ -131,6 +138,8 @@ export function NpcEditModal({
|
||||
const hasImage = Boolean(filePath) || Boolean(initial?.avatarAssetId);
|
||||
const canSave = nameOk && !nameDup && hasImage && !saving;
|
||||
const previewSrc = localPreviewUrl ?? existingUrl;
|
||||
const progressPercent = saveProgress?.percent ?? (saving ? 0 : 0);
|
||||
const progressDetail = saveProgress?.detail ?? t('npcs.savingWait');
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -139,7 +148,9 @@ export function NpcEditModal({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
onClick={() => {
|
||||
if (!saving) onClose();
|
||||
}}
|
||||
className={editorStyles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
|
||||
@@ -148,8 +159,11 @@ export function NpcEditModal({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
onClick={() => {
|
||||
if (!saving) onClose();
|
||||
}}
|
||||
className={editorStyles.modalClose}
|
||||
disabled={saving}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -208,6 +222,7 @@ export function NpcEditModal({
|
||||
<div className={matStyles.imageDropEmpty}>
|
||||
<div className={editorStyles.muted}>{t('npcs.avatarEmpty')}</div>
|
||||
<Button
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
const picked = await onPickImage();
|
||||
@@ -222,6 +237,7 @@ export function NpcEditModal({
|
||||
)}
|
||||
{previewSrc ? (
|
||||
<Button
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
const picked = await onPickImage();
|
||||
@@ -237,10 +253,6 @@ export function NpcEditModal({
|
||||
{!hasImage ? <div className={editorStyles.fieldError}>{t('npcs.avatarRequired')}</div> : null}
|
||||
</div>
|
||||
|
||||
{project && !initial ? (
|
||||
<NpcBindingFields project={project} binding={binding} onChange={setBinding} />
|
||||
) : null}
|
||||
|
||||
{error ? <div className={editorStyles.fieldError}>{error}</div> : null}
|
||||
|
||||
<div className={editorStyles.modalFooter}>
|
||||
@@ -253,27 +265,54 @@ export function NpcEditModal({
|
||||
onClick={() => {
|
||||
if (!canSave) return;
|
||||
void (async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
flushSync(() => {
|
||||
setSaving(true);
|
||||
setSaveProgress({ percent: 0, detail: t('npcs.savingWait') });
|
||||
setError(null);
|
||||
});
|
||||
try {
|
||||
await onSave({
|
||||
name: trimmed,
|
||||
...(filePath ? { filePath } : {}),
|
||||
...(!initial ? { groupId: groupId || null, binding } : {}),
|
||||
...(!initial ? { groupId: groupId || null } : {}),
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setSaveProgress(null);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('common.save')}
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{saving ? (
|
||||
<div
|
||||
className={editorStyles.progressOverlay}
|
||||
role="dialog"
|
||||
aria-label={t('npcs.savingProgress')}
|
||||
aria-busy
|
||||
>
|
||||
<div className={editorStyles.progressModal}>
|
||||
<div className={editorStyles.progressTitle}>{t('npcs.savingTitle')}</div>
|
||||
<div className={editorStyles.previewSpinner} aria-hidden />
|
||||
<div className={editorStyles.progressBar}>
|
||||
<div
|
||||
className={editorStyles.progressFill}
|
||||
style={{ width: `${String(Math.max(0, Math.min(100, progressPercent)))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={editorStyles.progressMeta}>
|
||||
<div>{progressDetail}</div>
|
||||
<div>{progressPercent}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
@@ -302,7 +302,7 @@ function FilterToolbar({
|
||||
return (
|
||||
<Panel position="top-left">
|
||||
<Select
|
||||
className={styles.filterSelect}
|
||||
{...(styles.filterSelect ? { className: styles.filterSelect } : {})}
|
||||
ariaLabel={ui.graphFilter}
|
||||
value={value}
|
||||
onChange={(next) => onChange(next as GraphGroupFilter)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
||||
import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups';
|
||||
import type { NpcGroupId, NpcId, ProjectNpc } from '../../shared/types';
|
||||
import matStyles from '../editor/MaterialsModals.module.css';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
@@ -12,6 +13,32 @@ import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import styles from './NpcsApp.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>
|
||||
);
|
||||
}
|
||||
|
||||
/** Убрать пустые ветки групп (удобно при поиске). */
|
||||
function pruneEmptyGroupNodes(nodes: NpcGroupTreeNode[]): NpcGroupTreeNode[] {
|
||||
const out: NpcGroupTreeNode[] = [];
|
||||
@@ -137,12 +164,18 @@ export function NpcsApp() {
|
||||
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);
|
||||
}, [hasActive, overlayApi]);
|
||||
}, [hasActive, overlay?.zoomTool, overlayApi]);
|
||||
|
||||
const zoomTool = overlay?.zoomTool ?? null;
|
||||
|
||||
const npcs = useMemo(() => session?.project?.npcs ?? [], [session?.project?.npcs]);
|
||||
const npcGroups = useMemo(() => session?.project?.npcGroups ?? [], [session?.project?.npcGroups]);
|
||||
@@ -152,6 +185,15 @@ export function NpcsApp() {
|
||||
() => activeIds.map((id) => npcs.find((n) => n.id === id)).filter((n): n is ProjectNpc => Boolean(n)),
|
||||
[activeIds, npcs],
|
||||
);
|
||||
/** Описание: сфокусированный НПС (открытие информации / последний выбор), иначе все активные. */
|
||||
const detailNpcs = useMemo(() => {
|
||||
const focusId = overlay?.focusNpcId ?? null;
|
||||
if (focusId) {
|
||||
const focused = npcs.find((n) => n.id === focusId);
|
||||
if (focused) return [focused];
|
||||
}
|
||||
return selectedNpcs;
|
||||
}, [npcs, overlay?.focusNpcId, selectedNpcs]);
|
||||
|
||||
const filteredNpcs = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
@@ -193,7 +235,7 @@ export function NpcsApp() {
|
||||
|
||||
const relationsByNpcId = useMemo(() => {
|
||||
const map = new Map<NpcId, { id: string; text: string }[]>();
|
||||
for (const npc of selectedNpcs) {
|
||||
for (const npc of detailNpcs) {
|
||||
const list = relations
|
||||
.filter((r) => r.sourceNpcId === npc.id)
|
||||
.map((r) => {
|
||||
@@ -203,7 +245,7 @@ export function NpcsApp() {
|
||||
map.set(npc.id, list);
|
||||
}
|
||||
return map;
|
||||
}, [npcs, relations, selectedNpcs]);
|
||||
}, [detailNpcs, npcs, relations]);
|
||||
|
||||
const onSelectTile = useCallback(
|
||||
(id: NpcId) => {
|
||||
@@ -215,6 +257,45 @@ export function NpcsApp() {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.toolbar}>
|
||||
<div className={matStyles.browserToolbarRow}>
|
||||
<Button
|
||||
variant={zoomTool === 'zoomIn' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title={t('npcs.zoomIn')}
|
||||
ariaLabel={t('npcs.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('npcs.zoomOut')}
|
||||
ariaLabel={t('npcs.zoomOut')}
|
||||
tooltipPlacement="bottom"
|
||||
onClick={() => {
|
||||
void overlayApi.dispatch({
|
||||
kind: 'zoomTool.set',
|
||||
tool: zoomTool === 'zoomOut' ? null : 'zoomOut',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ZoomOutIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div className={matStyles.browserToolbarHint}>
|
||||
{zoomTool === 'zoomIn'
|
||||
? t('npcs.zoomInHint')
|
||||
: zoomTool === 'zoomOut'
|
||||
? t('npcs.zoomOutHint')
|
||||
: t('npcs.zoomIdleHint')}
|
||||
</div>
|
||||
<div className={styles.toolbarRow}>
|
||||
<Button
|
||||
title={t('npcs.closeOverlay')}
|
||||
@@ -232,8 +313,8 @@ export function NpcsApp() {
|
||||
|
||||
<div className={styles.body}>
|
||||
<div className={styles.detail}>
|
||||
{selectedNpcs.length > 0 ? (
|
||||
selectedNpcs.map((npc) => {
|
||||
{detailNpcs.length > 0 ? (
|
||||
detailNpcs.map((npc) => {
|
||||
const safeHtml = sanitizeSceneDescriptionHtml(npc.description);
|
||||
const npcRelations = relationsByNpcId.get(npc.id) ?? [];
|
||||
return (
|
||||
|
||||
@@ -32,6 +32,16 @@
|
||||
border-right: 1px solid var(--stroke);
|
||||
}
|
||||
|
||||
.graphLoading {
|
||||
height: 100%;
|
||||
min-height: 240px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text2);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.col:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
@@ -179,6 +189,15 @@
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.colorInput {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.avatarPick {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
||||
import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups';
|
||||
import type {
|
||||
NpcBinding,
|
||||
NpcGroupId,
|
||||
NpcId,
|
||||
NpcRelationId,
|
||||
@@ -15,13 +14,15 @@ import type {
|
||||
import editorStyles from '../editor/EditorApp.module.css';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
|
||||
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
|
||||
import { Button, Input, Select } from '../shared/ui/controls';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
import { npcDispositionRingColor } from '../../shared/types/npcDisposition';
|
||||
|
||||
import { NpcBindingFields } from './NpcBindingFields';
|
||||
import { NpcDescriptionField } from './NpcDescriptionField';
|
||||
import { NpcEditModal } from './NpcEditModal';
|
||||
import { NpcGraph, type GraphGroupFilter } from './NpcGraph';
|
||||
import type { GraphGroupFilter } from './NpcGraph';
|
||||
import { NpcGroupModal } from './NpcGroupModal';
|
||||
import {
|
||||
flattenGroupOptions,
|
||||
@@ -32,6 +33,11 @@ import {
|
||||
import { NpcRelationModal } from './NpcRelationModal';
|
||||
import styles from './NpcsEditorApp.module.css';
|
||||
|
||||
const NpcGraph = lazy(async () => {
|
||||
const mod = await import('./NpcGraph');
|
||||
return { default: mod.NpcGraph };
|
||||
});
|
||||
|
||||
const DND_NPC_ID_MIME = 'application/x-dnd-npc-id';
|
||||
const DND_NPC_GROUP_ID_MIME = 'application/x-dnd-npc-group-id';
|
||||
|
||||
@@ -671,56 +677,58 @@ export function NpcsEditorApp() {
|
||||
</div>
|
||||
|
||||
<div className={styles.col}>
|
||||
<NpcGraph
|
||||
npcs={npcs}
|
||||
relations={relations}
|
||||
npcGroups={npcGroups}
|
||||
selectedNpcId={selectedId}
|
||||
graphFilter={graphFilter}
|
||||
onGraphFilterChange={setGraphFilter}
|
||||
graphUi={graphUi}
|
||||
onSelect={setSelectedId}
|
||||
onConnectRequest={(sourceNpcId, targetNpcId) => {
|
||||
setRelationModal({ mode: 'create', sourceNpcId, targetNpcId });
|
||||
}}
|
||||
onNodePositionCommit={(npcId, x, y) => {
|
||||
void (async () => {
|
||||
setSession((prev) => {
|
||||
if (!prev?.project) return prev;
|
||||
return {
|
||||
...prev,
|
||||
project: {
|
||||
...prev.project,
|
||||
npcs: prev.project.npcs.map((n) => (n.id === npcId ? { ...n, x, y } : n)),
|
||||
},
|
||||
};
|
||||
});
|
||||
try {
|
||||
const res = await api.invoke(ipcChannels.project.updateNpcPosition, {
|
||||
npcId,
|
||||
x,
|
||||
y,
|
||||
<Suspense fallback={<div className={styles.graphLoading}>{t('npcs.graphLoading')}</div>}>
|
||||
<NpcGraph
|
||||
npcs={npcs}
|
||||
relations={relations}
|
||||
npcGroups={npcGroups}
|
||||
selectedNpcId={selectedId}
|
||||
graphFilter={graphFilter}
|
||||
onGraphFilterChange={setGraphFilter}
|
||||
graphUi={graphUi}
|
||||
onSelect={setSelectedId}
|
||||
onConnectRequest={(sourceNpcId, targetNpcId) => {
|
||||
setRelationModal({ mode: 'create', sourceNpcId, targetNpcId });
|
||||
}}
|
||||
onNodePositionCommit={(npcId, x, y) => {
|
||||
void (async () => {
|
||||
setSession((prev) => {
|
||||
if (!prev?.project) return prev;
|
||||
return {
|
||||
...prev,
|
||||
project: {
|
||||
...prev.project,
|
||||
npcs: prev.project.npcs.map((n) => (n.id === npcId ? { ...n, x, y } : n)),
|
||||
},
|
||||
};
|
||||
});
|
||||
setSession({
|
||||
project: res.project,
|
||||
currentSceneId: res.project?.currentSceneId ?? null,
|
||||
});
|
||||
} catch {
|
||||
/* позиция уже оптимистично в UI; следующий session sync поправит при CRUD */
|
||||
}
|
||||
})();
|
||||
}}
|
||||
onEditRelation={(relationId) => {
|
||||
const rel = relations.find((r) => r.id === relationId);
|
||||
if (!rel) return;
|
||||
setRelationModal({ mode: 'edit', relationId, label: rel.label });
|
||||
}}
|
||||
onDeleteRelation={(relationId) => {
|
||||
const rel = relations.find((r) => r.id === relationId);
|
||||
if (!rel) return;
|
||||
setPendingDeleteRelation(rel);
|
||||
}}
|
||||
/>
|
||||
try {
|
||||
const res = await api.invoke(ipcChannels.project.updateNpcPosition, {
|
||||
npcId,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
setSession({
|
||||
project: res.project,
|
||||
currentSceneId: res.project?.currentSceneId ?? null,
|
||||
});
|
||||
} catch {
|
||||
/* позиция уже оптимистично в UI; следующий session sync поправит при CRUD */
|
||||
}
|
||||
})();
|
||||
}}
|
||||
onEditRelation={(relationId) => {
|
||||
const rel = relations.find((r) => r.id === relationId);
|
||||
if (!rel) return;
|
||||
setRelationModal({ mode: 'edit', relationId, label: rel.label });
|
||||
}}
|
||||
onDeleteRelation={(relationId) => {
|
||||
const rel = relations.find((r) => r.id === relationId);
|
||||
if (!rel) return;
|
||||
setPendingDeleteRelation(rel);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div className={[styles.col, styles.inspector].join(' ')}>
|
||||
@@ -730,11 +738,35 @@ export function NpcsEditorApp() {
|
||||
<div>
|
||||
<div className={styles.fieldLabel}>{t('npcs.avatar')}</div>
|
||||
<div className={styles.avatarPick}>
|
||||
<div className={styles.avatarPreview}>
|
||||
{selectedUrl ? (
|
||||
<img className={styles.avatarPreviewImg} src={selectedUrl} alt="" />
|
||||
) : null}
|
||||
</div>
|
||||
{USERS_BRANCH_FEATURES_ENABLED ? (
|
||||
<PlayerTokenView
|
||||
name={selected.name}
|
||||
imageUrl={selectedUrl}
|
||||
ringColor={npcDispositionRingColor(selected.disposition ?? 'neutral')}
|
||||
imageOffset={selected.imageOffset}
|
||||
imageScale={selected.imageScale}
|
||||
sizePx={140}
|
||||
panEnabled
|
||||
onImageOffsetChange={(imageOffset) => {
|
||||
void api.invoke(ipcChannels.project.updateNpcFields, {
|
||||
npcId: selected.id,
|
||||
imageOffset,
|
||||
});
|
||||
}}
|
||||
onImageScaleChange={(imageScale) => {
|
||||
void api.invoke(ipcChannels.project.updateNpcFields, {
|
||||
npcId: selected.id,
|
||||
imageScale,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.avatarPreview}>
|
||||
{selectedUrl ? (
|
||||
<img className={styles.avatarPreviewImg} src={selectedUrl} alt="" />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
disabled={avatarBusy}
|
||||
onClick={() => {
|
||||
@@ -759,12 +791,32 @@ export function NpcsEditorApp() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{USERS_BRANCH_FEATURES_ENABLED ? (
|
||||
<label>
|
||||
<div className={styles.fieldLabel}>{t('npcs.disposition')}</div>
|
||||
<Select
|
||||
value={selected.disposition ?? 'neutral'}
|
||||
ariaLabel={t('npcs.disposition')}
|
||||
onChange={(next) => {
|
||||
void api.invoke(ipcChannels.project.updateNpcFields, {
|
||||
npcId: selected.id,
|
||||
disposition: next as 'hostile' | 'neutral' | 'friendly',
|
||||
});
|
||||
}}
|
||||
options={[
|
||||
{ value: 'hostile', label: t('npcs.disposition.hostile') },
|
||||
{ value: 'neutral', label: t('npcs.disposition.neutral') },
|
||||
{ value: 'friendly', label: t('npcs.disposition.friendly') },
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<div className={styles.fieldLabel}>{t('npcs.name')}</div>
|
||||
<input
|
||||
className={controlStyles.input}
|
||||
<Input
|
||||
value={nameDraft}
|
||||
onChange={(e) => setNameDraft(e.target.value)}
|
||||
onChange={setNameDraft}
|
||||
onBlur={() => {
|
||||
const next = nameDraft.trim();
|
||||
if (!next || next === selected.name) {
|
||||
@@ -804,6 +856,7 @@ export function NpcsEditorApp() {
|
||||
<div>
|
||||
<div className={styles.fieldLabel}>{t('npcs.description')}</div>
|
||||
<NpcDescriptionField
|
||||
key={selected.id}
|
||||
html={selected.description}
|
||||
onCommit={(html) => {
|
||||
if (html === selected.description) return;
|
||||
@@ -815,20 +868,6 @@ export function NpcsEditorApp() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={styles.fieldLabel}>{t('npcs.bindingEnable')}</div>
|
||||
<NpcBindingFields
|
||||
project={project}
|
||||
binding={selected.binding}
|
||||
onChange={(binding: NpcBinding) => {
|
||||
void api.invoke(ipcChannels.project.updateNpcFields, {
|
||||
npcId: selected.id,
|
||||
binding,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{relationsForSelected.length > 0 ? (
|
||||
<div>
|
||||
<div className={styles.relationsTitle}>{t('npcs.relations')}</div>
|
||||
@@ -853,7 +892,6 @@ export function NpcsEditorApp() {
|
||||
open={editOpen}
|
||||
initial={editInitial}
|
||||
existingNames={npcs.map((n) => n.name)}
|
||||
project={project}
|
||||
npcGroups={npcGroups}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onPickImage={pickAvatar}
|
||||
@@ -863,7 +901,6 @@ export function NpcsEditorApp() {
|
||||
name: input.name,
|
||||
...(input.filePath ? { filePath: input.filePath } : {}),
|
||||
...(input.groupId !== undefined ? { groupId: input.groupId } : {}),
|
||||
...(input.binding !== undefined ? { binding: input.binding } : {}),
|
||||
});
|
||||
const created = res.project.npcs.find((n) => n.name === input.name.trim());
|
||||
if (created) setSelectedId(created.id);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||
|
||||
import { NpcsEditorApp } from './NpcsEditorApp';
|
||||
|
||||
@@ -13,8 +14,10 @@ if (!rootEl) {
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<EditorI18nProvider>
|
||||
<NpcsEditorApp />
|
||||
</EditorI18nProvider>
|
||||
<WindowErrorBoundary title="НПС">
|
||||
<EditorI18nProvider>
|
||||
<NpcsEditorApp />
|
||||
</EditorI18nProvider>
|
||||
</WindowErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||
|
||||
import { NpcsApp } from './NpcsApp';
|
||||
|
||||
@@ -13,8 +14,10 @@ if (!rootEl) {
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<EditorI18nProvider>
|
||||
<NpcsApp />
|
||||
</EditorI18nProvider>
|
||||
<WindowErrorBoundary title="НПС">
|
||||
<EditorI18nProvider>
|
||||
<NpcsApp />
|
||||
</EditorI18nProvider>
|
||||
</WindowErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { readTipTapHtmlSafe } from './tiptapEditorSafe';
|
||||
|
||||
void test('readTipTapHtmlSafe: null / destroyed → null', () => {
|
||||
assert.equal(readTipTapHtmlSafe(null), null);
|
||||
assert.equal(readTipTapHtmlSafe(undefined), null);
|
||||
assert.equal(
|
||||
readTipTapHtmlSafe({
|
||||
isDestroyed: true,
|
||||
getHTML: () => '<p>x</p>',
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
void test('readTipTapHtmlSafe: getHTML throw → null', () => {
|
||||
assert.equal(
|
||||
readTipTapHtmlSafe({
|
||||
isDestroyed: false,
|
||||
getHTML: () => {
|
||||
throw new TypeError("Cannot read properties of null (reading 'cached')");
|
||||
},
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
void test('readTipTapHtmlSafe: ok → html', () => {
|
||||
assert.equal(
|
||||
readTipTapHtmlSafe({
|
||||
isDestroyed: false,
|
||||
getHTML: () => '<p>ok</p>',
|
||||
}),
|
||||
'<p>ok</p>',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Безопасное чтение HTML из TipTap/ProseMirror (StrictMode / destroy mid-flight). */
|
||||
export function readTipTapHtmlSafe(editor: {
|
||||
isDestroyed?: boolean;
|
||||
getHTML: () => string;
|
||||
} | null | undefined): string | null {
|
||||
if (!editor || editor.isDestroyed) return null;
|
||||
try {
|
||||
return editor.getHTML();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||
|
||||
import { PresentationApp } from './PresentationApp';
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
@@ -11,6 +13,8 @@ if (!rootEl) {
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<PresentationApp />
|
||||
<WindowErrorBoundary title="Презентация">
|
||||
<PresentationApp />
|
||||
</WindowErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||
|
||||
import { SceneDescriptionApp } from './SceneDescriptionApp';
|
||||
|
||||
@@ -13,8 +14,10 @@ if (!rootEl) {
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<EditorI18nProvider>
|
||||
<SceneDescriptionApp />
|
||||
</EditorI18nProvider>
|
||||
<WindowErrorBoundary title="Описание сцены">
|
||||
<EditorI18nProvider>
|
||||
<SceneDescriptionApp />
|
||||
</EditorI18nProvider>
|
||||
</WindowErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -11,10 +11,11 @@
|
||||
.sidebar {
|
||||
border-right: 1px solid var(--stroke, #2a2f3a);
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sideTitle {
|
||||
@@ -23,6 +24,25 @@
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.85;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.65;
|
||||
line-height: 1.35;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.accordionScroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.accordion {
|
||||
@@ -30,6 +50,7 @@
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.accordionHead {
|
||||
@@ -147,12 +168,6 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.65;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
@@ -272,6 +287,34 @@
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.npcTile {
|
||||
display: grid;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
padding: 7px 4px;
|
||||
border: 1px solid var(--stroke, #2a2f3a);
|
||||
border-radius: 8px;
|
||||
background: rgb(0 0 0 / 22%);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.sceneNpcToken {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
overflow: visible;
|
||||
transform: translate(-50%, -50%);
|
||||
cursor: move;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.sceneNpcToken > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sceneNpcToken .handle {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tokenThumb {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 6px;
|
||||
@@ -437,3 +480,49 @@
|
||||
.toolbar button {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ctxMenuBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ctxMenu {
|
||||
position: fixed;
|
||||
z-index: 41;
|
||||
min-width: 200px;
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--stroke, #2a2f3a);
|
||||
background: var(--color-surface-menu, #1a1e28);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ctxItem,
|
||||
.ctxItemDanger {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text1, #e8eaef);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ctxItemDanger {
|
||||
color: var(--color-danger, #e57373);
|
||||
}
|
||||
|
||||
.ctxItem:hover,
|
||||
.ctxItemDanger:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,27 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
||||
import type { SceneGrid, SceneToken, SceneTrap, SceneTrapType, TokenId } from '../../shared/types';
|
||||
import type {
|
||||
NpcDisposition,
|
||||
NpcId,
|
||||
ProjectNpc,
|
||||
SceneGrid,
|
||||
SceneNpcToken,
|
||||
SceneToken,
|
||||
SceneTrap,
|
||||
SceneTrapType,
|
||||
TokenId,
|
||||
} from '../../shared/types';
|
||||
import {
|
||||
asSceneNpcTokenId,
|
||||
clampSceneNpcTokenSizeN,
|
||||
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import {
|
||||
normalizeNpcDisposition,
|
||||
npcDispositionRingColor,
|
||||
otherNpcDispositions,
|
||||
} from '../../shared/types/npcDisposition';
|
||||
import {
|
||||
asSceneTokenId,
|
||||
asTokenId,
|
||||
@@ -14,6 +34,7 @@ import {
|
||||
DEFAULT_SCENE_GRID,
|
||||
SCENE_GRID_SIZE_MAX,
|
||||
SCENE_GRID_SIZE_MIN,
|
||||
sceneGridTokenFitFactor,
|
||||
sceneGridTypeLabelRu,
|
||||
} from '../../shared/types/sceneGrid';
|
||||
import {
|
||||
@@ -23,20 +44,33 @@ import {
|
||||
trapTypeLabelRu,
|
||||
} from '../../shared/types/sceneTraps';
|
||||
import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
|
||||
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
|
||||
import editorStyles from '../editor/EditorApp.module.css';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { EllipsisText } from '../shared/ui/EllipsisText';
|
||||
import ellipsisStyles from '../shared/ui/ellipsisText.module.css';
|
||||
import { ContainedVideo } from '../shared/ContainedVideo';
|
||||
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
|
||||
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { TokenPathsOverlay } from '../shared/tokens/TokenPathsOverlay';
|
||||
import { useAppTokens } from '../shared/tokens/useAppTokens';
|
||||
import { TrapGlyph } from '../shared/traps/TrapGlyph';
|
||||
import { Button, Input, Select } from '../shared/ui/controls';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
import { sampleTokenPathAtDistance } from '../../shared/types/tokenPath';
|
||||
|
||||
import { SceneTokenMarker } from './SceneTokenMarker';
|
||||
import styles from './SceneEditorApp.module.css';
|
||||
import { SceneTokenMarker } from './SceneTokenMarker';
|
||||
import { TokenEditModal } from './TokenEditModal';
|
||||
import { TOKEN_DND_MIME, TokenTile } from './TokenTile';
|
||||
|
||||
function dispositionMakeKey(d: NpcDisposition): string {
|
||||
if (d === 'hostile') return 'npcs.makeHostile';
|
||||
if (d === 'friendly') return 'npcs.makeFriendly';
|
||||
return 'npcs.makeNeutral';
|
||||
}
|
||||
function isTypingTarget(el: EventTarget | null): boolean {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const tag = el.tagName;
|
||||
@@ -45,14 +79,37 @@ function isTypingTarget(el: EventTarget | null): boolean {
|
||||
}
|
||||
|
||||
type LocalView = { scale: number; ox: number; oy: number };
|
||||
type Selection = { kind: 'trap' | 'token'; id: string } | null;
|
||||
type Selection = { kind: 'trap' | 'token' | 'npcToken'; id: string } | null;
|
||||
|
||||
type DragMode =
|
||||
| { kind: 'pan'; lastX: number; lastY: number }
|
||||
| { kind: 'moveTrap'; trapId: string; startNx: number; startNy: number; pointerNx: number; pointerNy: number }
|
||||
| {
|
||||
kind: 'moveTrap';
|
||||
trapId: string;
|
||||
startNx: number;
|
||||
startNy: number;
|
||||
pointerNx: number;
|
||||
pointerNy: number;
|
||||
}
|
||||
| { kind: 'resizeTrap'; trapId: string; startSize: number; startDist: number }
|
||||
| { kind: 'moveToken'; tokenId: string; startNx: number; startNy: number; pointerNx: number; pointerNy: number }
|
||||
| {
|
||||
kind: 'moveToken';
|
||||
tokenId: string;
|
||||
startNx: number;
|
||||
startNy: number;
|
||||
pointerNx: number;
|
||||
pointerNy: number;
|
||||
}
|
||||
| { kind: 'resizeToken'; tokenId: string; startSize: number; startDist: number }
|
||||
| {
|
||||
kind: 'moveNpcToken';
|
||||
tokenId: string;
|
||||
startNx: number;
|
||||
startNy: number;
|
||||
pointerNx: number;
|
||||
pointerNy: number;
|
||||
}
|
||||
| { kind: 'resizeNpcToken'; tokenId: string; startSize: number; startDist: number }
|
||||
| {
|
||||
kind: 'rotateToken';
|
||||
tokenId: string;
|
||||
@@ -78,27 +135,121 @@ function shortestAngleDelta(fromDeg: number, toDeg: number): number {
|
||||
return d;
|
||||
}
|
||||
|
||||
const SCENE_NPC_DND_MIME = 'application/x-dnd-scene-npc-id';
|
||||
|
||||
function SceneNpcMarker({
|
||||
placement,
|
||||
npc,
|
||||
left,
|
||||
top,
|
||||
sizePx,
|
||||
selected,
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
onMovePointerDown,
|
||||
onResizePointerDown,
|
||||
}: {
|
||||
placement: SceneNpcToken;
|
||||
npc: ProjectNpc;
|
||||
left: number;
|
||||
top: number;
|
||||
sizePx: number;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onContextMenu: (e: React.MouseEvent) => void;
|
||||
onMovePointerDown: (e: React.PointerEvent) => void;
|
||||
onResizePointerDown: (e: React.PointerEvent) => void;
|
||||
}) {
|
||||
const imageUrl = useAssetUrl(npc.avatarAssetId);
|
||||
const disposition = normalizeNpcDisposition(placement.disposition ?? npc.disposition);
|
||||
return (
|
||||
<div
|
||||
data-testid={`scene-npc-token-${placement.id}`}
|
||||
className={[styles.sceneNpcToken, selected ? styles.sceneTokenSelected : ''].filter(Boolean).join(' ')}
|
||||
style={{ left, top, width: sizePx, height: sizePx }}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e);
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
onSelect();
|
||||
onMovePointerDown(e);
|
||||
}}
|
||||
>
|
||||
<PlayerTokenView
|
||||
name={npc.name}
|
||||
imageUrl={imageUrl}
|
||||
ringColor={npcDispositionRingColor(disposition)}
|
||||
imageOffset={npc.imageOffset}
|
||||
imageScale={npc.imageScale}
|
||||
sizePx={sizePx}
|
||||
/>
|
||||
{selected ? <div className={styles.handle} onPointerDown={onResizePointerDown} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NpcPaletteTile({ npc }: { npc: ProjectNpc }) {
|
||||
const imageUrl = useAssetUrl(npc.avatarAssetId);
|
||||
const disposition = normalizeNpcDisposition(npc.disposition);
|
||||
return (
|
||||
<div
|
||||
className={styles.npcTile}
|
||||
data-testid={`scene-npc-tile-${npc.id}`}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData(SCENE_NPC_DND_MIME, npc.id);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
title={npc.name}
|
||||
>
|
||||
<PlayerTokenView
|
||||
name={npc.name}
|
||||
imageUrl={imageUrl}
|
||||
ringColor={npcDispositionRingColor(disposition)}
|
||||
imageOffset={npc.imageOffset}
|
||||
imageScale={npc.imageScale}
|
||||
sizePx={70}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SceneEditorApp() {
|
||||
const api = getDndApi();
|
||||
const { t } = useEditorI18n();
|
||||
const appTokens = useAppTokens();
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const [trapsOpen, setTrapsOpen] = useState(false);
|
||||
const [gridOpen, setGridOpen] = useState(false);
|
||||
const [tokensOpen, setTokensOpen] = useState(false);
|
||||
const [npcsOpen, setNpcsOpen] = useState(false);
|
||||
const [tokenSearch, setTokenSearch] = useState('');
|
||||
const [tokenModal, setTokenModal] = useState<{ mode: 'create' } | { mode: 'edit'; tokenId: TokenId } | null>(
|
||||
null,
|
||||
);
|
||||
const [npcSearch, setNpcSearch] = useState('');
|
||||
const [tokenModal, setTokenModal] = useState<
|
||||
{ mode: 'create' } | { mode: 'edit'; tokenId: TokenId } | null
|
||||
>(null);
|
||||
const [pendingDeleteToken, setPendingDeleteToken] = useState<{ id: TokenId; name: string } | null>(null);
|
||||
const [npcCtxMenu, setNpcCtxMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
placementId: string;
|
||||
} | null>(null);
|
||||
const [tokenCtxMenu, setTokenCtxMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
placementId: string;
|
||||
} | null>(null);
|
||||
const [selected, setSelected] = useState<Selection>(null);
|
||||
const [view, setView] = useState<LocalView>({ scale: 1, ox: 0.5, oy: 0.5 });
|
||||
const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragRef = useRef<DragMode>(null);
|
||||
const saveTrapsTimerRef = useRef(0);
|
||||
const saveTokensTimerRef = useRef(0);
|
||||
const saveNpcTokensTimerRef = useRef(0);
|
||||
const saveGridTimerRef = useRef(0);
|
||||
const spaceDownRef = useRef(false);
|
||||
|
||||
@@ -109,15 +260,24 @@ export function SceneEditorApp() {
|
||||
const rot = scene?.previewRotationDeg ?? 0;
|
||||
const [localTraps, setLocalTraps] = useState<SceneTrap[]>([]);
|
||||
const [localTokens, setLocalTokens] = useState<SceneToken[]>([]);
|
||||
const [localNpcTokens, setLocalNpcTokens] = useState<SceneNpcToken[]>([]);
|
||||
const [localGrid, setLocalGrid] = useState<SceneGrid>({ ...DEFAULT_SCENE_GRID });
|
||||
const trapsRef = useRef<SceneTrap[]>([]);
|
||||
const tokensRef = useRef<SceneToken[]>([]);
|
||||
trapsRef.current = localTraps;
|
||||
tokensRef.current = localTokens;
|
||||
const npcTokensRef = useRef<SceneNpcToken[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
trapsRef.current = localTraps;
|
||||
tokensRef.current = localTokens;
|
||||
npcTokensRef.current = localNpcTokens;
|
||||
}, [localNpcTokens, localTokens, localTraps]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalTraps(scene?.traps ?? []);
|
||||
setLocalTokens((scene?.tokens ?? []).filter((t) => appTokens.some((a) => a.id === t.tokenId)));
|
||||
setLocalNpcTokens(
|
||||
(scene?.npcTokens ?? []).filter((t) => project?.npcs.some((npc) => npc.id === t.npcId)),
|
||||
);
|
||||
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
|
||||
setSelected(null);
|
||||
setView({ scale: 1, ox: 0.5, oy: 0.5 });
|
||||
@@ -134,6 +294,12 @@ export function SceneEditorApp() {
|
||||
setLocalTokens((scene?.tokens ?? []).filter((t) => known.has(t.tokenId)));
|
||||
}, [scene?.tokens, appTokens]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dragRef.current) return;
|
||||
const known = new Set((project?.npcs ?? []).map((npc) => npc.id));
|
||||
setLocalNpcTokens((scene?.npcTokens ?? []).filter((t) => known.has(t.npcId)));
|
||||
}, [scene?.npcTokens, project?.npcs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dragRef.current) return;
|
||||
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
|
||||
@@ -174,6 +340,19 @@ export function SceneEditorApp() {
|
||||
[api, sceneId],
|
||||
);
|
||||
|
||||
const persistNpcTokens = useCallback(
|
||||
(next: SceneNpcToken[]) => {
|
||||
if (!sceneId) return;
|
||||
setLocalNpcTokens(next);
|
||||
npcTokensRef.current = next;
|
||||
if (saveNpcTokensTimerRef.current) window.clearTimeout(saveNpcTokensTimerRef.current);
|
||||
saveNpcTokensTimerRef.current = window.setTimeout(() => {
|
||||
void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { npcTokens: next } });
|
||||
}, 120);
|
||||
},
|
||||
[api, sceneId],
|
||||
);
|
||||
|
||||
const persistGrid = useCallback(
|
||||
(next: SceneGrid) => {
|
||||
if (!sceneId) return;
|
||||
@@ -194,8 +373,10 @@ export function SceneEditorApp() {
|
||||
e.preventDefault();
|
||||
if (selected.kind === 'trap') {
|
||||
persistTraps(trapsRef.current.filter((t) => t.id !== selected.id));
|
||||
} else {
|
||||
} else if (selected.kind === 'token') {
|
||||
persistTokens(tokensRef.current.filter((t) => t.id !== selected.id));
|
||||
} else {
|
||||
persistNpcTokens(npcTokensRef.current.filter((t) => t.id !== selected.id));
|
||||
}
|
||||
setSelected(null);
|
||||
}
|
||||
@@ -210,7 +391,7 @@ export function SceneEditorApp() {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
};
|
||||
}, [persistTraps, persistTokens, sceneId, selected]);
|
||||
}, [persistNpcTokens, persistTraps, persistTokens, sceneId, selected]);
|
||||
|
||||
const hostToNorm = (clientX: number, clientY: number): { x: number; y: number } | null => {
|
||||
const host = hostRef.current;
|
||||
@@ -284,6 +465,24 @@ export function SceneEditorApp() {
|
||||
persistTokens([...tokensRef.current, placement]);
|
||||
};
|
||||
|
||||
const addNpcTokenAt = (npcId: NpcId, nx: number, ny: number) => {
|
||||
const gridCellSize =
|
||||
localGrid.enabled && Number.isFinite(localGrid.sizeN)
|
||||
? clampSceneNpcTokenSizeN(localGrid.sizeN)
|
||||
: DEFAULT_SCENE_NPC_TOKEN_SIZE_N;
|
||||
const npc = project?.npcs.find((item) => item.id === npcId);
|
||||
const placement: SceneNpcToken = {
|
||||
id: asSceneNpcTokenId(randomId('snpc')),
|
||||
npcId,
|
||||
nx,
|
||||
ny,
|
||||
sizeN: gridCellSize,
|
||||
disposition: normalizeNpcDisposition(npc?.disposition),
|
||||
};
|
||||
setSelected({ kind: 'npcToken', id: placement.id });
|
||||
persistNpcTokens([...npcTokensRef.current, placement]);
|
||||
};
|
||||
|
||||
const onStageDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
@@ -293,6 +492,11 @@ export function SceneEditorApp() {
|
||||
addTokenAt(asTokenId(tokenId), p.x, p.y);
|
||||
return;
|
||||
}
|
||||
const npcId = e.dataTransfer.getData(SCENE_NPC_DND_MIME);
|
||||
if (USERS_BRANCH_FEATURES_ENABLED && npcId) {
|
||||
addNpcTokenAt(npcId as NpcId, p.x, p.y);
|
||||
return;
|
||||
}
|
||||
const type = e.dataTransfer.getData('application/x-dnd-trap-type') as SceneTrapType;
|
||||
if (!SCENE_TRAP_TYPES.includes(type)) return;
|
||||
addTrapAt(type, p.x, p.y);
|
||||
@@ -306,23 +510,40 @@ export function SceneEditorApp() {
|
||||
persistTokens(tokensRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t)));
|
||||
};
|
||||
|
||||
const updateNpcToken = (id: string, patch: Partial<SceneNpcToken>) => {
|
||||
persistNpcTokens(npcTokensRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t)));
|
||||
};
|
||||
|
||||
const filteredTokens = useMemo(() => {
|
||||
const q = tokenSearch.trim().toLowerCase();
|
||||
if (!q) return appTokens;
|
||||
return appTokens.filter((t) => t.name.toLowerCase().includes(q));
|
||||
}, [appTokens, tokenSearch]);
|
||||
|
||||
const editingToken = tokenModal?.mode === 'edit' ? appTokens.find((t) => t.id === tokenModal.tokenId) ?? null : null;
|
||||
const filteredNpcs = useMemo(() => {
|
||||
const q = npcSearch.trim().toLowerCase();
|
||||
const npcs = project?.npcs ?? [];
|
||||
return q ? npcs.filter((npc) => npc.name.toLowerCase().includes(q)) : npcs;
|
||||
}, [npcSearch, project?.npcs]);
|
||||
|
||||
const editingToken =
|
||||
tokenModal?.mode === 'edit' ? (appTokens.find((t) => t.id === tokenModal.tokenId) ?? null) : null;
|
||||
const isImage = scene?.previewAssetType === 'image' && Boolean(url);
|
||||
const isVideo = scene?.previewAssetType === 'video' && Boolean(url);
|
||||
const hasMapMedia = isImage || isVideo;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<aside className={styles.sidebar}>
|
||||
<div className={styles.sideTitle}>{scene?.title ?? 'Сцена'}</div>
|
||||
<EllipsisText
|
||||
text={scene?.title ?? 'Сцена'}
|
||||
className={[styles.sideTitle, ellipsisStyles.root].join(' ')}
|
||||
/>
|
||||
<div className={styles.hint}>
|
||||
Колесо — зум. СКМ / Space+ЛКМ — пан. Delete — удалить выбранное.
|
||||
</div>
|
||||
|
||||
<div className={styles.accordionScroll}>
|
||||
<div className={styles.accordion}>
|
||||
<button type="button" className={styles.accordionHead} onClick={() => setGridOpen((v) => !v)}>
|
||||
Сетка {gridOpen ? '▾' : '▸'}
|
||||
@@ -390,6 +611,30 @@ export function SceneEditorApp() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{USERS_BRANCH_FEATURES_ENABLED ? (
|
||||
<div className={styles.accordion} data-testid="scene-npc-accordion">
|
||||
<button type="button" className={styles.accordionHead} onClick={() => setNpcsOpen((v) => !v)}>
|
||||
НПС {npcsOpen ? '▾' : '▸'}
|
||||
</button>
|
||||
{npcsOpen ? (
|
||||
<div className={styles.tokensPanel}>
|
||||
<Input
|
||||
value={npcSearch}
|
||||
onChange={setNpcSearch}
|
||||
placeholder="Поиск НПС…"
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className={styles.tokenGrid}>
|
||||
{filteredNpcs.map((npc) => (
|
||||
<NpcPaletteTile key={npc.id} npc={npc} />
|
||||
))}
|
||||
{filteredNpcs.length === 0 ? <div className={styles.hint}>Нет НПС</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={styles.accordion}>
|
||||
<button type="button" className={styles.accordionHead} onClick={() => setTokensOpen((v) => !v)}>
|
||||
Неигровые токены {tokensOpen ? '▾' : '▸'}
|
||||
@@ -403,7 +648,6 @@ export function SceneEditorApp() {
|
||||
value={tokenSearch}
|
||||
onChange={setTokenSearch}
|
||||
placeholder="Поиск…"
|
||||
autoFocus={tokensOpen}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className={styles.tokenGrid}>
|
||||
@@ -451,21 +695,26 @@ export function SceneEditorApp() {
|
||||
|
||||
<div className={styles.clearSceneBtn}>
|
||||
<Button
|
||||
disabled={!sceneId || (localTraps.length === 0 && localTokens.length === 0)}
|
||||
data-testid="scene-clear-btn"
|
||||
disabled={
|
||||
!sceneId || (localTraps.length === 0 && localTokens.length === 0 && localNpcTokens.length === 0)
|
||||
}
|
||||
onClick={() => {
|
||||
persistTraps([]);
|
||||
persistTokens([]);
|
||||
persistNpcTokens([]);
|
||||
setSelected(null);
|
||||
}}
|
||||
>
|
||||
Очистить сцену
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className={styles.stage}>
|
||||
{!isImage ? (
|
||||
<div className={styles.empty}>Нужно изображение сцены</div>
|
||||
{!hasMapMedia ? (
|
||||
<div className={styles.empty}>Нужно изображение или видео сцены</div>
|
||||
) : (
|
||||
<div
|
||||
ref={hostRef}
|
||||
@@ -536,6 +785,26 @@ export function SceneEditorApp() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (d.kind === 'moveNpcToken') {
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
updateNpcToken(d.tokenId, {
|
||||
nx: Math.max(0, Math.min(1, d.startNx + (p.x - d.pointerNx))),
|
||||
ny: Math.max(0, Math.min(1, d.startNy + (p.y - d.pointerNy))),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (d.kind === 'resizeNpcToken') {
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
const tok = npcTokensRef.current.find((t) => t.id === d.tokenId);
|
||||
if (!p || !tok) return;
|
||||
const dist = Math.hypot(p.x - tok.nx, p.y - tok.ny);
|
||||
const ratio = d.startDist > 1e-6 ? dist / d.startDist : 1;
|
||||
updateNpcToken(d.tokenId, {
|
||||
sizeN: clampSceneNpcTokenSizeN(d.startSize * ratio),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (d.kind === 'rotateToken') {
|
||||
const ang = pointerAngleDeg(d.centerClientX, d.centerClientY, e.clientX, e.clientY);
|
||||
const delta = shortestAngleDelta(d.startPointerAngle, ang);
|
||||
@@ -549,14 +818,33 @@ export function SceneEditorApp() {
|
||||
dragRef.current = null;
|
||||
}}
|
||||
>
|
||||
<RotatedImage
|
||||
url={url!}
|
||||
rotationDeg={rot}
|
||||
mode="contain"
|
||||
viewCamera={viewCamera}
|
||||
onContentRectChange={setContentRect}
|
||||
/>
|
||||
{isImage ? (
|
||||
<RotatedImage
|
||||
url={url!}
|
||||
rotationDeg={rot}
|
||||
mode="contain"
|
||||
viewCamera={viewCamera}
|
||||
onContentRectChange={setContentRect}
|
||||
/>
|
||||
) : (
|
||||
<ContainedVideo
|
||||
url={url!}
|
||||
rotationDeg={rot}
|
||||
muted
|
||||
playsInline
|
||||
loop
|
||||
preload="metadata"
|
||||
viewCamera={viewCamera}
|
||||
onContentRectChange={setContentRect}
|
||||
/>
|
||||
)}
|
||||
<SceneGridOverlay grid={localGrid} viewport={contentRect} />
|
||||
<TokenPathsOverlay
|
||||
tokens={localTokens}
|
||||
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? localNpcTokens : []}
|
||||
viewport={contentRect}
|
||||
mode="always"
|
||||
/>
|
||||
{contentRect
|
||||
? localTokens.map((tok) => {
|
||||
const minDim = Math.min(contentRect.w, contentRect.h);
|
||||
@@ -575,8 +863,8 @@ export function SceneEditorApp() {
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
persistTokens(tokensRef.current.filter((t) => t.id !== tok.id));
|
||||
setSelected((cur) => (cur?.kind === 'token' && cur.id === tok.id ? null : cur));
|
||||
setSelected({ kind: 'token', id: tok.id });
|
||||
setTokenCtxMenu({ x: e.clientX, y: e.clientY, placementId: tok.id });
|
||||
}}
|
||||
onMovePointerDown={(e) => {
|
||||
if (spaceDownRef.current) return;
|
||||
@@ -628,6 +916,60 @@ export function SceneEditorApp() {
|
||||
);
|
||||
})
|
||||
: null}
|
||||
{USERS_BRANCH_FEATURES_ENABLED && contentRect
|
||||
? localNpcTokens.map((tok) => {
|
||||
const npc = project?.npcs.find((item) => item.id === tok.npcId);
|
||||
if (!npc) return null;
|
||||
const minDim = Math.min(contentRect.w, contentRect.h);
|
||||
const sizePx = Math.max(16, tok.sizeN * sceneGridTokenFitFactor(localGrid) * minDim);
|
||||
const left = contentRect.x + tok.nx * contentRect.w;
|
||||
const top = contentRect.y + tok.ny * contentRect.h;
|
||||
return (
|
||||
<SceneNpcMarker
|
||||
key={tok.id}
|
||||
placement={tok}
|
||||
npc={npc}
|
||||
left={left}
|
||||
top={top}
|
||||
sizePx={sizePx}
|
||||
selected={selected?.kind === 'npcToken' && selected.id === tok.id}
|
||||
onSelect={() => setSelected({ kind: 'npcToken', id: tok.id })}
|
||||
onContextMenu={(e) => {
|
||||
setSelected({ kind: 'npcToken', id: tok.id });
|
||||
setNpcCtxMenu({ x: e.clientX, y: e.clientY, placementId: tok.id });
|
||||
}}
|
||||
onMovePointerDown={(e) => {
|
||||
if (spaceDownRef.current) return;
|
||||
e.stopPropagation();
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
dragRef.current = {
|
||||
kind: 'moveNpcToken',
|
||||
tokenId: tok.id,
|
||||
startNx: tok.nx,
|
||||
startNy: tok.ny,
|
||||
pointerNx: p.x,
|
||||
pointerNy: p.y,
|
||||
};
|
||||
}}
|
||||
onResizePointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
dragRef.current = {
|
||||
kind: 'resizeNpcToken',
|
||||
tokenId: tok.id,
|
||||
startSize: tok.sizeN,
|
||||
startDist: Math.max(1e-4, Math.hypot(p.x - tok.nx, p.y - tok.ny)),
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
{contentRect
|
||||
? localTraps.map((trap) => {
|
||||
const minDim = Math.min(contentRect.w, contentRect.h);
|
||||
@@ -638,7 +980,9 @@ export function SceneEditorApp() {
|
||||
return (
|
||||
<div
|
||||
key={trap.id}
|
||||
className={[styles.trap, isSelected ? styles.trapSelected : ''].filter(Boolean).join(' ')}
|
||||
className={[styles.trap, isSelected ? styles.trapSelected : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{ left, top, width: sizePx, height: sizePx }}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0 || spaceDownRef.current) return;
|
||||
@@ -737,6 +1081,174 @@ export function SceneEditorApp() {
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
|
||||
{tokenCtxMenu
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxMenuBackdrop}
|
||||
aria-label={t('common.close')}
|
||||
onClick={() => setTokenCtxMenu(null)}
|
||||
/>
|
||||
<div
|
||||
className={styles.ctxMenu}
|
||||
style={{ left: tokenCtxMenu.x, top: tokenCtxMenu.y }}
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
const id = tokenCtxMenu.placementId;
|
||||
setTokenCtxMenu(null);
|
||||
void api
|
||||
.invoke(ipcChannels.windows.openTokenPathEditor, {
|
||||
kind: 'token',
|
||||
placementId: id,
|
||||
})
|
||||
.catch((err) => console.error('[sceneEditor] openTokenPathEditor', err));
|
||||
}}
|
||||
>
|
||||
Указать движение
|
||||
</button>
|
||||
{(() => {
|
||||
const tok = localTokens.find((item) => item.id === tokenCtxMenu.placementId);
|
||||
if (!tok?.path || tok.path.points.length < 2) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
const sample = sampleTokenPathAtDistance(tok.path!, 0);
|
||||
if (sample) {
|
||||
updateToken(tok.id, {
|
||||
nx: sample.nx,
|
||||
ny: sample.ny,
|
||||
...(tok.path?.facingMode === 'fixed'
|
||||
? { rotationDeg: tok.path.fixedRotationDeg }
|
||||
: { rotationDeg: sample.rotationDeg }),
|
||||
});
|
||||
}
|
||||
setTokenCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
Сбросить на старт пути
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItemDanger}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
const id = tokenCtxMenu.placementId;
|
||||
setTokenCtxMenu(null);
|
||||
persistTokens(tokensRef.current.filter((item) => item.id !== id));
|
||||
setSelected((current) =>
|
||||
current?.kind === 'token' && current.id === id ? null : current,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
|
||||
{USERS_BRANCH_FEATURES_ENABLED && npcCtxMenu
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxMenuBackdrop}
|
||||
aria-label={t('common.close')}
|
||||
onClick={() => setNpcCtxMenu(null)}
|
||||
/>
|
||||
<div
|
||||
className={styles.ctxMenu}
|
||||
style={{ left: npcCtxMenu.x, top: npcCtxMenu.y }}
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
const id = npcCtxMenu.placementId;
|
||||
setNpcCtxMenu(null);
|
||||
void api
|
||||
.invoke(ipcChannels.windows.openTokenPathEditor, {
|
||||
kind: 'npcToken',
|
||||
placementId: id,
|
||||
})
|
||||
.catch((err) => console.error('[sceneEditor] openTokenPathEditor', err));
|
||||
}}
|
||||
>
|
||||
Указать движение
|
||||
</button>
|
||||
{(() => {
|
||||
const tok = localNpcTokens.find((item) => item.id === npcCtxMenu.placementId);
|
||||
if (!tok?.path || tok.path.points.length < 2) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
const sample = sampleTokenPathAtDistance(tok.path!, 0);
|
||||
if (sample) {
|
||||
updateNpcToken(tok.id, { nx: sample.nx, ny: sample.ny });
|
||||
}
|
||||
setNpcCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
Сбросить на старт пути
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItemDanger}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
const id = npcCtxMenu.placementId;
|
||||
setNpcCtxMenu(null);
|
||||
persistNpcTokens(npcTokensRef.current.filter((item) => item.id !== id));
|
||||
setSelected((current) => (current?.id === id ? null : current));
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
{(() => {
|
||||
const tok = localNpcTokens.find((item) => item.id === npcCtxMenu.placementId);
|
||||
const npc = tok ? project?.npcs.find((item) => item.id === tok.npcId) : undefined;
|
||||
if (!tok || !npc) return null;
|
||||
const current = normalizeNpcDisposition(tok.disposition ?? npc.disposition);
|
||||
return otherNpcDispositions(current).map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
updateNpcToken(tok.id, { disposition: d });
|
||||
setNpcCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
{t(dispositionMakeKey(d))}
|
||||
</button>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||
|
||||
import { SceneEditorApp } from './SceneEditorApp';
|
||||
|
||||
@@ -13,8 +14,10 @@ if (!rootEl) {
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<EditorI18nProvider>
|
||||
<SceneEditorApp />
|
||||
</EditorI18nProvider>
|
||||
<WindowErrorBoundary title="Редактор сцены">
|
||||
<EditorI18nProvider>
|
||||
<SceneEditorApp />
|
||||
</EditorI18nProvider>
|
||||
</WindowErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
transform-origin: center;
|
||||
display: block;
|
||||
background: #000;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
containMediaLayout,
|
||||
type MediaRotationDeg,
|
||||
} from '../../shared/types/containMediaRect';
|
||||
import { DEFAULT_SCENE_VIEW_CAMERA, type SceneViewCamera } from '../../shared/types/sceneView';
|
||||
|
||||
import styles from './ContainedVideo.module.css';
|
||||
|
||||
export type ContainedVideoProps = {
|
||||
url: string;
|
||||
/** Same 90° steps as scene image previewRotationDeg. */
|
||||
rotationDeg?: MediaRotationDeg;
|
||||
/** Default contain (map overlays). Cover for editor/graph thumbnails. */
|
||||
mode?: 'contain' | 'cover';
|
||||
/** Зум/пан как у RotatedImage в mode=contain. */
|
||||
viewCamera?: SceneViewCamera | null;
|
||||
onContentRectChange?: ((rect: { x: number; y: number; w: number; h: number }) => void) | undefined;
|
||||
videoRef?: React.Ref<HTMLVideoElement | null>;
|
||||
loop?: boolean;
|
||||
muted?: boolean;
|
||||
playsInline?: boolean;
|
||||
autoPlay?: boolean;
|
||||
preload?: React.VideoHTMLAttributes<HTMLVideoElement>['preload'];
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
onTimeUpdate?: React.VideoHTMLAttributes<HTMLVideoElement>['onTimeUpdate'];
|
||||
onLoadedMetadata?: React.VideoHTMLAttributes<HTMLVideoElement>['onLoadedMetadata'];
|
||||
onLoadedData?: React.VideoHTMLAttributes<HTMLVideoElement>['onLoadedData'];
|
||||
onError?: React.VideoHTMLAttributes<HTMLVideoElement>['onError'];
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
function useElementSize<T extends HTMLElement>() {
|
||||
const ref = useRef<T | null>(null);
|
||||
const [size, setSize] = useState<{ w: number; h: number }>({ w: 0, h: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const readLayoutSize = () => {
|
||||
setSize({ w: el.clientWidth, h: el.clientHeight });
|
||||
};
|
||||
const ro = new ResizeObserver(() => {
|
||||
readLayoutSize();
|
||||
});
|
||||
ro.observe(el);
|
||||
readLayoutSize();
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
return [ref, size] as const;
|
||||
}
|
||||
|
||||
function assignRef<T>(ref: React.Ref<T> | undefined, value: T): void {
|
||||
if (!ref) return;
|
||||
if (typeof ref === 'function') {
|
||||
ref(value);
|
||||
return;
|
||||
}
|
||||
(ref as React.MutableRefObject<T>).current = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Video laid out like RotatedImage: reports the visible content rect
|
||||
* so grid / traps / tokens / effects align with the letterboxed (or cover) frame.
|
||||
*/
|
||||
export function ContainedVideo({
|
||||
url,
|
||||
rotationDeg = 0,
|
||||
mode = 'contain',
|
||||
viewCamera = null,
|
||||
onContentRectChange,
|
||||
videoRef,
|
||||
loop = false,
|
||||
muted = false,
|
||||
playsInline = true,
|
||||
autoPlay = false,
|
||||
preload = 'auto',
|
||||
className,
|
||||
style,
|
||||
onTimeUpdate,
|
||||
onLoadedMetadata,
|
||||
onLoadedData,
|
||||
onError,
|
||||
children,
|
||||
}: ContainedVideoProps) {
|
||||
const [hostRef, size] = useElementSize<HTMLDivElement>();
|
||||
const [mediaSize, setMediaSize] = useState<{ w: number; h: number } | null>(null);
|
||||
const elRef = useRef<HTMLVideoElement | null>(null);
|
||||
|
||||
const cam = viewCamera ?? DEFAULT_SCENE_VIEW_CAMERA;
|
||||
const viewScale = mode === 'contain' ? Math.max(1, cam.scale) : 1;
|
||||
const viewOx = mode === 'contain' ? cam.ox : 0.5;
|
||||
const viewOy = mode === 'contain' ? cam.oy : 0.5;
|
||||
|
||||
const syncMediaSize = (el: HTMLVideoElement) => {
|
||||
const w0 = el.videoWidth || 0;
|
||||
const h0 = el.videoHeight || 0;
|
||||
if (w0 <= 0 || h0 <= 0) return;
|
||||
setMediaSize((prev) => (prev && prev.w === w0 && prev.h === h0 ? prev : { w: w0, h: h0 }));
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = elRef.current;
|
||||
if (!el) return;
|
||||
if (el.readyState >= 1) syncMediaSize(el);
|
||||
}, [url]);
|
||||
|
||||
const layout = useMemo(() => {
|
||||
if (!mediaSize) return null;
|
||||
return containMediaLayout({
|
||||
hostW: size.w,
|
||||
hostH: size.h,
|
||||
mediaW: mediaSize.w,
|
||||
mediaH: mediaSize.h,
|
||||
scale: viewScale,
|
||||
ox: viewOx,
|
||||
oy: viewOy,
|
||||
rotationDeg,
|
||||
mode,
|
||||
});
|
||||
}, [mediaSize, mode, rotationDeg, size.h, size.w, viewOx, viewOy, viewScale]);
|
||||
|
||||
const contentRect = layout?.contentRect ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!onContentRectChange || !contentRect) return;
|
||||
onContentRectChange(contentRect);
|
||||
}, [contentRect, onContentRectChange]);
|
||||
|
||||
const leftPx = contentRect ? contentRect.x + contentRect.w / 2 : undefined;
|
||||
const topPx = contentRect ? contentRect.y + contentRect.h / 2 : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={hostRef}
|
||||
className={[styles.root, className].filter(Boolean).join(' ')}
|
||||
style={style}
|
||||
>
|
||||
<video
|
||||
ref={(el) => {
|
||||
elRef.current = el;
|
||||
assignRef(videoRef, el);
|
||||
}}
|
||||
className={styles.video}
|
||||
src={url}
|
||||
loop={loop}
|
||||
muted={muted}
|
||||
playsInline={playsInline}
|
||||
autoPlay={autoPlay}
|
||||
preload={preload}
|
||||
draggable={false}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onLoadedMetadata={(e) => {
|
||||
syncMediaSize(e.currentTarget);
|
||||
onLoadedMetadata?.(e);
|
||||
}}
|
||||
onLoadedData={onLoadedData}
|
||||
onError={onError}
|
||||
style={{
|
||||
width: layout ? layout.elementW : '100%',
|
||||
height: layout ? layout.elementH : '100%',
|
||||
left: leftPx !== undefined ? `${String(leftPx)}px` : '50%',
|
||||
top: topPx !== undefined ? `${String(topPx)}px` : '50%',
|
||||
objectFit: mediaSize ? undefined : mode,
|
||||
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
|
||||
import type { SessionState } from '../../shared/ipc/contracts';
|
||||
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
|
||||
|
||||
import { ExplosionVideoOverlay } from './effects/ExplosionVideoOverlay';
|
||||
import { PixiEffectsOverlay } from './effects/PxiEffectsOverlay';
|
||||
@@ -15,17 +16,27 @@ import { MaterialOverlay } from './materials/MaterialOverlay';
|
||||
import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState';
|
||||
import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay';
|
||||
import { useNpcsOverlayState } from './npcs/useNpcsOverlayState';
|
||||
import { SceneNpcTokensOverlay } from './playerToken/SceneNpcTokensOverlay';
|
||||
import { ScenePlayerTokensOverlay } from './playerToken/ScenePlayerTokensOverlay';
|
||||
import { useAppPlayers } from './playerToken/useAppPlayers';
|
||||
import { useSceneNpcTokensSession } from './playerToken/useSceneNpcTokensSession';
|
||||
import { useScenePlayerTokensSession } from './playerToken/useScenePlayerTokensSession';
|
||||
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
|
||||
import { useSceneViewState } from './sceneView/useSceneViewState';
|
||||
import { SceneTokensOverlay } from './tokens/SceneTokensOverlay';
|
||||
import { TokenPathsOverlay } from './tokens/TokenPathsOverlay';
|
||||
import { useAppTokens } from './tokens/useAppTokens';
|
||||
import { useSceneTokensSession } from './tokens/useSceneTokensSession';
|
||||
import { useTokenPathLivePoses } from './tokens/useTokenPathLivePoses';
|
||||
import { useTokenPathSession } from './tokens/useTokenPathSession';
|
||||
import { SceneTrapsOverlay } from './traps/SceneTrapsOverlay';
|
||||
import { useSceneTrapsState } from './traps/useSceneTrapsState';
|
||||
import styles from './PresentationView.module.css';
|
||||
import { ContainedVideo } from './ContainedVideo';
|
||||
import { RotatedImage } from './RotatedImage';
|
||||
import { useAssetUrl } from './useAssetImageUrl';
|
||||
import { useVideoPlaybackState } from './video/useVideoPlaybackState';
|
||||
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../shared/types/appPlayers';
|
||||
|
||||
export type PresentationViewProps = {
|
||||
session: SessionState | null;
|
||||
@@ -48,7 +59,12 @@ export function PresentationView({
|
||||
const [materialsOverlay] = useMaterialsOverlayState();
|
||||
const [npcsOverlay] = useNpcsOverlayState();
|
||||
const appTokens = useAppTokens();
|
||||
const appPlayersResult = useAppPlayers();
|
||||
const appPlayers = USERS_BRANCH_FEATURES_ENABLED ? appPlayersResult.players : [];
|
||||
const [sceneTokensSession] = useSceneTokensSession();
|
||||
const [sceneNpcTokensSession] = useSceneNpcTokensSession();
|
||||
const [scenePlayerTokensSession] = useScenePlayerTokensSession();
|
||||
const [tokenPathSession, tokenPathApi] = useTokenPathSession();
|
||||
const [vp] = useVideoPlaybackState();
|
||||
const videoElRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>(
|
||||
@@ -56,11 +72,30 @@ 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 project = session?.project;
|
||||
const pathLivePoses = useTokenPathLivePoses({
|
||||
tokens: scene?.tokens ?? [],
|
||||
npcTokens: scene?.npcTokens ?? [],
|
||||
pathSession: tokenPathSession,
|
||||
enabled: Boolean(scene),
|
||||
onMarkDone: (kind, placementId) => {
|
||||
void tokenPathApi.dispatch({ kind: 'markDone', target: { kind, placementId } });
|
||||
},
|
||||
});
|
||||
const activeMaterialItems =
|
||||
project && (materialsOverlay?.activeMaterialIds?.length ?? 0) > 0
|
||||
? (materialsOverlay?.activeMaterialIds ?? [])
|
||||
.map((id) => {
|
||||
const material = (project.materials ?? []).find((m) => m.id === id);
|
||||
if (!material) return null;
|
||||
return {
|
||||
material,
|
||||
layout: materialsOverlay?.layouts[id] ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT,
|
||||
legendLayout: materialsOverlay?.legendLayouts[id] ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT,
|
||||
};
|
||||
})
|
||||
.filter((x): x is NonNullable<typeof x> => x !== null)
|
||||
: [];
|
||||
const activeNpcItems =
|
||||
project && (npcsOverlay?.activeNpcIds?.length ?? 0) > 0
|
||||
? (npcsOverlay?.activeNpcIds ?? [])
|
||||
@@ -150,34 +185,71 @@ export function PresentationView({
|
||||
/>
|
||||
</div>
|
||||
) : originalUrl && scene?.previewAssetType === 'video' ? (
|
||||
<video
|
||||
ref={videoElRef}
|
||||
className={styles.video}
|
||||
src={originalUrl}
|
||||
muted
|
||||
playsInline
|
||||
loop={false}
|
||||
preload="auto"
|
||||
onError={() => {
|
||||
// noop: status surfaced in control app; keep presentation clean
|
||||
}}
|
||||
/>
|
||||
<div className={styles.fill}>
|
||||
<ContainedVideo
|
||||
url={originalUrl}
|
||||
rotationDeg={rot}
|
||||
videoRef={videoElRef}
|
||||
muted
|
||||
playsInline
|
||||
loop={Boolean(scene?.settings?.loopVideo)}
|
||||
preload="auto"
|
||||
viewCamera={sceneView}
|
||||
onContentRectChange={setContentRect}
|
||||
onError={() => {
|
||||
// noop: status surfaced in control app; keep presentation clean
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.placeholderBg} />
|
||||
)}
|
||||
{scene?.previewAssetType === 'image' ? (
|
||||
{scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video' ? (
|
||||
<SceneGridOverlay grid={scene.grid} viewport={contentRect} />
|
||||
) : null}
|
||||
<div className={styles.vignette} />
|
||||
{scene?.previewAssetType === 'image' && contentRect ? (
|
||||
{(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') && contentRect ? (
|
||||
<TokenPathsOverlay
|
||||
tokens={scene.tokens ?? []}
|
||||
npcTokens={USERS_BRANCH_FEATURES_ENABLED ? (scene.npcTokens ?? []) : []}
|
||||
viewport={contentRect}
|
||||
mode="presentation"
|
||||
pathSession={tokenPathSession}
|
||||
/>
|
||||
) : null}
|
||||
{(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') && contentRect ? (
|
||||
<SceneTokensOverlay
|
||||
placements={scene.tokens ?? []}
|
||||
library={appTokens}
|
||||
session={sceneTokensSession}
|
||||
viewport={contentRect}
|
||||
poseOverrides={pathLivePoses.tokenPoses}
|
||||
/>
|
||||
) : null}
|
||||
{scene?.previewAssetType === 'image' && contentRect ? (
|
||||
{USERS_BRANCH_FEATURES_ENABLED &&
|
||||
(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') &&
|
||||
contentRect ? (
|
||||
<SceneNpcTokensOverlay
|
||||
placements={scene.npcTokens ?? []}
|
||||
library={project?.npcs ?? []}
|
||||
session={sceneNpcTokensSession}
|
||||
viewport={contentRect}
|
||||
grid={scene.grid}
|
||||
poseOverrides={pathLivePoses.npcPoses}
|
||||
/>
|
||||
) : null}
|
||||
{USERS_BRANCH_FEATURES_ENABLED &&
|
||||
(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') &&
|
||||
contentRect ? (
|
||||
<ScenePlayerTokensOverlay
|
||||
library={appPlayers}
|
||||
session={scenePlayerTokensSession}
|
||||
displayScale={sceneNpcTokensSession.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE}
|
||||
viewport={contentRect}
|
||||
grid={scene.grid}
|
||||
/>
|
||||
) : null}
|
||||
{(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') && contentRect ? (
|
||||
<SceneTrapsOverlay
|
||||
traps={scene.traps ?? []}
|
||||
session={sceneTraps}
|
||||
@@ -185,7 +257,7 @@ export function PresentationView({
|
||||
mode="presentation"
|
||||
/>
|
||||
) : null}
|
||||
{showEffects && scene?.previewAssetType !== 'video' ? (
|
||||
{showEffects && (scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') ? (
|
||||
<PixiEffectsOverlay
|
||||
state={fxState}
|
||||
style={{ zIndex: 6 }}
|
||||
@@ -196,29 +268,30 @@ export function PresentationView({
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{showEffects && scene?.previewAssetType !== 'video' ? (
|
||||
{showEffects && (scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') ? (
|
||||
<ExplosionVideoOverlay state={fxState} viewport={contentRect} />
|
||||
) : null}
|
||||
{showEffects && scene?.previewAssetType === 'image' && scene.darkenScene && contentRect ? (
|
||||
<SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} />
|
||||
{showEffects &&
|
||||
(scene?.previewAssetType === 'image' || scene?.previewAssetType === 'video') &&
|
||||
scene.darkenScene &&
|
||||
contentRect ? (
|
||||
<SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} style={{ zIndex: 30 }} />
|
||||
) : null}
|
||||
<SceneOverlayHost active={Boolean(activeMaterial) || activeNpcItems.length > 0}>
|
||||
{activeMaterial ? (
|
||||
<MaterialOverlay
|
||||
embedded
|
||||
assetId={activeMaterial.assetId}
|
||||
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
|
||||
{...(activeMaterial.legend?.enabled
|
||||
? { legendMarkers: activeMaterial.legend.markers ?? [] }
|
||||
: {})}
|
||||
/>
|
||||
) : null}
|
||||
{activeMaterial?.legend?.enabled ? (
|
||||
<MaterialLegendPanel
|
||||
legend={activeMaterial.legend}
|
||||
layout={materialsOverlay?.legendLayout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
|
||||
/>
|
||||
) : null}
|
||||
<SceneOverlayHost active={activeMaterialItems.length > 0 || activeNpcItems.length > 0}>
|
||||
{activeMaterialItems.map(({ material, layout, legendLayout }) => (
|
||||
<React.Fragment key={material.id}>
|
||||
<MaterialOverlay
|
||||
embedded
|
||||
assetId={material.assetId}
|
||||
layout={layout}
|
||||
materialId={material.id}
|
||||
{...(material.legend?.enabled ? { legendMarkers: material.legend.markers ?? [] } : {})}
|
||||
/>
|
||||
{material.legend?.enabled ? (
|
||||
<MaterialLegendPanel legend={material.legend} layout={legendLayout} />
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{activeNpcItems.length > 0 ? <NpcsSceneOverlay embedded items={activeNpcItems} /> : null}
|
||||
</SceneOverlayHost>
|
||||
{showTitle ? (
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
import type { SceneDarknessRevealStroke, SceneDarknessState } from '../../../shared/types';
|
||||
import type {
|
||||
SceneDarknessRevealStroke,
|
||||
SceneDarknessState,
|
||||
SceneDarknessStrokeMode,
|
||||
} from '../../../shared/types';
|
||||
import { normalizeSceneDarknessStrokeMode } from '../../../shared/types/sceneDarkness';
|
||||
|
||||
export type SceneDarknessOverlayProps = {
|
||||
state: SceneDarknessState | null;
|
||||
@@ -10,14 +15,18 @@ export type SceneDarknessOverlayProps = {
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
function drawRevealStroke(
|
||||
function drawDarknessStroke(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
stroke: SceneDarknessRevealStroke | { points: { x: number; y: number }[]; radiusN: number },
|
||||
stroke: SceneDarknessRevealStroke | { points: { x: number; y: number }[]; radiusN: number; mode?: SceneDarknessStrokeMode },
|
||||
w: number,
|
||||
h: number,
|
||||
): void {
|
||||
const pts = stroke.points;
|
||||
if (pts.length === 0) return;
|
||||
const mode = normalizeSceneDarknessStrokeMode(stroke.mode);
|
||||
ctx.globalCompositeOperation = mode === 'cover' ? 'source-over' : 'destination-out';
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.strokeStyle = '#000000';
|
||||
const r = stroke.radiusN * Math.min(w, h);
|
||||
if (pts.length === 1) {
|
||||
const p = pts[0];
|
||||
@@ -30,7 +39,6 @@ function drawRevealStroke(
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineWidth = r * 2;
|
||||
ctx.strokeStyle = 'rgba(0,0,0,1)';
|
||||
ctx.beginPath();
|
||||
const first = pts[0];
|
||||
if (!first) return;
|
||||
@@ -65,12 +73,11 @@ export function SceneDarknessOverlay({
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
ctx.globalCompositeOperation = 'destination-out';
|
||||
for (const stroke of state.strokes) {
|
||||
drawRevealStroke(ctx, stroke, w, h);
|
||||
drawDarknessStroke(ctx, stroke, w, h);
|
||||
}
|
||||
if (state.draft && state.draft.points.length > 0) {
|
||||
drawRevealStroke(ctx, state.draft, w, h);
|
||||
drawDarknessStroke(ctx, state.draft, w, h);
|
||||
}
|
||||
}, [state, viewport]);
|
||||
|
||||
|
||||
@@ -17,6 +17,15 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.viewportGuide {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 2px solid rgba(245, 197, 66, 0.55);
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.captureZoom {
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -29,9 +38,11 @@
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
/** Затемнение всего родителя (превью пульта / экран презентации); только при открытом материале/NPC. */
|
||||
.dim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 39;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { AssetId, MaterialsOverlayLayout, MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types';
|
||||
import type { AssetId, MaterialId, MaterialsOverlayLayout, MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types';
|
||||
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types';
|
||||
import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext';
|
||||
import { useAssetUrl } from '../useAssetImageUrl';
|
||||
@@ -12,6 +12,7 @@ type Corner = 'nw' | 'ne' | 'sw' | 'se';
|
||||
type MaterialOverlayProps = {
|
||||
assetId: AssetId | null;
|
||||
layout: MaterialsOverlayLayout;
|
||||
materialId?: MaterialId;
|
||||
editable?: boolean;
|
||||
zoomTool?: MaterialsZoomTool | NpcsZoomTool;
|
||||
showClose?: boolean;
|
||||
@@ -92,6 +93,7 @@ function localToScreenOffset(localX: number, localY: number, rotationDeg: number
|
||||
export function MaterialOverlay({
|
||||
assetId,
|
||||
layout,
|
||||
materialId,
|
||||
editable = false,
|
||||
zoomTool = null,
|
||||
showClose = false,
|
||||
@@ -366,6 +368,7 @@ export function MaterialOverlay({
|
||||
<div
|
||||
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : ''].filter(Boolean).join(' ')}
|
||||
data-overlay-kind="material"
|
||||
{...(materialId ? { 'data-material-id': materialId } : {})}
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.circle {
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
border: 4px solid #c9a227;
|
||||
overflow: hidden;
|
||||
background: #18181b;
|
||||
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.45);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.circlePan {
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.circlePan:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 140%;
|
||||
height: 140%;
|
||||
object-fit: cover;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.avatarEmpty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
#27272a,
|
||||
#27272a 6px,
|
||||
#3f3f46 6px,
|
||||
#3f3f46 12px
|
||||
);
|
||||
}
|
||||
|
||||
.namePlate {
|
||||
max-width: 100%;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
background: rgba(9, 9, 11, 0.88);
|
||||
color: #fafafa;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import type { PlayerImageOffset } from '../../../shared/types/appPlayers';
|
||||
import {
|
||||
clampPlayerImageOffset,
|
||||
clampPlayerImageScale,
|
||||
DEFAULT_PLAYER_IMAGE_SCALE,
|
||||
DEFAULT_PLAYER_RING_COLOR,
|
||||
PLAYER_IMAGE_SCALE_STEP,
|
||||
} from '../../../shared/types/appPlayers';
|
||||
|
||||
import styles from './PlayerTokenView.module.css';
|
||||
|
||||
export type PlayerTokenViewProps = {
|
||||
name: string;
|
||||
imageUrl: string | null;
|
||||
ringColor?: string;
|
||||
imageOffset?: PlayerImageOffset;
|
||||
imageScale?: number;
|
||||
/** Размер круга в px (для превью в модалке). На сцене задавайте через CSS width/height родителя. */
|
||||
sizePx?: number;
|
||||
/** Разрешить drag-pan аватара внутри круга и zoom колесом. */
|
||||
panEnabled?: boolean;
|
||||
onImageOffsetChange?: (offset: PlayerImageOffset) => void;
|
||||
onImageScaleChange?: (scale: number) => void;
|
||||
className?: string;
|
||||
/** Без подписи имени (компактный маркер). */
|
||||
hideName?: boolean;
|
||||
/** Session «Неактивен»: серый фильтр аватара. */
|
||||
inactive?: boolean;
|
||||
'data-testid'?: string;
|
||||
};
|
||||
|
||||
export function PlayerTokenView({
|
||||
name,
|
||||
imageUrl,
|
||||
ringColor = DEFAULT_PLAYER_RING_COLOR,
|
||||
imageOffset,
|
||||
imageScale,
|
||||
sizePx = 160,
|
||||
panEnabled = false,
|
||||
onImageOffsetChange,
|
||||
onImageScaleChange,
|
||||
className,
|
||||
hideName = false,
|
||||
inactive = false,
|
||||
'data-testid': testId,
|
||||
}: PlayerTokenViewProps) {
|
||||
const offset = clampPlayerImageOffset(imageOffset);
|
||||
const scale = clampPlayerImageScale(imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE);
|
||||
const circleRef = useRef<HTMLDivElement | null>(null);
|
||||
const scaleRef = useRef(scale);
|
||||
scaleRef.current = scale;
|
||||
const onImageScaleChangeRef = useRef(onImageScaleChange);
|
||||
onImageScaleChangeRef.current = onImageScaleChange;
|
||||
const dragRef = useRef<{
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
origX: number;
|
||||
origY: number;
|
||||
} | null>(null);
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!panEnabled || !onImageOffsetChange) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
dragRef.current = {
|
||||
pointerId: e.pointerId,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
origX: offset.x,
|
||||
origY: offset.y,
|
||||
};
|
||||
},
|
||||
[offset.x, offset.y, onImageOffsetChange, panEnabled],
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || d.pointerId !== e.pointerId || !onImageOffsetChange) return;
|
||||
const dx = (e.clientX - d.startX) / Math.max(1, sizePx);
|
||||
const dy = (e.clientY - d.startY) / Math.max(1, sizePx);
|
||||
onImageOffsetChange(clampPlayerImageOffset({ x: d.origX + dx, y: d.origY + dy }));
|
||||
},
|
||||
[onImageOffsetChange, sizePx],
|
||||
);
|
||||
|
||||
const onPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || d.pointerId !== e.pointerId) return;
|
||||
dragRef.current = null;
|
||||
try {
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = circleRef.current;
|
||||
if (!el || !panEnabled) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
const cb = onImageScaleChangeRef.current;
|
||||
if (!cb) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const direction = e.deltaY < 0 ? 1 : -1;
|
||||
cb(clampPlayerImageScale(scaleRef.current + direction * PLAYER_IMAGE_SCALE_STEP));
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, [panEnabled]);
|
||||
|
||||
const rootClass = [styles.root, className ?? ''].filter(Boolean).join(' ');
|
||||
const nameFontPx = Math.max(9, Math.min(13, Math.round(sizePx * 0.16)));
|
||||
const nameGapPx = Math.max(2, Math.min(8, Math.round(sizePx * 0.06)));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={rootClass}
|
||||
style={{ width: sizePx, gap: hideName ? undefined : nameGapPx }}
|
||||
data-testid={testId}
|
||||
>
|
||||
<div
|
||||
ref={circleRef}
|
||||
className={[styles.circle, panEnabled ? styles.circlePan : ''].filter(Boolean).join(' ')}
|
||||
style={{
|
||||
width: sizePx,
|
||||
height: sizePx,
|
||||
borderColor: inactive ? '#9ca3af' : ringColor,
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<img
|
||||
className={styles.avatar}
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${String(offset.x * 100)}%, ${String(offset.y * 100)}%) scale(${String(scale)})`,
|
||||
...(inactive ? { filter: 'grayscale(1)', opacity: 0.85 } : {}),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.avatarEmpty} />
|
||||
)}
|
||||
</div>
|
||||
{!hideName ? (
|
||||
<div className={styles.namePlate} title={name} style={{ fontSize: nameFontPx }}>
|
||||
{name || '—'}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
.layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 9;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.token {
|
||||
position: absolute;
|
||||
overflow: visible;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.token > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.editable {
|
||||
pointer-events: auto;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.editable:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
NpcDisposition,
|
||||
ProjectNpc,
|
||||
SceneGrid,
|
||||
SceneNpcToken,
|
||||
SceneNpcTokensSessionState,
|
||||
} from '../../../shared/types';
|
||||
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../../shared/types/appPlayers';
|
||||
import {
|
||||
normalizeNpcDisposition,
|
||||
npcDispositionRingColor,
|
||||
} from '../../../shared/types/npcDisposition';
|
||||
import { sceneGridTokenFitFactor } from '../../../shared/types/sceneGrid';
|
||||
import { useAssetUrl } from '../useAssetImageUrl';
|
||||
|
||||
import { PlayerTokenView } from './PlayerTokenView';
|
||||
import { useLiveDragBroadcast } from './useLiveDragBroadcast';
|
||||
import styles from './SceneNpcTokensOverlay.module.css';
|
||||
|
||||
type Viewport = { x: number; y: number; w: number; h: number };
|
||||
|
||||
export function resolveNpcTokenDisposition(
|
||||
placement: SceneNpcToken,
|
||||
npc: ProjectNpc,
|
||||
sessionDisposition?: NpcDisposition,
|
||||
): NpcDisposition {
|
||||
if (sessionDisposition) return normalizeNpcDisposition(sessionDisposition);
|
||||
return normalizeNpcDisposition(placement.disposition ?? npc.disposition);
|
||||
}
|
||||
|
||||
function NpcSprite({
|
||||
placement,
|
||||
npc,
|
||||
nx,
|
||||
ny,
|
||||
viewport,
|
||||
editable,
|
||||
dragEnabled,
|
||||
displayScale,
|
||||
gridFit,
|
||||
disposition,
|
||||
inactive,
|
||||
onMove,
|
||||
onContextMenu,
|
||||
snapNorm,
|
||||
}: {
|
||||
placement: SceneNpcToken;
|
||||
npc: ProjectNpc;
|
||||
nx: number;
|
||||
ny: number;
|
||||
viewport: Viewport;
|
||||
editable: boolean;
|
||||
dragEnabled: boolean;
|
||||
displayScale: number;
|
||||
gridFit: number;
|
||||
disposition: NpcDisposition;
|
||||
inactive: boolean;
|
||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
}) {
|
||||
const imageUrl = useAssetUrl(npc.avatarAssetId);
|
||||
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
|
||||
const dragRef = useRef<{
|
||||
pointerId: number;
|
||||
startNx: number;
|
||||
startNy: number;
|
||||
pointerNx: number;
|
||||
pointerNy: number;
|
||||
lastNx: number;
|
||||
lastNy: number;
|
||||
} | null>(null);
|
||||
const { schedule, flush } = useLiveDragBroadcast(onMove, String(placement.id));
|
||||
const pos = localPos ?? { nx, ny };
|
||||
const minDim = Math.min(viewport.w, viewport.h);
|
||||
const sizePx = Math.max(16, placement.sizeN * gridFit * displayScale * minDim);
|
||||
const canDrag = editable && dragEnabled && onMove !== undefined;
|
||||
|
||||
const point = (e: React.PointerEvent) => {
|
||||
const host = e.currentTarget.parentElement;
|
||||
const rect = host?.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.max(0, Math.min(1, (e.clientX - ((rect?.left ?? 0) + viewport.x)) / Math.max(1, viewport.w))),
|
||||
y: Math.max(0, Math.min(1, (e.clientY - ((rect?.top ?? 0) + viewport.y)) / Math.max(1, viewport.h))),
|
||||
};
|
||||
};
|
||||
|
||||
const end = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (drag?.pointerId !== e.pointerId) return;
|
||||
dragRef.current = null;
|
||||
flush(drag.lastNx, drag.lastNy);
|
||||
setLocalPos(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[styles.token, canDrag ? styles.editable : ''].filter(Boolean).join(' ')}
|
||||
data-testid={`session-npc-token-${placement.id}`}
|
||||
style={{
|
||||
left: viewport.x + pos.nx * viewport.w,
|
||||
top: viewport.y + pos.ny * viewport.h,
|
||||
width: sizePx,
|
||||
height: sizePx,
|
||||
pointerEvents: canDrag || onContextMenu ? 'auto' : undefined,
|
||||
}}
|
||||
onContextMenu={
|
||||
onContextMenu
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, placement);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPointerDown={
|
||||
canDrag
|
||||
? (e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const p = point(e);
|
||||
dragRef.current = {
|
||||
pointerId: e.pointerId,
|
||||
startNx: pos.nx,
|
||||
startNy: pos.ny,
|
||||
pointerNx: p.x,
|
||||
pointerNy: p.y,
|
||||
lastNx: pos.nx,
|
||||
lastNy: pos.ny,
|
||||
};
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPointerMove={
|
||||
canDrag
|
||||
? (e) => {
|
||||
const drag = dragRef.current;
|
||||
if (drag?.pointerId !== e.pointerId) return;
|
||||
const p = point(e);
|
||||
const rawNx = Math.max(0, Math.min(1, drag.startNx + p.x - drag.pointerNx));
|
||||
const rawNy = Math.max(0, Math.min(1, drag.startNy + p.y - drag.pointerNy));
|
||||
const snapped = snapNorm ? snapNorm(rawNx, rawNy) : { nx: rawNx, ny: rawNy };
|
||||
drag.lastNx = snapped.nx;
|
||||
drag.lastNy = snapped.ny;
|
||||
setLocalPos({ nx: drag.lastNx, ny: drag.lastNy });
|
||||
schedule(drag.lastNx, drag.lastNy);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPointerUp={end}
|
||||
onPointerCancel={end}
|
||||
>
|
||||
<PlayerTokenView
|
||||
name={npc.name}
|
||||
imageUrl={imageUrl}
|
||||
ringColor={npcDispositionRingColor(disposition, inactive)}
|
||||
imageOffset={npc.imageOffset}
|
||||
imageScale={npc.imageScale}
|
||||
sizePx={sizePx}
|
||||
inactive={inactive}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SceneNpcTokensOverlay({
|
||||
placements,
|
||||
library,
|
||||
session,
|
||||
viewport,
|
||||
grid = null,
|
||||
editable = false,
|
||||
onMove,
|
||||
onContextMenu,
|
||||
snapNorm,
|
||||
poseOverrides = null,
|
||||
dragEnabledById = null,
|
||||
}: {
|
||||
placements: readonly SceneNpcToken[];
|
||||
library: readonly ProjectNpc[];
|
||||
session: SceneNpcTokensSessionState | null;
|
||||
viewport: Viewport | null;
|
||||
/** Для гекса — вписанный диаметр ячейки, не описанный. */
|
||||
grid?: SceneGrid | null;
|
||||
editable?: boolean;
|
||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
poseOverrides?: Record<string, { nx: number; ny: number }> | null;
|
||||
dragEnabledById?: Record<string, boolean> | null;
|
||||
}) {
|
||||
if (!viewport) return null;
|
||||
const byId = new Map(library.map((npc) => [npc.id, npc]));
|
||||
const displayScale = session?.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE;
|
||||
const gridFit = sceneGridTokenFitFactor(grid);
|
||||
return (
|
||||
<div className={styles.layer}>
|
||||
{placements.map((placement) => {
|
||||
const npc = byId.get(placement.npcId);
|
||||
if (!npc) return null;
|
||||
const key = String(placement.id);
|
||||
const pose = poseOverrides?.[key];
|
||||
const override = session?.byPlacementId[key];
|
||||
const disposition = resolveNpcTokenDisposition(placement, npc, override?.disposition);
|
||||
const inactive = Boolean(override?.inactive);
|
||||
const dragEnabled = dragEnabledById ? Boolean(dragEnabledById[key]) : true;
|
||||
return (
|
||||
<NpcSprite
|
||||
key={placement.id}
|
||||
placement={placement}
|
||||
npc={npc}
|
||||
nx={pose?.nx ?? override?.nx ?? placement.nx}
|
||||
ny={pose?.ny ?? override?.ny ?? placement.ny}
|
||||
viewport={viewport}
|
||||
editable={editable}
|
||||
dragEnabled={dragEnabled}
|
||||
displayScale={displayScale}
|
||||
gridFit={gridFit}
|
||||
disposition={disposition}
|
||||
inactive={inactive}
|
||||
{...(onMove ? { onMove } : {})}
|
||||
{...(onContextMenu ? { onContextMenu } : {})}
|
||||
{...(snapNorm ? { snapNorm } : {})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
AppPlayer,
|
||||
SceneGrid,
|
||||
ScenePlayerTokensSessionState,
|
||||
} from '../../../shared/types';
|
||||
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../../shared/types/appPlayers';
|
||||
import { sceneGridTokenFitFactor } from '../../../shared/types/sceneGrid';
|
||||
import { usePlayerImageUrl } from './usePlayerImageUrl';
|
||||
|
||||
import { PlayerTokenView } from './PlayerTokenView';
|
||||
import { useLiveDragBroadcast } from './useLiveDragBroadcast';
|
||||
import styles from './SceneNpcTokensOverlay.module.css';
|
||||
|
||||
type Viewport = { x: number; y: number; w: number; h: number };
|
||||
|
||||
function PlayerSprite({
|
||||
player,
|
||||
nx,
|
||||
ny,
|
||||
sizeN,
|
||||
viewport,
|
||||
editable,
|
||||
displayScale,
|
||||
gridFit,
|
||||
onMove,
|
||||
snapNorm,
|
||||
}: {
|
||||
player: AppPlayer;
|
||||
nx: number;
|
||||
ny: number;
|
||||
sizeN: number;
|
||||
viewport: Viewport;
|
||||
editable: boolean;
|
||||
displayScale: number;
|
||||
gridFit: number;
|
||||
onMove?: (playerId: string, nx: number, ny: number) => void;
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
}) {
|
||||
const imageUrl = usePlayerImageUrl(player.id);
|
||||
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
|
||||
const dragRef = useRef<{
|
||||
pointerId: number;
|
||||
startNx: number;
|
||||
startNy: number;
|
||||
pointerNx: number;
|
||||
pointerNy: number;
|
||||
lastNx: number;
|
||||
lastNy: number;
|
||||
} | null>(null);
|
||||
const { schedule, flush } = useLiveDragBroadcast(onMove, String(player.id));
|
||||
const pos = localPos ?? { nx, ny };
|
||||
const minDim = Math.min(viewport.w, viewport.h);
|
||||
const sizePx = Math.max(16, sizeN * gridFit * displayScale * minDim);
|
||||
|
||||
const point = (e: React.PointerEvent) => {
|
||||
const host = e.currentTarget.parentElement;
|
||||
const rect = host?.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.max(0, Math.min(1, (e.clientX - ((rect?.left ?? 0) + viewport.x)) / Math.max(1, viewport.w))),
|
||||
y: Math.max(0, Math.min(1, (e.clientY - ((rect?.top ?? 0) + viewport.y)) / Math.max(1, viewport.h))),
|
||||
};
|
||||
};
|
||||
|
||||
const end = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (drag?.pointerId !== e.pointerId) return;
|
||||
dragRef.current = null;
|
||||
flush(drag.lastNx, drag.lastNy);
|
||||
setLocalPos(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[styles.token, editable ? styles.editable : ''].filter(Boolean).join(' ')}
|
||||
data-testid={`session-player-token-${player.id}`}
|
||||
style={{
|
||||
left: viewport.x + pos.nx * viewport.w,
|
||||
top: viewport.y + pos.ny * viewport.h,
|
||||
width: sizePx,
|
||||
height: sizePx,
|
||||
}}
|
||||
onPointerDown={
|
||||
editable && onMove !== undefined
|
||||
? (e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const p = point(e);
|
||||
dragRef.current = {
|
||||
pointerId: e.pointerId,
|
||||
startNx: pos.nx,
|
||||
startNy: pos.ny,
|
||||
pointerNx: p.x,
|
||||
pointerNy: p.y,
|
||||
lastNx: pos.nx,
|
||||
lastNy: pos.ny,
|
||||
};
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPointerMove={
|
||||
editable && onMove
|
||||
? (e) => {
|
||||
const drag = dragRef.current;
|
||||
if (drag?.pointerId !== e.pointerId) return;
|
||||
const p = point(e);
|
||||
const rawNx = Math.max(0, Math.min(1, drag.startNx + p.x - drag.pointerNx));
|
||||
const rawNy = Math.max(0, Math.min(1, drag.startNy + p.y - drag.pointerNy));
|
||||
const snapped = snapNorm ? snapNorm(rawNx, rawNy) : { nx: rawNx, ny: rawNy };
|
||||
drag.lastNx = snapped.nx;
|
||||
drag.lastNy = snapped.ny;
|
||||
setLocalPos({ nx: drag.lastNx, ny: drag.lastNy });
|
||||
schedule(drag.lastNx, drag.lastNy);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPointerUp={end}
|
||||
onPointerCancel={end}
|
||||
>
|
||||
<PlayerTokenView
|
||||
name={player.name}
|
||||
imageUrl={imageUrl}
|
||||
ringColor={player.ringColor}
|
||||
imageOffset={player.imageOffset}
|
||||
imageScale={player.imageScale}
|
||||
sizePx={sizePx}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScenePlayerTokensOverlay({
|
||||
library,
|
||||
session,
|
||||
displayScale = DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
viewport,
|
||||
grid = null,
|
||||
editable = false,
|
||||
onMove,
|
||||
snapNorm,
|
||||
}: {
|
||||
library: readonly AppPlayer[];
|
||||
session: ScenePlayerTokensSessionState | null;
|
||||
/** Общий масштаб с НПС-токенами. */
|
||||
displayScale?: number;
|
||||
viewport: Viewport | null;
|
||||
grid?: SceneGrid | null;
|
||||
editable?: boolean;
|
||||
onMove?: (playerId: string, nx: number, ny: number) => void;
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
}) {
|
||||
if (!viewport || !session?.visible) return null;
|
||||
const byId = new Map(library.map((p) => [String(p.id), p]));
|
||||
const gridFit = sceneGridTokenFitFactor(grid);
|
||||
return (
|
||||
<div className={styles.layer}>
|
||||
{session.selectedPlayerIds.map((playerId) => {
|
||||
const player = byId.get(playerId);
|
||||
const placement = session.byPlayerId[playerId];
|
||||
if (!player || !placement) return null;
|
||||
return (
|
||||
<PlayerSprite
|
||||
key={playerId}
|
||||
player={player}
|
||||
nx={placement.nx}
|
||||
ny={placement.ny}
|
||||
sizeN={placement.sizeN}
|
||||
viewport={viewport}
|
||||
editable={editable}
|
||||
displayScale={displayScale}
|
||||
gridFit={gridFit}
|
||||
{...(onMove ? { onMove } : {})}
|
||||
{...(snapNorm ? { snapNorm } : {})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type { AppPlayer, AppPlayerTeam } from '../../../shared/types';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
export function useAppPlayers(): { players: AppPlayer[]; teams: AppPlayerTeam[] } {
|
||||
const api = getDndApi();
|
||||
const [players, setPlayers] = useState<AppPlayer[]>([]);
|
||||
const [teams, setTeams] = useState<AppPlayerTeam[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.players.list, {}).then((res) => {
|
||||
setPlayers(res.players);
|
||||
setTeams(res.teams);
|
||||
});
|
||||
return api.on(ipcChannels.players.stateChanged, ({ players: nextPlayers, teams: nextTeams }) => {
|
||||
setPlayers(nextPlayers);
|
||||
setTeams(nextTeams);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
return { players, teams };
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
/** Во время drag шлёт onMove не чаще одного раза за кадр (для live-синхронизации презентации). */
|
||||
export function useLiveDragBroadcast(
|
||||
onMove: ((id: string, nx: number, ny: number) => void) | undefined,
|
||||
id: string,
|
||||
): {
|
||||
schedule: (nx: number, ny: number) => void;
|
||||
flush: (nx: number, ny: number) => void;
|
||||
} {
|
||||
const rafRef = useRef(0);
|
||||
const pendingRef = useRef<{ nx: number; ny: number } | null>(null);
|
||||
const onMoveRef = useRef(onMove);
|
||||
onMoveRef.current = onMove;
|
||||
const idRef = useRef(id);
|
||||
idRef.current = id;
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const schedule = useCallback((nx: number, ny: number) => {
|
||||
if (!onMoveRef.current) return;
|
||||
pendingRef.current = { nx, ny };
|
||||
if (rafRef.current) return;
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = 0;
|
||||
const pending = pendingRef.current;
|
||||
const cb = onMoveRef.current;
|
||||
if (!pending || !cb) return;
|
||||
cb(idRef.current, pending.nx, pending.ny);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const flush = useCallback((nx: number, ny: number) => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = 0;
|
||||
}
|
||||
pendingRef.current = null;
|
||||
onMoveRef.current?.(idRef.current, nx, ny);
|
||||
}, []);
|
||||
|
||||
return { schedule, flush };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type { PlayerId } from '../../../shared/types';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
export function usePlayerImageUrl(playerId: PlayerId | null | undefined): string | null {
|
||||
const api = getDndApi();
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playerId) {
|
||||
setUrl(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void api.invoke(ipcChannels.players.imageUrl, { id: playerId }).then((res) => {
|
||||
if (!cancelled) setUrl(res.url);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [api, playerId]);
|
||||
|
||||
return url;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type {
|
||||
SceneNpcTokensSessionEvent,
|
||||
SceneNpcTokensSessionPlacement,
|
||||
SceneNpcTokensSessionState,
|
||||
} from '../../../shared/types';
|
||||
import {
|
||||
clampNpcTokenSessionScale,
|
||||
DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
} from '../../../shared/types/appPlayers';
|
||||
import { normalizeNpcDisposition } from '../../../shared/types/npcDisposition';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
function withScale(state: SceneNpcTokensSessionState): SceneNpcTokensSessionState {
|
||||
return {
|
||||
...state,
|
||||
scale: clampNpcTokenSessionScale(state.scale),
|
||||
};
|
||||
}
|
||||
|
||||
function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
|
||||
return p.nx === undefined && p.ny === undefined && !p.disposition && !p.inactive;
|
||||
}
|
||||
|
||||
function applyEvent(
|
||||
prev: SceneNpcTokensSessionState,
|
||||
event: SceneNpcTokensSessionEvent,
|
||||
): SceneNpcTokensSessionState {
|
||||
if (event.kind === 'clear') {
|
||||
return {
|
||||
revision: prev.revision + 1,
|
||||
byPlacementId: {},
|
||||
scale: DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
};
|
||||
}
|
||||
if (event.kind === 'setScale') {
|
||||
const scale = clampNpcTokenSessionScale(event.scale);
|
||||
if (prev.scale === scale) return prev;
|
||||
return { ...prev, revision: prev.revision + 1, scale };
|
||||
}
|
||||
if (event.kind === 'setDisposition') {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return prev;
|
||||
const disposition = normalizeNpcDisposition(event.disposition);
|
||||
const cur = prev.byPlacementId[placementId];
|
||||
if (cur?.disposition === disposition) return prev;
|
||||
return {
|
||||
revision: prev.revision + 1,
|
||||
byPlacementId: {
|
||||
...prev.byPlacementId,
|
||||
[placementId]: {
|
||||
...(cur?.nx !== undefined ? { nx: cur.nx } : {}),
|
||||
...(cur?.ny !== undefined ? { ny: cur.ny } : {}),
|
||||
disposition,
|
||||
...(cur?.inactive ? { inactive: true } : {}),
|
||||
},
|
||||
},
|
||||
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
};
|
||||
}
|
||||
if (event.kind === 'setInactive') {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return prev;
|
||||
const inactive = Boolean(event.inactive);
|
||||
const cur = prev.byPlacementId[placementId];
|
||||
if (Boolean(cur?.inactive) === inactive) return prev;
|
||||
const next: SceneNpcTokensSessionPlacement = {
|
||||
...(cur?.nx !== undefined ? { nx: cur.nx } : {}),
|
||||
...(cur?.ny !== undefined ? { ny: cur.ny } : {}),
|
||||
...(cur?.disposition ? { disposition: cur.disposition } : {}),
|
||||
...(inactive ? { inactive: true } : {}),
|
||||
};
|
||||
if (isEmptyPlacement(next)) {
|
||||
const { [placementId]: _removed, ...rest } = prev.byPlacementId;
|
||||
return {
|
||||
revision: prev.revision + 1,
|
||||
byPlacementId: rest,
|
||||
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
};
|
||||
}
|
||||
return {
|
||||
revision: prev.revision + 1,
|
||||
byPlacementId: {
|
||||
...prev.byPlacementId,
|
||||
[placementId]: next,
|
||||
},
|
||||
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
};
|
||||
}
|
||||
// move
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return prev;
|
||||
const cur = prev.byPlacementId[placementId];
|
||||
return {
|
||||
revision: prev.revision + 1,
|
||||
byPlacementId: {
|
||||
...prev.byPlacementId,
|
||||
[placementId]: {
|
||||
nx: event.nx,
|
||||
ny: event.ny,
|
||||
...(cur?.disposition ? { disposition: cur.disposition } : {}),
|
||||
...(cur?.inactive ? { inactive: true } : {}),
|
||||
},
|
||||
},
|
||||
scale: prev.scale ?? DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSceneNpcTokensSession(): [
|
||||
SceneNpcTokensSessionState,
|
||||
{ dispatch: (event: SceneNpcTokensSessionEvent) => void },
|
||||
] {
|
||||
const api = getDndApi();
|
||||
const [state, setState] = useState<SceneNpcTokensSessionState>({
|
||||
revision: 0,
|
||||
byPlacementId: {},
|
||||
scale: DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
});
|
||||
const localRevRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.sceneNpcTokensSession.getState, {}).then((res) => {
|
||||
const next = withScale(res.state);
|
||||
// Не затираем более свежий optimistic/broadcast стейт устаревшим getState.
|
||||
if (next.revision < localRevRef.current) return;
|
||||
localRevRef.current = next.revision;
|
||||
setState(next);
|
||||
});
|
||||
return api.on(ipcChannels.sceneNpcTokensSession.stateChanged, ({ state: incoming }) => {
|
||||
const next = withScale(incoming);
|
||||
if (next.revision < localRevRef.current) return;
|
||||
localRevRef.current = next.revision;
|
||||
setState(next);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const dispatch = useCallback(
|
||||
(event: SceneNpcTokensSessionEvent) => {
|
||||
setState((prev) => {
|
||||
const next = applyEvent(prev, event);
|
||||
localRevRef.current = Math.max(localRevRef.current, next.revision);
|
||||
return next;
|
||||
});
|
||||
void api.invoke(ipcChannels.sceneNpcTokensSession.dispatch, { event });
|
||||
},
|
||||
[api],
|
||||
);
|
||||
|
||||
return [state, { dispatch }];
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type {
|
||||
ScenePlayerTokensSessionEvent,
|
||||
ScenePlayerTokensSessionState,
|
||||
} from '../../../shared/types';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
function applyEvent(
|
||||
prev: ScenePlayerTokensSessionState,
|
||||
event: ScenePlayerTokensSessionEvent,
|
||||
): ScenePlayerTokensSessionState {
|
||||
if (event.kind === 'clear') {
|
||||
return { revision: prev.revision + 1, selectedPlayerIds: [], visible: false, byPlayerId: {} };
|
||||
}
|
||||
if (event.kind === 'clearPlacements') {
|
||||
return { ...prev, revision: prev.revision + 1, visible: false, byPlayerId: {} };
|
||||
}
|
||||
if (event.kind === 'setSelection') {
|
||||
const seen = new Set<string>();
|
||||
const selectedPlayerIds: string[] = [];
|
||||
for (const id of event.playerIds) {
|
||||
const s = String(id ?? '').trim();
|
||||
if (!s || seen.has(s)) continue;
|
||||
seen.add(s);
|
||||
selectedPlayerIds.push(s);
|
||||
}
|
||||
return { revision: prev.revision + 1, selectedPlayerIds, visible: false, byPlayerId: {} };
|
||||
}
|
||||
if (event.kind === 'setVisible') {
|
||||
if (event.visible && prev.selectedPlayerIds.length === 0) return prev;
|
||||
return { ...prev, revision: prev.revision + 1, visible: Boolean(event.visible) };
|
||||
}
|
||||
if (event.kind === 'show') {
|
||||
if (prev.selectedPlayerIds.length === 0) return prev;
|
||||
// placements приходят с main; локально только включаем visible после seed через broadcast
|
||||
return { ...prev, revision: prev.revision + 1, visible: true };
|
||||
}
|
||||
if (event.kind === 'seedBottom') {
|
||||
return prev;
|
||||
}
|
||||
if (event.kind === 'move') {
|
||||
const prevPlacement = prev.byPlayerId[event.playerId];
|
||||
return {
|
||||
...prev,
|
||||
revision: prev.revision + 1,
|
||||
byPlayerId: {
|
||||
...prev.byPlayerId,
|
||||
[event.playerId]: {
|
||||
nx: event.nx,
|
||||
ny: event.ny,
|
||||
sizeN: prevPlacement?.sizeN ?? 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
|
||||
export function useScenePlayerTokensSession(): [
|
||||
ScenePlayerTokensSessionState,
|
||||
{ dispatch: (event: ScenePlayerTokensSessionEvent) => void },
|
||||
] {
|
||||
const api = getDndApi();
|
||||
const [state, setState] = useState<ScenePlayerTokensSessionState>({
|
||||
revision: 0,
|
||||
selectedPlayerIds: [],
|
||||
visible: false,
|
||||
byPlayerId: {},
|
||||
});
|
||||
const localRevRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.scenePlayerTokensSession.getState, {}).then((res) => {
|
||||
if (res.state.revision < localRevRef.current) return;
|
||||
localRevRef.current = res.state.revision;
|
||||
setState(res.state);
|
||||
});
|
||||
return api.on(ipcChannels.scenePlayerTokensSession.stateChanged, ({ state: next }) => {
|
||||
if (next.revision < localRevRef.current) return;
|
||||
localRevRef.current = next.revision;
|
||||
setState(next);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const dispatch = useCallback(
|
||||
(event: ScenePlayerTokensSessionEvent) => {
|
||||
if (event.kind !== 'seedBottom' && event.kind !== 'show') {
|
||||
setState((prev) => {
|
||||
const next = applyEvent(prev, event);
|
||||
localRevRef.current = Math.max(localRevRef.current, next.revision);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
void api.invoke(ipcChannels.scenePlayerTokensSession.dispatch, { event });
|
||||
},
|
||||
[api],
|
||||
);
|
||||
|
||||
return [state, { dispatch }];
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types';
|
||||
import styles from '../materials/MaterialOverlay.module.css';
|
||||
|
||||
import { overlayRootStyle, type SceneOverlayViewport } from './overlayViewport';
|
||||
import { SceneOverlayViewContext } from './SceneOverlayViewContext';
|
||||
|
||||
export type SceneOverlayCloseAction = {
|
||||
@@ -14,6 +15,13 @@ export type SceneOverlayCloseAction = {
|
||||
type SceneOverlayHostProps = {
|
||||
/** Есть ли что показывать (материал и/или NPC). */
|
||||
active: boolean;
|
||||
/**
|
||||
* Область раскладки кадров материалов/NPC (и жёлтой рамки).
|
||||
* На пульте — прямоугольник соотношения сторон презентации; на презентации обычно не задаётся (весь экран).
|
||||
*/
|
||||
viewport?: SceneOverlayViewport | null;
|
||||
/** Жёлтая рамка видимой области презентации (предпросмотр пульта). Без dim. */
|
||||
showViewportGuide?: boolean;
|
||||
zoomTool?: MaterialsZoomTool | NpcsZoomTool;
|
||||
onZoomAt?: (nx: number, ny: number, target: EventTarget | null) => void;
|
||||
closes?: readonly SceneOverlayCloseAction[];
|
||||
@@ -21,11 +29,14 @@ type SceneOverlayHostProps = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Общий слой подложки для Materials + NPCs: один root и один `.dim`.
|
||||
* Кадры остаются в дочерних оверлеях (`embedded`).
|
||||
* Общий слой подложки для Materials + NPCs.
|
||||
* Dim — только при `active`, на весь родитель (экран презентации / рамка превью пульта).
|
||||
* Кадры остаются в дочерних оверлеях (`embedded`) внутри `viewport`.
|
||||
*/
|
||||
export function SceneOverlayHost({
|
||||
active,
|
||||
viewport = null,
|
||||
showViewportGuide = false,
|
||||
zoomTool = null,
|
||||
onZoomAt,
|
||||
closes = [],
|
||||
@@ -54,11 +65,11 @@ export function SceneOverlayHost({
|
||||
ro.disconnect();
|
||||
if (raf !== 0) window.cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [active]);
|
||||
}, [active, showViewportGuide, viewport]);
|
||||
|
||||
const ctx = useMemo(() => ({ rootRef, view }), [view]);
|
||||
|
||||
if (!active) return null;
|
||||
if (!active && !showViewportGuide) return null;
|
||||
|
||||
const captureZoom = Boolean(zoomTool && onZoomAt);
|
||||
const zoomCursor =
|
||||
@@ -76,11 +87,13 @@ export function SceneOverlayHost({
|
||||
|
||||
return (
|
||||
<SceneOverlayViewContext.Provider value={ctx}>
|
||||
{active ? <div className={styles.dim} aria-hidden /> : null}
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={[styles.root, styles.hostHitThrough, captureZoom ? styles.captureZoom : '', zoomCursor]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={overlayRootStyle(viewport)}
|
||||
role="presentation"
|
||||
onClick={(e) => {
|
||||
if (!captureZoom || !onZoomAt) return;
|
||||
@@ -89,7 +102,7 @@ export function SceneOverlayHost({
|
||||
onZoomAt(nx, ny, e.target);
|
||||
}}
|
||||
>
|
||||
<div className={styles.dim} />
|
||||
{showViewportGuide ? <div className={styles.viewportGuide} aria-hidden /> : null}
|
||||
{children}
|
||||
{closes.length > 0 ? (
|
||||
<div className={styles.closeStack}>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export type SceneOverlayViewport = {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
|
||||
export function overlayRootStyle(viewport: SceneOverlayViewport | null | undefined): CSSProperties | undefined {
|
||||
if (!viewport || viewport.w <= 0 || viewport.h <= 0) return undefined;
|
||||
return {
|
||||
left: viewport.x,
|
||||
top: viewport.y,
|
||||
width: viewport.w,
|
||||
height: viewport.h,
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,9 @@ void test('SceneOverlayHost: один dim для materials + npcs в Control и
|
||||
|
||||
assert.ok(host.includes('styles.dim'));
|
||||
assert.ok(host.includes('hostHitThrough'));
|
||||
// Dim только при active; жёлтая рамка без затемнения.
|
||||
assert.match(host, /\{active \? <div className=\{styles\.dim\}/);
|
||||
assert.ok(host.includes('showViewportGuide'));
|
||||
assert.ok(control.includes('SceneOverlayHost'));
|
||||
assert.ok(control.includes('embedded'));
|
||||
assert.ok(presentation.includes('SceneOverlayHost'));
|
||||
@@ -29,6 +32,7 @@ void test('SceneOverlayHost: один dim для materials + npcs в Control и
|
||||
assert.ok(control.includes('embedded'));
|
||||
assert.ok(presentation.includes('embedded'));
|
||||
assert.match(css, /\.hostHitThrough\s*\{[^}]*pointer-events:\s*none/s);
|
||||
assert.match(css, /\.dim\s*\{[^}]*inset:\s*0/s);
|
||||
});
|
||||
|
||||
void test('MaterialOverlay / NpcsSceneOverlay поддерживают embedded без собственного dim', () => {
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.tokenInteractive {
|
||||
pointer-events: auto;
|
||||
cursor: context-menu;
|
||||
}
|
||||
|
||||
.tokenImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -7,6 +7,12 @@ import styles from './SceneTokensOverlay.module.css';
|
||||
|
||||
type Viewport = { x: number; y: number; w: number; h: number };
|
||||
|
||||
export type TokenPoseOverride = {
|
||||
nx: number;
|
||||
ny: number;
|
||||
rotationDeg?: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
placements: readonly SceneToken[];
|
||||
library: readonly AppToken[];
|
||||
@@ -14,22 +20,37 @@ type Props = {
|
||||
viewport: Viewport | null;
|
||||
editable?: boolean;
|
||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||
/** Snap во время drag (пульт, привязка к сетке). */
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
onContextMenu?: (e: React.MouseEvent, placement: SceneToken) => void;
|
||||
/** Live path playback / other pose overrides by placement id. */
|
||||
poseOverrides?: Record<string, TokenPoseOverride> | null;
|
||||
/** Per-placement drag lock (e.g. while path is animating). Default: all editable. */
|
||||
dragEnabledById?: Record<string, boolean> | null;
|
||||
};
|
||||
|
||||
function TokenSprite({
|
||||
placement,
|
||||
nx,
|
||||
ny,
|
||||
rotationDeg,
|
||||
viewport,
|
||||
editable,
|
||||
dragEnabled,
|
||||
onMove,
|
||||
snapNorm,
|
||||
onContextMenu,
|
||||
}: {
|
||||
placement: SceneToken;
|
||||
nx: number;
|
||||
ny: number;
|
||||
rotationDeg: number;
|
||||
viewport: Viewport;
|
||||
editable: boolean;
|
||||
dragEnabled: boolean;
|
||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
onContextMenu?: (e: React.MouseEvent, placement: SceneToken) => void;
|
||||
}) {
|
||||
const url = useTokenImageUrl(placement.tokenId);
|
||||
const dragRef = useRef<{
|
||||
@@ -62,6 +83,8 @@ function TokenSprite({
|
||||
const sizePx = Math.max(16, placement.sizeN * minDim);
|
||||
const left = viewport.x + posNx * viewport.w;
|
||||
const top = viewport.y + posNy * viewport.h;
|
||||
const canDrag = editable && dragEnabled && Boolean(onMove);
|
||||
const interactive = canDrag || Boolean(onContextMenu);
|
||||
|
||||
const hostToNorm = (clientX: number, clientY: number, host: HTMLElement) => {
|
||||
const r = host.getBoundingClientRect();
|
||||
@@ -81,7 +104,6 @@ function TokenSprite({
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = 0;
|
||||
}
|
||||
// Финальный commit в session store — один раз на отпускание.
|
||||
onMove?.(String(placement.id), d.lastNx, d.lastNy);
|
||||
setLocalPos({ nx: d.lastNx, ny: d.lastNy });
|
||||
try {
|
||||
@@ -93,16 +115,31 @@ function TokenSprite({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[styles.token, editable ? styles.tokenEditable : ''].filter(Boolean).join(' ')}
|
||||
className={[
|
||||
styles.token,
|
||||
canDrag ? styles.tokenEditable : '',
|
||||
interactive && !canDrag ? styles.tokenInteractive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
width: sizePx,
|
||||
height: sizePx,
|
||||
transform: `translate(-50%, -50%) rotate(${String(placement.rotationDeg)}deg)`,
|
||||
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
|
||||
}}
|
||||
onContextMenu={
|
||||
onContextMenu
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, placement);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPointerDown={
|
||||
editable && onMove
|
||||
canDrag
|
||||
? (e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
@@ -124,7 +161,7 @@ function TokenSprite({
|
||||
: undefined
|
||||
}
|
||||
onPointerMove={
|
||||
editable && onMove
|
||||
canDrag
|
||||
? (e) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || d.pointerId !== e.pointerId) return;
|
||||
@@ -132,8 +169,9 @@ function TokenSprite({
|
||||
const p = hostToNorm(e.clientX, e.clientY, host);
|
||||
const nextNx = Math.max(0, Math.min(1, d.startNx + (p.x - d.pointerNx)));
|
||||
const nextNy = Math.max(0, Math.min(1, d.startNy + (p.y - d.pointerNy)));
|
||||
d.lastNx = nextNx;
|
||||
d.lastNy = nextNy;
|
||||
const snapped = snapNorm ? snapNorm(nextNx, nextNy) : { nx: nextNx, ny: nextNy };
|
||||
d.lastNx = snapped.nx;
|
||||
d.lastNy = snapped.ny;
|
||||
if (frameRef.current) return;
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
frameRef.current = 0;
|
||||
@@ -163,6 +201,10 @@ export function SceneTokensOverlay({
|
||||
viewport,
|
||||
editable = false,
|
||||
onMove,
|
||||
snapNorm,
|
||||
onContextMenu,
|
||||
poseOverrides = null,
|
||||
dragEnabledById = null,
|
||||
}: Props) {
|
||||
if (!viewport || placements.length === 0) return null;
|
||||
const known = new Set(library.map((t) => t.id));
|
||||
@@ -173,18 +215,25 @@ export function SceneTokensOverlay({
|
||||
.filter((p) => known.has(p.tokenId))
|
||||
.map((placement) => {
|
||||
const key = String(placement.id);
|
||||
const pose = poseOverrides?.[key];
|
||||
const override = session?.byPlacementId[key] ?? session?.byPlacementId[placement.id];
|
||||
const nx = override?.nx ?? placement.nx;
|
||||
const ny = override?.ny ?? placement.ny;
|
||||
const nx = pose?.nx ?? override?.nx ?? placement.nx;
|
||||
const ny = pose?.ny ?? override?.ny ?? placement.ny;
|
||||
const rotationDeg = pose?.rotationDeg ?? placement.rotationDeg;
|
||||
const dragEnabled = dragEnabledById ? Boolean(dragEnabledById[key]) : true;
|
||||
return (
|
||||
<TokenSprite
|
||||
key={key}
|
||||
placement={placement}
|
||||
nx={nx}
|
||||
ny={ny}
|
||||
rotationDeg={rotationDeg}
|
||||
viewport={viewport}
|
||||
editable={editable}
|
||||
onMove={onMove}
|
||||
dragEnabled={dragEnabled}
|
||||
{...(onMove ? { onMove } : {})}
|
||||
{...(snapNorm ? { snapNorm } : {})}
|
||||
{...(onContextMenu ? { onContextMenu } : {})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
.layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 7;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.lineGlow {
|
||||
stroke: rgba(40, 180, 255, 0.28);
|
||||
stroke-width: 6;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.line {
|
||||
stroke: rgba(90, 210, 255, 0.92);
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-dasharray: 7 5;
|
||||
}
|
||||
|
||||
.start {
|
||||
fill: rgba(120, 230, 255, 0.95);
|
||||
stroke: rgba(0, 0, 0, 0.45);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.closed {
|
||||
stroke: rgba(255, 200, 80, 0.9);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.emphasized .line {
|
||||
stroke: rgba(255, 220, 120, 0.95);
|
||||
stroke-dasharray: none;
|
||||
}
|
||||
|
||||
.emphasized .lineGlow {
|
||||
stroke: rgba(255, 200, 80, 0.35);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { TokenPath } from '../../../shared/types/tokenPath';
|
||||
import { tokenPathPolyline } from '../../../shared/types/tokenPath';
|
||||
import type { SceneNpcToken, SceneToken } from '../../../shared/types';
|
||||
import { tokenPathKey, type TokenPathSessionState } from '../../../shared/types/tokenPathSession';
|
||||
|
||||
import styles from './TokenPathsOverlay.module.css';
|
||||
|
||||
type Viewport = { x: number; y: number; w: number; h: number };
|
||||
|
||||
type PathItem = {
|
||||
key: string;
|
||||
path: TokenPath;
|
||||
emphasized?: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
tokens: readonly SceneToken[];
|
||||
npcTokens?: readonly SceneNpcToken[];
|
||||
viewport: Viewport | null;
|
||||
/** control/editor: always; presentation: only presentationVisible */
|
||||
mode: 'always' | 'presentation';
|
||||
pathSession?: TokenPathSessionState | null;
|
||||
/** Highlight path currently edited */
|
||||
emphasizeKey?: string | null;
|
||||
};
|
||||
|
||||
function toSvgPoints(path: TokenPath, viewport: Viewport): string {
|
||||
const pts = tokenPathPolyline(path);
|
||||
return pts
|
||||
.map((p) => {
|
||||
const x = viewport.x + p.nx * viewport.w;
|
||||
const y = viewport.y + p.ny * viewport.h;
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function TokenPathsOverlay({
|
||||
tokens,
|
||||
npcTokens = [],
|
||||
viewport,
|
||||
mode,
|
||||
pathSession = null,
|
||||
emphasizeKey = null,
|
||||
}: Props) {
|
||||
if (!viewport) return null;
|
||||
|
||||
const items: PathItem[] = [];
|
||||
for (const t of tokens) {
|
||||
if (!t.path || t.path.points.length < 2) continue;
|
||||
const key = tokenPathKey('token', String(t.id));
|
||||
if (mode === 'presentation' && !pathSession?.presentationVisible[key]) continue;
|
||||
items.push({ key, path: t.path, emphasized: emphasizeKey === key });
|
||||
}
|
||||
for (const t of npcTokens) {
|
||||
if (!t.path || t.path.points.length < 2) continue;
|
||||
const key = tokenPathKey('npcToken', String(t.id));
|
||||
if (mode === 'presentation' && !pathSession?.presentationVisible[key]) continue;
|
||||
items.push({ key, path: t.path, emphasized: emphasizeKey === key });
|
||||
}
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<svg className={styles.layer} width="100%" height="100%" aria-hidden>
|
||||
{items.map((item) => {
|
||||
const pts = toSvgPoints(item.path, viewport);
|
||||
if (!pts) return null;
|
||||
const first = item.path.points[0]!;
|
||||
const fx = viewport.x + first.nx * viewport.w;
|
||||
const fy = viewport.y + first.ny * viewport.h;
|
||||
return (
|
||||
<g key={item.key} className={item.emphasized ? styles.emphasized : undefined}>
|
||||
<polyline className={styles.lineGlow} points={pts} fill="none" />
|
||||
<polyline className={styles.line} points={pts} fill="none" />
|
||||
<circle className={styles.start} cx={fx} cy={fy} r={4} />
|
||||
{item.path.closed ? (
|
||||
<circle
|
||||
className={styles.closed}
|
||||
cx={fx}
|
||||
cy={fy}
|
||||
r={7}
|
||||
fill="none"
|
||||
/>
|
||||
) : null}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
export function useTokenGridSnapSession(): [
|
||||
boolean,
|
||||
{ setEnabled: (enabled: boolean) => Promise<boolean> },
|
||||
] {
|
||||
const api = getDndApi();
|
||||
const [enabled, setEnabledState] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.tokenGridSnap.getState, {}).then((res) => {
|
||||
setEnabledState(Boolean(res.enabled));
|
||||
});
|
||||
return api.on(ipcChannels.tokenGridSnap.stateChanged, ({ enabled: next }) => {
|
||||
setEnabledState(Boolean(next));
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const setEnabled = useCallback(
|
||||
async (next: boolean) => {
|
||||
setEnabledState(next);
|
||||
const res = await api.invoke(ipcChannels.tokenGridSnap.setEnabled, { enabled: next });
|
||||
setEnabledState(Boolean(res.enabled));
|
||||
return Boolean(res.enabled);
|
||||
},
|
||||
[api],
|
||||
);
|
||||
|
||||
return [enabled, { setEnabled }];
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { computePathPlaybackSample } from '../../../shared/types/tokenPathPlayback';
|
||||
import {
|
||||
tokenPathKey,
|
||||
type TokenPathPlaybackPhase,
|
||||
type TokenPathSessionState,
|
||||
} from '../../../shared/types/tokenPathSession';
|
||||
import type { SceneNpcToken, SceneToken } from '../../../shared/types';
|
||||
|
||||
export type TokenPathLivePose = {
|
||||
nx: number;
|
||||
ny: number;
|
||||
rotationDeg: number;
|
||||
phase: TokenPathPlaybackPhase;
|
||||
dist: number;
|
||||
};
|
||||
|
||||
type PoseMaps = {
|
||||
tokenPoses: Record<string, TokenPathLivePose>;
|
||||
npcPoses: Record<string, TokenPathLivePose>;
|
||||
};
|
||||
|
||||
const EMPTY: PoseMaps = { tokenPoses: {}, npcPoses: {} };
|
||||
|
||||
function posesEqual(a: PoseMaps, b: PoseMaps): boolean {
|
||||
const aT = a.tokenPoses;
|
||||
const bT = b.tokenPoses;
|
||||
const aN = a.npcPoses;
|
||||
const bN = b.npcPoses;
|
||||
const aTk = Object.keys(aT);
|
||||
const bTk = Object.keys(bT);
|
||||
const aNk = Object.keys(aN);
|
||||
const bNk = Object.keys(bN);
|
||||
if (aTk.length !== bTk.length || aNk.length !== bNk.length) return false;
|
||||
for (const k of aTk) {
|
||||
const x = aT[k];
|
||||
const y = bT[k];
|
||||
if (!y || x!.nx !== y.nx || x!.ny !== y.ny || x!.rotationDeg !== y.rotationDeg || x!.phase !== y.phase) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (const k of aNk) {
|
||||
const x = aN[k];
|
||||
const y = bN[k];
|
||||
if (!y || x!.nx !== y.nx || x!.ny !== y.ny || x!.rotationDeg !== y.rotationDeg || x!.phase !== y.phase) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* RAF poses for control/presentation while path playback is active.
|
||||
* When phase === 'stopped', pose is omitted so session/placement (drag) wins.
|
||||
*
|
||||
* Uses local Date.now() — segmentStartedAtMs is also wall-clock from main (same machine).
|
||||
* Do NOT clamp to stale serverNowMs: that froze motion after ~250ms until the next IPC bump.
|
||||
*/
|
||||
export function useTokenPathLivePoses(args: {
|
||||
tokens: readonly SceneToken[];
|
||||
npcTokens: readonly SceneNpcToken[];
|
||||
pathSession: TokenPathSessionState | null;
|
||||
enabled?: boolean;
|
||||
onMarkDone?: (kind: 'token' | 'npcToken', placementId: string, atDist: number) => void;
|
||||
}): PoseMaps {
|
||||
const { tokens, npcTokens, pathSession, enabled = true, onMarkDone } = args;
|
||||
const [poses, setPoses] = useState<PoseMaps>(EMPTY);
|
||||
const markedDoneRef = useRef<Set<string>>(new Set());
|
||||
const onMarkDoneRef = useRef(onMarkDone);
|
||||
onMarkDoneRef.current = onMarkDone;
|
||||
|
||||
const pathSessionRef = useRef(pathSession);
|
||||
pathSessionRef.current = pathSession;
|
||||
const tokensRef = useRef(tokens);
|
||||
tokensRef.current = tokens;
|
||||
const npcTokensRef = useRef(npcTokens);
|
||||
npcTokensRef.current = npcTokens;
|
||||
|
||||
useEffect(() => {
|
||||
markedDoneRef.current.clear();
|
||||
}, [pathSession?.revision]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setPoses(EMPTY);
|
||||
return;
|
||||
}
|
||||
let raf = 0;
|
||||
let alive = true;
|
||||
|
||||
const tick = () => {
|
||||
if (!alive) return;
|
||||
const session = pathSessionRef.current;
|
||||
if (!session) {
|
||||
setPoses((prev) => (prev === EMPTY || Object.keys(prev.tokenPoses).length + Object.keys(prev.npcPoses).length === 0 ? prev : EMPTY));
|
||||
raf = requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
|
||||
// Wall clock: matches main's Date.now() for segmentStartedAtMs (Electron, one host).
|
||||
const sampleNow = Date.now();
|
||||
const tokenPoses: Record<string, TokenPathLivePose> = {};
|
||||
const npcPoses: Record<string, TokenPathLivePose> = {};
|
||||
|
||||
const sampleOne = (
|
||||
kind: 'token' | 'npcToken',
|
||||
placementId: string,
|
||||
path: NonNullable<SceneToken['path']>,
|
||||
out: Record<string, TokenPathLivePose>,
|
||||
) => {
|
||||
const key = tokenPathKey(kind, placementId);
|
||||
const entry = session.playback[key];
|
||||
if (!entry) return;
|
||||
if (entry.phase === 'stopped') return;
|
||||
const result = computePathPlaybackSample({ path, entry, nowMs: sampleNow });
|
||||
if (!result) return;
|
||||
out[placementId] = {
|
||||
nx: result.sample.nx,
|
||||
ny: result.sample.ny,
|
||||
rotationDeg: result.sample.rotationDeg,
|
||||
phase: result.phase,
|
||||
dist: result.sample.dist,
|
||||
};
|
||||
if (result.markDone && !markedDoneRef.current.has(key)) {
|
||||
markedDoneRef.current.add(key);
|
||||
onMarkDoneRef.current?.(kind, placementId, result.sample.dist);
|
||||
}
|
||||
};
|
||||
|
||||
for (const t of tokensRef.current) {
|
||||
if (t.path && t.path.points.length >= 2) {
|
||||
sampleOne('token', String(t.id), t.path, tokenPoses);
|
||||
}
|
||||
}
|
||||
for (const t of npcTokensRef.current) {
|
||||
if (t.path && t.path.points.length >= 2) {
|
||||
sampleOne('npcToken', String(t.id), t.path, npcPoses);
|
||||
}
|
||||
}
|
||||
|
||||
const next: PoseMaps = { tokenPoses, npcPoses };
|
||||
setPoses((prev) => (posesEqual(prev, next) ? prev : next));
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
alive = false;
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return poses;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type { TokenPathSessionEvent, TokenPathSessionState } from '../../../shared/types';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
export function useTokenPathSession(): [
|
||||
TokenPathSessionState | null,
|
||||
{ dispatch: (event: TokenPathSessionEvent) => Promise<void> },
|
||||
] {
|
||||
const api = getDndApi();
|
||||
const [state, setState] = useState<TokenPathSessionState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.tokenPathSession.getState, {}).then(({ state: s }) => {
|
||||
setState(s);
|
||||
});
|
||||
return api.on(ipcChannels.tokenPathSession.stateChanged, ({ state: s }) => {
|
||||
setState(s);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const apiWrap = useMemo(
|
||||
() => ({
|
||||
dispatch: async (event: TokenPathSessionEvent) => {
|
||||
const res = await api.invoke(ipcChannels.tokenPathSession.dispatch, { event });
|
||||
void res;
|
||||
},
|
||||
}),
|
||||
[api],
|
||||
);
|
||||
|
||||
return [state, apiWrap];
|
||||
}
|
||||
@@ -11,13 +11,15 @@
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.5);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 2px solid rgba(255, 255, 255, 0.72);
|
||||
background: rgba(12, 14, 20, 0.62);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: auto;
|
||||
cursor: context-menu;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.45),
|
||||
0 0 10px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.trapActive {
|
||||
@@ -30,12 +32,22 @@
|
||||
.trapDisarmed {
|
||||
border-color: #9ca3af;
|
||||
filter: grayscale(0.7);
|
||||
opacity: 0.75;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.trapGmHidden {
|
||||
opacity: 0.55;
|
||||
/* Hidden from players, but still readable for the GM on control preview. */
|
||||
opacity: 0.88;
|
||||
border-style: dashed;
|
||||
border-color: rgba(255, 230, 160, 0.85);
|
||||
background: rgba(28, 24, 12, 0.72);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.4),
|
||||
0 0 12px rgba(255, 200, 80, 0.22);
|
||||
}
|
||||
|
||||
.trapNonInteractive {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
|
||||
@@ -21,6 +21,8 @@ type Props = {
|
||||
viewport: { x: number; y: number; w: number; h: number } | null;
|
||||
/** Пульт: показывать все ловушки + RMB меню. Презентация: только revealed. */
|
||||
mode: 'control' | 'presentation';
|
||||
/** На пульте: false — без контекстного меню (например, активна кисть эффектов). */
|
||||
interactive?: boolean;
|
||||
onReveal?: (trapId: string) => void;
|
||||
onActivate?: (trapId: string) => void;
|
||||
onDisarm?: (trapId: string) => void;
|
||||
@@ -47,6 +49,7 @@ export function SceneTrapsOverlay({
|
||||
session,
|
||||
viewport,
|
||||
mode,
|
||||
interactive = true,
|
||||
onReveal,
|
||||
onActivate,
|
||||
onDisarm,
|
||||
@@ -54,6 +57,10 @@ export function SceneTrapsOverlay({
|
||||
const [menu, setMenu] = useState<{ trapId: string; x: number; y: number } | null>(null);
|
||||
const [activationFx, setActivationFx] = useState<ActivationFx | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!interactive) setMenu(null);
|
||||
}, [interactive]);
|
||||
|
||||
useEffect(() => {
|
||||
const act = session?.lastActivation;
|
||||
if (!act) return;
|
||||
@@ -112,6 +119,7 @@ export function SceneTrapsOverlay({
|
||||
rt.status === 'active' ? styles.trapActive : '',
|
||||
rt.status === 'disarmed' ? styles.trapDisarmed : '',
|
||||
mode === 'control' && !rt.revealed ? styles.trapGmHidden : '',
|
||||
mode === 'control' && !interactive ? styles.trapNonInteractive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
@@ -121,7 +129,7 @@ export function SceneTrapsOverlay({
|
||||
className={cls}
|
||||
style={{ left, top, width: sizePx, height: sizePx }}
|
||||
onContextMenu={
|
||||
mode === 'control'
|
||||
mode === 'control' && interactive
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -163,7 +171,7 @@ export function SceneTrapsOverlay({
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{menu && mode === 'control'
|
||||
{menu && mode === 'control' && interactive
|
||||
? createPortal(
|
||||
<div
|
||||
role="menu"
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** Однострочный текст с ellipsis; полный title при обрезке. */
|
||||
export function EllipsisText({ text, className }: Props) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const [title, setTitle] = useState<string | undefined>(undefined);
|
||||
|
||||
const syncTitle = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (!el) {
|
||||
setTitle(undefined);
|
||||
return;
|
||||
}
|
||||
setTitle(el.scrollWidth > el.clientWidth + 1 ? text : undefined);
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={className}
|
||||
title={title}
|
||||
onMouseEnter={syncTitle}
|
||||
onMouseLeave={() => setTitle(undefined)}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user