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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-27 11:04:12 +08:00
parent bdeb64e356
commit f270812219
44 changed files with 2617 additions and 341 deletions
+117 -9
View File
@@ -29,6 +29,8 @@ import { NpcsOverlayStore } from './npcs/npcsOverlayStore';
import { ZipProjectStore } from './project/zipStore';
import { SceneViewStore } from './sceneView/sceneViewStore';
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
import { SceneTokensSessionStore } from './tokens/sceneTokensSessionStore';
import { TokensStore } from './tokens/tokensStore';
import { installAutoUpdater } from './update/installAutoUpdater';
import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo';
import { VideoPlaybackStore } from './video/videoPlaybackStore';
@@ -165,6 +167,8 @@ const sceneViewStore = new SceneViewStore();
const videoStore = new VideoPlaybackStore();
const materialsOverlayStore = new MaterialsOverlayStore();
const npcsOverlayStore = new NpcsOverlayStore();
const sceneTokensSessionStore = new SceneTokensSessionStore();
let tokensStore: TokensStore | null = null;
function emitEffectsState(): void {
const state = effectsStore.getState();
@@ -219,6 +223,20 @@ function emitSceneViewState(): void {
}
}
function emitTokensState(): void {
const tokens = tokensStore?.list() ?? [];
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.tokens.stateChanged, { tokens });
}
}
function emitSceneTokensSessionState(): void {
const state = sceneTokensSessionStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.sceneTokensSession.stateChanged, { state });
}
}
function syncSceneDarknessForProject(project: Project): void {
const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null;
const scene = project.currentSceneId ? project.scenes[project.currentSceneId] : undefined;
@@ -366,11 +384,13 @@ async function main() {
});
const licenseService = new LicenseService(app.getPath('userData'));
tokensStore = new TokensStore(app.getPath('userData'));
await tokensStore.ensureLoaded();
setLicenseAssert(() => {
licenseService.assertForIpc();
});
installAppMenuForSession();
registerDndAssetProtocol(projectStore);
registerDndAssetProtocol(projectStore, tokensStore);
registerHandler(ipcChannels.app.quit, () => {
markAppQuitting();
app.quit();
@@ -388,6 +408,7 @@ async function main() {
registerHandler(ipcChannels.windows.openMultiWindow, () => {
sceneDarknessStore.resetSession();
sceneTrapsStore.resetSession();
sceneTokensSessionStore.reset();
effectsStore.dispatch({ kind: 'tool.set', tool: effectsDefaultTool() });
openMultiWindow();
const project = projectStore.getOpenProject();
@@ -397,6 +418,7 @@ async function main() {
}
emitSceneDarknessState();
emitSceneTrapsState();
emitSceneTokensSessionState();
emitEffectsState();
return { ok: true };
});
@@ -491,7 +513,9 @@ async function main() {
registerHandler(ipcChannels.project.open, async ({ projectId }) => {
const project = await projectStore.openProjectById(projectId);
sceneViewStore.reset();
sceneTokensSessionStore.reset();
emitSceneViewState();
emitSceneTokensSessionState();
emitSessionState();
return { project };
});
@@ -503,12 +527,14 @@ async function main() {
sceneDarknessStore.resetSession();
sceneTrapsStore.resetSession();
sceneViewStore.reset();
sceneTokensSessionStore.reset();
emitEffectsState();
emitMaterialsOverlayState();
emitNpcsOverlayState();
emitSceneDarknessState();
emitSceneTrapsState();
emitSceneViewState();
emitSceneTokensSessionState();
emitSessionState();
return { ok: true };
});
@@ -525,6 +551,7 @@ async function main() {
materialsOverlayStore.clear();
npcsOverlayStore.clear();
sceneViewStore.reset();
// Token moves persist for the whole play session (reset only on project open/close).
const project = projectStore.getOpenProject();
if (project) {
syncSceneDarknessForProject(project);
@@ -536,6 +563,7 @@ async function main() {
emitSceneDarknessState();
emitSceneTrapsState();
emitSceneViewState();
emitSceneTokensSessionState();
emitSessionState();
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
});
@@ -552,6 +580,7 @@ async function main() {
materialsOverlayStore.clear();
npcsOverlayStore.clear();
sceneViewStore.reset();
// Token moves persist for the whole play session (reset only on project open/close).
const project = projectStore.getOpenProject();
if (project) {
syncSceneDarknessForProject(project);
@@ -563,6 +592,7 @@ async function main() {
emitSceneDarknessState();
emitSceneTrapsState();
emitSceneViewState();
emitSceneTokensSessionState();
emitSessionState();
const p = projectStore.getOpenProject();
return {
@@ -1018,6 +1048,11 @@ async function main() {
});
},
npcResolutions,
async (cacheDir) => {
const remap = await tokensStore!.importFromExportDir(cacheDir);
emitTokensState();
return remap;
},
);
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState();
@@ -1041,6 +1076,13 @@ async function main() {
});
},
npcResolutions,
async (cacheDir) => {
// Импорт из другого локального проекта: app-tokens в zip нет — только из внешнего архива.
// Если в sourceCache нет app-tokens, remap пустой.
const remap = await tokensStore!.importFromExportDir(cacheDir);
emitTokensState();
return remap;
},
);
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState();
@@ -1121,14 +1163,23 @@ async function main() {
const dest = normalizeSaveProjectZipPath(filePath);
try {
emitZipProgress({ kind: 'export', stage: 'zip', percent: 0, detail: 'Экспорт…' });
await projectStore.exportStorylinesZipToPath(projectId, storylineSelections, dest, labels, (p) => {
emitZipProgress({
kind: 'export',
stage: p.stage,
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
});
await projectStore.exportStorylinesZipToPath(
projectId,
storylineSelections,
dest,
labels,
(p) => {
emitZipProgress({
kind: 'export',
stage: p.stage,
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
},
async (tokenIds, exportRoot) => {
await tokensStore!.packForExport(tokenIds, exportRoot);
},
);
emitZipProgress({ kind: 'export', stage: 'done', percent: 100, detail: 'Готово' });
return { canceled: false as const };
} catch (err) {
@@ -1182,6 +1233,63 @@ async function main() {
return { ok: true };
});
registerHandler(ipcChannels.tokens.list, async () => {
await tokensStore!.ensureLoaded();
return { tokens: tokensStore!.list() };
});
registerHandler(ipcChannels.tokens.upsert, async ({ id, name, filePath }) => {
const token = await tokensStore!.upsert({ id, name, filePath });
emitTokensState();
return { token };
});
registerHandler(ipcChannels.tokens.delete, async ({ id }) => {
await tokensStore!.delete(id);
// Убрать placements с текущей сцены.
const project = projectStore.getOpenProject();
const sceneId = project?.currentSceneId;
if (sceneId && project) {
const scene = project.scenes[sceneId];
if (scene?.tokens?.some((t) => t.tokenId === id)) {
await projectStore.updateScene(sceneId, {
tokens: scene.tokens.filter((t) => t.tokenId !== id),
});
emitSessionState();
}
}
emitTokensState();
return { ok: true };
});
registerHandler(ipcChannels.tokens.pickImage, async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: openDialogFilterLabel('images', app.getLocale()),
extensions: ['png', 'jpg', 'jpeg', 'webp'],
},
],
});
if (canceled || filePaths.length === 0) return { canceled: true as const };
const filePath = filePaths[0]!;
const buf = await fs.readFile(filePath);
const ext = path.extname(filePath).toLowerCase();
const mime =
ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
return { canceled: false as const, filePath, previewDataUrl };
});
registerHandler(ipcChannels.tokens.imageUrl, ({ id }) => {
return { url: tokensStore!.getImageUrl(id) };
});
registerHandler(ipcChannels.sceneTokensSession.getState, () => {
return { state: sceneTokensSessionStore.getState() };
});
registerHandler(ipcChannels.sceneTokensSession.dispatch, ({ event }) => {
sceneTokensSessionStore.dispatch(event);
emitSceneTokensSessionState();
return { ok: true };
});
registerHandler(ipcChannels.video.getState, () => {
return { state: videoStore.getState() };
});