feat(tokens): app-local non-player tokens with session moves and UI polish

Add token library/placements, keep play-time moves for the session, lock presentation interactions, and fix export/import modal layout plus freeform trap label.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-27 11:04:12 +08:00
parent bdeb64e356
commit f270812219
44 changed files with 2617 additions and 341 deletions
+1
View File
@@ -387,6 +387,7 @@ export async function buildProjectFromFoundryDocuments(
previewRotationDeg: 0, previewRotationDeg: 0,
darkenScene: false, darkenScene: false,
traps: [], traps: [],
tokens: [],
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' }, grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
media: { videos: [], audios: audioRefs }, media: { videos: [], audios: audioRefs },
settings: { settings: {
+117 -9
View File
@@ -29,6 +29,8 @@ import { NpcsOverlayStore } from './npcs/npcsOverlayStore';
import { ZipProjectStore } from './project/zipStore'; import { ZipProjectStore } from './project/zipStore';
import { SceneViewStore } from './sceneView/sceneViewStore'; import { SceneViewStore } from './sceneView/sceneViewStore';
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol'; import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
import { SceneTokensSessionStore } from './tokens/sceneTokensSessionStore';
import { TokensStore } from './tokens/tokensStore';
import { installAutoUpdater } from './update/installAutoUpdater'; import { installAutoUpdater } from './update/installAutoUpdater';
import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo'; import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo';
import { VideoPlaybackStore } from './video/videoPlaybackStore'; import { VideoPlaybackStore } from './video/videoPlaybackStore';
@@ -165,6 +167,8 @@ const sceneViewStore = new SceneViewStore();
const videoStore = new VideoPlaybackStore(); const videoStore = new VideoPlaybackStore();
const materialsOverlayStore = new MaterialsOverlayStore(); const materialsOverlayStore = new MaterialsOverlayStore();
const npcsOverlayStore = new NpcsOverlayStore(); const npcsOverlayStore = new NpcsOverlayStore();
const sceneTokensSessionStore = new SceneTokensSessionStore();
let tokensStore: TokensStore | null = null;
function emitEffectsState(): void { function emitEffectsState(): void {
const state = effectsStore.getState(); const state = effectsStore.getState();
@@ -219,6 +223,20 @@ function emitSceneViewState(): void {
} }
} }
function emitTokensState(): void {
const tokens = tokensStore?.list() ?? [];
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.tokens.stateChanged, { tokens });
}
}
function emitSceneTokensSessionState(): void {
const state = sceneTokensSessionStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.sceneTokensSession.stateChanged, { state });
}
}
function syncSceneDarknessForProject(project: Project): void { function syncSceneDarknessForProject(project: Project): void {
const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null; const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null;
const scene = project.currentSceneId ? project.scenes[project.currentSceneId] : undefined; const scene = project.currentSceneId ? project.scenes[project.currentSceneId] : undefined;
@@ -366,11 +384,13 @@ async function main() {
}); });
const licenseService = new LicenseService(app.getPath('userData')); const licenseService = new LicenseService(app.getPath('userData'));
tokensStore = new TokensStore(app.getPath('userData'));
await tokensStore.ensureLoaded();
setLicenseAssert(() => { setLicenseAssert(() => {
licenseService.assertForIpc(); licenseService.assertForIpc();
}); });
installAppMenuForSession(); installAppMenuForSession();
registerDndAssetProtocol(projectStore); registerDndAssetProtocol(projectStore, tokensStore);
registerHandler(ipcChannels.app.quit, () => { registerHandler(ipcChannels.app.quit, () => {
markAppQuitting(); markAppQuitting();
app.quit(); app.quit();
@@ -388,6 +408,7 @@ async function main() {
registerHandler(ipcChannels.windows.openMultiWindow, () => { registerHandler(ipcChannels.windows.openMultiWindow, () => {
sceneDarknessStore.resetSession(); sceneDarknessStore.resetSession();
sceneTrapsStore.resetSession(); sceneTrapsStore.resetSession();
sceneTokensSessionStore.reset();
effectsStore.dispatch({ kind: 'tool.set', tool: effectsDefaultTool() }); effectsStore.dispatch({ kind: 'tool.set', tool: effectsDefaultTool() });
openMultiWindow(); openMultiWindow();
const project = projectStore.getOpenProject(); const project = projectStore.getOpenProject();
@@ -397,6 +418,7 @@ async function main() {
} }
emitSceneDarknessState(); emitSceneDarknessState();
emitSceneTrapsState(); emitSceneTrapsState();
emitSceneTokensSessionState();
emitEffectsState(); emitEffectsState();
return { ok: true }; return { ok: true };
}); });
@@ -491,7 +513,9 @@ async function main() {
registerHandler(ipcChannels.project.open, async ({ projectId }) => { registerHandler(ipcChannels.project.open, async ({ projectId }) => {
const project = await projectStore.openProjectById(projectId); const project = await projectStore.openProjectById(projectId);
sceneViewStore.reset(); sceneViewStore.reset();
sceneTokensSessionStore.reset();
emitSceneViewState(); emitSceneViewState();
emitSceneTokensSessionState();
emitSessionState(); emitSessionState();
return { project }; return { project };
}); });
@@ -503,12 +527,14 @@ async function main() {
sceneDarknessStore.resetSession(); sceneDarknessStore.resetSession();
sceneTrapsStore.resetSession(); sceneTrapsStore.resetSession();
sceneViewStore.reset(); sceneViewStore.reset();
sceneTokensSessionStore.reset();
emitEffectsState(); emitEffectsState();
emitMaterialsOverlayState(); emitMaterialsOverlayState();
emitNpcsOverlayState(); emitNpcsOverlayState();
emitSceneDarknessState(); emitSceneDarknessState();
emitSceneTrapsState(); emitSceneTrapsState();
emitSceneViewState(); emitSceneViewState();
emitSceneTokensSessionState();
emitSessionState(); emitSessionState();
return { ok: true }; return { ok: true };
}); });
@@ -525,6 +551,7 @@ async function main() {
materialsOverlayStore.clear(); materialsOverlayStore.clear();
npcsOverlayStore.clear(); npcsOverlayStore.clear();
sceneViewStore.reset(); sceneViewStore.reset();
// Token moves persist for the whole play session (reset only on project open/close).
const project = projectStore.getOpenProject(); const project = projectStore.getOpenProject();
if (project) { if (project) {
syncSceneDarknessForProject(project); syncSceneDarknessForProject(project);
@@ -536,6 +563,7 @@ async function main() {
emitSceneDarknessState(); emitSceneDarknessState();
emitSceneTrapsState(); emitSceneTrapsState();
emitSceneViewState(); emitSceneViewState();
emitSceneTokensSessionState();
emitSessionState(); emitSessionState();
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null }; return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
}); });
@@ -552,6 +580,7 @@ async function main() {
materialsOverlayStore.clear(); materialsOverlayStore.clear();
npcsOverlayStore.clear(); npcsOverlayStore.clear();
sceneViewStore.reset(); sceneViewStore.reset();
// Token moves persist for the whole play session (reset only on project open/close).
const project = projectStore.getOpenProject(); const project = projectStore.getOpenProject();
if (project) { if (project) {
syncSceneDarknessForProject(project); syncSceneDarknessForProject(project);
@@ -563,6 +592,7 @@ async function main() {
emitSceneDarknessState(); emitSceneDarknessState();
emitSceneTrapsState(); emitSceneTrapsState();
emitSceneViewState(); emitSceneViewState();
emitSceneTokensSessionState();
emitSessionState(); emitSessionState();
const p = projectStore.getOpenProject(); const p = projectStore.getOpenProject();
return { return {
@@ -1018,6 +1048,11 @@ async function main() {
}); });
}, },
npcResolutions, npcResolutions,
async (cacheDir) => {
const remap = await tokensStore!.importFromExportDir(cacheDir);
emitTokensState();
return remap;
},
); );
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' }); emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState(); emitSessionState();
@@ -1041,6 +1076,13 @@ async function main() {
}); });
}, },
npcResolutions, npcResolutions,
async (cacheDir) => {
// Импорт из другого локального проекта: app-tokens в zip нет — только из внешнего архива.
// Если в sourceCache нет app-tokens, remap пустой.
const remap = await tokensStore!.importFromExportDir(cacheDir);
emitTokensState();
return remap;
},
); );
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' }); emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState(); emitSessionState();
@@ -1121,14 +1163,23 @@ async function main() {
const dest = normalizeSaveProjectZipPath(filePath); const dest = normalizeSaveProjectZipPath(filePath);
try { try {
emitZipProgress({ kind: 'export', stage: 'zip', percent: 0, detail: 'Экспорт…' }); emitZipProgress({ kind: 'export', stage: 'zip', percent: 0, detail: 'Экспорт…' });
await projectStore.exportStorylinesZipToPath(projectId, storylineSelections, dest, labels, (p) => { await projectStore.exportStorylinesZipToPath(
emitZipProgress({ projectId,
kind: 'export', storylineSelections,
stage: p.stage, dest,
percent: p.percent, labels,
...(p.detail ? { detail: p.detail } : null), (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: 'Готово' }); emitZipProgress({ kind: 'export', stage: 'done', percent: 100, detail: 'Готово' });
return { canceled: false as const }; return { canceled: false as const };
} catch (err) { } catch (err) {
@@ -1182,6 +1233,63 @@ async function main() {
return { ok: true }; return { ok: true };
}); });
registerHandler(ipcChannels.tokens.list, async () => {
await tokensStore!.ensureLoaded();
return { tokens: tokensStore!.list() };
});
registerHandler(ipcChannels.tokens.upsert, async ({ id, name, filePath }) => {
const token = await tokensStore!.upsert({ id, name, filePath });
emitTokensState();
return { token };
});
registerHandler(ipcChannels.tokens.delete, async ({ id }) => {
await tokensStore!.delete(id);
// Убрать placements с текущей сцены.
const project = projectStore.getOpenProject();
const sceneId = project?.currentSceneId;
if (sceneId && project) {
const scene = project.scenes[sceneId];
if (scene?.tokens?.some((t) => t.tokenId === id)) {
await projectStore.updateScene(sceneId, {
tokens: scene.tokens.filter((t) => t.tokenId !== id),
});
emitSessionState();
}
}
emitTokensState();
return { ok: true };
});
registerHandler(ipcChannels.tokens.pickImage, async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: openDialogFilterLabel('images', app.getLocale()),
extensions: ['png', 'jpg', 'jpeg', 'webp'],
},
],
});
if (canceled || filePaths.length === 0) return { canceled: true as const };
const filePath = filePaths[0]!;
const buf = await fs.readFile(filePath);
const ext = path.extname(filePath).toLowerCase();
const mime =
ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
return { canceled: false as const, filePath, previewDataUrl };
});
registerHandler(ipcChannels.tokens.imageUrl, ({ id }) => {
return { url: tokensStore!.getImageUrl(id) };
});
registerHandler(ipcChannels.sceneTokensSession.getState, () => {
return { state: sceneTokensSessionStore.getState() };
});
registerHandler(ipcChannels.sceneTokensSession.dispatch, ({ event }) => {
sceneTokensSessionStore.dispatch(event);
emitSceneTokensSessionState();
return { ok: true };
});
registerHandler(ipcChannels.video.getState, () => { registerHandler(ipcChannels.video.getState, () => {
return { state: videoStore.getState() }; return { state: videoStore.getState() };
}); });
+34 -2
View File
@@ -15,11 +15,13 @@ import {
} from '../../shared/graph/sceneListOrder'; } from '../../shared/graph/sceneListOrder';
import { import {
buildPartialExportProject, buildPartialExportProject,
collectTokenIdsFromProject,
computeGraphImportOffsetX, computeGraphImportOffsetX,
listExportableStorylines, listExportableStorylines,
listImportableStorylines, listImportableStorylines,
mergeStorylinesIntoProject, mergeStorylinesIntoProject,
newExportBundleProjectId, newExportBundleProjectId,
remapProjectSceneTokenIds,
type NpcImportResolution, type NpcImportResolution,
type SceneImportResolution, type SceneImportResolution,
type StorylineImportMergeReport, type StorylineImportMergeReport,
@@ -51,6 +53,7 @@ import type {
} from '../../shared/types'; } from '../../shared/types';
import { PROJECT_SCHEMA_VERSION } from '../../shared/types'; import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
import { normalizeMaterialLegend } from '../../shared/types/materialLegend'; import { normalizeMaterialLegend } from '../../shared/types/materialLegend';
import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens';
import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid'; import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid';
import { normalizeSceneTrap } from '../../shared/types/sceneTraps'; 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';
@@ -617,12 +620,14 @@ export class ZipProjectStore {
previewRotationDeg: 0, previewRotationDeg: 0,
darkenScene: false, darkenScene: false,
traps: [], traps: [],
tokens: [],
grid: { ...DEFAULT_SCENE_GRID }, grid: { ...DEFAULT_SCENE_GRID },
} satisfies Scene); } satisfies Scene);
const next: Scene = { const next: Scene = {
...base, ...base,
traps: base.traps ?? [], traps: base.traps ?? [],
tokens: base.tokens ?? [],
grid: base.grid ?? { ...DEFAULT_SCENE_GRID }, grid: base.grid ?? { ...DEFAULT_SCENE_GRID },
...(patch.title !== undefined ? { title: patch.title } : null), ...(patch.title !== undefined ? { title: patch.title } : null),
...(patch.description !== undefined ? { description: patch.description } : null), ...(patch.description !== undefined ? { description: patch.description } : null),
@@ -643,6 +648,13 @@ export class ZipProjectStore {
.filter((t): t is SceneTrap => Boolean(t)), .filter((t): t is SceneTrap => Boolean(t)),
} }
: null), : null),
...(patch.tokens !== undefined
? {
tokens: patch.tokens
.map((t) => normalizeSceneToken(t))
.filter((t): t is SceneToken => Boolean(t)),
}
: null),
...(patch.grid !== undefined ? { grid: normalizeSceneGrid(patch.grid) } : null), ...(patch.grid !== undefined ? { grid: normalizeSceneGrid(patch.grid) } : null),
...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null), ...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null),
...(patch.media ? { media: { ...base.media, ...patch.media } } : null), ...(patch.media ? { media: { ...base.media, ...patch.media } } : null),
@@ -1992,6 +2004,7 @@ export class ZipProjectStore {
destinationPath: string, destinationPath: string,
labels: StorylineLabels, labels: StorylineLabels,
onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void, onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void,
packAppTokens?: (tokenIds: string[], exportRoot: string) => Promise<void>,
): Promise<void> { ): Promise<void> {
if (selections.length === 0) throw new Error('Не выбрана ни одна сюжетная линия'); if (selections.length === 0) throw new Error('Не выбрана ни одна сюжетная линия');
await this.ensureRoots(); await this.ensureRoots();
@@ -2016,10 +2029,14 @@ export class ZipProjectStore {
await fs.mkdir(path.dirname(destAbs), { recursive: true }); await fs.mkdir(path.dirname(destAbs), { recursive: true });
await fs.copyFile(srcAbs, destAbs); await fs.copyFile(srcAbs, destAbs);
if (onProgress) { if (onProgress) {
const pct = Math.round(((i + 1) / Math.max(1, assetIds.length)) * 80); const pct = Math.round(((i + 1) / Math.max(1, assetIds.length)) * 75);
onProgress({ stage: 'zip', percent: pct, detail: 'Сборка архива…' }); onProgress({ stage: 'zip', percent: pct, detail: 'Сборка архива…' });
} }
} }
if (packAppTokens) {
const tokenIds = collectTokenIdsFromProject(partial);
await packAppTokens(tokenIds, exportCache);
}
const now = new Date().toISOString(); const now = new Date().toISOString();
const toWrite: Project = { const toWrite: Project = {
...partial, ...partial,
@@ -2116,12 +2133,18 @@ export class ZipProjectStore {
sceneResolutions: SceneImportResolution[], sceneResolutions: SceneImportResolution[],
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void, onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
npcResolutions?: NpcImportResolution[], npcResolutions?: NpcImportResolution[],
importAppTokens?: (sourceCacheDir: string) => Promise<Map<string, string>>,
): Promise<{ project: Project; report: StorylineImportMergeReport }> { ): Promise<{ project: Project; report: StorylineImportMergeReport }> {
if (!this.openProject) throw new Error('Нет открытого проекта'); if (!this.openProject) throw new Error('Нет открытого проекта');
let sourceForMerge = source;
if (importAppTokens) {
const remap = await importAppTokens(sourceCache);
sourceForMerge = remapProjectSceneTokenIds(source, remap);
}
const offsetX = computeGraphImportOffsetX(this.openProject.project); const offsetX = computeGraphImportOffsetX(this.openProject.project);
const { project: merged, report, assetCopies } = mergeStorylinesIntoProject( const { project: merged, report, assetCopies } = mergeStorylinesIntoProject(
this.openProject.project, this.openProject.project,
source, sourceForMerge,
selections, selections,
sceneResolutions, sceneResolutions,
{ {
@@ -2162,6 +2185,7 @@ export class ZipProjectStore {
sceneResolutions: SceneImportResolution[], sceneResolutions: SceneImportResolution[],
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void, onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
npcResolutions?: NpcImportResolution[], npcResolutions?: NpcImportResolution[],
importAppTokens?: (sourceCacheDir: string) => Promise<Map<string, string>>,
): Promise<{ project: Project; report: StorylineImportMergeReport }> { ): Promise<{ project: Project; report: StorylineImportMergeReport }> {
this.assertStorylineMergeAllowed(selections); this.assertStorylineMergeAllowed(selections);
const snap = await this.loadProjectSnapshot(sourceProjectId); const snap = await this.loadProjectSnapshot(sourceProjectId);
@@ -2173,6 +2197,7 @@ export class ZipProjectStore {
sceneResolutions, sceneResolutions,
onProgress, onProgress,
npcResolutions, npcResolutions,
importAppTokens,
); );
} finally { } finally {
if (snap.ownsCache) { if (snap.ownsCache) {
@@ -2187,6 +2212,7 @@ export class ZipProjectStore {
sceneResolutions: SceneImportResolution[], sceneResolutions: SceneImportResolution[],
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void, onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
npcResolutions?: NpcImportResolution[], npcResolutions?: NpcImportResolution[],
importAppTokens?: (sourceCacheDir: string) => Promise<Map<string, string>>,
): Promise<{ project: Project; report: StorylineImportMergeReport }> { ): Promise<{ project: Project; report: StorylineImportMergeReport }> {
this.assertStorylineMergeAllowed(selections); this.assertStorylineMergeAllowed(selections);
const source = await this.readExternalProjectForImport(sourcePath); const source = await this.readExternalProjectForImport(sourcePath);
@@ -2201,6 +2227,7 @@ export class ZipProjectStore {
sceneResolutions, sceneResolutions,
onProgress, onProgress,
npcResolutions, npcResolutions,
importAppTokens,
); );
} finally { } finally {
await fs.rm(sourceCache, { recursive: true, force: true }).catch(() => undefined); await fs.rm(sourceCache, { recursive: true, force: true }).catch(() => undefined);
@@ -2329,6 +2356,10 @@ function normalizeScene(s: Scene): Scene {
const traps = (Array.isArray(rawTraps) ? rawTraps : []) const traps = (Array.isArray(rawTraps) ? rawTraps : [])
.map((t) => normalizeSceneTrap(t)) .map((t) => normalizeSceneTrap(t))
.filter((t): t is SceneTrap => Boolean(t)); .filter((t): t is SceneTrap => Boolean(t));
const rawTokens = (s as unknown as { tokens?: unknown[] }).tokens;
const tokens = (Array.isArray(rawTokens) ? rawTokens : [])
.map((t) => normalizeSceneToken(t))
.filter((t): t is SceneToken => Boolean(t));
const grid = normalizeSceneGrid((s as unknown as { grid?: unknown }).grid); const grid = normalizeSceneGrid((s as unknown as { grid?: unknown }).grid);
const rawAudios = Array.isArray(raw.audios) ? raw.audios : []; const rawAudios = Array.isArray(raw.audios) ? raw.audios : [];
@@ -2359,6 +2390,7 @@ function normalizeScene(s: Scene): Scene {
previewRotationDeg, previewRotationDeg,
darkenScene, darkenScene,
traps, traps,
tokens,
grid, grid,
layout: layoutIn ?? { x: 0, y: 0 }, layout: layoutIn ?? { x: 0, y: 0 },
media: { media: {
+79 -67
View File
@@ -2,38 +2,39 @@ import fs from 'node:fs/promises';
import { session } from 'electron'; import { session } from 'electron';
import { asAssetId } from '../../shared/types/ids'; import { asAssetId, asTokenId } from '../../shared/types/ids';
import type { ZipProjectStore } from '../project/zipStore'; import type { ZipProjectStore } from '../project/zipStore';
import type { TokensStore } from '../tokens/tokensStore';
/** type ReadInfo = { absPath: string; mime: string };
* Обслуживает `dnd://asset?...` — без этого `<img src="file://...">` в рендерере часто ломается.
*/
export function registerDndAssetProtocol(projectStore: ZipProjectStore): void {
session.defaultSession.protocol.handle('dnd', async (request) => {
const url = new URL(request.url);
if (url.hostname !== 'asset') {
return new Response(null, { status: 404 });
}
const id = url.searchParams.get('id');
if (!id) {
return new Response(null, { status: 404 });
}
const info = projectStore.getAssetReadInfo(asAssetId(id));
if (!info) {
return new Response(null, { status: 404 });
}
try {
const stat = await fs.stat(info.absPath);
const total = stat.size;
const range = request.headers.get('range') ?? request.headers.get('Range');
if (range) { async function serveFile(info: ReadInfo, request: Request): Promise<Response> {
const m = /^bytes=(\d+)-(\d+)?$/iu.exec(range.trim()); try {
if (m) { const stat = await fs.stat(info.absPath);
const start = Number(m[1]); const total = stat.size;
const endRaw = m[2] ? Number(m[2]) : total - 1; const range = request.headers.get('range') ?? request.headers.get('Range');
const end = Math.min(endRaw, total - 1);
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= total || end < start) { if (range) {
const m = /^bytes=(\d+)-(\d+)?$/iu.exec(range.trim());
if (m) {
const start = Number(m[1]);
const endRaw = m[2] ? Number(m[2]) : total - 1;
const end = Math.min(endRaw, total - 1);
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= total || end < start) {
return new Response(null, {
status: 416,
headers: {
'Content-Range': `bytes */${String(total)}`,
'Cache-Control': 'no-store',
},
});
}
const len = end - start + 1;
const fh = await fs.open(info.absPath, 'r');
try {
const buf = Buffer.alloc(len);
const { bytesRead } = await fh.read(buf, 0, len, start);
if (bytesRead <= 0) {
return new Response(null, { return new Response(null, {
status: 416, status: 416,
headers: { headers: {
@@ -42,48 +43,59 @@ export function registerDndAssetProtocol(projectStore: ZipProjectStore): void {
}, },
}); });
} }
const len = end - start + 1; const body = bytesRead === len ? buf : Buffer.from(buf.subarray(0, bytesRead));
const fh = await fs.open(info.absPath, 'r'); const actualEnd = start + bytesRead - 1;
try { return new Response(body, {
const buf = Buffer.alloc(len); status: 206,
const { bytesRead } = await fh.read(buf, 0, len, start); headers: {
if (bytesRead <= 0) { 'Content-Type': info.mime,
return new Response(null, { 'Accept-Ranges': 'bytes',
status: 416, 'Content-Range': `bytes ${String(start)}-${String(actualEnd)}/${String(total)}`,
headers: { 'Content-Length': String(body.length),
'Content-Range': `bytes */${String(total)}`, 'Cache-Control': 'no-store',
'Cache-Control': 'no-store', },
}, });
}); } finally {
} await fh.close();
const body = bytesRead === len ? buf : Buffer.from(buf.subarray(0, bytesRead));
const actualEnd = start + bytesRead - 1;
return new Response(body, {
status: 206,
headers: {
'Content-Type': info.mime,
'Accept-Ranges': 'bytes',
'Content-Range': `bytes ${String(start)}-${String(actualEnd)}/${String(total)}`,
'Content-Length': String(body.length),
'Cache-Control': 'no-store',
},
});
} finally {
await fh.close();
}
} }
} }
}
const buf = await fs.readFile(info.absPath); const buf = await fs.readFile(info.absPath);
return new Response(buf, { return new Response(buf, {
headers: { headers: {
'Content-Type': info.mime, 'Content-Type': info.mime,
'Accept-Ranges': 'bytes', 'Accept-Ranges': 'bytes',
'Cache-Control': 'no-store', 'Cache-Control': 'no-store',
}, },
}); });
} catch { } catch {
return new Response(null, { status: 404 });
}
}
/**
* Обслуживает `dnd://asset?...` и `dnd://token?...`.
*/
export function registerDndAssetProtocol(
projectStore: ZipProjectStore,
tokensStore: TokensStore,
): void {
session.defaultSession.protocol.handle('dnd', async (request) => {
const url = new URL(request.url);
const id = url.searchParams.get('id');
if (!id) {
return new Response(null, { status: 404 }); return new Response(null, { status: 404 });
} }
let info: ReadInfo | null = null;
if (url.hostname === 'asset') {
info = projectStore.getAssetReadInfo(asAssetId(id));
} else if (url.hostname === 'token') {
info = tokensStore.getImageReadInfo(asTokenId(id));
}
if (!info) {
return new Response(null, { status: 404 });
}
return serveFile(info, request);
}); });
} }
@@ -0,0 +1,54 @@
import type { SceneTokensSessionEvent, SceneTokensSessionState } from '../../shared/types';
function emptyState(): SceneTokensSessionState {
return {
revision: 1,
byPlacementId: {},
};
}
export class SceneTokensSessionStore {
private state: SceneTokensSessionState = emptyState();
getState(): SceneTokensSessionState {
return this.state;
}
reset(): SceneTokensSessionState {
if (Object.keys(this.state.byPlacementId).length === 0) return this.state;
this.state = {
revision: this.state.revision + 1,
byPlacementId: {},
};
return this.state;
}
dispatch(event: SceneTokensSessionEvent): SceneTokensSessionState {
switch (event.kind) {
case 'clear':
return this.reset();
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]: { nx, ny },
},
};
return this.state;
}
default: {
const _exhaustive: never = event;
void _exhaustive;
return this.state;
}
}
}
}
+277
View File
@@ -0,0 +1,277 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import type { AppToken, TokenId } from '../../shared/types';
import { asTokenId } from '../../shared/types/ids';
import { optimizeImageBufferVisuallyLossless } from '../project/optimizeImageImport.lib.mjs';
type TokensManifest = {
tokens: AppToken[];
};
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 || 'token';
}
function randomTokenId(): TokenId {
return asTokenId(`token_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
}
export class TokensStore {
private readonly rootDir: string;
private readonly filesDir: string;
private readonly manifestPath: string;
private tokens: AppToken[] = [];
private loaded = false;
constructor(userData: string) {
this.rootDir = path.join(userData, 'tokens');
this.filesDir = path.join(this.rootDir, 'files');
this.manifestPath = path.join(this.rootDir, 'tokens.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 TokensManifest;
this.tokens = Array.isArray(parsed.tokens)
? parsed.tokens.filter(
(t): t is AppToken =>
Boolean(t) &&
typeof t.id === 'string' &&
typeof t.name === 'string' &&
typeof t.imageRelPath === 'string' &&
typeof t.sha256 === 'string',
)
: [];
} catch {
this.tokens = [];
await this.persist();
}
this.loaded = true;
}
list(): AppToken[] {
return [...this.tokens];
}
getById(id: TokenId): AppToken | null {
return this.tokens.find((t) => t.id === id) ?? null;
}
findBySha256(sha256: string): AppToken | null {
return this.tokens.find((t) => t.sha256 === sha256) ?? null;
}
getImageReadInfo(id: TokenId): { absPath: string; mime: string } | null {
const token = this.getById(id);
if (!token) return null;
const absPath = path.join(this.rootDir, token.imageRelPath);
return { absPath, mime: mimeFromExt(path.extname(token.imageRelPath)) };
}
getImageUrl(id: TokenId): string | null {
if (!this.getImageReadInfo(id)) return null;
return `dnd://token?id=${encodeURIComponent(id)}`;
}
absPathForRel(relPath: string): string {
return path.join(this.rootDir, relPath);
}
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');
const existing = input.id ? this.getById(input.id) : null;
if (input.id && !existing) throw new Error('Token not found');
if (!existing && !input.filePath) throw new Error('Token image is required');
let imageRelPath = existing?.imageRelPath ?? '';
let sha256 = existing?.sha256 ?? '';
if (input.filePath) {
let buf = await fs.readFile(input.filePath);
try {
const opt = await optimizeImageBufferVisuallyLossless(buf);
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
} catch {
/* keep original */
}
sha256 = crypto.createHash('sha256').update(buf).digest('hex');
const id = existing?.id ?? randomTokenId();
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 });
await fs.writeFile(abs, buf);
if (existing && existing.imageRelPath !== imageRelPath) {
try {
await fs.unlink(path.join(this.rootDir, existing.imageRelPath));
} catch {
/* ignore */
}
}
const token: AppToken = {
id,
name,
imageRelPath,
sha256,
};
if (existing) {
this.tokens = this.tokens.map((t) => (t.id === id ? token : t));
} else {
this.tokens = [...this.tokens, token];
}
await this.persist();
return token;
}
const token: AppToken = {
id: existing!.id,
name,
imageRelPath,
sha256,
};
this.tokens = this.tokens.map((t) => (t.id === token.id ? token : t));
await this.persist();
return token;
}
/** Импорт токена из внешнего файла (storyline zip) с заданным id или дедупом по sha256. */
async importFromFile(input: {
preferredId: TokenId;
name: string;
absFilePath: string;
sha256?: string;
}): 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 existingByHash = this.findBySha256(sha256);
if (existingByHash) {
return { token: existingByHash, remappedFrom: input.preferredId };
}
const existingById = this.getById(input.preferredId);
const id = existingById ? randomTokenId() : input.preferredId;
try {
const opt = await optimizeImageBufferVisuallyLossless(buf);
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
} catch {
/* keep */
}
const ext = path.extname(input.absFilePath) || '.png';
const fileName = `${id}_${safeFileBase(input.name)}${ext.toLowerCase()}`;
const imageRelPath = path.join('files', fileName).replace(/\\/gu, '/');
const abs = path.join(this.rootDir, imageRelPath);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, buf);
const token: AppToken = {
id,
name: input.name.trim() || 'Token',
imageRelPath,
sha256: crypto.createHash('sha256').update(buf).digest('hex'),
};
this.tokens = [...this.tokens, token];
await this.persist();
return { token, remappedFrom: input.preferredId };
}
async delete(id: TokenId): Promise<void> {
await this.ensureLoaded();
const existing = this.getById(id);
if (!existing) return;
this.tokens = this.tokens.filter((t) => t.id !== id);
await this.persist();
try {
await fs.unlink(path.join(this.rootDir, existing.imageRelPath));
} catch {
/* ignore */
}
}
/** Упаковать выбранные токены в каталог экспорта (`app-tokens/`). */
async packForExport(tokenIds: string[], exportRoot: string): Promise<void> {
await this.ensureLoaded();
const idSet = new Set(tokenIds);
const selected = this.tokens.filter((t) => idSet.has(t.id));
if (selected.length === 0) return;
const outDir = path.join(exportRoot, 'app-tokens');
const filesOut = path.join(outDir, 'files');
await fs.mkdir(filesOut, { recursive: true });
const packed: AppToken[] = [];
for (const t of selected) {
const src = path.join(this.rootDir, t.imageRelPath);
const base = path.basename(t.imageRelPath);
const destRel = path.join('files', base).replace(/\\/gu, '/');
await fs.copyFile(src, path.join(outDir, destRel));
packed.push({ ...t, imageRelPath: destRel });
}
await fs.writeFile(
path.join(outDir, 'tokens.json'),
`${JSON.stringify({ tokens: packed }, null, 2)}\n`,
'utf8',
);
}
/**
* Импорт из `sourceCache/app-tokens/`.
* @returns map oldTokenId → newTokenId
*/
async importFromExportDir(sourceCache: string): Promise<Map<string, string>> {
await this.ensureLoaded();
const remap = new Map<string, string>();
const manifestPath = path.join(sourceCache, 'app-tokens', 'tokens.json');
let raw: string;
try {
raw = await fs.readFile(manifestPath, 'utf8');
} catch {
return remap;
}
let parsed: TokensManifest;
try {
parsed = JSON.parse(raw) as TokensManifest;
} catch {
return remap;
}
const list = Array.isArray(parsed.tokens) ? parsed.tokens : [];
for (const t of list) {
if (!t?.id || !t.imageRelPath) continue;
const abs = path.join(sourceCache, 'app-tokens', t.imageRelPath);
const { token, remappedFrom } = await this.importFromFile({
preferredId: asTokenId(t.id),
name: typeof t.name === 'string' ? t.name : 'Token',
absFilePath: abs,
sha256: typeof t.sha256 === 'string' ? t.sha256 : undefined,
});
remap.set(remappedFrom, token.id);
}
return remap;
}
private async persist(): Promise<void> {
await fs.mkdir(this.rootDir, { recursive: true });
const payload: TokensManifest = { tokens: this.tokens };
await fs.writeFile(this.manifestPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
}
}
@@ -109,6 +109,10 @@
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
width: 520px; width: 520px;
max-width: calc(100vw - 32px); max-width: calc(100vw - 32px);
max-height: calc(100vh - 32px);
overflow: auto;
box-sizing: border-box;
min-width: 0;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
border: 1px solid var(--stroke); border: 1px solid var(--stroke);
background: var(--color-surface-elevated); background: var(--color-surface-elevated);
@@ -116,6 +120,7 @@
padding: 16px; padding: 16px;
display: grid; display: grid;
gap: 12px; gap: 12px;
grid-template-columns: minmax(0, 1fr);
} }
.descriptionViewDialog { .descriptionViewDialog {
+17
View File
@@ -36,6 +36,9 @@ import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost'; import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost';
import { useSceneViewState } from '../shared/sceneView/useSceneViewState'; import { useSceneViewState } from '../shared/sceneView/useSceneViewState';
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay'; import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
import { SceneTokensOverlay } from '../shared/tokens/SceneTokensOverlay';
import { useAppTokens } from '../shared/tokens/useAppTokens';
import { useSceneTokensSession } from '../shared/tokens/useSceneTokensSession';
import { SceneTrapsOverlay } from '../shared/traps/SceneTrapsOverlay'; import { SceneTrapsOverlay } from '../shared/traps/SceneTrapsOverlay';
import { useSceneTrapsState } from '../shared/traps/useSceneTrapsState'; import { useSceneTrapsState } from '../shared/traps/useSceneTrapsState';
import { Button } from '../shared/ui/controls'; import { Button } from '../shared/ui/controls';
@@ -126,6 +129,8 @@ export function ControlApp() {
const [effectsSfxGainUi, setEffectsSfxGainUi] = useState(() => getEffectsSfxGain()); const [effectsSfxGainUi, setEffectsSfxGainUi] = useState(() => getEffectsSfxGain());
const [sdState, sd] = useSceneDarknessState(); const [sdState, sd] = useSceneDarknessState();
const [sceneTraps, sceneTrapsApi] = useSceneTrapsState(); const [sceneTraps, sceneTrapsApi] = useSceneTrapsState();
const appTokens = useAppTokens();
const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession();
const [sceneView, sceneViewApi] = useSceneViewState(); const [sceneView, sceneViewApi] = useSceneViewState();
const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null); const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null);
const [materialsOverlay, materialsApi] = useMaterialsOverlayState(); const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
@@ -1884,6 +1889,18 @@ export function ControlApp() {
clearDraftFromPixi(); clearDraftFromPixi();
}} }}
/> />
{previewContentRect ? (
<SceneTokensOverlay
placements={currentScene?.tokens ?? []}
library={appTokens}
session={sceneTokensSession}
viewport={previewContentRect}
editable
onMove={(placementId, nx, ny) => {
void sceneTokensApi.dispatch({ kind: 'move', placementId, nx, ny });
}}
/>
) : null}
{previewContentRect ? ( {previewContentRect ? (
<SceneTrapsOverlay <SceneTrapsOverlay
traps={currentScene?.traps ?? []} traps={currentScene?.traps ?? []}
+21 -16
View File
@@ -328,6 +328,10 @@
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
width: 520px; width: 520px;
max-width: calc(100vw - 32px); max-width: calc(100vw - 32px);
max-height: calc(100vh - 32px);
overflow: auto;
box-sizing: border-box;
min-width: 0;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
border: 1px solid var(--stroke); border: 1px solid var(--stroke);
background: var(--color-surface-elevated); background: var(--color-surface-elevated);
@@ -335,6 +339,7 @@
padding: 16px; padding: 16px;
display: grid; display: grid;
gap: 12px; gap: 12px;
grid-template-columns: minmax(0, 1fr);
} }
.modalHeader { .modalHeader {
@@ -364,6 +369,17 @@
.fieldGrid { .fieldGrid {
display: grid; display: grid;
gap: 6px; gap: 6px;
min-width: 0;
}
.fieldGroup {
display: grid;
gap: 6px;
min-width: 0;
}
.fieldGroupSpaced {
margin-top: 10px;
} }
.fieldLabel { .fieldLabel {
@@ -377,22 +393,6 @@
font-size: var(--text-xs); font-size: var(--text-xs);
} }
.selectInput {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
padding-right: 28px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background-color: var(--bg0);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.25L6 7.75L9.5 4.25' stroke='rgba(255,255,255,0.72)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 12px 12px;
color: var(--text0);
font: inherit;
}
.rowFlex { .rowFlex {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -402,8 +402,13 @@
.modalFooter { .modalFooter {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
align-items: center;
flex-wrap: wrap;
gap: 10px; gap: 10px;
margin-top: 4px; margin-top: 4px;
width: 100%;
min-width: 0;
box-sizing: border-box;
} }
.licenseBlockTitle { .licenseBlockTitle {
+10 -9
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Button } from '../shared/ui/controls'; import { Button, Select } from '../shared/ui/controls';
import styles from './EditorApp.module.css'; import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext'; import { useEditorI18n } from './i18n/EditorI18nContext';
@@ -74,19 +74,20 @@ export function FoundryImportModal({ open, pickSource, onClose, onImport }: Foun
<div className={styles.muted}>{t('foundryImport.hint')}</div> <div className={styles.muted}>{t('foundryImport.hint')}</div>
<div className={styles.fieldLabel}>{t('foundryImport.sourceType')}</div> <div className={styles.fieldLabel}>{t('foundryImport.sourceType')}</div>
<select <Select
className={styles.selectInput}
value={mode} value={mode}
disabled={submitting} disabled={submitting}
onChange={(e) => { ariaLabel={t('foundryImport.sourceType')}
setMode(e.target.value as 'folder' | 'archive'); options={[
{ value: 'folder', label: t('foundryImport.folder') },
{ value: 'archive', label: t('foundryImport.archive') },
]}
onChange={(next) => {
setMode(next as 'folder' | 'archive');
setPicked(null); setPicked(null);
setError(null); setError(null);
}} }}
> />
<option value="folder">{t('foundryImport.folder')}</option>
<option value="archive">{t('foundryImport.archive')}</option>
</select>
<div className={styles.fieldLabel}>{t('foundryImport.source')}</div> <div className={styles.fieldLabel}>{t('foundryImport.source')}</div>
<div className={styles.importFileRow}> <div className={styles.importFileRow}>
+57 -58
View File
@@ -17,7 +17,7 @@ import {
type StorylineSelection, type StorylineSelection,
} from '../../shared/graph/storylineExportImport'; } from '../../shared/graph/storylineExportImport';
import type { Project, ProjectId, SceneId } from '../../shared/types'; import type { Project, ProjectId, SceneId } from '../../shared/types';
import { Button } from '../shared/ui/controls'; import { Button, Select } from '../shared/ui/controls';
import styles from './EditorApp.module.css'; import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext'; import { useEditorI18n } from './i18n/EditorI18nContext';
@@ -135,18 +135,16 @@ export function ExportProjectModal({
<div className={styles.fieldGrid}> <div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>{t('export.project')}</div> <div className={styles.fieldLabel}>{t('export.project')}</div>
<select <Select
className={styles.selectInput}
value={projectId ?? ''} value={projectId ?? ''}
onChange={(e) => setProjectId((e.target.value as ProjectId) || null)} onChange={(next) => setProjectId((next as ProjectId) || null)}
disabled={projects.length === 0} disabled={projects.length === 0}
> ariaLabel={t('export.project')}
{projects.map((p) => ( options={projects.map((p) => ({
<option key={p.id} value={p.id}> value: p.id,
{p.name} ({p.fileName}) label: `${p.name} (${p.fileName})`,
</option> }))}
))} />
</select>
<div className={styles.fieldLabel}>{t('storyline.section')}</div> <div className={styles.fieldLabel}>{t('storyline.section')}</div>
{loadingStorylines ? ( {loadingStorylines ? (
@@ -309,43 +307,46 @@ export function ImportSourceModal({
<div className={styles.fieldGrid}> <div className={styles.fieldGrid}>
{canImportFromProject ? ( {canImportFromProject ? (
<> <div className={styles.fieldGroup}>
<div className={styles.fieldLabel}>{t('importSource.type')}</div> <div className={styles.fieldLabel}>{t('importSource.type')}</div>
<select <Select
className={styles.selectInput}
value={importKind} value={importKind}
onChange={(e) => setImportKind(e.target.value as 'project' | 'file')} onChange={(next) => setImportKind(next as 'project' | 'file')}
disabled={submitting} disabled={submitting}
> ariaLabel={t('importSource.type')}
<option value="project">{t('importSource.fromProject')}</option> options={[
<option value="file">{t('importSource.fromFile')}</option> { value: 'project', label: t('importSource.fromProject') },
</select> { value: 'file', label: t('importSource.fromFile') },
</> ]}
/>
</div>
) : ( ) : (
<div className={styles.muted}>{t('importSource.fileOnlyHint')}</div> <div className={styles.muted}>{t('importSource.fileOnlyHint')}</div>
)} )}
{importKind === 'project' && canImportFromProject ? ( {importKind === 'project' && canImportFromProject ? (
<> <div className={[styles.fieldGroup, canImportFromProject ? styles.fieldGroupSpaced : ''].filter(Boolean).join(' ')}>
<div className={styles.fieldLabel}>{t('importSource.project')}</div> <div className={styles.fieldLabel}>{t('importSource.project')}</div>
<select <Select
className={styles.selectInput}
value={sourceProjectId ?? ''} value={sourceProjectId ?? ''}
onChange={(e) => setSourceProjectId((e.target.value as ProjectId) || null)} onChange={(next) => setSourceProjectId((next as ProjectId) || null)}
disabled={availableProjects.length === 0 || submitting} disabled={availableProjects.length === 0 || submitting}
> ariaLabel={t('importSource.project')}
{availableProjects.map((p) => ( options={availableProjects.map((p) => ({
<option key={p.id} value={p.id}> value: p.id,
{p.name} ({p.fileName}) label: `${p.name} (${p.fileName})`,
</option> }))}
))} />
</select>
{availableProjects.length === 0 ? ( {availableProjects.length === 0 ? (
<div className={styles.muted}>{t('importSource.noOtherProjects')}</div> <div className={styles.muted}>{t('importSource.noOtherProjects')}</div>
) : null} ) : null}
</> </div>
) : ( ) : (
<> <div
className={[styles.fieldGroup, canImportFromProject ? styles.fieldGroupSpaced : '']
.filter(Boolean)
.join(' ')}
>
<div className={styles.fieldLabel}>{t('importSource.file')}</div> <div className={styles.fieldLabel}>{t('importSource.file')}</div>
<div className={styles.importFileRow}> <div className={styles.importFileRow}>
<Button <Button
@@ -366,7 +367,7 @@ export function ImportSourceModal({
{pickedFile ? pickedFile.name : t('importSource.noFileSelected')} {pickedFile ? pickedFile.name : t('importSource.noFileSelected')}
</span> </span>
</div> </div>
</> </div>
)} )}
</div> </div>
@@ -572,24 +573,23 @@ export function SceneConflictModal({ open, conflicts, onClose, onConfirm }: Scen
{conflicts.map((c) => ( {conflicts.map((c) => (
<div key={c.sourceSceneId} className={styles.conflictRow}> <div key={c.sourceSceneId} className={styles.conflictRow}>
<div className={styles.conflictTitle}>{c.sourceTitle}</div> <div className={styles.conflictTitle}>{c.sourceTitle}</div>
<select <Select
className={styles.selectInput}
value={choices[c.sourceSceneId] ?? 'create'} value={choices[c.sourceSceneId] ?? 'create'}
onChange={(e) => { onChange={(v) => {
const v = e.target.value;
setChoices((prev) => ({ setChoices((prev) => ({
...prev, ...prev,
[c.sourceSceneId]: v === 'create' ? 'create' : (v as SceneId), [c.sourceSceneId]: v === 'create' ? 'create' : (v as SceneId),
})); }));
}} }}
> ariaLabel={c.sourceTitle}
<option value="create">{t('importStoryline.createNewScene')}</option> options={[
{c.matches.map((m) => ( { value: 'create', label: t('importStoryline.createNewScene') },
<option key={m.sceneId} value={m.sceneId}> ...c.matches.map((m) => ({
{t('importStoryline.useExistingScene', { title: m.title })} value: m.sceneId,
</option> label: t('importStoryline.useExistingScene', { title: m.title }),
))} })),
</select> ]}
/>
</div> </div>
))} ))}
</div> </div>
@@ -687,24 +687,23 @@ function NpcConflictModalBody({
{conflicts.map((c) => ( {conflicts.map((c) => (
<div key={c.sourceNpcId} className={styles.conflictRow}> <div key={c.sourceNpcId} className={styles.conflictRow}>
<div className={styles.conflictTitle}>{c.sourceName}</div> <div className={styles.conflictTitle}>{c.sourceName}</div>
<select <Select
className={styles.selectInput}
value={choices[c.sourceNpcId] ?? 'create'} value={choices[c.sourceNpcId] ?? 'create'}
onChange={(e) => { onChange={(v) => {
const v = e.target.value;
setChoices((prev) => ({ setChoices((prev) => ({
...prev, ...prev,
[c.sourceNpcId]: v === 'create' ? 'create' : v, [c.sourceNpcId]: v === 'create' ? 'create' : v,
})); }));
}} }}
> ariaLabel={c.sourceName}
<option value="create">{t('importStoryline.createNewNpc')}</option> options={[
{c.matches.map((m) => ( { value: 'create', label: t('importStoryline.createNewNpc') },
<option key={m.npcId} value={m.npcId}> ...c.matches.map((m) => ({
{t('importStoryline.useExistingNpc', { name: m.name })} value: m.npcId,
</option> label: t('importStoryline.useExistingNpc', { name: m.name }),
))} })),
</select> ]}
/>
</div> </div>
))} ))}
</div> </div>
@@ -350,6 +350,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
previewRotationDeg: 0, previewRotationDeg: 0,
darkenScene: false, darkenScene: false,
traps: [], traps: [],
tokens: [],
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' }, grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
media: { videos: [], audios: [] }, media: { videos: [], audios: [] },
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true }, settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
@@ -592,6 +593,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
: null), : null),
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null), ...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
...(patch.traps !== undefined ? { traps: patch.traps } : null), ...(patch.traps !== undefined ? { traps: patch.traps } : null),
...(patch.tokens !== undefined ? { tokens: patch.tokens } : null),
...(patch.grid !== undefined ? { grid: patch.grid } : null), ...(patch.grid !== undefined ? { grid: patch.grid } : null),
...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null), ...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null),
...(patch.media ? { media: { ...scene.media, ...patch.media } } : null), ...(patch.media ? { media: { ...scene.media, ...patch.media } } : null),
+26 -31
View File
@@ -4,7 +4,7 @@ import { isNpcBindingNone, listStorylineOptionsForBinding, noneBinding } from '.
import type { GraphNodeId, NpcBinding, Project, SceneId } from '../../shared/types'; import type { GraphNodeId, NpcBinding, Project, SceneId } from '../../shared/types';
import editorStyles from '../editor/EditorApp.module.css'; import editorStyles from '../editor/EditorApp.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import controlStyles from '../shared/ui/Controls.module.css'; import { Select } from '../shared/ui/controls';
type NpcBindingFieldsProps = { type NpcBindingFieldsProps = {
project: Project; project: Project;
@@ -65,11 +65,14 @@ export function NpcBindingFields({ project, binding, onChange }: NpcBindingField
{enabled ? ( {enabled ? (
<> <>
<div className={editorStyles.fieldLabel}>{t('npcs.bindingKind')}</div> <div className={editorStyles.fieldLabel}>{t('npcs.bindingKind')}</div>
<select <Select
className={controlStyles.input}
value={kind} value={kind}
onChange={(e) => { ariaLabel={t('npcs.bindingKind')}
const nextKind = e.target.value; options={[
{ value: 'storyline', label: t('npcs.bindingStoryline') },
{ value: 'scene', label: t('npcs.bindingScene') },
]}
onChange={(nextKind) => {
if (nextKind === 'scene') { if (nextKind === 'scene') {
const first = sceneOptions[0]; const first = sceneOptions[0];
onChange(first ? { kind: 'scene', sceneId: first.id } : noneBinding()); onChange(first ? { kind: 'scene', sceneId: first.id } : noneBinding());
@@ -86,17 +89,26 @@ export function NpcBindingFields({ project, binding, onChange }: NpcBindingField
onChange(noneBinding()); onChange(noneBinding());
} }
}} }}
> />
<option value="storyline">{t('npcs.bindingStoryline')}</option>
<option value="scene">{t('npcs.bindingScene')}</option>
</select>
<div className={editorStyles.fieldLabel}>{t('npcs.bindingSelect')}</div> <div className={editorStyles.fieldLabel}>{t('npcs.bindingSelect')}</div>
<select <Select
className={controlStyles.input}
value={bindingTargetValue} value={bindingTargetValue}
onChange={(e) => { ariaLabel={t('npcs.bindingSelect')}
const v = e.target.value; 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') { if (kind === 'scene') {
onChange({ kind: 'scene', sceneId: v as SceneId }); onChange({ kind: 'scene', sceneId: v as SceneId });
return; return;
@@ -115,24 +127,7 @@ export function NpcBindingFields({ project, binding, onChange }: NpcBindingField
}); });
} }
}} }}
> />
{kind === 'storyline' ? (
<>
{storylineOpts.main ? <option value="main">{t('npcs.bindingMain')}</option> : null}
{storylineOpts.sides.map((s) => (
<option key={s.startGraphNodeId} value={`side:${s.startGraphNodeId}`}>
{s.label}
</option>
))}
</>
) : (
sceneOptions.map((s) => (
<option key={s.id} value={s.id}>
{s.title}
</option>
))
)}
</select>
</> </>
) : null} ) : null}
</div> </div>
+9 -13
View File
@@ -13,8 +13,7 @@ import {
} from '../editor/fileDrop'; } from '../editor/fileDrop';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import matStyles from '../editor/MaterialsModals.module.css'; import matStyles from '../editor/MaterialsModals.module.css';
import { Button, Input } from '../shared/ui/controls'; import { Button, Input, Select } from '../shared/ui/controls';
import controlStyles from '../shared/ui/Controls.module.css';
import { useAssetUrl } from '../shared/useAssetImageUrl'; import { useAssetUrl } from '../shared/useAssetImageUrl';
import { NpcBindingFields } from './NpcBindingFields'; import { NpcBindingFields } from './NpcBindingFields';
@@ -166,18 +165,15 @@ export function NpcEditModal({
{!initial && groupOptions.length > 0 ? ( {!initial && groupOptions.length > 0 ? (
<div className={editorStyles.fieldGrid}> <div className={editorStyles.fieldGrid}>
<div className={editorStyles.fieldLabel}>{t('npcs.group')}</div> <div className={editorStyles.fieldLabel}>{t('npcs.group')}</div>
<select <Select
className={controlStyles.input}
value={groupId} value={groupId}
onChange={(e) => setGroupId(e.target.value as NpcGroupId | '')} ariaLabel={t('npcs.group')}
> onChange={(next) => setGroupId(next as NpcGroupId | '')}
<option value="">{t('npcs.ungrouped')}</option> options={[
{groupOptions.map((g) => ( { value: '', label: t('npcs.ungrouped') },
<option key={g.id} value={g.id}> ...groupOptions.map((g) => ({ value: g.id, label: g.label })),
{g.label} ]}
</option> />
))}
</select>
</div> </div>
) : null} ) : null}
+3 -9
View File
@@ -164,15 +164,9 @@
.filterSelect { .filterSelect {
min-width: 160px; min-width: 160px;
padding: 6px 10px; width: auto;
padding-right: 28px; min-height: 30px;
border-radius: 8px; background: rgba(24, 24, 27, 0.92);
border: 1px solid var(--stroke);
background-color: rgba(24, 24, 27, 0.92);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.25L6 7.75L9.5 4.25' stroke='rgba(255,255,255,0.72)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 12px 12px;
color: var(--text1); color: var(--text1);
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
+10 -12
View File
@@ -31,6 +31,7 @@ import type {
ProjectNpcGroup, ProjectNpcGroup,
ProjectNpcRelation, ProjectNpcRelation,
} from '../../shared/types'; } from '../../shared/types';
import { Select } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl'; import { useAssetUrl } from '../shared/useAssetImageUrl';
import styles from './NpcGraph.module.css'; import styles from './NpcGraph.module.css';
@@ -300,20 +301,17 @@ function FilterToolbar({
}) { }) {
return ( return (
<Panel position="top-left"> <Panel position="top-left">
<select <Select
className={styles.filterSelect} className={styles.filterSelect}
aria-label={ui.graphFilter} ariaLabel={ui.graphFilter}
value={value} value={value}
onChange={(e) => onChange(e.target.value as GraphGroupFilter)} onChange={(next) => onChange(next as GraphGroupFilter)}
> options={[
<option value="all">{ui.graphFilterAll}</option> { value: 'all', label: ui.graphFilterAll },
<option value="ungrouped">{ui.graphFilterUngrouped}</option> { value: 'ungrouped', label: ui.graphFilterUngrouped },
{groups.map((g) => ( ...groups.map((g) => ({ value: g.id, label: g.name })),
<option key={g.id} value={g.id}> ]}
{g.name} />
</option>
))}
</select>
</Panel> </Panel>
); );
} }
+10 -14
View File
@@ -15,8 +15,7 @@ import type {
import editorStyles from '../editor/EditorApp.module.css'; import editorStyles from '../editor/EditorApp.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi'; import { getDndApi } from '../shared/dndApi';
import { Button, Input } from '../shared/ui/controls'; import { Button, Input, Select } from '../shared/ui/controls';
import controlStyles from '../shared/ui/Controls.module.css';
import { useAssetUrl } from '../shared/useAssetImageUrl'; import { useAssetUrl } from '../shared/useAssetImageUrl';
import { NpcBindingFields } from './NpcBindingFields'; import { NpcBindingFields } from './NpcBindingFields';
@@ -785,24 +784,21 @@ export function NpcsEditorApp() {
<div> <div>
<div className={styles.fieldLabel}>{t('npcs.group')}</div> <div className={styles.fieldLabel}>{t('npcs.group')}</div>
<select <Select
className={controlStyles.input}
value={selected.groupId ?? ''} value={selected.groupId ?? ''}
onChange={(e) => { ariaLabel={t('npcs.group')}
const groupId = (e.target.value || null) as NpcGroupId | null; onChange={(next) => {
const groupId = (next || null) as NpcGroupId | null;
void api.invoke(ipcChannels.project.updateNpcFields, { void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id, npcId: selected.id,
groupId, groupId,
}); });
}} }}
> options={[
<option value="">{t('npcs.ungrouped')}</option> { value: '', label: t('npcs.ungrouped') },
{groupOptions.map((g) => ( ...groupOptions.map((g) => ({ value: g.id, label: g.label })),
<option key={g.id} value={g.id}> ]}
{g.label} />
</option>
))}
</select>
</div> </div>
<div> <div>
@@ -2,3 +2,9 @@
height: 100vh; height: 100vh;
width: 100vw; width: 100vw;
} }
.viewPassthrough {
height: 100%;
width: 100%;
pointer-events: none;
}
@@ -40,7 +40,10 @@ export function PresentationApp() {
className={styles.root} className={styles.root}
onDoubleClick={() => void api.invoke(ipcChannels.windows.togglePresentationFullscreen, {})} onDoubleClick={() => void api.invoke(ipcChannels.windows.togglePresentationFullscreen, {})}
> >
<PresentationView session={session} showTitle={false} /> {/* Презентация только для просмотра: никаких кликов/drag по сцене и оверлеям. */}
<div className={styles.viewPassthrough}>
<PresentationView session={session} showTitle={false} />
</div>
</div> </div>
); );
} }
@@ -90,22 +90,6 @@
opacity: 0.75; opacity: 0.75;
} }
.select {
width: 100%;
box-sizing: border-box;
padding: 7px 28px 7px 10px;
border-radius: 8px;
border: 1px solid var(--stroke, #2a2f3a);
background-color: rgba(0, 0, 0, 0.25);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.25L6 7.75L9.5 4.25' stroke='rgba(255,255,255,0.72)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 12px 12px;
color: inherit;
font: inherit;
appearance: none;
}
.range { .range {
width: 100%; width: 100%;
accent-color: #f5c542; accent-color: #f5c542;
@@ -200,7 +184,7 @@
.trap { .trap {
position: absolute; position: absolute;
z-index: 1; z-index: 2;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
border-radius: 50%; border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.55); border: 2px solid rgba(255, 255, 255, 0.55);
@@ -252,6 +236,189 @@
opacity: 0.7; opacity: 0.7;
} }
.tokensPanel {
display: grid;
gap: 8px;
padding: 10px 12px 12px;
}
.tokensToolbar {
display: grid;
}
.tokensToolbar button {
width: 100%;
box-sizing: border-box;
}
.tokenGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.tokenTile {
display: grid;
gap: 4px;
padding: 6px;
border-radius: 8px;
border: 1px solid var(--stroke, #2a2f3a);
background: rgba(0, 0, 0, 0.22);
cursor: grab;
user-select: none;
}
.tokenTile:active {
cursor: grabbing;
}
.tokenThumb {
aspect-ratio: 1;
border-radius: 6px;
overflow: hidden;
background: rgba(0, 0, 0, 0.35);
display: grid;
place-items: center;
}
.tokenThumb img {
width: 100%;
height: 100%;
object-fit: contain;
}
.tokenTileMeta {
display: flex;
align-items: center;
gap: 2px;
min-width: 0;
}
.tokenTileName {
flex: 1 1 auto;
min-width: 0;
font-size: 11px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tokenTileMore {
flex: 0 0 auto;
width: 22px;
height: 22px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: inherit;
font-size: 16px;
line-height: 1;
cursor: pointer;
opacity: 0.75;
}
.tokenTileMore:hover,
.tokenTileMore[aria-expanded='true'] {
opacity: 1;
background: rgba(255, 255, 255, 0.08);
}
.tokenCtxMenu {
position: fixed;
z-index: 80;
min-width: 160px;
padding: 6px;
border-radius: 10px;
border: 1px solid var(--stroke, #333);
background: #1a1d24;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
display: grid;
gap: 2px;
}
.tokenCtxItem,
.tokenCtxItemDanger {
text-align: left;
padding: 8px 10px;
border-radius: 6px;
border: none;
background: transparent;
color: #e8eaef;
font-size: 13px;
cursor: pointer;
width: 100%;
}
.tokenCtxItem:hover {
background: rgba(255, 255, 255, 0.08);
}
.tokenCtxItemDanger {
color: #f87171;
}
.tokenCtxItemDanger:hover {
background: rgba(248, 113, 113, 0.12);
}
.clearSceneBtn {
width: 100%;
box-sizing: border-box;
}
.clearSceneBtn button {
width: 100%;
box-sizing: border-box;
}
.sceneToken {
position: absolute;
z-index: 1;
border-radius: 8px;
border: 2px solid rgba(255, 255, 255, 0.35);
background: rgba(0, 0, 0, 0.25);
overflow: visible;
cursor: move;
touch-action: none;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
}
.sceneTokenSelected {
border-color: #f5c542;
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.4),
0 0 0 3px rgba(245, 197, 66, 0.35);
}
.sceneTokenImg {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 6px;
pointer-events: none;
display: block;
}
.rotateHandle {
position: absolute;
left: 50%;
top: -18px;
transform: translateX(-50%);
width: 22px;
height: 22px;
border-radius: 50%;
border: 1px solid #000;
background: #f5c542;
color: #111;
font-size: 12px;
line-height: 1;
cursor: grab;
touch-action: none;
padding: 0;
}
.toolbar { .toolbar {
display: flex; display: flex;
gap: 8px; gap: 8px;
+348 -66
View File
@@ -1,7 +1,14 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts'; import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import type { SceneGrid, SceneTrap, SceneTrapType } from '../../shared/types'; import type { SceneGrid, SceneToken, SceneTrap, SceneTrapType, TokenId } from '../../shared/types';
import {
asSceneTokenId,
asTokenId,
clampSceneTokenSizeN,
DEFAULT_SCENE_TOKEN_SIZE_N,
} from '../../shared/types/appTokens';
import { import {
clampSceneGridSizeN, clampSceneGridSizeN,
DEFAULT_SCENE_GRID, DEFAULT_SCENE_GRID,
@@ -16,33 +23,74 @@ import {
trapTypeLabelRu, trapTypeLabelRu,
} from '../../shared/types/sceneTraps'; } from '../../shared/types/sceneTraps';
import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView'; import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
import editorStyles from '../editor/EditorApp.module.css';
import { getDndApi } from '../shared/dndApi'; import { getDndApi } from '../shared/dndApi';
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay'; import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
import { RotatedImage } from '../shared/RotatedImage'; import { RotatedImage } from '../shared/RotatedImage';
import { useAppTokens } from '../shared/tokens/useAppTokens';
import { TrapGlyph } from '../shared/traps/TrapGlyph'; import { TrapGlyph } from '../shared/traps/TrapGlyph';
import { Button } from '../shared/ui/controls'; import { Button, Input, Select } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl'; import { useAssetUrl } from '../shared/useAssetImageUrl';
import { SceneTokenMarker } from './SceneTokenMarker';
import styles from './SceneEditorApp.module.css'; import styles from './SceneEditorApp.module.css';
import { TokenEditModal } from './TokenEditModal';
import { TOKEN_DND_MIME, TokenTile } from './TokenTile';
function isTypingTarget(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
return el.isContentEditable;
}
type LocalView = { scale: number; ox: number; oy: number }; type LocalView = { scale: number; ox: number; oy: number };
type Selection = { kind: 'trap' | 'token'; id: string } | null;
type DragMode = type DragMode =
| { kind: 'pan'; lastX: number; lastY: number } | { kind: 'pan'; lastX: number; lastY: number }
| { kind: 'move'; trapId: string; startNx: number; startNy: number; pointerNx: number; pointerNy: number } | { kind: 'moveTrap'; trapId: string; startNx: number; startNy: number; pointerNx: number; pointerNy: number }
| { kind: 'resize'; trapId: string; startSize: number; startDist: number } | { kind: 'resizeTrap'; trapId: string; startSize: number; startDist: number }
| { kind: 'moveToken'; tokenId: string; startNx: number; startNy: number; pointerNx: number; pointerNy: number }
| { kind: 'resizeToken'; tokenId: string; startSize: number; startDist: number }
| {
kind: 'rotateToken';
tokenId: string;
startRotation: number;
startPointerAngle: number;
centerClientX: number;
centerClientY: number;
}
| null; | null;
function randomTrapId(): string { function randomId(prefix: string): string {
return `trap_${Math.random().toString(36).slice(2, 10)}`; return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
}
function pointerAngleDeg(cx: number, cy: number, x: number, y: number): number {
return (Math.atan2(y - cy, x - cx) * 180) / Math.PI;
}
function shortestAngleDelta(fromDeg: number, toDeg: number): number {
let d = toDeg - fromDeg;
while (d > 180) d -= 360;
while (d < -180) d += 360;
return d;
} }
export function SceneEditorApp() { export function SceneEditorApp() {
const api = getDndApi(); const api = getDndApi();
const appTokens = useAppTokens();
const [session, setSession] = useState<SessionState | null>(null); const [session, setSession] = useState<SessionState | null>(null);
const [trapsOpen, setTrapsOpen] = useState(true); const [trapsOpen, setTrapsOpen] = useState(false);
const [gridOpen, setGridOpen] = useState(true); const [gridOpen, setGridOpen] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null); const [tokensOpen, setTokensOpen] = useState(false);
const [tokenSearch, setTokenSearch] = useState('');
const [tokenModal, setTokenModal] = useState<{ mode: 'create' } | { mode: 'edit'; tokenId: TokenId } | null>(
null,
);
const [pendingDeleteToken, setPendingDeleteToken] = useState<{ id: TokenId; name: string } | null>(null);
const [selected, setSelected] = useState<Selection>(null);
const [view, setView] = useState<LocalView>({ scale: 1, ox: 0.5, oy: 0.5 }); 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>( const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(
null, null,
@@ -50,6 +98,7 @@ export function SceneEditorApp() {
const hostRef = useRef<HTMLDivElement | null>(null); const hostRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<DragMode>(null); const dragRef = useRef<DragMode>(null);
const saveTrapsTimerRef = useRef(0); const saveTrapsTimerRef = useRef(0);
const saveTokensTimerRef = useRef(0);
const saveGridTimerRef = useRef(0); const saveGridTimerRef = useRef(0);
const spaceDownRef = useRef(false); const spaceDownRef = useRef(false);
@@ -59,23 +108,32 @@ export function SceneEditorApp() {
const url = useAssetUrl(scene?.previewAssetId ?? null); const url = useAssetUrl(scene?.previewAssetId ?? null);
const rot = scene?.previewRotationDeg ?? 0; const rot = scene?.previewRotationDeg ?? 0;
const [localTraps, setLocalTraps] = useState<SceneTrap[]>([]); const [localTraps, setLocalTraps] = useState<SceneTrap[]>([]);
const [localTokens, setLocalTokens] = useState<SceneToken[]>([]);
const [localGrid, setLocalGrid] = useState<SceneGrid>({ ...DEFAULT_SCENE_GRID }); const [localGrid, setLocalGrid] = useState<SceneGrid>({ ...DEFAULT_SCENE_GRID });
const trapsRef = useRef<SceneTrap[]>([]); const trapsRef = useRef<SceneTrap[]>([]);
const tokensRef = useRef<SceneToken[]>([]);
trapsRef.current = localTraps; trapsRef.current = localTraps;
tokensRef.current = localTokens;
useEffect(() => { useEffect(() => {
setLocalTraps(scene?.traps ?? []); setLocalTraps(scene?.traps ?? []);
setLocalTokens((scene?.tokens ?? []).filter((t) => appTokens.some((a) => a.id === t.tokenId)));
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID }); setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
setSelectedId(null); setSelected(null);
setView({ scale: 1, ox: 0.5, oy: 0.5 }); setView({ scale: 1, ox: 0.5, oy: 0.5 });
}, [sceneId, scene?.previewAssetId]); }, [sceneId, scene?.previewAssetId]);
useEffect(() => { useEffect(() => {
// External updates (other windows) — sync when not dragging
if (dragRef.current) return; if (dragRef.current) return;
setLocalTraps(scene?.traps ?? []); setLocalTraps(scene?.traps ?? []);
}, [scene?.traps]); }, [scene?.traps]);
useEffect(() => {
if (dragRef.current) return;
const known = new Set(appTokens.map((t) => t.id));
setLocalTokens((scene?.tokens ?? []).filter((t) => known.has(t.tokenId)));
}, [scene?.tokens, appTokens]);
useEffect(() => { useEffect(() => {
if (dragRef.current) return; if (dragRef.current) return;
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID }); setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
@@ -91,7 +149,7 @@ export function SceneEditorApp() {
}, [api]); }, [api]);
const persistTraps = useCallback( const persistTraps = useCallback(
async (next: SceneTrap[]) => { (next: SceneTrap[]) => {
if (!sceneId) return; if (!sceneId) return;
setLocalTraps(next); setLocalTraps(next);
trapsRef.current = next; trapsRef.current = next;
@@ -103,6 +161,19 @@ export function SceneEditorApp() {
[api, sceneId], [api, sceneId],
); );
const persistTokens = useCallback(
(next: SceneToken[]) => {
if (!sceneId) return;
setLocalTokens(next);
tokensRef.current = next;
if (saveTokensTimerRef.current) window.clearTimeout(saveTokensTimerRef.current);
saveTokensTimerRef.current = window.setTimeout(() => {
void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { tokens: next } });
}, 120);
},
[api, sceneId],
);
const persistGrid = useCallback( const persistGrid = useCallback(
(next: SceneGrid) => { (next: SceneGrid) => {
if (!sceneId) return; if (!sceneId) return;
@@ -117,15 +188,20 @@ export function SceneEditorApp() {
useEffect(() => { useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (isTypingTarget(e.target)) return;
if (e.code === 'Space') spaceDownRef.current = true; if (e.code === 'Space') spaceDownRef.current = true;
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId && sceneId) { if ((e.key === 'Delete' || e.key === 'Backspace') && selected && sceneId) {
e.preventDefault(); e.preventDefault();
const next = trapsRef.current.filter((t) => t.id !== selectedId); if (selected.kind === 'trap') {
setSelectedId(null); persistTraps(trapsRef.current.filter((t) => t.id !== selected.id));
void persistTraps(next); } else {
persistTokens(tokensRef.current.filter((t) => t.id !== selected.id));
}
setSelected(null);
} }
}; };
const onKeyUp = (e: KeyboardEvent) => { const onKeyUp = (e: KeyboardEvent) => {
if (isTypingTarget(e.target)) return;
if (e.code === 'Space') spaceDownRef.current = false; if (e.code === 'Space') spaceDownRef.current = false;
}; };
window.addEventListener('keydown', onKeyDown); window.addEventListener('keydown', onKeyDown);
@@ -134,7 +210,7 @@ export function SceneEditorApp() {
window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp); window.removeEventListener('keyup', onKeyUp);
}; };
}, [persistTraps, sceneId, selectedId]); }, [persistTraps, persistTokens, sceneId, selected]);
const hostToNorm = (clientX: number, clientY: number): { x: number; y: number } | null => { const hostToNorm = (clientX: number, clientY: number): { x: number; y: number } | null => {
const host = hostRef.current; const host = hostRef.current;
@@ -147,11 +223,6 @@ export function SceneEditorApp() {
}; };
}; };
const onWheel = (_e: React.WheelEvent) => {
// native non-passive listener handles zoom
};
void onWheel;
const viewCamera = useMemo(() => view, [view]); const viewCamera = useMemo(() => view, [view]);
useEffect(() => { useEffect(() => {
@@ -190,37 +261,58 @@ export function SceneEditorApp() {
const addTrapAt = (type: SceneTrapType, nx: number, ny: number) => { const addTrapAt = (type: SceneTrapType, nx: number, ny: number) => {
const trap: SceneTrap = { const trap: SceneTrap = {
id: asSceneTrapId(randomTrapId()), id: asSceneTrapId(randomId('trap')),
type, type,
nx, nx,
ny, ny,
sizeN: DEFAULT_SCENE_TRAP_SIZE_N, sizeN: DEFAULT_SCENE_TRAP_SIZE_N,
...(type === 'freeform' ? { label: trapTypeLabelRu(type) } : {}),
}; };
const next = [...trapsRef.current, trap]; setSelected({ kind: 'trap', id: trap.id });
setSelectedId(trap.id); persistTraps([...trapsRef.current, trap]);
void persistTraps(next);
}; };
const onPaletteDragStart = (type: SceneTrapType) => (e: React.DragEvent) => { const addTokenAt = (tokenId: TokenId, nx: number, ny: number) => {
e.dataTransfer.setData('application/x-dnd-trap-type', type); const placement: SceneToken = {
e.dataTransfer.effectAllowed = 'copy'; id: asSceneTokenId(randomId('stoken')),
tokenId,
nx,
ny,
sizeN: DEFAULT_SCENE_TOKEN_SIZE_N,
rotationDeg: 0,
};
setSelected({ kind: 'token', id: placement.id });
persistTokens([...tokensRef.current, placement]);
}; };
const onStageDrop = (e: React.DragEvent) => { const onStageDrop = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
const type = e.dataTransfer.getData('application/x-dnd-trap-type') as SceneTrapType;
if (!SCENE_TRAP_TYPES.includes(type)) return;
const p = hostToNorm(e.clientX, e.clientY); const p = hostToNorm(e.clientX, e.clientY);
if (!p) return; if (!p) return;
const tokenId = e.dataTransfer.getData(TOKEN_DND_MIME);
if (tokenId) {
addTokenAt(asTokenId(tokenId), 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); addTrapAt(type, p.x, p.y);
}; };
const updateTrap = (id: string, patch: Partial<SceneTrap>) => { const updateTrap = (id: string, patch: Partial<SceneTrap>) => {
const next = trapsRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t)); persistTraps(trapsRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t)));
void persistTraps(next);
}; };
const updateToken = (id: string, patch: Partial<SceneToken>) => {
persistTokens(tokensRef.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 isImage = scene?.previewAssetType === 'image' && Boolean(url); const isImage = scene?.previewAssetType === 'image' && Boolean(url);
return ( return (
@@ -228,8 +320,9 @@ export function SceneEditorApp() {
<aside className={styles.sidebar}> <aside className={styles.sidebar}>
<div className={styles.sideTitle}>{scene?.title ?? 'Сцена'}</div> <div className={styles.sideTitle}>{scene?.title ?? 'Сцена'}</div>
<div className={styles.hint}> <div className={styles.hint}>
Колесо зум. СКМ / Space+ЛКМ пан. Delete удалить выбранную ловушку. Колесо зум. СКМ / Space+ЛКМ пан. Delete удалить выбранное.
</div> </div>
<div className={styles.accordion}> <div className={styles.accordion}>
<button type="button" className={styles.accordionHead} onClick={() => setGridOpen((v) => !v)}> <button type="button" className={styles.accordionHead} onClick={() => setGridOpen((v) => !v)}>
Сетка {gridOpen ? '▾' : '▸'} Сетка {gridOpen ? '▾' : '▸'}
@@ -246,20 +339,21 @@ export function SceneEditorApp() {
</label> </label>
<label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}> <label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
<span className={styles.fieldLabel}>Тип</span> <span className={styles.fieldLabel}>Тип</span>
<select <Select
className={styles.select}
disabled={!localGrid.enabled} disabled={!localGrid.enabled}
value={localGrid.type} value={localGrid.type}
onChange={(e) => ariaLabel="Тип сетки"
options={[
{ value: 'square', label: sceneGridTypeLabelRu('square') },
{ value: 'hex', label: sceneGridTypeLabelRu('hex') },
]}
onChange={(next) =>
persistGrid({ persistGrid({
...localGrid, ...localGrid,
type: e.target.value === 'hex' ? 'hex' : 'square', type: next === 'hex' ? 'hex' : 'square',
}) })
} }
> />
<option value="square">{sceneGridTypeLabelRu('square')}</option>
<option value="hex">{sceneGridTypeLabelRu('hex')}</option>
</select>
</label> </label>
<label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}> <label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
<span className={styles.fieldLabel}>Цвет</span> <span className={styles.fieldLabel}>Цвет</span>
@@ -295,6 +389,41 @@ export function SceneEditorApp() {
</div> </div>
) : null} ) : null}
</div> </div>
<div className={styles.accordion}>
<button type="button" className={styles.accordionHead} onClick={() => setTokensOpen((v) => !v)}>
Неигровые токены {tokensOpen ? '▾' : '▸'}
</button>
{tokensOpen ? (
<div className={styles.tokensPanel}>
<div className={styles.tokensToolbar}>
<Button onClick={() => setTokenModal({ mode: 'create' })}>Добавить</Button>
</div>
<Input
value={tokenSearch}
onChange={setTokenSearch}
placeholder="Поиск…"
autoFocus={tokensOpen}
onKeyDown={(e) => e.stopPropagation()}
/>
<div className={styles.tokenGrid}>
{filteredTokens.length === 0 ? (
<div className={styles.hint}>Нет токенов</div>
) : (
filteredTokens.map((token) => (
<TokenTile
key={token.id}
token={token}
onEdit={() => setTokenModal({ mode: 'edit', tokenId: token.id })}
onDelete={() => setPendingDeleteToken({ id: token.id, name: token.name })}
/>
))
)}
</div>
</div>
) : null}
</div>
<div className={styles.accordion}> <div className={styles.accordion}>
<button type="button" className={styles.accordionHead} onClick={() => setTrapsOpen((v) => !v)}> <button type="button" className={styles.accordionHead} onClick={() => setTrapsOpen((v) => !v)}>
Ловушки {trapsOpen ? '▾' : '▸'} Ловушки {trapsOpen ? '▾' : '▸'}
@@ -306,7 +435,10 @@ export function SceneEditorApp() {
key={type} key={type}
className={styles.paletteItem} className={styles.paletteItem}
draggable draggable
onDragStart={onPaletteDragStart(type)} onDragStart={(e) => {
e.dataTransfer.setData('application/x-dnd-trap-type', type);
e.dataTransfer.effectAllowed = 'copy';
}}
title="Перетащите на карту" title="Перетащите на карту"
> >
<TrapGlyph type={type} size={22} /> <TrapGlyph type={type} size={22} />
@@ -316,19 +448,19 @@ export function SceneEditorApp() {
</div> </div>
) : null} ) : null}
</div> </div>
{selectedId ? (
<div className={styles.toolbar}> <div className={styles.clearSceneBtn}>
<Button <Button
onClick={() => { disabled={!sceneId || (localTraps.length === 0 && localTokens.length === 0)}
const next = localTraps.filter((t) => t.id !== selectedId); onClick={() => {
setSelectedId(null); persistTraps([]);
void persistTraps(next); persistTokens([]);
}} setSelected(null);
> }}
Удалить >
</Button> Очистить сцену
</div> </Button>
) : null} </div>
</aside> </aside>
<div className={styles.stage}> <div className={styles.stage}>
@@ -364,7 +496,7 @@ export function SceneEditorApp() {
}); });
return; return;
} }
if (d.kind === 'move') { if (d.kind === 'moveTrap') {
const p = hostToNorm(e.clientX, e.clientY); const p = hostToNorm(e.clientX, e.clientY);
if (!p) return; if (!p) return;
updateTrap(d.trapId, { updateTrap(d.trapId, {
@@ -373,7 +505,7 @@ export function SceneEditorApp() {
}); });
return; return;
} }
if (d.kind === 'resize') { if (d.kind === 'resizeTrap') {
const p = hostToNorm(e.clientX, e.clientY); const p = hostToNorm(e.clientX, e.clientY);
const trap = trapsRef.current.find((t) => t.id === d.trapId); const trap = trapsRef.current.find((t) => t.id === d.trapId);
if (!p || !trap) return; if (!p || !trap) return;
@@ -382,6 +514,32 @@ export function SceneEditorApp() {
updateTrap(d.trapId, { updateTrap(d.trapId, {
sizeN: Math.max(0.02, Math.min(0.45, d.startSize * ratio)), sizeN: Math.max(0.02, Math.min(0.45, d.startSize * ratio)),
}); });
return;
}
if (d.kind === 'moveToken') {
const p = hostToNorm(e.clientX, e.clientY);
if (!p) return;
updateToken(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 === 'resizeToken') {
const p = hostToNorm(e.clientX, e.clientY);
const tok = tokensRef.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;
updateToken(d.tokenId, {
sizeN: clampSceneTokenSizeN(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);
updateToken(d.tokenId, { rotationDeg: d.startRotation + delta });
} }
}} }}
onPointerUp={() => { onPointerUp={() => {
@@ -399,27 +557,98 @@ export function SceneEditorApp() {
onContentRectChange={setContentRect} onContentRectChange={setContentRect}
/> />
<SceneGridOverlay grid={localGrid} viewport={contentRect} /> <SceneGridOverlay grid={localGrid} viewport={contentRect} />
{contentRect
? localTokens.map((tok) => {
const minDim = Math.min(contentRect.w, contentRect.h);
const sizePx = Math.max(16, tok.sizeN * minDim);
const left = contentRect.x + tok.nx * contentRect.w;
const top = contentRect.y + tok.ny * contentRect.h;
return (
<SceneTokenMarker
key={tok.id}
token={tok}
left={left}
top={top}
sizePx={sizePx}
selected={selected?.kind === 'token' && selected.id === tok.id}
onSelect={() => setSelected({ kind: 'token', id: tok.id })}
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));
}}
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: 'moveToken',
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: 'resizeToken',
tokenId: tok.id,
startSize: tok.sizeN,
startDist: Math.max(1e-4, Math.hypot(p.x - tok.nx, p.y - tok.ny)),
};
}}
onRotatePointerDown={(e) => {
e.stopPropagation();
e.preventDefault();
const host = hostRef.current;
if (!host || !contentRect) return;
const r = host.getBoundingClientRect();
const cx = r.left + contentRect.x + tok.nx * contentRect.w;
const cy = r.top + contentRect.y + tok.ny * contentRect.h;
(e.currentTarget as HTMLButtonElement).setPointerCapture(e.pointerId);
dragRef.current = {
kind: 'rotateToken',
tokenId: tok.id,
startRotation: tok.rotationDeg,
startPointerAngle: pointerAngleDeg(cx, cy, e.clientX, e.clientY),
centerClientX: cx,
centerClientY: cy,
};
}}
/>
);
})
: null}
{contentRect {contentRect
? localTraps.map((trap) => { ? localTraps.map((trap) => {
const minDim = Math.min(contentRect.w, contentRect.h); const minDim = Math.min(contentRect.w, contentRect.h);
const sizePx = Math.max(16, trap.sizeN * minDim); const sizePx = Math.max(16, trap.sizeN * minDim);
const left = contentRect.x + trap.nx * contentRect.w; const left = contentRect.x + trap.nx * contentRect.w;
const top = contentRect.y + trap.ny * contentRect.h; const top = contentRect.y + trap.ny * contentRect.h;
const selected = selectedId === trap.id; const isSelected = selected?.kind === 'trap' && selected.id === trap.id;
return ( return (
<div <div
key={trap.id} key={trap.id}
className={[styles.trap, selected ? styles.trapSelected : ''].filter(Boolean).join(' ')} className={[styles.trap, isSelected ? styles.trapSelected : ''].filter(Boolean).join(' ')}
style={{ left, top, width: sizePx, height: sizePx }} style={{ left, top, width: sizePx, height: sizePx }}
onPointerDown={(e) => { onPointerDown={(e) => {
if (e.button !== 0 || spaceDownRef.current) return; if (e.button !== 0 || spaceDownRef.current) return;
e.stopPropagation(); e.stopPropagation();
setSelectedId(trap.id); setSelected({ kind: 'trap', id: trap.id });
const p = hostToNorm(e.clientX, e.clientY); const p = hostToNorm(e.clientX, e.clientY);
if (!p) return; if (!p) return;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
dragRef.current = { dragRef.current = {
kind: 'move', kind: 'moveTrap',
trapId: trap.id, trapId: trap.id,
startNx: trap.nx, startNx: trap.nx,
startNy: trap.ny, startNy: trap.ny,
@@ -429,8 +658,10 @@ export function SceneEditorApp() {
}} }}
> >
<TrapGlyph type={trap.type} size={Math.max(14, sizePx * 0.55)} /> <TrapGlyph type={trap.type} size={Math.max(14, sizePx * 0.55)} />
{trap.label ? <div className={styles.trapLabel}>{trap.label}</div> : null} {trap.label && trap.label.trim().toLowerCase() !== 'свободная' ? (
{selected ? ( <div className={styles.trapLabel}>{trap.label}</div>
) : null}
{isSelected ? (
<div <div
className={styles.handle} className={styles.handle}
onPointerDown={(e) => { onPointerDown={(e) => {
@@ -440,7 +671,7 @@ export function SceneEditorApp() {
if (!p) return; if (!p) return;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
dragRef.current = { dragRef.current = {
kind: 'resize', kind: 'resizeTrap',
trapId: trap.id, trapId: trap.id,
startSize: trap.sizeN, startSize: trap.sizeN,
startDist: Math.max(1e-4, Math.hypot(p.x - trap.nx, p.y - trap.ny)), startDist: Math.max(1e-4, Math.hypot(p.x - trap.nx, p.y - trap.ny)),
@@ -455,6 +686,57 @@ export function SceneEditorApp() {
</div> </div>
)} )}
</div> </div>
<TokenEditModal
open={tokenModal !== null}
initial={editingToken}
existingNames={appTokens.map((t) => t.name)}
onClose={() => setTokenModal(null)}
onSaved={() => setTokenModal(null)}
/>
{pendingDeleteToken
? createPortal(
<>
<button
type="button"
aria-label="Закрыть"
className={editorStyles.modalBackdrop}
onClick={() => setPendingDeleteToken(null)}
/>
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
<div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}>Удалить токен</div>
<button
type="button"
className={editorStyles.modalClose}
onClick={() => setPendingDeleteToken(null)}
>
×
</button>
</div>
<div>
Удалить токен «{pendingDeleteToken.name}» из пула? Он также будет убран с текущей сцены.
</div>
<div className={editorStyles.modalFooter}>
<Button onClick={() => setPendingDeleteToken(null)}>Отмена</Button>
<Button
variant="primary"
onClick={() => {
const id = pendingDeleteToken.id;
setPendingDeleteToken(null);
void api.invoke(ipcChannels.tokens.delete, { id });
persistTokens(tokensRef.current.filter((t) => t.tokenId !== id));
}}
>
Удалить
</Button>
</div>
</div>
</>,
document.body,
)
: null}
</div> </div>
); );
} }
@@ -0,0 +1,69 @@
import React from 'react';
import type { SceneToken } from '../../shared/types';
import { useTokenImageUrl } from '../shared/tokens/useTokenImageUrl';
import styles from './SceneEditorApp.module.css';
type Props = {
token: SceneToken;
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;
onRotatePointerDown: (e: React.PointerEvent) => void;
};
export function SceneTokenMarker({
token,
left,
top,
sizePx,
selected,
onSelect,
onContextMenu,
onMovePointerDown,
onResizePointerDown,
onRotatePointerDown,
}: Props) {
const url = useTokenImageUrl(token.tokenId);
return (
<div
className={[styles.sceneToken, selected ? styles.sceneTokenSelected : ''].filter(Boolean).join(' ')}
style={{
left,
top,
width: sizePx,
height: sizePx,
transform: `translate(-50%, -50%) rotate(${String(token.rotationDeg)}deg)`,
}}
title="Перетащите · ПКМ — удалить"
onContextMenu={onContextMenu}
onPointerDown={(e) => {
if (e.button !== 0) return;
onSelect();
onMovePointerDown(e);
}}
>
{url ? <img className={styles.sceneTokenImg} src={url} alt="" draggable={false} /> : null}
{selected ? (
<>
<div className={styles.handle} onPointerDown={onResizePointerDown} />
<button
type="button"
className={styles.rotateHandle}
aria-label="Повернуть"
title="Повернуть"
onPointerDown={onRotatePointerDown}
>
</button>
</>
) : null}
</div>
);
}
+193
View File
@@ -0,0 +1,193 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { ipcChannels } from '../../shared/ipc/contracts';
import type { AppToken } from '../../shared/types';
import {
filterMaterialImagePaths,
getDroppedFileEntries,
pickFirstMaterialImagePath,
useFileDropZone,
} from '../editor/fileDrop';
import editorStyles from '../editor/EditorApp.module.css';
import matStyles from '../editor/MaterialsModals.module.css';
import { getDndApi } from '../shared/dndApi';
import { Button, Input } from '../shared/ui/controls';
import { useTokenImageUrl } from '../shared/tokens/useTokenImageUrl';
type Props = {
open: boolean;
initial: AppToken | null;
existingNames: string[];
onClose: () => void;
onSaved: (token: AppToken) => void;
};
function normalizeName(input: string): string {
return input.trim().toLowerCase();
}
export function TokenEditModal({ open, initial, existingNames, onClose, onSaved }: Props) {
const api = getDndApi();
const [name, setName] = useState('');
const [filePath, setFilePath] = useState<string | null>(null);
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const existingUrl = useTokenImageUrl(initial?.id ?? null);
useEffect(() => {
if (!open) return;
setName(initial?.name ?? '');
setFilePath(null);
setLocalPreviewUrl(null);
setSaving(false);
setError(null);
}, [open, initial]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]);
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
setFilePath(path);
setLocalPreviewUrl((prev) => {
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
return previewUrl || null;
});
};
const drop = useFileDropZone({
onDropPaths: (paths) => {
const picked = pickFirstMaterialImagePath(paths);
if (!picked) return;
setPreviewFromPathAndUrl(picked, '');
},
filterPaths: filterMaterialImagePaths,
});
const trimmed = name.trim();
const nameOk = trimmed.length >= 1;
const nameDup =
nameOk &&
existingNames.some(
(n) =>
normalizeName(n) === normalizeName(trimmed) &&
normalizeName(n) !== normalizeName(initial?.name ?? ''),
);
const hasImage = Boolean(filePath) || Boolean(initial);
const canSave = nameOk && !nameDup && hasImage && !saving;
const previewSrc = localPreviewUrl || existingUrl;
if (!open) return null;
return createPortal(
<>
<button type="button" aria-label="Закрыть" onClick={onClose} className={editorStyles.modalBackdrop} />
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
<div className={editorStyles.modalHeader}>
<div className={editorStyles.modalTitle}>
{initial ? 'Изменить токен' : 'Добавить токен'}
</div>
<button type="button" aria-label="Закрыть" onClick={onClose} className={editorStyles.modalClose}>
×
</button>
</div>
<div className={editorStyles.fieldGrid}>
<div className={editorStyles.fieldLabel}>Название</div>
<Input value={name} onChange={setName} placeholder="Название токена" />
{!nameOk ? <div className={editorStyles.fieldError}>Укажите название</div> : null}
{nameDup ? <div className={editorStyles.fieldError}>Такое название уже есть</div> : null}
</div>
<div className={editorStyles.fieldGrid}>
<div className={editorStyles.fieldLabel}>Изображение</div>
<div
className={[matStyles.imageDrop, drop.dragOver ? matStyles.imageDropOver : ''].join(' ')}
onDragEnter={drop.onDragEnter}
onDragLeave={drop.onDragLeave}
onDragOver={drop.onDragOver}
onDrop={(e) => {
drop.onDrop(e);
const entries = getDroppedFileEntries(e);
const files = e.dataTransfer.files;
for (let i = 0; i < entries.length; i += 1) {
const entry = entries[i];
if (!entry || !pickFirstMaterialImagePath([entry.path])) continue;
const file = files[i];
if (file) {
setPreviewFromPathAndUrl(entry.path, URL.createObjectURL(file));
return;
}
setPreviewFromPathAndUrl(entry.path, '');
return;
}
}}
>
{drop.dragOver ? (
<div className={editorStyles.dropHintOverlay}>Отпустите, чтобы загрузить изображение</div>
) : null}
{previewSrc ? (
<img src={previewSrc} alt="" className={matStyles.previewThumb} draggable={false} />
) : (
<div className={editorStyles.muted}>Перетащите изображение или выберите файл</div>
)}
<Button
onClick={() => {
void (async () => {
const res = await api.invoke(ipcChannels.tokens.pickImage, {});
if (res.canceled) return;
setPreviewFromPathAndUrl(res.filePath, res.previewDataUrl);
})();
}}
>
Выбрать изображение
</Button>
</div>
{!hasImage ? <div className={editorStyles.fieldError}>Нужно изображение</div> : null}
</div>
{error ? <div className={editorStyles.fieldError}>{error}</div> : null}
<div className={editorStyles.modalFooter}>
<Button onClick={onClose} disabled={saving}>
Отмена
</Button>
<Button
variant="primary"
disabled={!canSave}
onClick={() => {
if (!canSave) return;
void (async () => {
setSaving(true);
setError(null);
try {
const { token } = await api.invoke(ipcChannels.tokens.upsert, {
id: initial?.id ?? null,
name: trimmed,
filePath: filePath ?? null,
});
onSaved(token);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : 'Не удалось сохранить');
} finally {
setSaving(false);
}
})();
}}
>
Сохранить
</Button>
</div>
</div>
</>,
document.body,
);
}
+126
View File
@@ -0,0 +1,126 @@
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { AppToken } from '../../shared/types';
import { useTokenImageUrl } from '../shared/tokens/useTokenImageUrl';
import styles from './SceneEditorApp.module.css';
const TOKEN_DND_MIME = 'application/x-dnd-app-token-id';
type Props = {
token: AppToken;
onEdit: () => void;
onDelete: () => void;
};
export function TokenTile({ token, onEdit, onDelete }: Props) {
const url = useTokenImageUrl(token.id);
const menuBtnRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
useEffect(() => {
if (!menu) return;
const onPointerDown = (e: PointerEvent) => {
const t = e.target;
if (!(t instanceof Node)) return;
if (menuBtnRef.current?.contains(t)) return;
if (menuRef.current?.contains(t)) return;
setMenu(null);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMenu(null);
};
window.addEventListener('pointerdown', onPointerDown, true);
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('pointerdown', onPointerDown, true);
window.removeEventListener('keydown', onKey);
};
}, [menu]);
const openMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const pad = 8;
const menuW = 160;
const menuH = 84;
const x = Math.max(pad, Math.min(e.clientX, window.innerWidth - menuW - pad));
const y = Math.max(pad, Math.min(e.clientY, window.innerHeight - menuH - pad));
setMenu({ x, y });
};
return (
<div
className={styles.tokenTile}
draggable
onDragStart={(e) => {
if (menuBtnRef.current?.contains(e.target as Node)) {
e.preventDefault();
return;
}
e.dataTransfer.setData(TOKEN_DND_MIME, token.id);
e.dataTransfer.effectAllowed = 'copy';
}}
title="Перетащите на карту"
>
<div className={styles.tokenThumb}>
{url ? <img src={url} alt="" draggable={false} /> : null}
</div>
<div className={styles.tokenTileMeta}>
<div className={styles.tokenTileName}>{token.name}</div>
<button
ref={menuBtnRef}
type="button"
className={styles.tokenTileMore}
aria-label="Действия"
aria-haspopup="menu"
aria-expanded={menu !== null}
title="Действия"
onClick={openMenu}
onPointerDown={(e) => e.stopPropagation()}
>
</button>
</div>
{menu
? createPortal(
<div
ref={menuRef}
role="menu"
className={styles.tokenCtxMenu}
style={{ left: menu.x, top: menu.y }}
onPointerDown={(e) => e.stopPropagation()}
>
<button
type="button"
role="menuitem"
className={styles.tokenCtxItem}
onClick={() => {
setMenu(null);
onEdit();
}}
>
Изменить
</button>
<button
type="button"
role="menuitem"
className={styles.tokenCtxItemDanger}
onClick={() => {
setMenu(null);
onDelete();
}}
>
Удалить
</button>
</div>,
document.body,
)
: null}
</div>
);
}
export { TOKEN_DND_MIME };
+13
View File
@@ -17,6 +17,9 @@ import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay';
import { useNpcsOverlayState } from './npcs/useNpcsOverlayState'; import { useNpcsOverlayState } from './npcs/useNpcsOverlayState';
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost'; import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
import { useSceneViewState } from './sceneView/useSceneViewState'; import { useSceneViewState } from './sceneView/useSceneViewState';
import { SceneTokensOverlay } from './tokens/SceneTokensOverlay';
import { useAppTokens } from './tokens/useAppTokens';
import { useSceneTokensSession } from './tokens/useSceneTokensSession';
import { SceneTrapsOverlay } from './traps/SceneTrapsOverlay'; import { SceneTrapsOverlay } from './traps/SceneTrapsOverlay';
import { useSceneTrapsState } from './traps/useSceneTrapsState'; import { useSceneTrapsState } from './traps/useSceneTrapsState';
import styles from './PresentationView.module.css'; import styles from './PresentationView.module.css';
@@ -44,6 +47,8 @@ export function PresentationView({
const [sceneView] = useSceneViewState(); const [sceneView] = useSceneViewState();
const [materialsOverlay] = useMaterialsOverlayState(); const [materialsOverlay] = useMaterialsOverlayState();
const [npcsOverlay] = useNpcsOverlayState(); const [npcsOverlay] = useNpcsOverlayState();
const appTokens = useAppTokens();
const [sceneTokensSession] = useSceneTokensSession();
const [vp] = useVideoPlaybackState(); const [vp] = useVideoPlaybackState();
const videoElRef = useRef<HTMLVideoElement | null>(null); const videoElRef = useRef<HTMLVideoElement | null>(null);
const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>( const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>(
@@ -164,6 +169,14 @@ export function PresentationView({
<SceneGridOverlay grid={scene.grid} viewport={contentRect} /> <SceneGridOverlay grid={scene.grid} viewport={contentRect} />
) : null} ) : null}
<div className={styles.vignette} /> <div className={styles.vignette} />
{scene?.previewAssetType === 'image' && contentRect ? (
<SceneTokensOverlay
placements={scene.tokens ?? []}
library={appTokens}
session={sceneTokensSession}
viewport={contentRect}
/>
) : null}
{scene?.previewAssetType === 'image' && contentRect ? ( {scene?.previewAssetType === 'image' && contentRect ? (
<SceneTrapsOverlay <SceneTrapsOverlay
traps={scene.traps ?? []} traps={scene.traps ?? []}
@@ -0,0 +1,35 @@
.layer {
position: absolute;
inset: 0;
pointer-events: none;
/* Выше brushLayer (3) и traps (5), ниже explosion (7) — drag должен стабильно ловиться. */
z-index: 8;
}
.token {
position: absolute;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(0, 0, 0, 0.2);
overflow: hidden;
pointer-events: none;
touch-action: none;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3);
}
.tokenEditable {
pointer-events: auto;
cursor: grab;
}
.tokenEditable:active {
cursor: grabbing;
}
.tokenImg {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
pointer-events: none;
}
@@ -0,0 +1,193 @@
import React, { useEffect, useRef, useState } from 'react';
import type { AppToken, SceneToken, SceneTokensSessionState } from '../../../shared/types';
import { useTokenImageUrl } from './useTokenImageUrl';
import styles from './SceneTokensOverlay.module.css';
type Viewport = { x: number; y: number; w: number; h: number };
type Props = {
placements: readonly SceneToken[];
library: readonly AppToken[];
session: SceneTokensSessionState | null;
viewport: Viewport | null;
editable?: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
};
function TokenSprite({
placement,
nx,
ny,
viewport,
editable,
onMove,
}: {
placement: SceneToken;
nx: number;
ny: number;
viewport: Viewport;
editable: boolean;
onMove?: (placementId: string, nx: number, ny: number) => void;
}) {
const url = useTokenImageUrl(placement.tokenId);
const dragRef = useRef<{
startNx: number;
startNy: number;
pointerNx: number;
pointerNy: number;
pointerId: number;
lastNx: number;
lastNy: number;
} | null>(null);
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
const frameRef = useRef(0);
useEffect(() => {
if (dragRef.current) return;
setLocalPos(null);
}, [nx, ny]);
useEffect(() => {
return () => {
if (frameRef.current) cancelAnimationFrame(frameRef.current);
};
}, []);
const posNx = localPos?.nx ?? nx;
const posNy = localPos?.ny ?? ny;
const minDim = Math.min(viewport.w, viewport.h);
const sizePx = Math.max(16, placement.sizeN * minDim);
const left = viewport.x + posNx * viewport.w;
const top = viewport.y + posNy * viewport.h;
const hostToNorm = (clientX: number, clientY: number, host: HTMLElement) => {
const r = host.getBoundingClientRect();
const w = Math.max(1e-6, viewport.w);
const h = Math.max(1e-6, viewport.h);
return {
x: Math.max(0, Math.min(1, (clientX - (r.left + viewport.x)) / w)),
y: Math.max(0, Math.min(1, (clientY - (r.top + viewport.y)) / h)),
};
};
const endDrag = (el: HTMLDivElement, pointerId: number) => {
const d = dragRef.current;
if (!d || d.pointerId !== pointerId) return;
dragRef.current = null;
if (frameRef.current) {
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 {
if (el.hasPointerCapture(pointerId)) el.releasePointerCapture(pointerId);
} catch {
/* ignore */
}
};
return (
<div
className={[styles.token, editable ? styles.tokenEditable : ''].filter(Boolean).join(' ')}
style={{
left,
top,
width: sizePx,
height: sizePx,
transform: `translate(-50%, -50%) rotate(${String(placement.rotationDeg)}deg)`,
}}
onPointerDown={
editable && onMove
? (e) => {
if (e.button !== 0) return;
e.stopPropagation();
e.preventDefault();
const host = (e.currentTarget.parentElement as HTMLElement | null) ?? e.currentTarget;
const p = hostToNorm(e.clientX, e.clientY, host);
dragRef.current = {
startNx: posNx,
startNy: posNy,
pointerNx: p.x,
pointerNy: p.y,
pointerId: e.pointerId,
lastNx: posNx,
lastNy: posNy,
};
setLocalPos({ nx: posNx, ny: posNy });
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
}
: undefined
}
onPointerMove={
editable && onMove
? (e) => {
const d = dragRef.current;
if (!d || d.pointerId !== e.pointerId) return;
const host = (e.currentTarget.parentElement as HTMLElement | null) ?? e.currentTarget;
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;
if (frameRef.current) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = 0;
const cur = dragRef.current;
if (!cur) return;
setLocalPos({ nx: cur.lastNx, ny: cur.lastNy });
});
}
: undefined
}
onPointerUp={(e) => {
endDrag(e.currentTarget as HTMLDivElement, e.pointerId);
}}
onPointerCancel={(e) => {
endDrag(e.currentTarget as HTMLDivElement, e.pointerId);
}}
>
{url ? <img className={styles.tokenImg} src={url} alt="" draggable={false} /> : null}
</div>
);
}
export function SceneTokensOverlay({
placements,
library,
session,
viewport,
editable = false,
onMove,
}: Props) {
if (!viewport || placements.length === 0) return null;
const known = new Set(library.map((t) => t.id));
return (
<div className={styles.layer}>
{placements
.filter((p) => known.has(p.tokenId))
.map((placement) => {
const key = String(placement.id);
const override = session?.byPlacementId[key] ?? session?.byPlacementId[placement.id];
const nx = override?.nx ?? placement.nx;
const ny = override?.ny ?? placement.ny;
return (
<TokenSprite
key={key}
placement={placement}
nx={nx}
ny={ny}
viewport={viewport}
editable={editable}
onMove={onMove}
/>
);
})}
</div>
);
}
@@ -0,0 +1,17 @@
import { useEffect, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { AppToken } from '../../../shared/types';
import { getDndApi } from '../dndApi';
export function useAppTokens(): AppToken[] {
const api = getDndApi();
const [tokens, setTokens] = useState<AppToken[]>([]);
useEffect(() => {
void api.invoke(ipcChannels.tokens.list, {}).then(({ tokens: list }) => setTokens(list));
return api.on(ipcChannels.tokens.stateChanged, ({ tokens: list }) => setTokens(list));
}, [api]);
return tokens;
}
@@ -0,0 +1,63 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { SceneTokensSessionEvent, SceneTokensSessionState } from '../../../shared/types';
import { getDndApi } from '../dndApi';
function applyEvent(
prev: SceneTokensSessionState | null,
event: SceneTokensSessionEvent,
): SceneTokensSessionState {
const base = prev ?? { revision: 0, byPlacementId: {} };
if (event.kind === 'clear') {
return { revision: base.revision + 1, byPlacementId: {} };
}
const placementId = String(event.placementId ?? '');
return {
revision: base.revision + 1,
byPlacementId: {
...base.byPlacementId,
[placementId]: { nx: event.nx, ny: event.ny },
},
};
}
export function useSceneTokensSession(): [
SceneTokensSessionState | null,
{ dispatch: (event: SceneTokensSessionEvent) => Promise<void> },
] {
const api = getDndApi();
const [state, setState] = useState<SceneTokensSessionState | null>(null);
const localRevisionRef = useRef(0);
useEffect(() => {
void api.invoke(ipcChannels.sceneTokensSession.getState, {}).then(({ state: s }) => {
localRevisionRef.current = Math.max(localRevisionRef.current, s.revision);
setState(s);
});
return api.on(ipcChannels.sceneTokensSession.stateChanged, ({ state: s }) => {
// Не затираем более свежий optimistic-стейт устаревшим broadcast
// (например, emit при смене сцены, пока последний move ещё в полёте).
if (localRevisionRef.current > s.revision) return;
localRevisionRef.current = s.revision;
setState(s);
});
}, [api]);
const apiWrap = useMemo(
() => ({
dispatch: async (event: SceneTokensSessionEvent) => {
setState((prev) => {
const next = applyEvent(prev, event);
localRevisionRef.current = Math.max(localRevisionRef.current, next.revision);
return next;
});
const res = await api.invoke(ipcChannels.sceneTokensSession.dispatch, { event });
void res;
},
}),
[api],
);
return [state, apiWrap];
}
@@ -0,0 +1,50 @@
import { useEffect, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { TokenId } from '../../../shared/types';
import { getDndApi } from '../dndApi';
const cache = new Map<string, string | null>();
export function useTokenImageUrl(tokenId: TokenId | null | undefined): string | null {
const api = getDndApi();
const [url, setUrl] = useState<string | null>(() =>
tokenId ? (cache.get(tokenId) ?? null) : null,
);
useEffect(() => {
if (!tokenId) {
setUrl(null);
return;
}
const cached = cache.get(tokenId);
if (cached !== undefined) {
setUrl(cached);
return;
}
let cancelled = false;
void api.invoke(ipcChannels.tokens.imageUrl, { id: tokenId }).then(({ url: next }) => {
cache.set(tokenId, next);
if (!cancelled) setUrl(next);
});
return () => {
cancelled = true;
};
}, [api, tokenId]);
useEffect(() => {
return api.on(ipcChannels.tokens.stateChanged, ({ tokens }) => {
for (const t of tokens) {
cache.delete(t.id);
}
if (tokenId) {
void api.invoke(ipcChannels.tokens.imageUrl, { id: tokenId }).then(({ url: next }) => {
cache.set(tokenId, next);
setUrl(next);
});
}
});
}, [api, tokenId]);
return url;
}
@@ -132,7 +132,9 @@ export function SceneTrapsOverlay({
} }
> >
<TrapGlyph type={trap.type} status={rt.status} size={Math.max(14, sizePx * 0.55)} /> <TrapGlyph type={trap.type} status={rt.status} size={Math.max(14, sizePx * 0.55)} />
{trap.label ? <div className={styles.label}>{trap.label}</div> : null} {trap.label && trap.label.trim().toLowerCase() !== 'свободная' ? (
<div className={styles.label}>{trap.label}</div>
) : null}
{activationFx?.trapId === trap.id && activationFx.kind === 'flash' ? ( {activationFx?.trapId === trap.id && activationFx.kind === 'flash' ? (
<div className={styles.flash} /> <div className={styles.flash} />
) : null} ) : null}
+7 -8
View File
@@ -1,3 +1,10 @@
.buttonHost {
display: inline-flex;
vertical-align: middle;
flex: 0 0 auto;
max-width: 100%;
}
.button { .button {
height: 34px; height: 34px;
padding: 0 14px; padding: 0 14px;
@@ -89,11 +96,3 @@
outline: none; outline: none;
color: inherit; color: inherit;
} }
select.input {
padding-right: 28px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.25L6 7.75L9.5 4.25' stroke='rgba(255,255,255,0.72)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 12px 12px;
}
+117
View File
@@ -0,0 +1,117 @@
.root {
width: 100%;
min-width: 0;
max-width: 100%;
}
.trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
min-width: 0;
min-height: 34px;
box-sizing: border-box;
padding: 0 10px 0 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--color-overlay-dark-3);
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
outline: none;
}
.trigger:hover:not(:disabled) {
border-color: var(--stroke-2);
background: var(--color-panel-2);
}
.trigger:focus-visible {
border-color: var(--accent-border);
box-shadow: 0 0 0 2px var(--accent-fill-soft);
}
.trigger:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.triggerOpen {
border-color: var(--accent-border);
background: var(--color-panel-2);
}
.value {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.placeholder {
color: var(--text2);
}
.chevron {
flex: 0 0 auto;
width: 12px;
height: 12px;
opacity: 0.72;
transition: transform 0.15s ease;
}
.chevronOpen {
transform: rotate(180deg);
}
.menu {
position: fixed;
z-index: calc(var(--z-modal) + 10);
max-height: min(280px, 50vh);
overflow: auto;
padding: 6px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke-2);
background: var(--color-surface-menu);
box-shadow: var(--shadow-menu);
backdrop-filter: var(--backdrop-blur-surface);
-webkit-backdrop-filter: var(--backdrop-blur-surface);
}
.option {
display: block;
width: 100%;
text-align: left;
padding: 8px 10px;
border: 0;
border-radius: var(--radius-xs);
background: transparent;
color: var(--text0);
font: inherit;
font-size: var(--text-sm);
cursor: pointer;
}
.option:hover:not(:disabled),
.optionActive:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
}
.optionSelected {
background: var(--accent-fill-soft-2);
color: var(--text-on-accent);
}
.optionSelected:hover:not(:disabled),
.optionSelected.optionActive:not(:disabled) {
background: var(--accent-fill-soft);
}
.option:disabled {
cursor: not-allowed;
opacity: 0.4;
}
+271
View File
@@ -0,0 +1,271 @@
import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import styles from './Select.module.css';
export type SelectOption = {
value: string;
label: string;
disabled?: boolean;
};
export type SelectProps = {
value: string;
options: readonly SelectOption[];
onChange: (value: string) => void;
disabled?: boolean;
ariaLabel?: string;
/** Доп. класс на кнопку-триггер (ширина и т.п.). */
className?: string;
placeholder?: string;
};
type MenuPos = { left: number; top: number; width: number; maxHeight: number };
function Chevron({ open }: { open: boolean }) {
return (
<svg
className={[styles.chevron, open ? styles.chevronOpen : ''].filter(Boolean).join(' ')}
viewBox="0 0 12 12"
aria-hidden
>
<path
d="M2.5 4.25L6 7.75L9.5 4.25"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</svg>
);
}
export function Select({
value,
options,
onChange,
disabled = false,
ariaLabel,
className,
placeholder = '—',
}: SelectProps) {
const listId = useId();
const triggerRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
const [menuPos, setMenuPos] = useState<MenuPos | null>(null);
const [activeIndex, setActiveIndex] = useState(0);
const selected = useMemo(() => options.find((o) => o.value === value) ?? null, [options, value]);
const enabledIndexes = useMemo(
() => options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0),
[options],
);
const close = useCallback(() => {
setOpen(false);
setMenuPos(null);
}, []);
const layoutMenu = useCallback(() => {
const trigger = triggerRef.current;
if (!trigger) return;
const r = trigger.getBoundingClientRect();
const pad = 8;
const gap = 4;
const preferredMax = Math.min(280, window.innerHeight * 0.5);
const spaceBelow = window.innerHeight - r.bottom - pad;
const spaceAbove = r.top - pad;
const placeBelow = spaceBelow >= 120 || spaceBelow >= spaceAbove;
const maxHeight = Math.max(96, Math.min(preferredMax, placeBelow ? spaceBelow - gap : spaceAbove - gap));
const top = placeBelow ? r.bottom + gap : Math.max(pad, r.top - gap - maxHeight);
setMenuPos({
left: Math.max(pad, Math.min(r.left, window.innerWidth - r.width - pad)),
top,
width: r.width,
maxHeight,
});
}, []);
const openMenu = useCallback(() => {
if (disabled) return;
const selectedIdx = options.findIndex((o) => o.value === value && !o.disabled);
const fallback = enabledIndexes[0] ?? 0;
setActiveIndex(selectedIdx >= 0 ? selectedIdx : fallback);
setOpen(true);
}, [disabled, enabledIndexes, options, value]);
useLayoutEffect(() => {
if (!open) return;
layoutMenu();
}, [layoutMenu, open, options.length]);
useEffect(() => {
if (!open) return;
const onWin = () => layoutMenu();
window.addEventListener('resize', onWin);
window.addEventListener('scroll', onWin, true);
return () => {
window.removeEventListener('resize', onWin);
window.removeEventListener('scroll', onWin, true);
};
}, [layoutMenu, open]);
useEffect(() => {
if (!open) return;
const onPointerDown = (e: PointerEvent) => {
const t = e.target;
if (!(t instanceof Node)) return;
if (triggerRef.current?.contains(t)) return;
if (menuRef.current?.contains(t)) return;
close();
};
window.addEventListener('pointerdown', onPointerDown, true);
return () => window.removeEventListener('pointerdown', onPointerDown, true);
}, [close, open]);
useEffect(() => {
if (!open) return;
const el = menuRef.current?.querySelector<HTMLElement>(`[data-select-index="${String(activeIndex)}"]`);
el?.scrollIntoView({ block: 'nearest' });
}, [activeIndex, open]);
const moveActive = (dir: 1 | -1) => {
if (enabledIndexes.length === 0) return;
const pos = enabledIndexes.indexOf(activeIndex);
const nextPos =
pos < 0
? dir === 1
? 0
: enabledIndexes.length - 1
: (pos + dir + enabledIndexes.length) % enabledIndexes.length;
setActiveIndex(enabledIndexes[nextPos]!);
};
const commitIndex = (index: number) => {
const opt = options[index];
if (!opt || opt.disabled) return;
onChange(opt.value);
close();
triggerRef.current?.focus();
};
const onTriggerKeyDown = (e: React.KeyboardEvent) => {
if (disabled) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!open) {
openMenu();
return;
}
if (e.key === 'ArrowDown') moveActive(1);
else if (e.key === 'ArrowUp') moveActive(-1);
else if (e.key === 'Enter' || e.key === ' ') commitIndex(activeIndex);
} else if (e.key === 'Escape' && open) {
e.preventDefault();
close();
}
};
const onMenuKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
moveActive(1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
moveActive(-1);
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
commitIndex(activeIndex);
} else if (e.key === 'Escape') {
e.preventDefault();
close();
triggerRef.current?.focus();
} else if (e.key === 'Tab') {
close();
}
};
const triggerClass = [
styles.trigger,
open ? styles.triggerOpen : '',
className ?? '',
]
.filter(Boolean)
.join(' ');
const menu =
open && menuPos && typeof document !== 'undefined'
? createPortal(
<div
ref={menuRef}
id={listId}
role="listbox"
className={styles.menu}
style={{
left: menuPos.left,
top: menuPos.top,
width: menuPos.width,
maxHeight: menuPos.maxHeight,
}}
onKeyDown={onMenuKeyDown}
>
{options.map((opt, index) => {
const selectedOpt = opt.value === value;
const active = index === activeIndex;
return (
<button
key={`${opt.value}::${String(index)}`}
type="button"
role="option"
data-select-index={index}
aria-selected={selectedOpt}
disabled={opt.disabled}
className={[
styles.option,
selectedOpt ? styles.optionSelected : '',
active ? styles.optionActive : '',
]
.filter(Boolean)
.join(' ')}
onMouseEnter={() => {
if (!opt.disabled) setActiveIndex(index);
}}
onClick={() => commitIndex(index)}
>
{opt.label}
</button>
);
})}
</div>,
document.body,
)
: null;
return (
<div className={styles.root}>
<button
ref={triggerRef}
type="button"
className={triggerClass}
disabled={disabled}
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={open ? listId : undefined}
onClick={() => {
if (open) close();
else openMenu();
}}
onKeyDown={onTriggerKeyDown}
>
<span className={[styles.value, selected ? '' : styles.placeholder].filter(Boolean).join(' ')}>
{selected?.label ?? placeholder}
</span>
<Chevron open={open} />
</button>
{menu}
</div>
);
}
+12 -5
View File
@@ -3,6 +3,8 @@ import { createPortal } from 'react-dom';
import styles from './Controls.module.css'; import styles from './Controls.module.css';
export { Select, type SelectOption, type SelectProps } from './Select';
type ButtonProps = { type ButtonProps = {
children: React.ReactNode; children: React.ReactNode;
onClick?: () => void; onClick?: () => void;
@@ -94,22 +96,23 @@ export function Button({
); );
// Disabled buttons don't receive mouse events — host span keeps tooltip usable. // Disabled buttons don't receive mouse events — host span keeps tooltip usable.
// Single DOM root (not Fragment) so flex/grid parents don't mis-place the button.
if (disabled && title) { if (disabled && title) {
return ( return (
<> <span className={styles.buttonHost}>
<span ref={hostRef} className={styles.disabledTipHost} onMouseEnter={showTip} onMouseLeave={hideTip}> <span ref={hostRef} className={styles.disabledTipHost} onMouseEnter={showTip} onMouseLeave={hideTip}>
{button} {button}
</span> </span>
{tip} {tip}
</> </span>
); );
} }
return ( return (
<> <span className={styles.buttonHost}>
{button} {button}
{tip} {tip}
</> </span>
); );
} }
@@ -117,15 +120,19 @@ type InputProps = {
value: string; value: string;
placeholder?: string; placeholder?: string;
onChange: (v: string) => void; onChange: (v: string) => void;
autoFocus?: boolean;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
}; };
export function Input({ value, placeholder, onChange }: InputProps) { export function Input({ value, placeholder, onChange, autoFocus, onKeyDown }: InputProps) {
return ( return (
<input <input
className={styles.input} className={styles.input}
value={value} value={value}
placeholder={placeholder} placeholder={placeholder}
autoFocus={autoFocus}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
/> />
); );
} }
+1
View File
@@ -23,6 +23,7 @@ function scene(id: string): Scene {
previewRotationDeg: 0, previewRotationDeg: 0,
darkenScene: false, darkenScene: false,
traps: [], traps: [],
tokens: [],
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' }, grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
media: { videos: [], audios: [] }, media: { videos: [], audios: [] },
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true }, settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
@@ -28,6 +28,7 @@ function scene(id: string, title: string): Scene {
previewRotationDeg: 0, previewRotationDeg: 0,
darkenScene: false, darkenScene: false,
traps: [], traps: [],
tokens: [],
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' }, grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
media: { videos: [], audios: [] }, media: { videos: [], audios: [] },
settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false }, settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
+34 -1
View File
@@ -11,7 +11,7 @@ import type {
SceneGraphNode, SceneGraphNode,
SceneId, SceneId,
} from '../types'; } from '../types';
import type { AssetId, NpcGroupId, ProjectId } from '../types/ids'; import type { AssetId, NpcGroupId, ProjectId, TokenId } from '../types/ids';
import { import {
asAssetId, asAssetId,
asGraphNodeId, asGraphNodeId,
@@ -21,6 +21,7 @@ import {
asNpcRelationId, asNpcRelationId,
asProjectId, asProjectId,
asSceneId, asSceneId,
asTokenId,
} from '../types/ids'; } from '../types/ids';
import { import {
@@ -812,3 +813,35 @@ function remapNpcBinding(
export function newExportBundleProjectId(): ProjectId { export function newExportBundleProjectId(): ProjectId {
return asProjectId(`p_${generateId()}`); return asProjectId(`p_${generateId()}`);
} }
/** Id app-токенов, реально стоящих на сценах проекта (для экспорта с линией). */
export function collectTokenIdsFromProject(project: Project): TokenId[] {
const ids = new Set<string>();
for (const scene of Object.values(project.scenes)) {
for (const t of scene.tokens ?? []) {
if (t.tokenId) ids.add(t.tokenId);
}
}
return [...ids].map((id) => asTokenId(id));
}
/** Переписать tokenId на сценах после импорта в app-пул. */
export function remapProjectSceneTokenIds(
project: Project,
remap: ReadonlyMap<string, string>,
): Project {
if (remap.size === 0) return project;
const scenes: Record<SceneId, Scene> = { ...project.scenes };
for (const sid of Object.keys(scenes) as SceneId[]) {
const scene = scenes[sid];
if (!scene?.tokens?.length) continue;
scenes[sid] = {
...scene,
tokens: scene.tokens.map((t) => ({
...t,
tokenId: asTokenId(remap.get(t.tokenId) ?? t.tokenId),
})),
};
}
return { ...project, scenes };
}
+51
View File
@@ -17,16 +17,21 @@ import type {
NpcsOverlayState, NpcsOverlayState,
Project, Project,
ProjectId, ProjectId,
AppToken,
Scene, Scene,
SceneDarknessEvent, SceneDarknessEvent,
SceneDarknessState, SceneDarknessState,
SceneGrid, SceneGrid,
SceneId, SceneId,
SceneToken,
SceneTokensSessionEvent,
SceneTokensSessionState,
SceneTrap, SceneTrap,
SceneTrapsEvent, SceneTrapsEvent,
SceneTrapsState, SceneTrapsState,
SceneViewEvent, SceneViewEvent,
SceneViewState, SceneViewState,
TokenId,
VideoPlaybackEvent, VideoPlaybackEvent,
VideoPlaybackState, VideoPlaybackState,
} from '../types'; } from '../types';
@@ -164,6 +169,19 @@ export const ipcChannels = {
dispatch: 'sceneView.dispatch', dispatch: 'sceneView.dispatch',
stateChanged: 'sceneView.stateChanged', stateChanged: 'sceneView.stateChanged',
}, },
tokens: {
list: 'tokens.list',
upsert: 'tokens.upsert',
delete: 'tokens.delete',
pickImage: 'tokens.pickImage',
imageUrl: 'tokens.imageUrl',
stateChanged: 'tokens.stateChanged',
},
sceneTokensSession: {
getState: 'sceneTokensSession.getState',
dispatch: 'sceneTokensSession.dispatch',
stateChanged: 'sceneTokensSession.stateChanged',
},
video: { video: {
getState: 'video.getState', getState: 'video.getState',
dispatch: 'video.dispatch', dispatch: 'video.dispatch',
@@ -232,6 +250,8 @@ export type IpcEventMap = {
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState }; [ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
[ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState }; [ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState };
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState }; [ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
[ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState }; [ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot }; [ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean }; [ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
@@ -651,6 +671,34 @@ export type IpcInvokeMap = {
req: { event: SceneViewEvent }; req: { event: SceneViewEvent };
res: { ok: true }; res: { ok: true };
}; };
[ipcChannels.tokens.list]: {
req: Record<string, never>;
res: { tokens: AppToken[] };
};
[ipcChannels.tokens.upsert]: {
req: { id?: TokenId | null; name: string; filePath?: string | null };
res: { token: AppToken };
};
[ipcChannels.tokens.delete]: {
req: { id: TokenId };
res: { ok: true };
};
[ipcChannels.tokens.pickImage]: {
req: Record<string, never>;
res: { canceled: true } | { canceled: false; filePath: string; previewDataUrl: string };
};
[ipcChannels.tokens.imageUrl]: {
req: { id: TokenId };
res: { url: string | null };
};
[ipcChannels.sceneTokensSession.getState]: {
req: Record<string, never>;
res: { state: SceneTokensSessionState };
};
[ipcChannels.sceneTokensSession.dispatch]: {
req: { event: SceneTokensSessionEvent };
res: { ok: true };
};
[ipcChannels.video.getState]: { [ipcChannels.video.getState]: {
req: Record<string, never>; req: Record<string, never>;
res: { state: VideoPlaybackState }; res: { state: VideoPlaybackState };
@@ -690,6 +738,8 @@ export type LegacyIpcEventMap = {
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState }; [ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
[ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState }; [ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState };
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState }; [ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
[ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState }; [ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: Record<string, never>; [ipcChannels.license.statusChanged]: Record<string, never>;
}; };
@@ -704,6 +754,7 @@ export type ScenePatch = {
previewRotationDeg?: 0 | 90 | 180 | 270; previewRotationDeg?: 0 | 90 | 180 | 270;
darkenScene?: boolean; darkenScene?: boolean;
traps?: SceneTrap[]; traps?: SceneTrap[];
tokens?: SceneToken[];
grid?: SceneGrid; grid?: SceneGrid;
settings?: Partial<Scene['settings']>; settings?: Partial<Scene['settings']>;
media?: Partial<Scene['media']>; media?: Partial<Scene['media']>;
+71
View File
@@ -0,0 +1,71 @@
/** App-local библиотека неигровых токенов (userData, не в project zip). */
import type { SceneTokenId, TokenId } from './ids';
import { asSceneTokenId, asTokenId } from './ids';
export type { SceneTokenId, TokenId };
export { asSceneTokenId, asTokenId };
export type AppToken = {
id: TokenId;
name: string;
/** Относительный путь файла в каталоге `userData/tokens/`. */
imageRelPath: string;
/** sha256 содержимого файла для дедупа при импорте. */
sha256: string;
};
/** Расстановка токена на карте сцены (в проекте). */
export type SceneToken = {
id: SceneTokenId;
tokenId: TokenId;
nx: number;
ny: number;
sizeN: number;
/** Непрерывный угол поворота в градусах. */
rotationDeg: number;
};
export const DEFAULT_SCENE_TOKEN_SIZE_N = 0.08;
export const SCENE_TOKEN_SIZE_MIN = 0.02;
export const SCENE_TOKEN_SIZE_MAX = 0.45;
export type SceneTokensSessionState = {
revision: number;
/** Смещение позиции на текущую сессию (только nx/ny). */
byPlacementId: Record<string, { nx: number; ny: number }>;
};
export type SceneTokensSessionEvent =
| { kind: 'move'; placementId: string; nx: number; ny: number }
| { kind: 'clear' };
export function clampSceneTokenSizeN(sizeN: number): number {
if (!Number.isFinite(sizeN)) return DEFAULT_SCENE_TOKEN_SIZE_N;
return Math.max(SCENE_TOKEN_SIZE_MIN, Math.min(SCENE_TOKEN_SIZE_MAX, sizeN));
}
export function normalizeSceneToken(raw: unknown): SceneToken | null {
if (!raw || typeof raw !== 'object') return null;
const obj = raw as Partial<SceneToken>;
if (typeof obj.id !== 'string' || !obj.id) return null;
if (typeof obj.tokenId !== 'string' || !obj.tokenId) return null;
const nx = typeof obj.nx === 'number' && Number.isFinite(obj.nx) ? obj.nx : null;
const ny = typeof obj.ny === 'number' && Number.isFinite(obj.ny) ? obj.ny : null;
if (nx === null || ny === null) return null;
const sizeN = clampSceneTokenSizeN(typeof obj.sizeN === 'number' ? obj.sizeN : DEFAULT_SCENE_TOKEN_SIZE_N);
const rotationDeg =
typeof obj.rotationDeg === 'number' && Number.isFinite(obj.rotationDeg) ? obj.rotationDeg : 0;
return {
id: asSceneTokenId(obj.id),
tokenId: asTokenId(obj.tokenId),
nx: Math.max(0, Math.min(1, nx)),
ny: Math.max(0, Math.min(1, ny)),
sizeN,
rotationDeg,
};
}
export type AppTokensExportBundle = {
tokens: AppToken[];
};
+4 -1
View File
@@ -9,10 +9,11 @@ import type {
SceneId, SceneId,
} from './ids'; } from './ids';
import type { MaterialLegend } from './materialLegend'; import type { MaterialLegend } from './materialLegend';
import type { SceneToken } from './appTokens';
import type { SceneGrid } from './sceneGrid'; import type { SceneGrid } from './sceneGrid';
import type { SceneTrap } from './sceneTraps'; import type { SceneTrap } from './sceneTraps';
export const PROJECT_SCHEMA_VERSION = 9 as const; export const PROJECT_SCHEMA_VERSION = 10 as const;
/** Материал кампании: изображение, показываемое поверх сцены во время игры. */ /** Материал кампании: изображение, показываемое поверх сцены во время игры. */
export type ProjectMaterial = { export type ProjectMaterial = {
@@ -163,6 +164,8 @@ export type Scene = {
darkenScene: boolean; darkenScene: boolean;
/** Ловушки на карте (только для image-превью); расстановка в проекте. */ /** Ловушки на карте (только для image-превью); расстановка в проекте. */
traps: SceneTrap[]; traps: SceneTrap[];
/** Неигровые токены на карте (ссылки на app-local пул). */
tokens: SceneToken[];
/** Боевая сетка поверх превью (под ловушками/эффектами). */ /** Боевая сетка поверх превью (под ловушками/эффектами). */
grid: SceneGrid; grid: SceneGrid;
media: SceneMediaRefs; media: SceneMediaRefs;
+10
View File
@@ -8,6 +8,8 @@ export type MaterialId = Brand<string, 'MaterialId'>;
export type NpcId = Brand<string, 'NpcId'>; export type NpcId = Brand<string, 'NpcId'>;
export type NpcRelationId = Brand<string, 'NpcRelationId'>; export type NpcRelationId = Brand<string, 'NpcRelationId'>;
export type NpcGroupId = Brand<string, 'NpcGroupId'>; export type NpcGroupId = Brand<string, 'NpcGroupId'>;
export type TokenId = Brand<string, 'TokenId'>;
export type SceneTokenId = Brand<string, 'SceneTokenId'>;
export function asProjectId(value: string): ProjectId { export function asProjectId(value: string): ProjectId {
return value as ProjectId; return value as ProjectId;
@@ -40,3 +42,11 @@ export function asNpcRelationId(value: string): NpcRelationId {
export function asNpcGroupId(value: string): NpcGroupId { export function asNpcGroupId(value: string): NpcGroupId {
return value as NpcGroupId; return value as NpcGroupId;
} }
export function asTokenId(value: string): TokenId {
return value as TokenId;
}
export function asSceneTokenId(value: string): SceneTokenId {
return value as SceneTokenId;
}
+1
View File
@@ -1,3 +1,4 @@
export * from './appTokens';
export * from './domain'; export * from './domain';
export * from './effects'; export * from './effects';
export * from './ids'; export * from './ids';
+1 -1
View File
@@ -114,7 +114,7 @@ export function trapTypeLabelRu(type: SceneTrapType): string {
case 'laser': case 'laser':
return 'Лазер'; return 'Лазер';
case 'freeform': case 'freeform':
return 'Свободная'; return 'Метка';
default: { default: {
const _x: never = type; const _x: never = type;
return String(_x); return String(_x);