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
+21 -16
View File
@@ -328,6 +328,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);
@@ -335,6 +339,7 @@
padding: 16px;
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr);
}
.modalHeader {
@@ -364,6 +369,17 @@
.fieldGrid {
display: grid;
gap: 6px;
min-width: 0;
}
.fieldGroup {
display: grid;
gap: 6px;
min-width: 0;
}
.fieldGroupSpaced {
margin-top: 10px;
}
.fieldLabel {
@@ -377,22 +393,6 @@
font-size: var(--text-xs);
}
.selectInput {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
padding-right: 28px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background-color: var(--bg0);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.25L6 7.75L9.5 4.25' stroke='rgba(255,255,255,0.72)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 12px 12px;
color: var(--text0);
font: inherit;
}
.rowFlex {
display: flex;
gap: 8px;
@@ -402,8 +402,13 @@
.modalFooter {
display: flex;
justify-content: flex-end;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-top: 4px;
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.licenseBlockTitle {
+10 -9
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { Button } from '../shared/ui/controls';
import { Button, Select } from '../shared/ui/controls';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
@@ -74,19 +74,20 @@ export function FoundryImportModal({ open, pickSource, onClose, onImport }: Foun
<div className={styles.muted}>{t('foundryImport.hint')}</div>
<div className={styles.fieldLabel}>{t('foundryImport.sourceType')}</div>
<select
className={styles.selectInput}
<Select
value={mode}
disabled={submitting}
onChange={(e) => {
setMode(e.target.value as 'folder' | 'archive');
ariaLabel={t('foundryImport.sourceType')}
options={[
{ value: 'folder', label: t('foundryImport.folder') },
{ value: 'archive', label: t('foundryImport.archive') },
]}
onChange={(next) => {
setMode(next as 'folder' | 'archive');
setPicked(null);
setError(null);
}}
>
<option value="folder">{t('foundryImport.folder')}</option>
<option value="archive">{t('foundryImport.archive')}</option>
</select>
/>
<div className={styles.fieldLabel}>{t('foundryImport.source')}</div>
<div className={styles.importFileRow}>
+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>
@@ -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),