feat(tokens): app-local non-player tokens with session moves and UI polish
Add token library/placements, keep play-time moves for the session, lock presentation interactions, and fix export/import modal layout plus freeform trap label. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import type { SceneTokensSessionEvent, SceneTokensSessionState } from '../../shared/types';
|
||||
|
||||
function emptyState(): SceneTokensSessionState {
|
||||
return {
|
||||
revision: 1,
|
||||
byPlacementId: {},
|
||||
};
|
||||
}
|
||||
|
||||
export class SceneTokensSessionStore {
|
||||
private state: SceneTokensSessionState = emptyState();
|
||||
|
||||
getState(): SceneTokensSessionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
reset(): SceneTokensSessionState {
|
||||
if (Object.keys(this.state.byPlacementId).length === 0) return this.state;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: {},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: SceneTokensSessionEvent): SceneTokensSessionState {
|
||||
switch (event.kind) {
|
||||
case 'clear':
|
||||
return this.reset();
|
||||
case 'move': {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return this.state;
|
||||
const nx = Math.max(0, Math.min(1, event.nx));
|
||||
const ny = Math.max(0, Math.min(1, event.ny));
|
||||
if (!Number.isFinite(nx) || !Number.isFinite(ny)) return this.state;
|
||||
const prev = this.state.byPlacementId[placementId];
|
||||
if (prev && prev.nx === nx && prev.ny === ny) return this.state;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
byPlacementId: {
|
||||
...this.state.byPlacementId,
|
||||
[placementId]: { nx, ny },
|
||||
},
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
default: {
|
||||
const _exhaustive: never = event;
|
||||
void _exhaustive;
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { AppToken, TokenId } from '../../shared/types';
|
||||
import { asTokenId } from '../../shared/types/ids';
|
||||
import { optimizeImageBufferVisuallyLossless } from '../project/optimizeImageImport.lib.mjs';
|
||||
|
||||
type TokensManifest = {
|
||||
tokens: AppToken[];
|
||||
};
|
||||
|
||||
function mimeFromExt(ext: string): string {
|
||||
const e = ext.toLowerCase();
|
||||
if (e === '.png') return 'image/png';
|
||||
if (e === '.jpg' || e === '.jpeg') return 'image/jpeg';
|
||||
if (e === '.webp') return 'image/webp';
|
||||
if (e === '.gif') return 'image/gif';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
function safeFileBase(name: string): string {
|
||||
const base = name.replace(/[^\w.\-]+/gu, '_').slice(0, 48);
|
||||
return base || 'token';
|
||||
}
|
||||
|
||||
function randomTokenId(): TokenId {
|
||||
return asTokenId(`token_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
|
||||
}
|
||||
|
||||
export class TokensStore {
|
||||
private readonly rootDir: string;
|
||||
private readonly filesDir: string;
|
||||
private readonly manifestPath: string;
|
||||
private tokens: AppToken[] = [];
|
||||
private loaded = false;
|
||||
|
||||
constructor(userData: string) {
|
||||
this.rootDir = path.join(userData, 'tokens');
|
||||
this.filesDir = path.join(this.rootDir, 'files');
|
||||
this.manifestPath = path.join(this.rootDir, 'tokens.json');
|
||||
}
|
||||
|
||||
async ensureLoaded(): Promise<void> {
|
||||
if (this.loaded) return;
|
||||
await fs.mkdir(this.filesDir, { recursive: true });
|
||||
try {
|
||||
const raw = await fs.readFile(this.manifestPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as TokensManifest;
|
||||
this.tokens = Array.isArray(parsed.tokens)
|
||||
? parsed.tokens.filter(
|
||||
(t): t is AppToken =>
|
||||
Boolean(t) &&
|
||||
typeof t.id === 'string' &&
|
||||
typeof t.name === 'string' &&
|
||||
typeof t.imageRelPath === 'string' &&
|
||||
typeof t.sha256 === 'string',
|
||||
)
|
||||
: [];
|
||||
} catch {
|
||||
this.tokens = [];
|
||||
await this.persist();
|
||||
}
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
list(): AppToken[] {
|
||||
return [...this.tokens];
|
||||
}
|
||||
|
||||
getById(id: TokenId): AppToken | null {
|
||||
return this.tokens.find((t) => t.id === id) ?? null;
|
||||
}
|
||||
|
||||
findBySha256(sha256: string): AppToken | null {
|
||||
return this.tokens.find((t) => t.sha256 === sha256) ?? null;
|
||||
}
|
||||
|
||||
getImageReadInfo(id: TokenId): { absPath: string; mime: string } | null {
|
||||
const token = this.getById(id);
|
||||
if (!token) return null;
|
||||
const absPath = path.join(this.rootDir, token.imageRelPath);
|
||||
return { absPath, mime: mimeFromExt(path.extname(token.imageRelPath)) };
|
||||
}
|
||||
|
||||
getImageUrl(id: TokenId): string | null {
|
||||
if (!this.getImageReadInfo(id)) return null;
|
||||
return `dnd://token?id=${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
absPathForRel(relPath: string): string {
|
||||
return path.join(this.rootDir, relPath);
|
||||
}
|
||||
|
||||
async upsert(input: {
|
||||
id?: TokenId | null;
|
||||
name: string;
|
||||
filePath?: string | null;
|
||||
}): Promise<AppToken> {
|
||||
await this.ensureLoaded();
|
||||
const name = input.name.trim();
|
||||
if (!name) throw new Error('Token name is required');
|
||||
|
||||
const existing = input.id ? this.getById(input.id) : null;
|
||||
if (input.id && !existing) throw new Error('Token not found');
|
||||
if (!existing && !input.filePath) throw new Error('Token image is required');
|
||||
|
||||
let imageRelPath = existing?.imageRelPath ?? '';
|
||||
let sha256 = existing?.sha256 ?? '';
|
||||
|
||||
if (input.filePath) {
|
||||
let buf = await fs.readFile(input.filePath);
|
||||
try {
|
||||
const opt = await optimizeImageBufferVisuallyLossless(buf);
|
||||
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
|
||||
} catch {
|
||||
/* keep original */
|
||||
}
|
||||
sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
||||
const id = existing?.id ?? randomTokenId();
|
||||
const ext = path.extname(input.filePath) || '.png';
|
||||
const fileName = `${id}_${safeFileBase(name)}${ext.toLowerCase()}`;
|
||||
imageRelPath = path.join('files', fileName).replace(/\\/gu, '/');
|
||||
const abs = path.join(this.rootDir, imageRelPath);
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||
await fs.writeFile(abs, buf);
|
||||
if (existing && existing.imageRelPath !== imageRelPath) {
|
||||
try {
|
||||
await fs.unlink(path.join(this.rootDir, existing.imageRelPath));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const token: AppToken = {
|
||||
id,
|
||||
name,
|
||||
imageRelPath,
|
||||
sha256,
|
||||
};
|
||||
if (existing) {
|
||||
this.tokens = this.tokens.map((t) => (t.id === id ? token : t));
|
||||
} else {
|
||||
this.tokens = [...this.tokens, token];
|
||||
}
|
||||
await this.persist();
|
||||
return token;
|
||||
}
|
||||
|
||||
const token: AppToken = {
|
||||
id: existing!.id,
|
||||
name,
|
||||
imageRelPath,
|
||||
sha256,
|
||||
};
|
||||
this.tokens = this.tokens.map((t) => (t.id === token.id ? token : t));
|
||||
await this.persist();
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Импорт токена из внешнего файла (storyline zip) с заданным id или дедупом по sha256. */
|
||||
async importFromFile(input: {
|
||||
preferredId: TokenId;
|
||||
name: string;
|
||||
absFilePath: string;
|
||||
sha256?: string;
|
||||
}): Promise<{ token: AppToken; remappedFrom: TokenId }> {
|
||||
await this.ensureLoaded();
|
||||
let buf = await fs.readFile(input.absFilePath);
|
||||
const sha256 =
|
||||
input.sha256 ?? crypto.createHash('sha256').update(buf).digest('hex');
|
||||
const existingByHash = this.findBySha256(sha256);
|
||||
if (existingByHash) {
|
||||
return { token: existingByHash, remappedFrom: input.preferredId };
|
||||
}
|
||||
const existingById = this.getById(input.preferredId);
|
||||
const id = existingById ? randomTokenId() : input.preferredId;
|
||||
try {
|
||||
const opt = await optimizeImageBufferVisuallyLossless(buf);
|
||||
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
|
||||
} catch {
|
||||
/* keep */
|
||||
}
|
||||
const ext = path.extname(input.absFilePath) || '.png';
|
||||
const fileName = `${id}_${safeFileBase(input.name)}${ext.toLowerCase()}`;
|
||||
const imageRelPath = path.join('files', fileName).replace(/\\/gu, '/');
|
||||
const abs = path.join(this.rootDir, imageRelPath);
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||
await fs.writeFile(abs, buf);
|
||||
const token: AppToken = {
|
||||
id,
|
||||
name: input.name.trim() || 'Token',
|
||||
imageRelPath,
|
||||
sha256: crypto.createHash('sha256').update(buf).digest('hex'),
|
||||
};
|
||||
this.tokens = [...this.tokens, token];
|
||||
await this.persist();
|
||||
return { token, remappedFrom: input.preferredId };
|
||||
}
|
||||
|
||||
async delete(id: TokenId): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
const existing = this.getById(id);
|
||||
if (!existing) return;
|
||||
this.tokens = this.tokens.filter((t) => t.id !== id);
|
||||
await this.persist();
|
||||
try {
|
||||
await fs.unlink(path.join(this.rootDir, existing.imageRelPath));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Упаковать выбранные токены в каталог экспорта (`app-tokens/`). */
|
||||
async packForExport(tokenIds: string[], exportRoot: string): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
const idSet = new Set(tokenIds);
|
||||
const selected = this.tokens.filter((t) => idSet.has(t.id));
|
||||
if (selected.length === 0) return;
|
||||
const outDir = path.join(exportRoot, 'app-tokens');
|
||||
const filesOut = path.join(outDir, 'files');
|
||||
await fs.mkdir(filesOut, { recursive: true });
|
||||
const packed: AppToken[] = [];
|
||||
for (const t of selected) {
|
||||
const src = path.join(this.rootDir, t.imageRelPath);
|
||||
const base = path.basename(t.imageRelPath);
|
||||
const destRel = path.join('files', base).replace(/\\/gu, '/');
|
||||
await fs.copyFile(src, path.join(outDir, destRel));
|
||||
packed.push({ ...t, imageRelPath: destRel });
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(outDir, 'tokens.json'),
|
||||
`${JSON.stringify({ tokens: packed }, null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Импорт из `sourceCache/app-tokens/`.
|
||||
* @returns map oldTokenId → newTokenId
|
||||
*/
|
||||
async importFromExportDir(sourceCache: string): Promise<Map<string, string>> {
|
||||
await this.ensureLoaded();
|
||||
const remap = new Map<string, string>();
|
||||
const manifestPath = path.join(sourceCache, 'app-tokens', 'tokens.json');
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(manifestPath, 'utf8');
|
||||
} catch {
|
||||
return remap;
|
||||
}
|
||||
let parsed: TokensManifest;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as TokensManifest;
|
||||
} catch {
|
||||
return remap;
|
||||
}
|
||||
const list = Array.isArray(parsed.tokens) ? parsed.tokens : [];
|
||||
for (const t of list) {
|
||||
if (!t?.id || !t.imageRelPath) continue;
|
||||
const abs = path.join(sourceCache, 'app-tokens', t.imageRelPath);
|
||||
const { token, remappedFrom } = await this.importFromFile({
|
||||
preferredId: asTokenId(t.id),
|
||||
name: typeof t.name === 'string' ? t.name : 'Token',
|
||||
absFilePath: abs,
|
||||
sha256: typeof t.sha256 === 'string' ? t.sha256 : undefined,
|
||||
});
|
||||
remap.set(remappedFrom, token.id);
|
||||
}
|
||||
return remap;
|
||||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
await fs.mkdir(this.rootDir, { recursive: true });
|
||||
const payload: TokensManifest = { tokens: this.tokens };
|
||||
await fs.writeFile(this.manifestPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user