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:
@@ -387,6 +387,7 @@ export async function buildProjectFromFoundryDocuments(
|
||||
previewRotationDeg: 0,
|
||||
darkenScene: false,
|
||||
traps: [],
|
||||
tokens: [],
|
||||
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
|
||||
media: { videos: [], audios: audioRefs },
|
||||
settings: {
|
||||
|
||||
+111
-3
@@ -29,6 +29,8 @@ import { NpcsOverlayStore } from './npcs/npcsOverlayStore';
|
||||
import { ZipProjectStore } from './project/zipStore';
|
||||
import { SceneViewStore } from './sceneView/sceneViewStore';
|
||||
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
|
||||
import { SceneTokensSessionStore } from './tokens/sceneTokensSessionStore';
|
||||
import { TokensStore } from './tokens/tokensStore';
|
||||
import { installAutoUpdater } from './update/installAutoUpdater';
|
||||
import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo';
|
||||
import { VideoPlaybackStore } from './video/videoPlaybackStore';
|
||||
@@ -165,6 +167,8 @@ const sceneViewStore = new SceneViewStore();
|
||||
const videoStore = new VideoPlaybackStore();
|
||||
const materialsOverlayStore = new MaterialsOverlayStore();
|
||||
const npcsOverlayStore = new NpcsOverlayStore();
|
||||
const sceneTokensSessionStore = new SceneTokensSessionStore();
|
||||
let tokensStore: TokensStore | null = null;
|
||||
|
||||
function emitEffectsState(): void {
|
||||
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 {
|
||||
const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null;
|
||||
const scene = project.currentSceneId ? project.scenes[project.currentSceneId] : undefined;
|
||||
@@ -366,11 +384,13 @@ async function main() {
|
||||
});
|
||||
|
||||
const licenseService = new LicenseService(app.getPath('userData'));
|
||||
tokensStore = new TokensStore(app.getPath('userData'));
|
||||
await tokensStore.ensureLoaded();
|
||||
setLicenseAssert(() => {
|
||||
licenseService.assertForIpc();
|
||||
});
|
||||
installAppMenuForSession();
|
||||
registerDndAssetProtocol(projectStore);
|
||||
registerDndAssetProtocol(projectStore, tokensStore);
|
||||
registerHandler(ipcChannels.app.quit, () => {
|
||||
markAppQuitting();
|
||||
app.quit();
|
||||
@@ -388,6 +408,7 @@ async function main() {
|
||||
registerHandler(ipcChannels.windows.openMultiWindow, () => {
|
||||
sceneDarknessStore.resetSession();
|
||||
sceneTrapsStore.resetSession();
|
||||
sceneTokensSessionStore.reset();
|
||||
effectsStore.dispatch({ kind: 'tool.set', tool: effectsDefaultTool() });
|
||||
openMultiWindow();
|
||||
const project = projectStore.getOpenProject();
|
||||
@@ -397,6 +418,7 @@ async function main() {
|
||||
}
|
||||
emitSceneDarknessState();
|
||||
emitSceneTrapsState();
|
||||
emitSceneTokensSessionState();
|
||||
emitEffectsState();
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -491,7 +513,9 @@ async function main() {
|
||||
registerHandler(ipcChannels.project.open, async ({ projectId }) => {
|
||||
const project = await projectStore.openProjectById(projectId);
|
||||
sceneViewStore.reset();
|
||||
sceneTokensSessionStore.reset();
|
||||
emitSceneViewState();
|
||||
emitSceneTokensSessionState();
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
@@ -503,12 +527,14 @@ async function main() {
|
||||
sceneDarknessStore.resetSession();
|
||||
sceneTrapsStore.resetSession();
|
||||
sceneViewStore.reset();
|
||||
sceneTokensSessionStore.reset();
|
||||
emitEffectsState();
|
||||
emitMaterialsOverlayState();
|
||||
emitNpcsOverlayState();
|
||||
emitSceneDarknessState();
|
||||
emitSceneTrapsState();
|
||||
emitSceneViewState();
|
||||
emitSceneTokensSessionState();
|
||||
emitSessionState();
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -525,6 +551,7 @@ async function main() {
|
||||
materialsOverlayStore.clear();
|
||||
npcsOverlayStore.clear();
|
||||
sceneViewStore.reset();
|
||||
// Token moves persist for the whole play session (reset only on project open/close).
|
||||
const project = projectStore.getOpenProject();
|
||||
if (project) {
|
||||
syncSceneDarknessForProject(project);
|
||||
@@ -536,6 +563,7 @@ async function main() {
|
||||
emitSceneDarknessState();
|
||||
emitSceneTrapsState();
|
||||
emitSceneViewState();
|
||||
emitSceneTokensSessionState();
|
||||
emitSessionState();
|
||||
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
|
||||
});
|
||||
@@ -552,6 +580,7 @@ async function main() {
|
||||
materialsOverlayStore.clear();
|
||||
npcsOverlayStore.clear();
|
||||
sceneViewStore.reset();
|
||||
// Token moves persist for the whole play session (reset only on project open/close).
|
||||
const project = projectStore.getOpenProject();
|
||||
if (project) {
|
||||
syncSceneDarknessForProject(project);
|
||||
@@ -563,6 +592,7 @@ async function main() {
|
||||
emitSceneDarknessState();
|
||||
emitSceneTrapsState();
|
||||
emitSceneViewState();
|
||||
emitSceneTokensSessionState();
|
||||
emitSessionState();
|
||||
const p = projectStore.getOpenProject();
|
||||
return {
|
||||
@@ -1018,6 +1048,11 @@ async function main() {
|
||||
});
|
||||
},
|
||||
npcResolutions,
|
||||
async (cacheDir) => {
|
||||
const remap = await tokensStore!.importFromExportDir(cacheDir);
|
||||
emitTokensState();
|
||||
return remap;
|
||||
},
|
||||
);
|
||||
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
|
||||
emitSessionState();
|
||||
@@ -1041,6 +1076,13 @@ async function main() {
|
||||
});
|
||||
},
|
||||
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: 'Готово' });
|
||||
emitSessionState();
|
||||
@@ -1121,14 +1163,23 @@ async function main() {
|
||||
const dest = normalizeSaveProjectZipPath(filePath);
|
||||
try {
|
||||
emitZipProgress({ kind: 'export', stage: 'zip', percent: 0, detail: 'Экспорт…' });
|
||||
await projectStore.exportStorylinesZipToPath(projectId, storylineSelections, dest, labels, (p) => {
|
||||
await projectStore.exportStorylinesZipToPath(
|
||||
projectId,
|
||||
storylineSelections,
|
||||
dest,
|
||||
labels,
|
||||
(p) => {
|
||||
emitZipProgress({
|
||||
kind: 'export',
|
||||
stage: p.stage,
|
||||
percent: p.percent,
|
||||
...(p.detail ? { detail: p.detail } : null),
|
||||
});
|
||||
});
|
||||
},
|
||||
async (tokenIds, exportRoot) => {
|
||||
await tokensStore!.packForExport(tokenIds, exportRoot);
|
||||
},
|
||||
);
|
||||
emitZipProgress({ kind: 'export', stage: 'done', percent: 100, detail: 'Готово' });
|
||||
return { canceled: false as const };
|
||||
} catch (err) {
|
||||
@@ -1182,6 +1233,63 @@ async function main() {
|
||||
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, () => {
|
||||
return { state: videoStore.getState() };
|
||||
});
|
||||
|
||||
@@ -15,11 +15,13 @@ import {
|
||||
} from '../../shared/graph/sceneListOrder';
|
||||
import {
|
||||
buildPartialExportProject,
|
||||
collectTokenIdsFromProject,
|
||||
computeGraphImportOffsetX,
|
||||
listExportableStorylines,
|
||||
listImportableStorylines,
|
||||
mergeStorylinesIntoProject,
|
||||
newExportBundleProjectId,
|
||||
remapProjectSceneTokenIds,
|
||||
type NpcImportResolution,
|
||||
type SceneImportResolution,
|
||||
type StorylineImportMergeReport,
|
||||
@@ -51,6 +53,7 @@ import type {
|
||||
} from '../../shared/types';
|
||||
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
|
||||
import { normalizeMaterialLegend } from '../../shared/types/materialLegend';
|
||||
import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens';
|
||||
import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid';
|
||||
import { normalizeSceneTrap } from '../../shared/types/sceneTraps';
|
||||
import type { AssetId, GraphNodeId, MaterialId, NpcGroupId, NpcId, NpcRelationId } from '../../shared/types/ids';
|
||||
@@ -617,12 +620,14 @@ export class ZipProjectStore {
|
||||
previewRotationDeg: 0,
|
||||
darkenScene: false,
|
||||
traps: [],
|
||||
tokens: [],
|
||||
grid: { ...DEFAULT_SCENE_GRID },
|
||||
} satisfies Scene);
|
||||
|
||||
const next: Scene = {
|
||||
...base,
|
||||
traps: base.traps ?? [],
|
||||
tokens: base.tokens ?? [],
|
||||
grid: base.grid ?? { ...DEFAULT_SCENE_GRID },
|
||||
...(patch.title !== undefined ? { title: patch.title } : null),
|
||||
...(patch.description !== undefined ? { description: patch.description } : null),
|
||||
@@ -643,6 +648,13 @@ export class ZipProjectStore {
|
||||
.filter((t): t is SceneTrap => Boolean(t)),
|
||||
}
|
||||
: 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.settings ? { settings: { ...base.settings, ...patch.settings } } : null),
|
||||
...(patch.media ? { media: { ...base.media, ...patch.media } } : null),
|
||||
@@ -1992,6 +2004,7 @@ export class ZipProjectStore {
|
||||
destinationPath: string,
|
||||
labels: StorylineLabels,
|
||||
onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void,
|
||||
packAppTokens?: (tokenIds: string[], exportRoot: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
if (selections.length === 0) throw new Error('Не выбрана ни одна сюжетная линия');
|
||||
await this.ensureRoots();
|
||||
@@ -2016,10 +2029,14 @@ export class ZipProjectStore {
|
||||
await fs.mkdir(path.dirname(destAbs), { recursive: true });
|
||||
await fs.copyFile(srcAbs, destAbs);
|
||||
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: 'Сборка архива…' });
|
||||
}
|
||||
}
|
||||
if (packAppTokens) {
|
||||
const tokenIds = collectTokenIdsFromProject(partial);
|
||||
await packAppTokens(tokenIds, exportCache);
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const toWrite: Project = {
|
||||
...partial,
|
||||
@@ -2116,12 +2133,18 @@ export class ZipProjectStore {
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
|
||||
npcResolutions?: NpcImportResolution[],
|
||||
importAppTokens?: (sourceCacheDir: string) => Promise<Map<string, string>>,
|
||||
): Promise<{ project: Project; report: StorylineImportMergeReport }> {
|
||||
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 { project: merged, report, assetCopies } = mergeStorylinesIntoProject(
|
||||
this.openProject.project,
|
||||
source,
|
||||
sourceForMerge,
|
||||
selections,
|
||||
sceneResolutions,
|
||||
{
|
||||
@@ -2162,6 +2185,7 @@ export class ZipProjectStore {
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
|
||||
npcResolutions?: NpcImportResolution[],
|
||||
importAppTokens?: (sourceCacheDir: string) => Promise<Map<string, string>>,
|
||||
): Promise<{ project: Project; report: StorylineImportMergeReport }> {
|
||||
this.assertStorylineMergeAllowed(selections);
|
||||
const snap = await this.loadProjectSnapshot(sourceProjectId);
|
||||
@@ -2173,6 +2197,7 @@ export class ZipProjectStore {
|
||||
sceneResolutions,
|
||||
onProgress,
|
||||
npcResolutions,
|
||||
importAppTokens,
|
||||
);
|
||||
} finally {
|
||||
if (snap.ownsCache) {
|
||||
@@ -2187,6 +2212,7 @@ export class ZipProjectStore {
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
|
||||
npcResolutions?: NpcImportResolution[],
|
||||
importAppTokens?: (sourceCacheDir: string) => Promise<Map<string, string>>,
|
||||
): Promise<{ project: Project; report: StorylineImportMergeReport }> {
|
||||
this.assertStorylineMergeAllowed(selections);
|
||||
const source = await this.readExternalProjectForImport(sourcePath);
|
||||
@@ -2201,6 +2227,7 @@ export class ZipProjectStore {
|
||||
sceneResolutions,
|
||||
onProgress,
|
||||
npcResolutions,
|
||||
importAppTokens,
|
||||
);
|
||||
} finally {
|
||||
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 : [])
|
||||
.map((t) => normalizeSceneTrap(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 rawAudios = Array.isArray(raw.audios) ? raw.audios : [];
|
||||
@@ -2359,6 +2390,7 @@ function normalizeScene(s: Scene): Scene {
|
||||
previewRotationDeg,
|
||||
darkenScene,
|
||||
traps,
|
||||
tokens,
|
||||
grid,
|
||||
layout: layoutIn ?? { x: 0, y: 0 },
|
||||
media: {
|
||||
|
||||
@@ -2,26 +2,13 @@ import fs from 'node:fs/promises';
|
||||
|
||||
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 { TokensStore } from '../tokens/tokensStore';
|
||||
|
||||
/**
|
||||
* Обслуживает `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 });
|
||||
}
|
||||
type ReadInfo = { absPath: string; mime: string };
|
||||
|
||||
async function serveFile(info: ReadInfo, request: Request): Promise<Response> {
|
||||
try {
|
||||
const stat = await fs.stat(info.absPath);
|
||||
const total = stat.size;
|
||||
@@ -85,5 +72,30 @@ export function registerDndAssetProtocol(projectStore: ZipProjectStore): void {
|
||||
} 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 });
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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%);
|
||||
width: 520px;
|
||||
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: 1px solid var(--stroke);
|
||||
background: var(--color-surface-elevated);
|
||||
@@ -116,6 +120,7 @@
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.descriptionViewDialog {
|
||||
|
||||
@@ -36,6 +36,9 @@ import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
|
||||
import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost';
|
||||
import { useSceneViewState } from '../shared/sceneView/useSceneViewState';
|
||||
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 { useSceneTrapsState } from '../shared/traps/useSceneTrapsState';
|
||||
import { Button } from '../shared/ui/controls';
|
||||
@@ -126,6 +129,8 @@ export function ControlApp() {
|
||||
const [effectsSfxGainUi, setEffectsSfxGainUi] = useState(() => getEffectsSfxGain());
|
||||
const [sdState, sd] = useSceneDarknessState();
|
||||
const [sceneTraps, sceneTrapsApi] = useSceneTrapsState();
|
||||
const appTokens = useAppTokens();
|
||||
const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession();
|
||||
const [sceneView, sceneViewApi] = useSceneViewState();
|
||||
const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null);
|
||||
const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
|
||||
@@ -1884,6 +1889,18 @@ export function ControlApp() {
|
||||
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 ? (
|
||||
<SceneTrapsOverlay
|
||||
traps={currentScene?.traps ?? []}
|
||||
|
||||
@@ -328,6 +328,10 @@
|
||||
transform: translate(-50%, -50%);
|
||||
width: 520px;
|
||||
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: 1px solid var(--stroke);
|
||||
background: var(--color-surface-elevated);
|
||||
@@ -335,6 +339,7 @@
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
@@ -364,6 +369,17 @@
|
||||
.fieldGrid {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fieldGroup {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fieldGroupSpaced {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
@@ -377,22 +393,6 @@
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -402,8 +402,13 @@
|
||||
.modalFooter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.licenseBlockTitle {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
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 { 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.fieldLabel}>{t('foundryImport.sourceType')}</div>
|
||||
<select
|
||||
className={styles.selectInput}
|
||||
<Select
|
||||
value={mode}
|
||||
disabled={submitting}
|
||||
onChange={(e) => {
|
||||
setMode(e.target.value as 'folder' | 'archive');
|
||||
ariaLabel={t('foundryImport.sourceType')}
|
||||
options={[
|
||||
{ value: 'folder', label: t('foundryImport.folder') },
|
||||
{ value: 'archive', label: t('foundryImport.archive') },
|
||||
]}
|
||||
onChange={(next) => {
|
||||
setMode(next as 'folder' | 'archive');
|
||||
setPicked(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.importFileRow}>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type StorylineSelection,
|
||||
} from '../../shared/graph/storylineExportImport';
|
||||
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 { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
@@ -135,18 +135,16 @@ export function ExportProjectModal({
|
||||
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.fieldLabel}>{t('export.project')}</div>
|
||||
<select
|
||||
className={styles.selectInput}
|
||||
<Select
|
||||
value={projectId ?? ''}
|
||||
onChange={(e) => setProjectId((e.target.value as ProjectId) || null)}
|
||||
onChange={(next) => setProjectId((next as ProjectId) || null)}
|
||||
disabled={projects.length === 0}
|
||||
>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.fileName})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
ariaLabel={t('export.project')}
|
||||
options={projects.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.name} (${p.fileName})`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<div className={styles.fieldLabel}>{t('storyline.section')}</div>
|
||||
{loadingStorylines ? (
|
||||
@@ -309,43 +307,46 @@ export function ImportSourceModal({
|
||||
|
||||
<div className={styles.fieldGrid}>
|
||||
{canImportFromProject ? (
|
||||
<>
|
||||
<div className={styles.fieldGroup}>
|
||||
<div className={styles.fieldLabel}>{t('importSource.type')}</div>
|
||||
<select
|
||||
className={styles.selectInput}
|
||||
<Select
|
||||
value={importKind}
|
||||
onChange={(e) => setImportKind(e.target.value as 'project' | 'file')}
|
||||
onChange={(next) => setImportKind(next as 'project' | 'file')}
|
||||
disabled={submitting}
|
||||
>
|
||||
<option value="project">{t('importSource.fromProject')}</option>
|
||||
<option value="file">{t('importSource.fromFile')}</option>
|
||||
</select>
|
||||
</>
|
||||
ariaLabel={t('importSource.type')}
|
||||
options={[
|
||||
{ value: 'project', label: t('importSource.fromProject') },
|
||||
{ value: 'file', label: t('importSource.fromFile') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.muted}>{t('importSource.fileOnlyHint')}</div>
|
||||
)}
|
||||
|
||||
{importKind === 'project' && canImportFromProject ? (
|
||||
<>
|
||||
<div className={[styles.fieldGroup, canImportFromProject ? styles.fieldGroupSpaced : ''].filter(Boolean).join(' ')}>
|
||||
<div className={styles.fieldLabel}>{t('importSource.project')}</div>
|
||||
<select
|
||||
className={styles.selectInput}
|
||||
<Select
|
||||
value={sourceProjectId ?? ''}
|
||||
onChange={(e) => setSourceProjectId((e.target.value as ProjectId) || null)}
|
||||
onChange={(next) => setSourceProjectId((next as ProjectId) || null)}
|
||||
disabled={availableProjects.length === 0 || submitting}
|
||||
>
|
||||
{availableProjects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.fileName})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
ariaLabel={t('importSource.project')}
|
||||
options={availableProjects.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.name} (${p.fileName})`,
|
||||
}))}
|
||||
/>
|
||||
{availableProjects.length === 0 ? (
|
||||
<div className={styles.muted}>{t('importSource.noOtherProjects')}</div>
|
||||
) : null}
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={[styles.fieldGroup, canImportFromProject ? styles.fieldGroupSpaced : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<div className={styles.fieldLabel}>{t('importSource.file')}</div>
|
||||
<div className={styles.importFileRow}>
|
||||
<Button
|
||||
@@ -366,7 +367,7 @@ export function ImportSourceModal({
|
||||
{pickedFile ? pickedFile.name : t('importSource.noFileSelected')}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -572,24 +573,23 @@ export function SceneConflictModal({ open, conflicts, onClose, onConfirm }: Scen
|
||||
{conflicts.map((c) => (
|
||||
<div key={c.sourceSceneId} className={styles.conflictRow}>
|
||||
<div className={styles.conflictTitle}>{c.sourceTitle}</div>
|
||||
<select
|
||||
className={styles.selectInput}
|
||||
<Select
|
||||
value={choices[c.sourceSceneId] ?? 'create'}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
onChange={(v) => {
|
||||
setChoices((prev) => ({
|
||||
...prev,
|
||||
[c.sourceSceneId]: v === 'create' ? 'create' : (v as SceneId),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<option value="create">{t('importStoryline.createNewScene')}</option>
|
||||
{c.matches.map((m) => (
|
||||
<option key={m.sceneId} value={m.sceneId}>
|
||||
{t('importStoryline.useExistingScene', { title: m.title })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
ariaLabel={c.sourceTitle}
|
||||
options={[
|
||||
{ value: 'create', label: t('importStoryline.createNewScene') },
|
||||
...c.matches.map((m) => ({
|
||||
value: m.sceneId,
|
||||
label: t('importStoryline.useExistingScene', { title: m.title }),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -687,24 +687,23 @@ function NpcConflictModalBody({
|
||||
{conflicts.map((c) => (
|
||||
<div key={c.sourceNpcId} className={styles.conflictRow}>
|
||||
<div className={styles.conflictTitle}>{c.sourceName}</div>
|
||||
<select
|
||||
className={styles.selectInput}
|
||||
<Select
|
||||
value={choices[c.sourceNpcId] ?? 'create'}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
onChange={(v) => {
|
||||
setChoices((prev) => ({
|
||||
...prev,
|
||||
[c.sourceNpcId]: v === 'create' ? 'create' : v,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<option value="create">{t('importStoryline.createNewNpc')}</option>
|
||||
{c.matches.map((m) => (
|
||||
<option key={m.npcId} value={m.npcId}>
|
||||
{t('importStoryline.useExistingNpc', { name: m.name })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
ariaLabel={c.sourceName}
|
||||
options={[
|
||||
{ value: 'create', label: t('importStoryline.createNewNpc') },
|
||||
...c.matches.map((m) => ({
|
||||
value: m.npcId,
|
||||
label: t('importStoryline.useExistingNpc', { name: m.name }),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -350,6 +350,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
previewRotationDeg: 0,
|
||||
darkenScene: false,
|
||||
traps: [],
|
||||
tokens: [],
|
||||
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
|
||||
media: { videos: [], audios: [] },
|
||||
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
|
||||
@@ -592,6 +593,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
: null),
|
||||
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
|
||||
...(patch.traps !== undefined ? { traps: patch.traps } : null),
|
||||
...(patch.tokens !== undefined ? { tokens: patch.tokens } : null),
|
||||
...(patch.grid !== undefined ? { grid: patch.grid } : null),
|
||||
...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null),
|
||||
...(patch.media ? { media: { ...scene.media, ...patch.media } } : null),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { isNpcBindingNone, listStorylineOptionsForBinding, noneBinding } from '.
|
||||
import type { GraphNodeId, NpcBinding, Project, SceneId } from '../../shared/types';
|
||||
import editorStyles from '../editor/EditorApp.module.css';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import controlStyles from '../shared/ui/Controls.module.css';
|
||||
import { Select } from '../shared/ui/controls';
|
||||
|
||||
type NpcBindingFieldsProps = {
|
||||
project: Project;
|
||||
@@ -65,11 +65,14 @@ export function NpcBindingFields({ project, binding, onChange }: NpcBindingField
|
||||
{enabled ? (
|
||||
<>
|
||||
<div className={editorStyles.fieldLabel}>{t('npcs.bindingKind')}</div>
|
||||
<select
|
||||
className={controlStyles.input}
|
||||
<Select
|
||||
value={kind}
|
||||
onChange={(e) => {
|
||||
const nextKind = e.target.value;
|
||||
ariaLabel={t('npcs.bindingKind')}
|
||||
options={[
|
||||
{ value: 'storyline', label: t('npcs.bindingStoryline') },
|
||||
{ value: 'scene', label: t('npcs.bindingScene') },
|
||||
]}
|
||||
onChange={(nextKind) => {
|
||||
if (nextKind === 'scene') {
|
||||
const first = sceneOptions[0];
|
||||
onChange(first ? { kind: 'scene', sceneId: first.id } : noneBinding());
|
||||
@@ -86,17 +89,26 @@ export function NpcBindingFields({ project, binding, onChange }: NpcBindingField
|
||||
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>
|
||||
<select
|
||||
className={controlStyles.input}
|
||||
<Select
|
||||
value={bindingTargetValue}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
ariaLabel={t('npcs.bindingSelect')}
|
||||
options={
|
||||
kind === 'storyline'
|
||||
? [
|
||||
...(storylineOpts.main
|
||||
? [{ value: 'main', label: t('npcs.bindingMain') }]
|
||||
: []),
|
||||
...storylineOpts.sides.map((s) => ({
|
||||
value: `side:${s.startGraphNodeId}`,
|
||||
label: s.label,
|
||||
})),
|
||||
]
|
||||
: sceneOptions.map((s) => ({ value: s.id, label: s.title }))
|
||||
}
|
||||
onChange={(v) => {
|
||||
if (kind === 'scene') {
|
||||
onChange({ kind: 'scene', sceneId: v as SceneId });
|
||||
return;
|
||||
@@ -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}
|
||||
</div>
|
||||
|
||||
@@ -13,8 +13,7 @@ import {
|
||||
} from '../editor/fileDrop';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import matStyles from '../editor/MaterialsModals.module.css';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
import controlStyles from '../shared/ui/Controls.module.css';
|
||||
import { Button, Input, Select } from '../shared/ui/controls';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import { NpcBindingFields } from './NpcBindingFields';
|
||||
@@ -166,18 +165,15 @@ export function NpcEditModal({
|
||||
{!initial && groupOptions.length > 0 ? (
|
||||
<div className={editorStyles.fieldGrid}>
|
||||
<div className={editorStyles.fieldLabel}>{t('npcs.group')}</div>
|
||||
<select
|
||||
className={controlStyles.input}
|
||||
<Select
|
||||
value={groupId}
|
||||
onChange={(e) => setGroupId(e.target.value as NpcGroupId | '')}
|
||||
>
|
||||
<option value="">{t('npcs.ungrouped')}</option>
|
||||
{groupOptions.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
ariaLabel={t('npcs.group')}
|
||||
onChange={(next) => setGroupId(next as NpcGroupId | '')}
|
||||
options={[
|
||||
{ value: '', label: t('npcs.ungrouped') },
|
||||
...groupOptions.map((g) => ({ value: g.id, label: g.label })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -164,15 +164,9 @@
|
||||
|
||||
.filterSelect {
|
||||
min-width: 160px;
|
||||
padding: 6px 10px;
|
||||
padding-right: 28px;
|
||||
border-radius: 8px;
|
||||
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;
|
||||
width: auto;
|
||||
min-height: 30px;
|
||||
background: rgba(24, 24, 27, 0.92);
|
||||
color: var(--text1);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
ProjectNpcGroup,
|
||||
ProjectNpcRelation,
|
||||
} from '../../shared/types';
|
||||
import { Select } from '../shared/ui/controls';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import styles from './NpcGraph.module.css';
|
||||
@@ -300,20 +301,17 @@ function FilterToolbar({
|
||||
}) {
|
||||
return (
|
||||
<Panel position="top-left">
|
||||
<select
|
||||
<Select
|
||||
className={styles.filterSelect}
|
||||
aria-label={ui.graphFilter}
|
||||
ariaLabel={ui.graphFilter}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value as GraphGroupFilter)}
|
||||
>
|
||||
<option value="all">{ui.graphFilterAll}</option>
|
||||
<option value="ungrouped">{ui.graphFilterUngrouped}</option>
|
||||
{groups.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(next) => onChange(next as GraphGroupFilter)}
|
||||
options={[
|
||||
{ value: 'all', label: ui.graphFilterAll },
|
||||
{ value: 'ungrouped', label: ui.graphFilterUngrouped },
|
||||
...groups.map((g) => ({ value: g.id, label: g.name })),
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,8 +15,7 @@ import type {
|
||||
import editorStyles from '../editor/EditorApp.module.css';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
import controlStyles from '../shared/ui/Controls.module.css';
|
||||
import { Button, Input, Select } from '../shared/ui/controls';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import { NpcBindingFields } from './NpcBindingFields';
|
||||
@@ -785,24 +784,21 @@ export function NpcsEditorApp() {
|
||||
|
||||
<div>
|
||||
<div className={styles.fieldLabel}>{t('npcs.group')}</div>
|
||||
<select
|
||||
className={controlStyles.input}
|
||||
<Select
|
||||
value={selected.groupId ?? ''}
|
||||
onChange={(e) => {
|
||||
const groupId = (e.target.value || null) as NpcGroupId | null;
|
||||
ariaLabel={t('npcs.group')}
|
||||
onChange={(next) => {
|
||||
const groupId = (next || null) as NpcGroupId | null;
|
||||
void api.invoke(ipcChannels.project.updateNpcFields, {
|
||||
npcId: selected.id,
|
||||
groupId,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">{t('npcs.ungrouped')}</option>
|
||||
{groupOptions.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
options={[
|
||||
{ value: '', label: t('npcs.ungrouped') },
|
||||
...groupOptions.map((g) => ({ value: g.id, label: g.label })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -2,3 +2,9 @@
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.viewPassthrough {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,10 @@ export function PresentationApp() {
|
||||
className={styles.root}
|
||||
onDoubleClick={() => void api.invoke(ipcChannels.windows.togglePresentationFullscreen, {})}
|
||||
>
|
||||
{/* Презентация только для просмотра: никаких кликов/drag по сцене и оверлеям. */}
|
||||
<div className={styles.viewPassthrough}>
|
||||
<PresentationView session={session} showTitle={false} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,22 +90,6 @@
|
||||
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 {
|
||||
width: 100%;
|
||||
accent-color: #f5c542;
|
||||
@@ -200,7 +184,7 @@
|
||||
|
||||
.trap {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
z-index: 2;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.55);
|
||||
@@ -252,6 +236,189 @@
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
||||
import type { SceneGrid, 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 {
|
||||
clampSceneGridSizeN,
|
||||
DEFAULT_SCENE_GRID,
|
||||
@@ -16,33 +23,74 @@ import {
|
||||
trapTypeLabelRu,
|
||||
} from '../../shared/types/sceneTraps';
|
||||
import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
|
||||
import editorStyles from '../editor/EditorApp.module.css';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { useAppTokens } from '../shared/tokens/useAppTokens';
|
||||
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 { SceneTokenMarker } from './SceneTokenMarker';
|
||||
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 Selection = { kind: 'trap' | 'token'; id: string } | null;
|
||||
|
||||
type DragMode =
|
||||
| { kind: 'pan'; lastX: number; lastY: number }
|
||||
| { kind: 'move'; trapId: string; startNx: number; startNy: number; pointerNx: number; pointerNy: number }
|
||||
| { kind: 'resize'; trapId: string; startSize: number; startDist: number }
|
||||
| { kind: 'moveTrap'; trapId: string; startNx: number; startNy: number; pointerNx: number; pointerNy: number }
|
||||
| { kind: 'resizeTrap'; trapId: string; startSize: number; startDist: number }
|
||||
| { kind: 'moveToken'; tokenId: string; startNx: number; startNy: number; pointerNx: number; pointerNy: number }
|
||||
| { kind: 'resizeToken'; tokenId: string; startSize: number; startDist: number }
|
||||
| {
|
||||
kind: 'rotateToken';
|
||||
tokenId: string;
|
||||
startRotation: number;
|
||||
startPointerAngle: number;
|
||||
centerClientX: number;
|
||||
centerClientY: number;
|
||||
}
|
||||
| null;
|
||||
|
||||
function randomTrapId(): string {
|
||||
return `trap_${Math.random().toString(36).slice(2, 10)}`;
|
||||
function randomId(prefix: string): string {
|
||||
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() {
|
||||
const api = getDndApi();
|
||||
const appTokens = useAppTokens();
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const [trapsOpen, setTrapsOpen] = useState(true);
|
||||
const [gridOpen, setGridOpen] = useState(true);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [trapsOpen, setTrapsOpen] = useState(false);
|
||||
const [gridOpen, setGridOpen] = useState(false);
|
||||
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 [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(
|
||||
null,
|
||||
@@ -50,6 +98,7 @@ export function SceneEditorApp() {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragRef = useRef<DragMode>(null);
|
||||
const saveTrapsTimerRef = useRef(0);
|
||||
const saveTokensTimerRef = useRef(0);
|
||||
const saveGridTimerRef = useRef(0);
|
||||
const spaceDownRef = useRef(false);
|
||||
|
||||
@@ -59,23 +108,32 @@ export function SceneEditorApp() {
|
||||
const url = useAssetUrl(scene?.previewAssetId ?? null);
|
||||
const rot = scene?.previewRotationDeg ?? 0;
|
||||
const [localTraps, setLocalTraps] = useState<SceneTrap[]>([]);
|
||||
const [localTokens, setLocalTokens] = useState<SceneToken[]>([]);
|
||||
const [localGrid, setLocalGrid] = useState<SceneGrid>({ ...DEFAULT_SCENE_GRID });
|
||||
const trapsRef = useRef<SceneTrap[]>([]);
|
||||
const tokensRef = useRef<SceneToken[]>([]);
|
||||
trapsRef.current = localTraps;
|
||||
tokensRef.current = localTokens;
|
||||
|
||||
useEffect(() => {
|
||||
setLocalTraps(scene?.traps ?? []);
|
||||
setLocalTokens((scene?.tokens ?? []).filter((t) => appTokens.some((a) => a.id === t.tokenId)));
|
||||
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
|
||||
setSelectedId(null);
|
||||
setSelected(null);
|
||||
setView({ scale: 1, ox: 0.5, oy: 0.5 });
|
||||
}, [sceneId, scene?.previewAssetId]);
|
||||
|
||||
useEffect(() => {
|
||||
// External updates (other windows) — sync when not dragging
|
||||
if (dragRef.current) return;
|
||||
setLocalTraps(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(() => {
|
||||
if (dragRef.current) return;
|
||||
setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID });
|
||||
@@ -91,7 +149,7 @@ export function SceneEditorApp() {
|
||||
}, [api]);
|
||||
|
||||
const persistTraps = useCallback(
|
||||
async (next: SceneTrap[]) => {
|
||||
(next: SceneTrap[]) => {
|
||||
if (!sceneId) return;
|
||||
setLocalTraps(next);
|
||||
trapsRef.current = next;
|
||||
@@ -103,6 +161,19 @@ export function SceneEditorApp() {
|
||||
[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(
|
||||
(next: SceneGrid) => {
|
||||
if (!sceneId) return;
|
||||
@@ -117,15 +188,20 @@ export function SceneEditorApp() {
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (isTypingTarget(e.target)) return;
|
||||
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();
|
||||
const next = trapsRef.current.filter((t) => t.id !== selectedId);
|
||||
setSelectedId(null);
|
||||
void persistTraps(next);
|
||||
if (selected.kind === 'trap') {
|
||||
persistTraps(trapsRef.current.filter((t) => t.id !== selected.id));
|
||||
} else {
|
||||
persistTokens(tokensRef.current.filter((t) => t.id !== selected.id));
|
||||
}
|
||||
setSelected(null);
|
||||
}
|
||||
};
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (isTypingTarget(e.target)) return;
|
||||
if (e.code === 'Space') spaceDownRef.current = false;
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
@@ -134,7 +210,7 @@ export function SceneEditorApp() {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
};
|
||||
}, [persistTraps, sceneId, selectedId]);
|
||||
}, [persistTraps, persistTokens, sceneId, selected]);
|
||||
|
||||
const hostToNorm = (clientX: number, clientY: number): { x: number; y: number } | null => {
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -190,37 +261,58 @@ export function SceneEditorApp() {
|
||||
|
||||
const addTrapAt = (type: SceneTrapType, nx: number, ny: number) => {
|
||||
const trap: SceneTrap = {
|
||||
id: asSceneTrapId(randomTrapId()),
|
||||
id: asSceneTrapId(randomId('trap')),
|
||||
type,
|
||||
nx,
|
||||
ny,
|
||||
sizeN: DEFAULT_SCENE_TRAP_SIZE_N,
|
||||
...(type === 'freeform' ? { label: trapTypeLabelRu(type) } : {}),
|
||||
};
|
||||
const next = [...trapsRef.current, trap];
|
||||
setSelectedId(trap.id);
|
||||
void persistTraps(next);
|
||||
setSelected({ kind: 'trap', id: trap.id });
|
||||
persistTraps([...trapsRef.current, trap]);
|
||||
};
|
||||
|
||||
const onPaletteDragStart = (type: SceneTrapType) => (e: React.DragEvent) => {
|
||||
e.dataTransfer.setData('application/x-dnd-trap-type', type);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
const addTokenAt = (tokenId: TokenId, nx: number, ny: number) => {
|
||||
const placement: SceneToken = {
|
||||
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) => {
|
||||
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);
|
||||
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);
|
||||
};
|
||||
|
||||
const updateTrap = (id: string, patch: Partial<SceneTrap>) => {
|
||||
const next = trapsRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t));
|
||||
void persistTraps(next);
|
||||
persistTraps(trapsRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t)));
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
return (
|
||||
@@ -228,8 +320,9 @@ export function SceneEditorApp() {
|
||||
<aside className={styles.sidebar}>
|
||||
<div className={styles.sideTitle}>{scene?.title ?? 'Сцена'}</div>
|
||||
<div className={styles.hint}>
|
||||
Колесо — зум. СКМ / Space+ЛКМ — пан. Delete — удалить выбранную ловушку.
|
||||
Колесо — зум. СКМ / Space+ЛКМ — пан. Delete — удалить выбранное.
|
||||
</div>
|
||||
|
||||
<div className={styles.accordion}>
|
||||
<button type="button" className={styles.accordionHead} onClick={() => setGridOpen((v) => !v)}>
|
||||
Сетка {gridOpen ? '▾' : '▸'}
|
||||
@@ -246,20 +339,21 @@ export function SceneEditorApp() {
|
||||
</label>
|
||||
<label className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
|
||||
<span className={styles.fieldLabel}>Тип</span>
|
||||
<select
|
||||
className={styles.select}
|
||||
<Select
|
||||
disabled={!localGrid.enabled}
|
||||
value={localGrid.type}
|
||||
onChange={(e) =>
|
||||
ariaLabel="Тип сетки"
|
||||
options={[
|
||||
{ value: 'square', label: sceneGridTypeLabelRu('square') },
|
||||
{ value: 'hex', label: sceneGridTypeLabelRu('hex') },
|
||||
]}
|
||||
onChange={(next) =>
|
||||
persistGrid({
|
||||
...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 className={[styles.field, localGrid.enabled ? '' : styles.fieldDisabled].join(' ')}>
|
||||
<span className={styles.fieldLabel}>Цвет</span>
|
||||
@@ -295,6 +389,41 @@ export function SceneEditorApp() {
|
||||
</div>
|
||||
) : null}
|
||||
</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}>
|
||||
<button type="button" className={styles.accordionHead} onClick={() => setTrapsOpen((v) => !v)}>
|
||||
Ловушки {trapsOpen ? '▾' : '▸'}
|
||||
@@ -306,7 +435,10 @@ export function SceneEditorApp() {
|
||||
key={type}
|
||||
className={styles.paletteItem}
|
||||
draggable
|
||||
onDragStart={onPaletteDragStart(type)}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('application/x-dnd-trap-type', type);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
title="Перетащите на карту"
|
||||
>
|
||||
<TrapGlyph type={type} size={22} />
|
||||
@@ -316,19 +448,19 @@ export function SceneEditorApp() {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedId ? (
|
||||
<div className={styles.toolbar}>
|
||||
|
||||
<div className={styles.clearSceneBtn}>
|
||||
<Button
|
||||
disabled={!sceneId || (localTraps.length === 0 && localTokens.length === 0)}
|
||||
onClick={() => {
|
||||
const next = localTraps.filter((t) => t.id !== selectedId);
|
||||
setSelectedId(null);
|
||||
void persistTraps(next);
|
||||
persistTraps([]);
|
||||
persistTokens([]);
|
||||
setSelected(null);
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
Очистить сцену
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
|
||||
<div className={styles.stage}>
|
||||
@@ -364,7 +496,7 @@ export function SceneEditorApp() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (d.kind === 'move') {
|
||||
if (d.kind === 'moveTrap') {
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
updateTrap(d.trapId, {
|
||||
@@ -373,7 +505,7 @@ export function SceneEditorApp() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (d.kind === 'resize') {
|
||||
if (d.kind === 'resizeTrap') {
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
const trap = trapsRef.current.find((t) => t.id === d.trapId);
|
||||
if (!p || !trap) return;
|
||||
@@ -382,6 +514,32 @@ export function SceneEditorApp() {
|
||||
updateTrap(d.trapId, {
|
||||
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={() => {
|
||||
@@ -399,27 +557,98 @@ export function SceneEditorApp() {
|
||||
onContentRectChange={setContentRect}
|
||||
/>
|
||||
<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
|
||||
? localTraps.map((trap) => {
|
||||
const minDim = Math.min(contentRect.w, contentRect.h);
|
||||
const sizePx = Math.max(16, trap.sizeN * minDim);
|
||||
const left = contentRect.x + trap.nx * contentRect.w;
|
||||
const top = contentRect.y + trap.ny * contentRect.h;
|
||||
const selected = selectedId === trap.id;
|
||||
const isSelected = selected?.kind === 'trap' && selected.id === trap.id;
|
||||
return (
|
||||
<div
|
||||
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 }}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0 || spaceDownRef.current) return;
|
||||
e.stopPropagation();
|
||||
setSelectedId(trap.id);
|
||||
setSelected({ kind: 'trap', id: trap.id });
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
dragRef.current = {
|
||||
kind: 'move',
|
||||
kind: 'moveTrap',
|
||||
trapId: trap.id,
|
||||
startNx: trap.nx,
|
||||
startNy: trap.ny,
|
||||
@@ -429,8 +658,10 @@ export function SceneEditorApp() {
|
||||
}}
|
||||
>
|
||||
<TrapGlyph type={trap.type} size={Math.max(14, sizePx * 0.55)} />
|
||||
{trap.label ? <div className={styles.trapLabel}>{trap.label}</div> : null}
|
||||
{selected ? (
|
||||
{trap.label && trap.label.trim().toLowerCase() !== 'свободная' ? (
|
||||
<div className={styles.trapLabel}>{trap.label}</div>
|
||||
) : null}
|
||||
{isSelected ? (
|
||||
<div
|
||||
className={styles.handle}
|
||||
onPointerDown={(e) => {
|
||||
@@ -440,7 +671,7 @@ export function SceneEditorApp() {
|
||||
if (!p) return;
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
dragRef.current = {
|
||||
kind: 'resize',
|
||||
kind: 'resizeTrap',
|
||||
trapId: trap.id,
|
||||
startSize: trap.sizeN,
|
||||
startDist: Math.max(1e-4, Math.hypot(p.x - trap.nx, p.y - trap.ny)),
|
||||
@@ -455,6 +686,57 @@ export function SceneEditorApp() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -17,6 +17,9 @@ import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay';
|
||||
import { useNpcsOverlayState } from './npcs/useNpcsOverlayState';
|
||||
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
|
||||
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 { useSceneTrapsState } from './traps/useSceneTrapsState';
|
||||
import styles from './PresentationView.module.css';
|
||||
@@ -44,6 +47,8 @@ export function PresentationView({
|
||||
const [sceneView] = useSceneViewState();
|
||||
const [materialsOverlay] = useMaterialsOverlayState();
|
||||
const [npcsOverlay] = useNpcsOverlayState();
|
||||
const appTokens = useAppTokens();
|
||||
const [sceneTokensSession] = useSceneTokensSession();
|
||||
const [vp] = useVideoPlaybackState();
|
||||
const videoElRef = useRef<HTMLVideoElement | null>(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} />
|
||||
) : null}
|
||||
<div className={styles.vignette} />
|
||||
{scene?.previewAssetType === 'image' && contentRect ? (
|
||||
<SceneTokensOverlay
|
||||
placements={scene.tokens ?? []}
|
||||
library={appTokens}
|
||||
session={sceneTokensSession}
|
||||
viewport={contentRect}
|
||||
/>
|
||||
) : null}
|
||||
{scene?.previewAssetType === 'image' && contentRect ? (
|
||||
<SceneTrapsOverlay
|
||||
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)} />
|
||||
{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' ? (
|
||||
<div className={styles.flash} />
|
||||
) : null}
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
.buttonHost {
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
flex: 0 0 auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.button {
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
@@ -89,11 +96,3 @@
|
||||
outline: none;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { createPortal } from 'react-dom';
|
||||
|
||||
import styles from './Controls.module.css';
|
||||
|
||||
export { Select, type SelectOption, type SelectProps } from './Select';
|
||||
|
||||
type ButtonProps = {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
@@ -94,22 +96,23 @@ export function Button({
|
||||
);
|
||||
|
||||
// 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) {
|
||||
return (
|
||||
<>
|
||||
<span className={styles.buttonHost}>
|
||||
<span ref={hostRef} className={styles.disabledTipHost} onMouseEnter={showTip} onMouseLeave={hideTip}>
|
||||
{button}
|
||||
</span>
|
||||
{tip}
|
||||
</>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={styles.buttonHost}>
|
||||
{button}
|
||||
{tip}
|
||||
</>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -117,15 +120,19 @@ type InputProps = {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
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 (
|
||||
<input
|
||||
className={styles.input}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ function scene(id: string): Scene {
|
||||
previewRotationDeg: 0,
|
||||
darkenScene: false,
|
||||
traps: [],
|
||||
tokens: [],
|
||||
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
|
||||
media: { videos: [], audios: [] },
|
||||
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
|
||||
|
||||
@@ -28,6 +28,7 @@ function scene(id: string, title: string): Scene {
|
||||
previewRotationDeg: 0,
|
||||
darkenScene: false,
|
||||
traps: [],
|
||||
tokens: [],
|
||||
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
|
||||
media: { videos: [], audios: [] },
|
||||
settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
SceneGraphNode,
|
||||
SceneId,
|
||||
} from '../types';
|
||||
import type { AssetId, NpcGroupId, ProjectId } from '../types/ids';
|
||||
import type { AssetId, NpcGroupId, ProjectId, TokenId } from '../types/ids';
|
||||
import {
|
||||
asAssetId,
|
||||
asGraphNodeId,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
asNpcRelationId,
|
||||
asProjectId,
|
||||
asSceneId,
|
||||
asTokenId,
|
||||
} from '../types/ids';
|
||||
|
||||
import {
|
||||
@@ -812,3 +813,35 @@ function remapNpcBinding(
|
||||
export function newExportBundleProjectId(): ProjectId {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -17,16 +17,21 @@ import type {
|
||||
NpcsOverlayState,
|
||||
Project,
|
||||
ProjectId,
|
||||
AppToken,
|
||||
Scene,
|
||||
SceneDarknessEvent,
|
||||
SceneDarknessState,
|
||||
SceneGrid,
|
||||
SceneId,
|
||||
SceneToken,
|
||||
SceneTokensSessionEvent,
|
||||
SceneTokensSessionState,
|
||||
SceneTrap,
|
||||
SceneTrapsEvent,
|
||||
SceneTrapsState,
|
||||
SceneViewEvent,
|
||||
SceneViewState,
|
||||
TokenId,
|
||||
VideoPlaybackEvent,
|
||||
VideoPlaybackState,
|
||||
} from '../types';
|
||||
@@ -164,6 +169,19 @@ export const ipcChannels = {
|
||||
dispatch: 'sceneView.dispatch',
|
||||
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: {
|
||||
getState: 'video.getState',
|
||||
dispatch: 'video.dispatch',
|
||||
@@ -232,6 +250,8 @@ export type IpcEventMap = {
|
||||
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
|
||||
[ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState };
|
||||
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
|
||||
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
|
||||
[ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState };
|
||||
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
|
||||
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
|
||||
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
|
||||
@@ -651,6 +671,34 @@ export type IpcInvokeMap = {
|
||||
req: { event: SceneViewEvent };
|
||||
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]: {
|
||||
req: Record<string, never>;
|
||||
res: { state: VideoPlaybackState };
|
||||
@@ -690,6 +738,8 @@ export type LegacyIpcEventMap = {
|
||||
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
|
||||
[ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState };
|
||||
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
|
||||
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
|
||||
[ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState };
|
||||
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
|
||||
[ipcChannels.license.statusChanged]: Record<string, never>;
|
||||
};
|
||||
@@ -704,6 +754,7 @@ export type ScenePatch = {
|
||||
previewRotationDeg?: 0 | 90 | 180 | 270;
|
||||
darkenScene?: boolean;
|
||||
traps?: SceneTrap[];
|
||||
tokens?: SceneToken[];
|
||||
grid?: SceneGrid;
|
||||
settings?: Partial<Scene['settings']>;
|
||||
media?: Partial<Scene['media']>;
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -9,10 +9,11 @@ import type {
|
||||
SceneId,
|
||||
} from './ids';
|
||||
import type { MaterialLegend } from './materialLegend';
|
||||
import type { SceneToken } from './appTokens';
|
||||
import type { SceneGrid } from './sceneGrid';
|
||||
import type { SceneTrap } from './sceneTraps';
|
||||
|
||||
export const PROJECT_SCHEMA_VERSION = 9 as const;
|
||||
export const PROJECT_SCHEMA_VERSION = 10 as const;
|
||||
|
||||
/** Материал кампании: изображение, показываемое поверх сцены во время игры. */
|
||||
export type ProjectMaterial = {
|
||||
@@ -163,6 +164,8 @@ export type Scene = {
|
||||
darkenScene: boolean;
|
||||
/** Ловушки на карте (только для image-превью); расстановка в проекте. */
|
||||
traps: SceneTrap[];
|
||||
/** Неигровые токены на карте (ссылки на app-local пул). */
|
||||
tokens: SceneToken[];
|
||||
/** Боевая сетка поверх превью (под ловушками/эффектами). */
|
||||
grid: SceneGrid;
|
||||
media: SceneMediaRefs;
|
||||
|
||||
@@ -8,6 +8,8 @@ export type MaterialId = Brand<string, 'MaterialId'>;
|
||||
export type NpcId = Brand<string, 'NpcId'>;
|
||||
export type NpcRelationId = Brand<string, 'NpcRelationId'>;
|
||||
export type NpcGroupId = Brand<string, 'NpcGroupId'>;
|
||||
export type TokenId = Brand<string, 'TokenId'>;
|
||||
export type SceneTokenId = Brand<string, 'SceneTokenId'>;
|
||||
|
||||
export function asProjectId(value: string): ProjectId {
|
||||
return value as ProjectId;
|
||||
@@ -40,3 +42,11 @@ export function asNpcRelationId(value: string): NpcRelationId {
|
||||
export function asNpcGroupId(value: string): 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,3 +1,4 @@
|
||||
export * from './appTokens';
|
||||
export * from './domain';
|
||||
export * from './effects';
|
||||
export * from './ids';
|
||||
|
||||
@@ -114,7 +114,7 @@ export function trapTypeLabelRu(type: SceneTrapType): string {
|
||||
case 'laser':
|
||||
return 'Лазер';
|
||||
case 'freeform':
|
||||
return 'Свободная';
|
||||
return 'Метка';
|
||||
default: {
|
||||
const _x: never = type;
|
||||
return String(_x);
|
||||
|
||||
Reference in New Issue
Block a user