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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-27 11:04:12 +08:00
parent bdeb64e356
commit f270812219
44 changed files with 2617 additions and 341 deletions
+57 -58
View File
@@ -17,7 +17,7 @@ import {
type StorylineSelection,
} from '../../shared/graph/storylineExportImport';
import type { Project, ProjectId, SceneId } from '../../shared/types';
import { Button } from '../shared/ui/controls';
import { Button, Select } from '../shared/ui/controls';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
@@ -135,18 +135,16 @@ export function ExportProjectModal({
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>{t('export.project')}</div>
<select
className={styles.selectInput}
<Select
value={projectId ?? ''}
onChange={(e) => setProjectId((e.target.value as ProjectId) || null)}
onChange={(next) => setProjectId((next as ProjectId) || null)}
disabled={projects.length === 0}
>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.fileName})
</option>
))}
</select>
ariaLabel={t('export.project')}
options={projects.map((p) => ({
value: p.id,
label: `${p.name} (${p.fileName})`,
}))}
/>
<div className={styles.fieldLabel}>{t('storyline.section')}</div>
{loadingStorylines ? (
@@ -309,43 +307,46 @@ export function ImportSourceModal({
<div className={styles.fieldGrid}>
{canImportFromProject ? (
<>
<div className={styles.fieldGroup}>
<div className={styles.fieldLabel}>{t('importSource.type')}</div>
<select
className={styles.selectInput}
<Select
value={importKind}
onChange={(e) => setImportKind(e.target.value as 'project' | 'file')}
onChange={(next) => setImportKind(next as 'project' | 'file')}
disabled={submitting}
>
<option value="project">{t('importSource.fromProject')}</option>
<option value="file">{t('importSource.fromFile')}</option>
</select>
</>
ariaLabel={t('importSource.type')}
options={[
{ value: 'project', label: t('importSource.fromProject') },
{ value: 'file', label: t('importSource.fromFile') },
]}
/>
</div>
) : (
<div className={styles.muted}>{t('importSource.fileOnlyHint')}</div>
)}
{importKind === 'project' && canImportFromProject ? (
<>
<div className={[styles.fieldGroup, canImportFromProject ? styles.fieldGroupSpaced : ''].filter(Boolean).join(' ')}>
<div className={styles.fieldLabel}>{t('importSource.project')}</div>
<select
className={styles.selectInput}
<Select
value={sourceProjectId ?? ''}
onChange={(e) => setSourceProjectId((e.target.value as ProjectId) || null)}
onChange={(next) => setSourceProjectId((next as ProjectId) || null)}
disabled={availableProjects.length === 0 || submitting}
>
{availableProjects.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.fileName})
</option>
))}
</select>
ariaLabel={t('importSource.project')}
options={availableProjects.map((p) => ({
value: p.id,
label: `${p.name} (${p.fileName})`,
}))}
/>
{availableProjects.length === 0 ? (
<div className={styles.muted}>{t('importSource.noOtherProjects')}</div>
) : null}
</>
</div>
) : (
<>
<div
className={[styles.fieldGroup, canImportFromProject ? styles.fieldGroupSpaced : '']
.filter(Boolean)
.join(' ')}
>
<div className={styles.fieldLabel}>{t('importSource.file')}</div>
<div className={styles.importFileRow}>
<Button
@@ -366,7 +367,7 @@ export function ImportSourceModal({
{pickedFile ? pickedFile.name : t('importSource.noFileSelected')}
</span>
</div>
</>
</div>
)}
</div>
@@ -572,24 +573,23 @@ export function SceneConflictModal({ open, conflicts, onClose, onConfirm }: Scen
{conflicts.map((c) => (
<div key={c.sourceSceneId} className={styles.conflictRow}>
<div className={styles.conflictTitle}>{c.sourceTitle}</div>
<select
className={styles.selectInput}
<Select
value={choices[c.sourceSceneId] ?? 'create'}
onChange={(e) => {
const v = e.target.value;
onChange={(v) => {
setChoices((prev) => ({
...prev,
[c.sourceSceneId]: v === 'create' ? 'create' : (v as SceneId),
}));
}}
>
<option value="create">{t('importStoryline.createNewScene')}</option>
{c.matches.map((m) => (
<option key={m.sceneId} value={m.sceneId}>
{t('importStoryline.useExistingScene', { title: m.title })}
</option>
))}
</select>
ariaLabel={c.sourceTitle}
options={[
{ value: 'create', label: t('importStoryline.createNewScene') },
...c.matches.map((m) => ({
value: m.sceneId,
label: t('importStoryline.useExistingScene', { title: m.title }),
})),
]}
/>
</div>
))}
</div>
@@ -687,24 +687,23 @@ function NpcConflictModalBody({
{conflicts.map((c) => (
<div key={c.sourceNpcId} className={styles.conflictRow}>
<div className={styles.conflictTitle}>{c.sourceName}</div>
<select
className={styles.selectInput}
<Select
value={choices[c.sourceNpcId] ?? 'create'}
onChange={(e) => {
const v = e.target.value;
onChange={(v) => {
setChoices((prev) => ({
...prev,
[c.sourceNpcId]: v === 'create' ? 'create' : v,
}));
}}
>
<option value="create">{t('importStoryline.createNewNpc')}</option>
{c.matches.map((m) => (
<option key={m.npcId} value={m.npcId}>
{t('importStoryline.useExistingNpc', { name: m.name })}
</option>
))}
</select>
ariaLabel={c.sourceName}
options={[
{ value: 'create', label: t('importStoryline.createNewNpc') },
...c.matches.map((m) => ({
value: m.npcId,
label: t('importStoryline.useExistingNpc', { name: m.name }),
})),
]}
/>
</div>
))}
</div>