diff --git a/app/main/foundry/foundryImport.ts b/app/main/foundry/foundryImport.ts index 57449b0..19241e6 100644 --- a/app/main/foundry/foundryImport.ts +++ b/app/main/foundry/foundryImport.ts @@ -387,6 +387,7 @@ export async function buildProjectFromFoundryDocuments( previewRotationDeg: 0, darkenScene: false, traps: [], + tokens: [], grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' }, media: { videos: [], audios: audioRefs }, settings: { diff --git a/app/main/index.ts b/app/main/index.ts index 77de575..dff6aea 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -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() }; }); diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts index 66908a7..3b8289e 100644 --- a/app/main/project/zipStore.ts +++ b/app/main/project/zipStore.ts @@ -15,11 +15,13 @@ import { } from '../../shared/graph/sceneListOrder'; import { buildPartialExportProject, + collectTokenIdsFromProject, computeGraphImportOffsetX, listExportableStorylines, listImportableStorylines, mergeStorylinesIntoProject, newExportBundleProjectId, + remapProjectSceneTokenIds, type NpcImportResolution, type SceneImportResolution, type StorylineImportMergeReport, @@ -51,6 +53,7 @@ import type { } from '../../shared/types'; import { PROJECT_SCHEMA_VERSION } from '../../shared/types'; import { normalizeMaterialLegend } from '../../shared/types/materialLegend'; +import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens'; import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid'; import { normalizeSceneTrap } from '../../shared/types/sceneTraps'; import type { AssetId, GraphNodeId, MaterialId, NpcGroupId, NpcId, NpcRelationId } from '../../shared/types/ids'; @@ -617,12 +620,14 @@ export class ZipProjectStore { previewRotationDeg: 0, darkenScene: false, traps: [], + tokens: [], grid: { ...DEFAULT_SCENE_GRID }, } satisfies Scene); const next: Scene = { ...base, traps: base.traps ?? [], + tokens: base.tokens ?? [], grid: base.grid ?? { ...DEFAULT_SCENE_GRID }, ...(patch.title !== undefined ? { title: patch.title } : null), ...(patch.description !== undefined ? { description: patch.description } : null), @@ -643,6 +648,13 @@ export class ZipProjectStore { .filter((t): t is SceneTrap => Boolean(t)), } : null), + ...(patch.tokens !== undefined + ? { + tokens: patch.tokens + .map((t) => normalizeSceneToken(t)) + .filter((t): t is SceneToken => Boolean(t)), + } + : null), ...(patch.grid !== undefined ? { grid: normalizeSceneGrid(patch.grid) } : null), ...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null), ...(patch.media ? { media: { ...base.media, ...patch.media } } : null), @@ -1992,6 +2004,7 @@ export class ZipProjectStore { destinationPath: string, labels: StorylineLabels, onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void, + packAppTokens?: (tokenIds: string[], exportRoot: string) => Promise, ): Promise { if (selections.length === 0) throw new Error('Не выбрана ни одна сюжетная линия'); await this.ensureRoots(); @@ -2016,10 +2029,14 @@ export class ZipProjectStore { await fs.mkdir(path.dirname(destAbs), { recursive: true }); await fs.copyFile(srcAbs, destAbs); if (onProgress) { - const pct = Math.round(((i + 1) / Math.max(1, assetIds.length)) * 80); + const pct = Math.round(((i + 1) / Math.max(1, assetIds.length)) * 75); onProgress({ stage: 'zip', percent: pct, detail: 'Сборка архива…' }); } } + if (packAppTokens) { + const tokenIds = collectTokenIdsFromProject(partial); + await packAppTokens(tokenIds, exportCache); + } const now = new Date().toISOString(); const toWrite: Project = { ...partial, @@ -2116,12 +2133,18 @@ export class ZipProjectStore { sceneResolutions: SceneImportResolution[], onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void, npcResolutions?: NpcImportResolution[], + importAppTokens?: (sourceCacheDir: string) => Promise>, ): Promise<{ project: Project; report: StorylineImportMergeReport }> { if (!this.openProject) throw new Error('Нет открытого проекта'); + let sourceForMerge = source; + if (importAppTokens) { + const remap = await importAppTokens(sourceCache); + sourceForMerge = remapProjectSceneTokenIds(source, remap); + } const offsetX = computeGraphImportOffsetX(this.openProject.project); const { project: merged, report, assetCopies } = mergeStorylinesIntoProject( this.openProject.project, - source, + sourceForMerge, selections, sceneResolutions, { @@ -2162,6 +2185,7 @@ export class ZipProjectStore { sceneResolutions: SceneImportResolution[], onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void, npcResolutions?: NpcImportResolution[], + importAppTokens?: (sourceCacheDir: string) => Promise>, ): Promise<{ project: Project; report: StorylineImportMergeReport }> { this.assertStorylineMergeAllowed(selections); const snap = await this.loadProjectSnapshot(sourceProjectId); @@ -2173,6 +2197,7 @@ export class ZipProjectStore { sceneResolutions, onProgress, npcResolutions, + importAppTokens, ); } finally { if (snap.ownsCache) { @@ -2187,6 +2212,7 @@ export class ZipProjectStore { sceneResolutions: SceneImportResolution[], onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void, npcResolutions?: NpcImportResolution[], + importAppTokens?: (sourceCacheDir: string) => Promise>, ): Promise<{ project: Project; report: StorylineImportMergeReport }> { this.assertStorylineMergeAllowed(selections); const source = await this.readExternalProjectForImport(sourcePath); @@ -2201,6 +2227,7 @@ export class ZipProjectStore { sceneResolutions, onProgress, npcResolutions, + importAppTokens, ); } finally { await fs.rm(sourceCache, { recursive: true, force: true }).catch(() => undefined); @@ -2329,6 +2356,10 @@ function normalizeScene(s: Scene): Scene { const traps = (Array.isArray(rawTraps) ? rawTraps : []) .map((t) => normalizeSceneTrap(t)) .filter((t): t is SceneTrap => Boolean(t)); + const rawTokens = (s as unknown as { tokens?: unknown[] }).tokens; + const tokens = (Array.isArray(rawTokens) ? rawTokens : []) + .map((t) => normalizeSceneToken(t)) + .filter((t): t is SceneToken => Boolean(t)); const grid = normalizeSceneGrid((s as unknown as { grid?: unknown }).grid); const rawAudios = Array.isArray(raw.audios) ? raw.audios : []; @@ -2359,6 +2390,7 @@ function normalizeScene(s: Scene): Scene { previewRotationDeg, darkenScene, traps, + tokens, grid, layout: layoutIn ?? { x: 0, y: 0 }, media: { diff --git a/app/main/protocol/dndAssetProtocol.ts b/app/main/protocol/dndAssetProtocol.ts index 4b7a3eb..ef9892a 100644 --- a/app/main/protocol/dndAssetProtocol.ts +++ b/app/main/protocol/dndAssetProtocol.ts @@ -2,38 +2,39 @@ import fs from 'node:fs/promises'; import { session } from 'electron'; -import { asAssetId } from '../../shared/types/ids'; +import { asAssetId, asTokenId } from '../../shared/types/ids'; import type { ZipProjectStore } from '../project/zipStore'; +import type { TokensStore } from '../tokens/tokensStore'; -/** - * Обслуживает `dnd://asset?...` — без этого `` в рендерере часто ломается. - */ -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'); +type ReadInfo = { absPath: string; mime: string }; - 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) { +async function serveFile(info: ReadInfo, request: Request): Promise { + try { + const stat = await fs.stat(info.absPath); + const total = stat.size; + const range = request.headers.get('range') ?? request.headers.get('Range'); + + 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, { status: 416, headers: { @@ -42,48 +43,59 @@ export function registerDndAssetProtocol(projectStore: ZipProjectStore): void { }, }); } - 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, { - status: 416, - headers: { - 'Content-Range': `bytes */${String(total)}`, - 'Cache-Control': 'no-store', - }, - }); - } - 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 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); - return new Response(buf, { - headers: { - 'Content-Type': info.mime, - 'Accept-Ranges': 'bytes', - 'Cache-Control': 'no-store', - }, - }); - } catch { + const buf = await fs.readFile(info.absPath); + return new Response(buf, { + headers: { + 'Content-Type': info.mime, + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'no-store', + }, + }); + } catch { + return new Response(null, { status: 404 }); + } +} + +/** + * Обслуживает `dnd://asset?...` и `dnd://token?...`. + */ +export function registerDndAssetProtocol( + projectStore: ZipProjectStore, + tokensStore: TokensStore, +): void { + session.defaultSession.protocol.handle('dnd', async (request) => { + const url = new URL(request.url); + const id = url.searchParams.get('id'); + if (!id) { return new Response(null, { status: 404 }); } + let info: ReadInfo | null = null; + if (url.hostname === 'asset') { + info = projectStore.getAssetReadInfo(asAssetId(id)); + } else if (url.hostname === 'token') { + info = tokensStore.getImageReadInfo(asTokenId(id)); + } + if (!info) { + return new Response(null, { status: 404 }); + } + return serveFile(info, request); }); } diff --git a/app/main/tokens/sceneTokensSessionStore.ts b/app/main/tokens/sceneTokensSessionStore.ts new file mode 100644 index 0000000..50c897c --- /dev/null +++ b/app/main/tokens/sceneTokensSessionStore.ts @@ -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; + } + } + } +} diff --git a/app/main/tokens/tokensStore.ts b/app/main/tokens/tokensStore.ts new file mode 100644 index 0000000..9855629 --- /dev/null +++ b/app/main/tokens/tokensStore.ts @@ -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 { + 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 { + 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 { + 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 { + 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> { + await this.ensureLoaded(); + const remap = new Map(); + 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 { + 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'); + } +} diff --git a/app/renderer/control/ControlApp.module.css b/app/renderer/control/ControlApp.module.css index d428261..258db76 100644 --- a/app/renderer/control/ControlApp.module.css +++ b/app/renderer/control/ControlApp.module.css @@ -109,6 +109,10 @@ transform: translate(-50%, -50%); width: 520px; max-width: calc(100vw - 32px); + max-height: calc(100vh - 32px); + overflow: auto; + box-sizing: border-box; + min-width: 0; border-radius: var(--radius-lg); border: 1px solid var(--stroke); background: var(--color-surface-elevated); @@ -116,6 +120,7 @@ padding: 16px; display: grid; gap: 12px; + grid-template-columns: minmax(0, 1fr); } .descriptionViewDialog { diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index 0fe9f7e..7c606b4 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -36,6 +36,9 @@ import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState'; import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost'; import { useSceneViewState } from '../shared/sceneView/useSceneViewState'; import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay'; +import { SceneTokensOverlay } from '../shared/tokens/SceneTokensOverlay'; +import { useAppTokens } from '../shared/tokens/useAppTokens'; +import { useSceneTokensSession } from '../shared/tokens/useSceneTokensSession'; import { SceneTrapsOverlay } from '../shared/traps/SceneTrapsOverlay'; import { useSceneTrapsState } from '../shared/traps/useSceneTrapsState'; import { Button } from '../shared/ui/controls'; @@ -126,6 +129,8 @@ export function ControlApp() { const [effectsSfxGainUi, setEffectsSfxGainUi] = useState(() => getEffectsSfxGain()); const [sdState, sd] = useSceneDarknessState(); const [sceneTraps, sceneTrapsApi] = useSceneTrapsState(); + const appTokens = useAppTokens(); + const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession(); const [sceneView, sceneViewApi] = useSceneViewState(); const [sceneViewDraft, setSceneViewDraft] = useState(null); const [materialsOverlay, materialsApi] = useMaterialsOverlayState(); @@ -1884,6 +1889,18 @@ export function ControlApp() { clearDraftFromPixi(); }} /> + {previewContentRect ? ( + { + void sceneTokensApi.dispatch({ kind: 'move', placementId, nx, ny }); + }} + /> + ) : null} {previewContentRect ? ( {t('foundryImport.hint')}
{t('foundryImport.sourceType')}
- + />
{t('foundryImport.source')}
diff --git a/app/renderer/editor/StorylineTransferModals.tsx b/app/renderer/editor/StorylineTransferModals.tsx index fdf5e56..e39409f 100644 --- a/app/renderer/editor/StorylineTransferModals.tsx +++ b/app/renderer/editor/StorylineTransferModals.tsx @@ -17,7 +17,7 @@ import { type StorylineSelection, } from '../../shared/graph/storylineExportImport'; import type { Project, ProjectId, SceneId } from '../../shared/types'; -import { Button } from '../shared/ui/controls'; +import { Button, Select } from '../shared/ui/controls'; import styles from './EditorApp.module.css'; import { useEditorI18n } from './i18n/EditorI18nContext'; @@ -135,18 +135,16 @@ export function ExportProjectModal({
{t('export.project')}
- + ariaLabel={t('export.project')} + options={projects.map((p) => ({ + value: p.id, + label: `${p.name} (${p.fileName})`, + }))} + />
{t('storyline.section')}
{loadingStorylines ? ( @@ -309,43 +307,46 @@ export function ImportSourceModal({
{canImportFromProject ? ( - <> +
{t('importSource.type')}
- - + ariaLabel={t('importSource.type')} + options={[ + { value: 'project', label: t('importSource.fromProject') }, + { value: 'file', label: t('importSource.fromFile') }, + ]} + /> +
) : (
{t('importSource.fileOnlyHint')}
)} {importKind === 'project' && canImportFromProject ? ( - <> +
{t('importSource.project')}
- + ariaLabel={t('importSource.project')} + options={availableProjects.map((p) => ({ + value: p.id, + label: `${p.name} (${p.fileName})`, + }))} + /> {availableProjects.length === 0 ? (
{t('importSource.noOtherProjects')}
) : null} - +
) : ( - <> +
{t('importSource.file')}
- +
)}
@@ -572,24 +573,23 @@ export function SceneConflictModal({ open, conflicts, onClose, onConfirm }: Scen {conflicts.map((c) => (
{c.sourceTitle}
- + ariaLabel={c.sourceTitle} + options={[ + { value: 'create', label: t('importStoryline.createNewScene') }, + ...c.matches.map((m) => ({ + value: m.sceneId, + label: t('importStoryline.useExistingScene', { title: m.title }), + })), + ]} + />
))}
@@ -687,24 +687,23 @@ function NpcConflictModalBody({ {conflicts.map((c) => (
{c.sourceName}
- + ariaLabel={c.sourceName} + options={[ + { value: 'create', label: t('importStoryline.createNewNpc') }, + ...c.matches.map((m) => ({ + value: m.npcId, + label: t('importStoryline.useExistingNpc', { name: m.name }), + })), + ]} + />
))}
diff --git a/app/renderer/editor/state/projectState.ts b/app/renderer/editor/state/projectState.ts index 63f4ac6..b6f23ff 100644 --- a/app/renderer/editor/state/projectState.ts +++ b/app/renderer/editor/state/projectState.ts @@ -350,6 +350,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) previewRotationDeg: 0, darkenScene: false, traps: [], + tokens: [], grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' }, media: { videos: [], audios: [] }, settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true }, @@ -592,6 +593,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) : null), ...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null), ...(patch.traps !== undefined ? { traps: patch.traps } : null), + ...(patch.tokens !== undefined ? { tokens: patch.tokens } : null), ...(patch.grid !== undefined ? { grid: patch.grid } : null), ...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null), ...(patch.media ? { media: { ...scene.media, ...patch.media } } : null), diff --git a/app/renderer/npcs/NpcBindingFields.tsx b/app/renderer/npcs/NpcBindingFields.tsx index 634beba..e8f9199 100644 --- a/app/renderer/npcs/NpcBindingFields.tsx +++ b/app/renderer/npcs/NpcBindingFields.tsx @@ -4,7 +4,7 @@ import { isNpcBindingNone, listStorylineOptionsForBinding, noneBinding } from '. import type { GraphNodeId, NpcBinding, Project, SceneId } from '../../shared/types'; import editorStyles from '../editor/EditorApp.module.css'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; -import controlStyles from '../shared/ui/Controls.module.css'; +import { Select } from '../shared/ui/controls'; type NpcBindingFieldsProps = { project: Project; @@ -65,11 +65,14 @@ export function NpcBindingFields({ project, binding, onChange }: NpcBindingField {enabled ? ( <>
{t('npcs.bindingKind')}
- + />
{t('npcs.bindingSelect')}
- + /> ) : null} diff --git a/app/renderer/npcs/NpcEditModal.tsx b/app/renderer/npcs/NpcEditModal.tsx index 98ef154..e41392f 100644 --- a/app/renderer/npcs/NpcEditModal.tsx +++ b/app/renderer/npcs/NpcEditModal.tsx @@ -13,8 +13,7 @@ import { } from '../editor/fileDrop'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import matStyles from '../editor/MaterialsModals.module.css'; -import { Button, Input } from '../shared/ui/controls'; -import controlStyles from '../shared/ui/Controls.module.css'; +import { Button, Input, Select } from '../shared/ui/controls'; import { useAssetUrl } from '../shared/useAssetImageUrl'; import { NpcBindingFields } from './NpcBindingFields'; @@ -166,18 +165,15 @@ export function NpcEditModal({ {!initial && groupOptions.length > 0 ? (
{t('npcs.group')}
- + ariaLabel={t('npcs.group')} + onChange={(next) => setGroupId(next as NpcGroupId | '')} + options={[ + { value: '', label: t('npcs.ungrouped') }, + ...groupOptions.map((g) => ({ value: g.id, label: g.label })), + ]} + />
) : null} diff --git a/app/renderer/npcs/NpcGraph.module.css b/app/renderer/npcs/NpcGraph.module.css index b1b3f36..756bacd 100644 --- a/app/renderer/npcs/NpcGraph.module.css +++ b/app/renderer/npcs/NpcGraph.module.css @@ -164,15 +164,9 @@ .filterSelect { min-width: 160px; - padding: 6px 10px; - padding-right: 28px; - border-radius: 8px; - border: 1px solid var(--stroke); - background-color: rgba(24, 24, 27, 0.92); - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.25L6 7.75L9.5 4.25' stroke='rgba(255,255,255,0.72)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 8px center; - background-size: 12px 12px; + width: auto; + min-height: 30px; + background: rgba(24, 24, 27, 0.92); color: var(--text1); font-size: 12px; font-weight: 600; diff --git a/app/renderer/npcs/NpcGraph.tsx b/app/renderer/npcs/NpcGraph.tsx index 8b616f7..0f0fbd2 100644 --- a/app/renderer/npcs/NpcGraph.tsx +++ b/app/renderer/npcs/NpcGraph.tsx @@ -31,6 +31,7 @@ import type { ProjectNpcGroup, ProjectNpcRelation, } from '../../shared/types'; +import { Select } from '../shared/ui/controls'; import { useAssetUrl } from '../shared/useAssetImageUrl'; import styles from './NpcGraph.module.css'; @@ -300,20 +301,17 @@ function FilterToolbar({ }) { return ( - + onChange={(next) => onChange(next as GraphGroupFilter)} + options={[ + { value: 'all', label: ui.graphFilterAll }, + { value: 'ungrouped', label: ui.graphFilterUngrouped }, + ...groups.map((g) => ({ value: g.id, label: g.name })), + ]} + /> ); } diff --git a/app/renderer/npcs/NpcsEditorApp.tsx b/app/renderer/npcs/NpcsEditorApp.tsx index 9db1b0b..c4e392c 100644 --- a/app/renderer/npcs/NpcsEditorApp.tsx +++ b/app/renderer/npcs/NpcsEditorApp.tsx @@ -15,8 +15,7 @@ import type { import editorStyles from '../editor/EditorApp.module.css'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { getDndApi } from '../shared/dndApi'; -import { Button, Input } from '../shared/ui/controls'; -import controlStyles from '../shared/ui/Controls.module.css'; +import { Button, Input, Select } from '../shared/ui/controls'; import { useAssetUrl } from '../shared/useAssetImageUrl'; import { NpcBindingFields } from './NpcBindingFields'; @@ -785,24 +784,21 @@ export function NpcsEditorApp() {
{t('npcs.group')}
- + options={[ + { value: '', label: t('npcs.ungrouped') }, + ...groupOptions.map((g) => ({ value: g.id, label: g.label })), + ]} + />
diff --git a/app/renderer/presentation/PresentationApp.module.css b/app/renderer/presentation/PresentationApp.module.css index bb8b951..8763890 100644 --- a/app/renderer/presentation/PresentationApp.module.css +++ b/app/renderer/presentation/PresentationApp.module.css @@ -2,3 +2,9 @@ height: 100vh; width: 100vw; } + +.viewPassthrough { + height: 100%; + width: 100%; + pointer-events: none; +} diff --git a/app/renderer/presentation/PresentationApp.tsx b/app/renderer/presentation/PresentationApp.tsx index 7ac0773..214db99 100644 --- a/app/renderer/presentation/PresentationApp.tsx +++ b/app/renderer/presentation/PresentationApp.tsx @@ -40,7 +40,10 @@ export function PresentationApp() { className={styles.root} onDoubleClick={() => void api.invoke(ipcChannels.windows.togglePresentationFullscreen, {})} > - + {/* Презентация только для просмотра: никаких кликов/drag по сцене и оверлеям. */} +
+ +
); } diff --git a/app/renderer/sceneEditor/SceneEditorApp.module.css b/app/renderer/sceneEditor/SceneEditorApp.module.css index 3f9667a..fe24368 100644 --- a/app/renderer/sceneEditor/SceneEditorApp.module.css +++ b/app/renderer/sceneEditor/SceneEditorApp.module.css @@ -90,22 +90,6 @@ opacity: 0.75; } -.select { - width: 100%; - box-sizing: border-box; - padding: 7px 28px 7px 10px; - border-radius: 8px; - border: 1px solid var(--stroke, #2a2f3a); - background-color: rgba(0, 0, 0, 0.25); - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.25L6 7.75L9.5 4.25' stroke='rgba(255,255,255,0.72)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 8px center; - background-size: 12px 12px; - color: inherit; - font: inherit; - appearance: none; -} - .range { width: 100%; accent-color: #f5c542; @@ -200,7 +184,7 @@ .trap { position: absolute; - z-index: 1; + z-index: 2; transform: translate(-50%, -50%); border-radius: 50%; border: 2px solid rgba(255, 255, 255, 0.55); @@ -252,6 +236,189 @@ opacity: 0.7; } +.tokensPanel { + display: grid; + gap: 8px; + padding: 10px 12px 12px; +} + +.tokensToolbar { + display: grid; +} + +.tokensToolbar button { + width: 100%; + box-sizing: border-box; +} + +.tokenGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.tokenTile { + display: grid; + gap: 4px; + padding: 6px; + border-radius: 8px; + border: 1px solid var(--stroke, #2a2f3a); + background: rgba(0, 0, 0, 0.22); + cursor: grab; + user-select: none; +} + +.tokenTile:active { + cursor: grabbing; +} + +.tokenThumb { + aspect-ratio: 1; + border-radius: 6px; + overflow: hidden; + background: rgba(0, 0, 0, 0.35); + display: grid; + place-items: center; +} + +.tokenThumb img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.tokenTileMeta { + display: flex; + align-items: center; + gap: 2px; + min-width: 0; +} + +.tokenTileName { + flex: 1 1 auto; + min-width: 0; + font-size: 11px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tokenTileMore { + flex: 0 0 auto; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: inherit; + font-size: 16px; + line-height: 1; + cursor: pointer; + opacity: 0.75; +} + +.tokenTileMore:hover, +.tokenTileMore[aria-expanded='true'] { + opacity: 1; + background: rgba(255, 255, 255, 0.08); +} + +.tokenCtxMenu { + position: fixed; + z-index: 80; + min-width: 160px; + padding: 6px; + border-radius: 10px; + border: 1px solid var(--stroke, #333); + background: #1a1d24; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45); + display: grid; + gap: 2px; +} + +.tokenCtxItem, +.tokenCtxItemDanger { + text-align: left; + padding: 8px 10px; + border-radius: 6px; + border: none; + background: transparent; + color: #e8eaef; + font-size: 13px; + cursor: pointer; + width: 100%; +} + +.tokenCtxItem:hover { + background: rgba(255, 255, 255, 0.08); +} + +.tokenCtxItemDanger { + color: #f87171; +} + +.tokenCtxItemDanger:hover { + background: rgba(248, 113, 113, 0.12); +} + +.clearSceneBtn { + width: 100%; + box-sizing: border-box; +} + +.clearSceneBtn button { + width: 100%; + box-sizing: border-box; +} + +.sceneToken { + position: absolute; + z-index: 1; + border-radius: 8px; + border: 2px solid rgba(255, 255, 255, 0.35); + background: rgba(0, 0, 0, 0.25); + overflow: visible; + cursor: move; + touch-action: none; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35); +} + +.sceneTokenSelected { + border-color: #f5c542; + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.4), + 0 0 0 3px rgba(245, 197, 66, 0.35); +} + +.sceneTokenImg { + width: 100%; + height: 100%; + object-fit: contain; + border-radius: 6px; + pointer-events: none; + display: block; +} + +.rotateHandle { + position: absolute; + left: 50%; + top: -18px; + transform: translateX(-50%); + width: 22px; + height: 22px; + border-radius: 50%; + border: 1px solid #000; + background: #f5c542; + color: #111; + font-size: 12px; + line-height: 1; + cursor: grab; + touch-action: none; + padding: 0; +} + .toolbar { display: flex; gap: 8px; diff --git a/app/renderer/sceneEditor/SceneEditorApp.tsx b/app/renderer/sceneEditor/SceneEditorApp.tsx index 120cc29..ccfb0a8 100644 --- a/app/renderer/sceneEditor/SceneEditorApp.tsx +++ b/app/renderer/sceneEditor/SceneEditorApp.tsx @@ -1,7 +1,14 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { ipcChannels, type SessionState } from '../../shared/ipc/contracts'; -import type { SceneGrid, SceneTrap, SceneTrapType } from '../../shared/types'; +import type { SceneGrid, SceneToken, SceneTrap, SceneTrapType, TokenId } from '../../shared/types'; +import { + asSceneTokenId, + asTokenId, + clampSceneTokenSizeN, + DEFAULT_SCENE_TOKEN_SIZE_N, +} from '../../shared/types/appTokens'; import { clampSceneGridSizeN, DEFAULT_SCENE_GRID, @@ -16,33 +23,74 @@ import { trapTypeLabelRu, } from '../../shared/types/sceneTraps'; import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView'; +import editorStyles from '../editor/EditorApp.module.css'; import { getDndApi } from '../shared/dndApi'; import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay'; import { RotatedImage } from '../shared/RotatedImage'; +import { useAppTokens } from '../shared/tokens/useAppTokens'; import { TrapGlyph } from '../shared/traps/TrapGlyph'; -import { Button } from '../shared/ui/controls'; +import { Button, Input, Select } from '../shared/ui/controls'; import { useAssetUrl } from '../shared/useAssetImageUrl'; +import { SceneTokenMarker } from './SceneTokenMarker'; import styles from './SceneEditorApp.module.css'; +import { TokenEditModal } from './TokenEditModal'; +import { TOKEN_DND_MIME, TokenTile } from './TokenTile'; + +function isTypingTarget(el: EventTarget | null): boolean { + if (!(el instanceof HTMLElement)) return false; + const tag = el.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; + return el.isContentEditable; +} type LocalView = { scale: number; ox: number; oy: number }; +type Selection = { kind: 'trap' | 'token'; id: string } | null; type DragMode = | { kind: 'pan'; lastX: number; lastY: number } - | { kind: 'move'; trapId: string; startNx: number; startNy: number; pointerNx: number; pointerNy: number } - | { kind: 'resize'; trapId: string; startSize: number; startDist: number } + | { kind: 'moveTrap'; trapId: string; startNx: number; startNy: number; pointerNx: number; pointerNy: number } + | { kind: 'resizeTrap'; trapId: string; startSize: number; startDist: number } + | { kind: 'moveToken'; tokenId: string; startNx: number; startNy: number; pointerNx: number; pointerNy: number } + | { kind: 'resizeToken'; tokenId: string; startSize: number; startDist: number } + | { + kind: 'rotateToken'; + tokenId: string; + startRotation: number; + startPointerAngle: number; + centerClientX: number; + centerClientY: number; + } | null; -function randomTrapId(): string { - return `trap_${Math.random().toString(36).slice(2, 10)}`; +function randomId(prefix: string): string { + return `${prefix}_${Math.random().toString(36).slice(2, 10)}`; +} + +function pointerAngleDeg(cx: number, cy: number, x: number, y: number): number { + return (Math.atan2(y - cy, x - cx) * 180) / Math.PI; +} + +function shortestAngleDelta(fromDeg: number, toDeg: number): number { + let d = toDeg - fromDeg; + while (d > 180) d -= 360; + while (d < -180) d += 360; + return d; } export function SceneEditorApp() { const api = getDndApi(); + const appTokens = useAppTokens(); const [session, setSession] = useState(null); - const [trapsOpen, setTrapsOpen] = useState(true); - const [gridOpen, setGridOpen] = useState(true); - const [selectedId, setSelectedId] = useState(null); + const [trapsOpen, setTrapsOpen] = useState(false); + const [gridOpen, setGridOpen] = useState(false); + const [tokensOpen, setTokensOpen] = useState(false); + const [tokenSearch, setTokenSearch] = useState(''); + const [tokenModal, setTokenModal] = useState<{ mode: 'create' } | { mode: 'edit'; tokenId: TokenId } | null>( + null, + ); + const [pendingDeleteToken, setPendingDeleteToken] = useState<{ id: TokenId; name: string } | null>(null); + const [selected, setSelected] = useState(null); const [view, setView] = useState({ scale: 1, ox: 0.5, oy: 0.5 }); const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>( null, @@ -50,6 +98,7 @@ export function SceneEditorApp() { const hostRef = useRef(null); const dragRef = useRef(null); const saveTrapsTimerRef = useRef(0); + const saveTokensTimerRef = useRef(0); const saveGridTimerRef = useRef(0); const spaceDownRef = useRef(false); @@ -59,23 +108,32 @@ export function SceneEditorApp() { const url = useAssetUrl(scene?.previewAssetId ?? null); const rot = scene?.previewRotationDeg ?? 0; const [localTraps, setLocalTraps] = useState([]); + const [localTokens, setLocalTokens] = useState([]); const [localGrid, setLocalGrid] = useState({ ...DEFAULT_SCENE_GRID }); const trapsRef = useRef([]); + const tokensRef = useRef([]); trapsRef.current = localTraps; + tokensRef.current = localTokens; useEffect(() => { setLocalTraps(scene?.traps ?? []); + setLocalTokens((scene?.tokens ?? []).filter((t) => appTokens.some((a) => a.id === t.tokenId))); setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID }); - setSelectedId(null); + setSelected(null); setView({ scale: 1, ox: 0.5, oy: 0.5 }); }, [sceneId, scene?.previewAssetId]); useEffect(() => { - // External updates (other windows) — sync when not dragging if (dragRef.current) return; setLocalTraps(scene?.traps ?? []); }, [scene?.traps]); + useEffect(() => { + if (dragRef.current) return; + const known = new Set(appTokens.map((t) => t.id)); + setLocalTokens((scene?.tokens ?? []).filter((t) => known.has(t.tokenId))); + }, [scene?.tokens, appTokens]); + useEffect(() => { if (dragRef.current) return; setLocalGrid(scene?.grid ?? { ...DEFAULT_SCENE_GRID }); @@ -91,7 +149,7 @@ export function SceneEditorApp() { }, [api]); const persistTraps = useCallback( - async (next: SceneTrap[]) => { + (next: SceneTrap[]) => { if (!sceneId) return; setLocalTraps(next); trapsRef.current = next; @@ -103,6 +161,19 @@ export function SceneEditorApp() { [api, sceneId], ); + const persistTokens = useCallback( + (next: SceneToken[]) => { + if (!sceneId) return; + setLocalTokens(next); + tokensRef.current = next; + if (saveTokensTimerRef.current) window.clearTimeout(saveTokensTimerRef.current); + saveTokensTimerRef.current = window.setTimeout(() => { + void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { tokens: next } }); + }, 120); + }, + [api, sceneId], + ); + const persistGrid = useCallback( (next: SceneGrid) => { if (!sceneId) return; @@ -117,15 +188,20 @@ export function SceneEditorApp() { useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { + if (isTypingTarget(e.target)) return; if (e.code === 'Space') spaceDownRef.current = true; - if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId && sceneId) { + if ((e.key === 'Delete' || e.key === 'Backspace') && selected && sceneId) { e.preventDefault(); - const next = trapsRef.current.filter((t) => t.id !== selectedId); - setSelectedId(null); - void persistTraps(next); + if (selected.kind === 'trap') { + persistTraps(trapsRef.current.filter((t) => t.id !== selected.id)); + } else { + persistTokens(tokensRef.current.filter((t) => t.id !== selected.id)); + } + setSelected(null); } }; const onKeyUp = (e: KeyboardEvent) => { + if (isTypingTarget(e.target)) return; if (e.code === 'Space') spaceDownRef.current = false; }; window.addEventListener('keydown', onKeyDown); @@ -134,7 +210,7 @@ export function SceneEditorApp() { window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keyup', onKeyUp); }; - }, [persistTraps, sceneId, selectedId]); + }, [persistTraps, persistTokens, sceneId, selected]); const hostToNorm = (clientX: number, clientY: number): { x: number; y: number } | null => { const host = hostRef.current; @@ -147,11 +223,6 @@ export function SceneEditorApp() { }; }; - const onWheel = (_e: React.WheelEvent) => { - // native non-passive listener handles zoom - }; - void onWheel; - const viewCamera = useMemo(() => view, [view]); useEffect(() => { @@ -190,37 +261,58 @@ export function SceneEditorApp() { const addTrapAt = (type: SceneTrapType, nx: number, ny: number) => { const trap: SceneTrap = { - id: asSceneTrapId(randomTrapId()), + id: asSceneTrapId(randomId('trap')), type, nx, ny, sizeN: DEFAULT_SCENE_TRAP_SIZE_N, - ...(type === 'freeform' ? { label: trapTypeLabelRu(type) } : {}), }; - const next = [...trapsRef.current, trap]; - setSelectedId(trap.id); - void persistTraps(next); + setSelected({ kind: 'trap', id: trap.id }); + persistTraps([...trapsRef.current, trap]); }; - const onPaletteDragStart = (type: SceneTrapType) => (e: React.DragEvent) => { - e.dataTransfer.setData('application/x-dnd-trap-type', type); - e.dataTransfer.effectAllowed = 'copy'; + const addTokenAt = (tokenId: TokenId, nx: number, ny: number) => { + const placement: SceneToken = { + id: asSceneTokenId(randomId('stoken')), + tokenId, + nx, + ny, + sizeN: DEFAULT_SCENE_TOKEN_SIZE_N, + rotationDeg: 0, + }; + setSelected({ kind: 'token', id: placement.id }); + persistTokens([...tokensRef.current, placement]); }; const onStageDrop = (e: React.DragEvent) => { e.preventDefault(); - const type = e.dataTransfer.getData('application/x-dnd-trap-type') as SceneTrapType; - if (!SCENE_TRAP_TYPES.includes(type)) return; const p = hostToNorm(e.clientX, e.clientY); if (!p) return; + const tokenId = e.dataTransfer.getData(TOKEN_DND_MIME); + if (tokenId) { + addTokenAt(asTokenId(tokenId), p.x, p.y); + return; + } + const type = e.dataTransfer.getData('application/x-dnd-trap-type') as SceneTrapType; + if (!SCENE_TRAP_TYPES.includes(type)) return; addTrapAt(type, p.x, p.y); }; const updateTrap = (id: string, patch: Partial) => { - const next = trapsRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t)); - void persistTraps(next); + persistTraps(trapsRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t))); }; + const updateToken = (id: string, patch: Partial) => { + persistTokens(tokensRef.current.map((t) => (t.id === id ? { ...t, ...patch } : t))); + }; + + const filteredTokens = useMemo(() => { + const q = tokenSearch.trim().toLowerCase(); + if (!q) return appTokens; + return appTokens.filter((t) => t.name.toLowerCase().includes(q)); + }, [appTokens, tokenSearch]); + + const editingToken = tokenModal?.mode === 'edit' ? appTokens.find((t) => t.id === tokenModal.tokenId) ?? null : null; const isImage = scene?.previewAssetType === 'image' && Boolean(url); return ( @@ -228,8 +320,9 @@ export function SceneEditorApp() {
@@ -364,7 +496,7 @@ export function SceneEditorApp() { }); return; } - if (d.kind === 'move') { + if (d.kind === 'moveTrap') { const p = hostToNorm(e.clientX, e.clientY); if (!p) return; updateTrap(d.trapId, { @@ -373,7 +505,7 @@ export function SceneEditorApp() { }); return; } - if (d.kind === 'resize') { + if (d.kind === 'resizeTrap') { const p = hostToNorm(e.clientX, e.clientY); const trap = trapsRef.current.find((t) => t.id === d.trapId); if (!p || !trap) return; @@ -382,6 +514,32 @@ export function SceneEditorApp() { updateTrap(d.trapId, { sizeN: Math.max(0.02, Math.min(0.45, d.startSize * ratio)), }); + return; + } + if (d.kind === 'moveToken') { + const p = hostToNorm(e.clientX, e.clientY); + if (!p) return; + updateToken(d.tokenId, { + nx: Math.max(0, Math.min(1, d.startNx + (p.x - d.pointerNx))), + ny: Math.max(0, Math.min(1, d.startNy + (p.y - d.pointerNy))), + }); + return; + } + if (d.kind === 'resizeToken') { + const p = hostToNorm(e.clientX, e.clientY); + const tok = tokensRef.current.find((t) => t.id === d.tokenId); + if (!p || !tok) return; + const dist = Math.hypot(p.x - tok.nx, p.y - tok.ny); + const ratio = d.startDist > 1e-6 ? dist / d.startDist : 1; + updateToken(d.tokenId, { + sizeN: clampSceneTokenSizeN(d.startSize * ratio), + }); + return; + } + if (d.kind === 'rotateToken') { + const ang = pointerAngleDeg(d.centerClientX, d.centerClientY, e.clientX, e.clientY); + const delta = shortestAngleDelta(d.startPointerAngle, ang); + updateToken(d.tokenId, { rotationDeg: d.startRotation + delta }); } }} onPointerUp={() => { @@ -399,27 +557,98 @@ export function SceneEditorApp() { onContentRectChange={setContentRect} /> + {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 ( + setSelected({ kind: 'token', id: tok.id })} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + persistTokens(tokensRef.current.filter((t) => t.id !== tok.id)); + setSelected((cur) => (cur?.kind === 'token' && cur.id === tok.id ? null : cur)); + }} + onMovePointerDown={(e) => { + if (spaceDownRef.current) return; + e.stopPropagation(); + const p = hostToNorm(e.clientX, e.clientY); + if (!p) return; + (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); + dragRef.current = { + kind: 'moveToken', + tokenId: tok.id, + startNx: tok.nx, + startNy: tok.ny, + pointerNx: p.x, + pointerNy: p.y, + }; + }} + onResizePointerDown={(e) => { + e.stopPropagation(); + e.preventDefault(); + const p = hostToNorm(e.clientX, e.clientY); + if (!p) return; + (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); + dragRef.current = { + kind: 'resizeToken', + tokenId: tok.id, + startSize: tok.sizeN, + startDist: Math.max(1e-4, Math.hypot(p.x - tok.nx, p.y - tok.ny)), + }; + }} + onRotatePointerDown={(e) => { + e.stopPropagation(); + e.preventDefault(); + const host = hostRef.current; + if (!host || !contentRect) return; + const r = host.getBoundingClientRect(); + const cx = r.left + contentRect.x + tok.nx * contentRect.w; + const cy = r.top + contentRect.y + tok.ny * contentRect.h; + (e.currentTarget as HTMLButtonElement).setPointerCapture(e.pointerId); + dragRef.current = { + kind: 'rotateToken', + tokenId: tok.id, + startRotation: tok.rotationDeg, + startPointerAngle: pointerAngleDeg(cx, cy, e.clientX, e.clientY), + centerClientX: cx, + centerClientY: cy, + }; + }} + /> + ); + }) + : null} {contentRect ? localTraps.map((trap) => { const minDim = Math.min(contentRect.w, contentRect.h); const sizePx = Math.max(16, trap.sizeN * minDim); const left = contentRect.x + trap.nx * contentRect.w; const top = contentRect.y + trap.ny * contentRect.h; - const selected = selectedId === trap.id; + const isSelected = selected?.kind === 'trap' && selected.id === trap.id; return (
{ if (e.button !== 0 || spaceDownRef.current) return; e.stopPropagation(); - setSelectedId(trap.id); + setSelected({ kind: 'trap', id: trap.id }); const p = hostToNorm(e.clientX, e.clientY); if (!p) return; (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); dragRef.current = { - kind: 'move', + kind: 'moveTrap', trapId: trap.id, startNx: trap.nx, startNy: trap.ny, @@ -429,8 +658,10 @@ export function SceneEditorApp() { }} > - {trap.label ?
{trap.label}
: null} - {selected ? ( + {trap.label && trap.label.trim().toLowerCase() !== 'свободная' ? ( +
{trap.label}
+ ) : null} + {isSelected ? (
{ @@ -440,7 +671,7 @@ export function SceneEditorApp() { if (!p) return; (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); dragRef.current = { - kind: 'resize', + kind: 'resizeTrap', trapId: trap.id, startSize: trap.sizeN, startDist: Math.max(1e-4, Math.hypot(p.x - trap.nx, p.y - trap.ny)), @@ -455,6 +686,57 @@ export function SceneEditorApp() {
)}
+ + t.name)} + onClose={() => setTokenModal(null)} + onSaved={() => setTokenModal(null)} + /> + + {pendingDeleteToken + ? createPortal( + <> + +
+
+ Удалить токен «{pendingDeleteToken.name}» из пула? Он также будет убран с текущей сцены. +
+
+ + +
+ + , + document.body, + ) + : null} ); } diff --git a/app/renderer/sceneEditor/SceneTokenMarker.tsx b/app/renderer/sceneEditor/SceneTokenMarker.tsx new file mode 100644 index 0000000..61eceb7 --- /dev/null +++ b/app/renderer/sceneEditor/SceneTokenMarker.tsx @@ -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 ( +
{ + if (e.button !== 0) return; + onSelect(); + onMovePointerDown(e); + }} + > + {url ? : null} + {selected ? ( + <> +
+ + + ) : null} +
+ ); +} diff --git a/app/renderer/sceneEditor/TokenEditModal.tsx b/app/renderer/sceneEditor/TokenEditModal.tsx new file mode 100644 index 0000000..7348d00 --- /dev/null +++ b/app/renderer/sceneEditor/TokenEditModal.tsx @@ -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(null); + const [localPreviewUrl, setLocalPreviewUrl] = useState(null); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(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( + <> + +
+ +
+
Название
+ + {!nameOk ?
Укажите название
: null} + {nameDup ?
Такое название уже есть
: null} +
+ +
+
Изображение
+
{ + 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 ? ( +
Отпустите, чтобы загрузить изображение
+ ) : null} + {previewSrc ? ( + + ) : ( +
Перетащите изображение или выберите файл
+ )} + +
+ {!hasImage ?
Нужно изображение
: null} +
+ + {error ?
{error}
: null} + +
+ + +
+ + , + document.body, + ); +} diff --git a/app/renderer/sceneEditor/TokenTile.tsx b/app/renderer/sceneEditor/TokenTile.tsx new file mode 100644 index 0000000..383cbae --- /dev/null +++ b/app/renderer/sceneEditor/TokenTile.tsx @@ -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(null); + const menuRef = useRef(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 ( +
{ + if (menuBtnRef.current?.contains(e.target as Node)) { + e.preventDefault(); + return; + } + e.dataTransfer.setData(TOKEN_DND_MIME, token.id); + e.dataTransfer.effectAllowed = 'copy'; + }} + title="Перетащите на карту" + > +
+ {url ? : null} +
+
+
{token.name}
+ +
+ {menu + ? createPortal( +
e.stopPropagation()} + > + + +
, + document.body, + ) + : null} +
+ ); +} + +export { TOKEN_DND_MIME }; diff --git a/app/renderer/shared/PresentationView.tsx b/app/renderer/shared/PresentationView.tsx index bb076b9..7c81564 100644 --- a/app/renderer/shared/PresentationView.tsx +++ b/app/renderer/shared/PresentationView.tsx @@ -17,6 +17,9 @@ import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay'; import { useNpcsOverlayState } from './npcs/useNpcsOverlayState'; import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost'; import { useSceneViewState } from './sceneView/useSceneViewState'; +import { SceneTokensOverlay } from './tokens/SceneTokensOverlay'; +import { useAppTokens } from './tokens/useAppTokens'; +import { useSceneTokensSession } from './tokens/useSceneTokensSession'; import { SceneTrapsOverlay } from './traps/SceneTrapsOverlay'; import { useSceneTrapsState } from './traps/useSceneTrapsState'; import styles from './PresentationView.module.css'; @@ -44,6 +47,8 @@ export function PresentationView({ const [sceneView] = useSceneViewState(); const [materialsOverlay] = useMaterialsOverlayState(); const [npcsOverlay] = useNpcsOverlayState(); + const appTokens = useAppTokens(); + const [sceneTokensSession] = useSceneTokensSession(); const [vp] = useVideoPlaybackState(); const videoElRef = useRef(null); const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>( @@ -164,6 +169,14 @@ export function PresentationView({ ) : null}
+ {scene?.previewAssetType === 'image' && contentRect ? ( + + ) : null} {scene?.previewAssetType === 'image' && contentRect ? ( 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 ( +
{ + 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 ? : null} +
+ ); +} + +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 ( +
+ {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 ( + + ); + })} +
+ ); +} diff --git a/app/renderer/shared/tokens/useAppTokens.ts b/app/renderer/shared/tokens/useAppTokens.ts new file mode 100644 index 0000000..e535c20 --- /dev/null +++ b/app/renderer/shared/tokens/useAppTokens.ts @@ -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([]); + + 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; +} diff --git a/app/renderer/shared/tokens/useSceneTokensSession.ts b/app/renderer/shared/tokens/useSceneTokensSession.ts new file mode 100644 index 0000000..dff5177 --- /dev/null +++ b/app/renderer/shared/tokens/useSceneTokensSession.ts @@ -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 }, +] { + const api = getDndApi(); + const [state, setState] = useState(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]; +} diff --git a/app/renderer/shared/tokens/useTokenImageUrl.ts b/app/renderer/shared/tokens/useTokenImageUrl.ts new file mode 100644 index 0000000..02e7c17 --- /dev/null +++ b/app/renderer/shared/tokens/useTokenImageUrl.ts @@ -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(); + +export function useTokenImageUrl(tokenId: TokenId | null | undefined): string | null { + const api = getDndApi(); + const [url, setUrl] = useState(() => + 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; +} diff --git a/app/renderer/shared/traps/SceneTrapsOverlay.tsx b/app/renderer/shared/traps/SceneTrapsOverlay.tsx index 6e97288..a5395ab 100644 --- a/app/renderer/shared/traps/SceneTrapsOverlay.tsx +++ b/app/renderer/shared/traps/SceneTrapsOverlay.tsx @@ -132,7 +132,9 @@ export function SceneTrapsOverlay({ } > - {trap.label ?
{trap.label}
: null} + {trap.label && trap.label.trim().toLowerCase() !== 'свободная' ? ( +
{trap.label}
+ ) : null} {activationFx?.trapId === trap.id && activationFx.kind === 'flash' ? (
) : null} diff --git a/app/renderer/shared/ui/Controls.module.css b/app/renderer/shared/ui/Controls.module.css index 772b153..07189aa 100644 --- a/app/renderer/shared/ui/Controls.module.css +++ b/app/renderer/shared/ui/Controls.module.css @@ -1,3 +1,10 @@ +.buttonHost { + display: inline-flex; + vertical-align: middle; + flex: 0 0 auto; + max-width: 100%; +} + .button { height: 34px; padding: 0 14px; @@ -89,11 +96,3 @@ outline: none; color: inherit; } - -select.input { - padding-right: 28px; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.25L6 7.75L9.5 4.25' stroke='rgba(255,255,255,0.72)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 8px center; - background-size: 12px 12px; -} diff --git a/app/renderer/shared/ui/Select.module.css b/app/renderer/shared/ui/Select.module.css new file mode 100644 index 0000000..7e90453 --- /dev/null +++ b/app/renderer/shared/ui/Select.module.css @@ -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; +} diff --git a/app/renderer/shared/ui/Select.tsx b/app/renderer/shared/ui/Select.tsx new file mode 100644 index 0000000..ca86094 --- /dev/null +++ b/app/renderer/shared/ui/Select.tsx @@ -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 ( + + + + ); +} + +export function Select({ + value, + options, + onChange, + disabled = false, + ariaLabel, + className, + placeholder = '—', +}: SelectProps) { + const listId = useId(); + const triggerRef = useRef(null); + const menuRef = useRef(null); + const [open, setOpen] = useState(false); + const [menuPos, setMenuPos] = useState(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(`[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( +
+ {options.map((opt, index) => { + const selectedOpt = opt.value === value; + const active = index === activeIndex; + return ( + + ); + })} +
, + document.body, + ) + : null; + + return ( +
+ + {menu} +
+ ); +} diff --git a/app/renderer/shared/ui/controls.tsx b/app/renderer/shared/ui/controls.tsx index 5533980..837c102 100644 --- a/app/renderer/shared/ui/controls.tsx +++ b/app/renderer/shared/ui/controls.tsx @@ -3,6 +3,8 @@ import { createPortal } from 'react-dom'; import styles from './Controls.module.css'; +export { Select, type SelectOption, type SelectProps } from './Select'; + type ButtonProps = { children: React.ReactNode; onClick?: () => void; @@ -94,22 +96,23 @@ export function Button({ ); // Disabled buttons don't receive mouse events — host span keeps tooltip usable. + // Single DOM root (not Fragment) so flex/grid parents don't mis-place the button. if (disabled && title) { return ( - <> + {button} {tip} - + ); } return ( - <> + {button} {tip} - + ); } @@ -117,15 +120,19 @@ type InputProps = { value: string; placeholder?: string; onChange: (v: string) => void; + autoFocus?: boolean; + onKeyDown?: (e: React.KeyboardEvent) => void; }; -export function Input({ value, placeholder, onChange }: InputProps) { +export function Input({ value, placeholder, onChange, autoFocus, onKeyDown }: InputProps) { return ( onChange(e.target.value)} + onKeyDown={onKeyDown} /> ); } diff --git a/app/shared/graph/sceneListOrder.test.ts b/app/shared/graph/sceneListOrder.test.ts index cd7e36a..e85ac67 100644 --- a/app/shared/graph/sceneListOrder.test.ts +++ b/app/shared/graph/sceneListOrder.test.ts @@ -23,6 +23,7 @@ function scene(id: string): Scene { previewRotationDeg: 0, darkenScene: false, traps: [], + tokens: [], grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' }, media: { videos: [], audios: [] }, settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true }, diff --git a/app/shared/graph/storylineExportImport.test.ts b/app/shared/graph/storylineExportImport.test.ts index 16bf60c..7240ead 100644 --- a/app/shared/graph/storylineExportImport.test.ts +++ b/app/shared/graph/storylineExportImport.test.ts @@ -28,6 +28,7 @@ function scene(id: string, title: string): Scene { previewRotationDeg: 0, darkenScene: false, traps: [], + tokens: [], grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' }, media: { videos: [], audios: [] }, settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false }, diff --git a/app/shared/graph/storylineExportImport.ts b/app/shared/graph/storylineExportImport.ts index 0d2f55d..b6dddcf 100644 --- a/app/shared/graph/storylineExportImport.ts +++ b/app/shared/graph/storylineExportImport.ts @@ -11,7 +11,7 @@ import type { SceneGraphNode, SceneId, } from '../types'; -import type { AssetId, NpcGroupId, ProjectId } from '../types/ids'; +import type { AssetId, NpcGroupId, ProjectId, TokenId } from '../types/ids'; import { asAssetId, asGraphNodeId, @@ -21,6 +21,7 @@ import { asNpcRelationId, asProjectId, asSceneId, + asTokenId, } from '../types/ids'; import { @@ -812,3 +813,35 @@ function remapNpcBinding( export function newExportBundleProjectId(): ProjectId { return asProjectId(`p_${generateId()}`); } + +/** Id app-токенов, реально стоящих на сценах проекта (для экспорта с линией). */ +export function collectTokenIdsFromProject(project: Project): TokenId[] { + const ids = new Set(); + 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, +): Project { + if (remap.size === 0) return project; + const scenes: Record = { ...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 }; +} diff --git a/app/shared/ipc/contracts.ts b/app/shared/ipc/contracts.ts index 7ddcc00..7aea41d 100644 --- a/app/shared/ipc/contracts.ts +++ b/app/shared/ipc/contracts.ts @@ -17,16 +17,21 @@ import type { NpcsOverlayState, Project, ProjectId, + AppToken, Scene, SceneDarknessEvent, SceneDarknessState, SceneGrid, SceneId, + SceneToken, + SceneTokensSessionEvent, + SceneTokensSessionState, SceneTrap, SceneTrapsEvent, SceneTrapsState, SceneViewEvent, SceneViewState, + TokenId, VideoPlaybackEvent, VideoPlaybackState, } from '../types'; @@ -164,6 +169,19 @@ export const ipcChannels = { dispatch: 'sceneView.dispatch', stateChanged: 'sceneView.stateChanged', }, + tokens: { + list: 'tokens.list', + upsert: 'tokens.upsert', + delete: 'tokens.delete', + pickImage: 'tokens.pickImage', + imageUrl: 'tokens.imageUrl', + stateChanged: 'tokens.stateChanged', + }, + sceneTokensSession: { + getState: 'sceneTokensSession.getState', + dispatch: 'sceneTokensSession.dispatch', + stateChanged: 'sceneTokensSession.stateChanged', + }, video: { getState: 'video.getState', dispatch: 'video.dispatch', @@ -232,6 +250,8 @@ export type IpcEventMap = { [ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState }; [ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState }; [ipcChannels.sceneView.stateChanged]: { state: SceneViewState }; + [ipcChannels.tokens.stateChanged]: { tokens: AppToken[] }; + [ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState }; [ipcChannels.video.stateChanged]: { state: VideoPlaybackState }; [ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot }; [ipcChannels.windows.multiWindowStateChanged]: { open: boolean }; @@ -651,6 +671,34 @@ export type IpcInvokeMap = { req: { event: SceneViewEvent }; res: { ok: true }; }; + [ipcChannels.tokens.list]: { + req: Record; + 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; + res: { canceled: true } | { canceled: false; filePath: string; previewDataUrl: string }; + }; + [ipcChannels.tokens.imageUrl]: { + req: { id: TokenId }; + res: { url: string | null }; + }; + [ipcChannels.sceneTokensSession.getState]: { + req: Record; + res: { state: SceneTokensSessionState }; + }; + [ipcChannels.sceneTokensSession.dispatch]: { + req: { event: SceneTokensSessionEvent }; + res: { ok: true }; + }; [ipcChannels.video.getState]: { req: Record; res: { state: VideoPlaybackState }; @@ -690,6 +738,8 @@ export type LegacyIpcEventMap = { [ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState }; [ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState }; [ipcChannels.sceneView.stateChanged]: { state: SceneViewState }; + [ipcChannels.tokens.stateChanged]: { tokens: AppToken[] }; + [ipcChannels.sceneTokensSession.stateChanged]: { state: SceneTokensSessionState }; [ipcChannels.video.stateChanged]: { state: VideoPlaybackState }; [ipcChannels.license.statusChanged]: Record; }; @@ -704,6 +754,7 @@ export type ScenePatch = { previewRotationDeg?: 0 | 90 | 180 | 270; darkenScene?: boolean; traps?: SceneTrap[]; + tokens?: SceneToken[]; grid?: SceneGrid; settings?: Partial; media?: Partial; diff --git a/app/shared/types/appTokens.ts b/app/shared/types/appTokens.ts new file mode 100644 index 0000000..3cc577c --- /dev/null +++ b/app/shared/types/appTokens.ts @@ -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; +}; + +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; + 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[]; +}; diff --git a/app/shared/types/domain.ts b/app/shared/types/domain.ts index e28904d..7881c0c 100644 --- a/app/shared/types/domain.ts +++ b/app/shared/types/domain.ts @@ -9,10 +9,11 @@ import type { SceneId, } from './ids'; import type { MaterialLegend } from './materialLegend'; +import type { SceneToken } from './appTokens'; import type { SceneGrid } from './sceneGrid'; import type { SceneTrap } from './sceneTraps'; -export const PROJECT_SCHEMA_VERSION = 9 as const; +export const PROJECT_SCHEMA_VERSION = 10 as const; /** Материал кампании: изображение, показываемое поверх сцены во время игры. */ export type ProjectMaterial = { @@ -163,6 +164,8 @@ export type Scene = { darkenScene: boolean; /** Ловушки на карте (только для image-превью); расстановка в проекте. */ traps: SceneTrap[]; + /** Неигровые токены на карте (ссылки на app-local пул). */ + tokens: SceneToken[]; /** Боевая сетка поверх превью (под ловушками/эффектами). */ grid: SceneGrid; media: SceneMediaRefs; diff --git a/app/shared/types/ids.ts b/app/shared/types/ids.ts index 36d0686..80fe343 100644 --- a/app/shared/types/ids.ts +++ b/app/shared/types/ids.ts @@ -8,6 +8,8 @@ export type MaterialId = Brand; export type NpcId = Brand; export type NpcRelationId = Brand; export type NpcGroupId = Brand; +export type TokenId = Brand; +export type SceneTokenId = Brand; export function asProjectId(value: string): ProjectId { return value as ProjectId; @@ -40,3 +42,11 @@ export function asNpcRelationId(value: string): NpcRelationId { export function asNpcGroupId(value: string): NpcGroupId { return value as NpcGroupId; } + +export function asTokenId(value: string): TokenId { + return value as TokenId; +} + +export function asSceneTokenId(value: string): SceneTokenId { + return value as SceneTokenId; +} diff --git a/app/shared/types/index.ts b/app/shared/types/index.ts index 02c6891..c851cf2 100644 --- a/app/shared/types/index.ts +++ b/app/shared/types/index.ts @@ -1,3 +1,4 @@ +export * from './appTokens'; export * from './domain'; export * from './effects'; export * from './ids'; diff --git a/app/shared/types/sceneTraps.ts b/app/shared/types/sceneTraps.ts index da0a7bc..0a52987 100644 --- a/app/shared/types/sceneTraps.ts +++ b/app/shared/types/sceneTraps.ts @@ -114,7 +114,7 @@ export function trapTypeLabelRu(type: SceneTrapType): string { case 'laser': return 'Лазер'; case 'freeform': - return 'Свободная'; + return 'Метка'; default: { const _x: never = type; return String(_x);