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