Merge branch 'users' into main

Players library, circular NPC tokens, disposition types; keep main overlay/release fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-04 08:03:22 +08:00
69 changed files with 5943 additions and 327 deletions
+141 -51
View File
@@ -55,6 +55,8 @@ import type { HelpSectionId } from './help/helpSections';
import { useEditorI18n } from './i18n/EditorI18nContext';
import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals';
import { MaterialEditModal, MaterialsManagerModal } from './MaterialsModals';
import { PlayersManagerModal } from './PlayersModals';
import { LaunchPlayersModal } from './LaunchPlayersModal';
import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml';
import { SceneDescriptionModal } from './SceneDescriptionModal';
import type { ProjectNoticeCode } from './state/projectState';
@@ -134,9 +136,9 @@ export function EditorApp() {
const [importConflictsOpen, setImportConflictsOpen] = useState(false);
const [importConflicts, setImportConflicts] = useState<ReturnType<typeof computeImportConflicts>>([]);
const [importNpcConflictsOpen, setImportNpcConflictsOpen] = useState(false);
const [importNpcConflicts, setImportNpcConflicts] = useState<
ReturnType<typeof computeNpcImportConflicts>
>([]);
const [importNpcConflicts, setImportNpcConflicts] = useState<ReturnType<typeof computeNpcImportConflicts>>(
[],
);
const [pendingImportSelections, setPendingImportSelections] = useState<StorylineSelection[]>([]);
const [pendingSceneResolutions, setPendingSceneResolutions] = useState<SceneImportResolution[]>([]);
const [importReportOpen, setImportReportOpen] = useState(false);
@@ -154,6 +156,11 @@ export function EditorApp() {
const licenseActive = licenseSnap?.active === true;
const [appNotice, setAppNotice] = useState<{ title?: string; message: string } | null>(null);
const [materialsManagerOpen, setMaterialsManagerOpen] = useState(false);
const [playersManagerOpen, setPlayersManagerOpen] = useState(false);
const [launchPlayersOpen, setLaunchPlayersOpen] = useState(false);
const [runMenuOpen, setRunMenuOpen] = useState(false);
const [runMenuPos, setRunMenuPos] = useState<{ left: number; top: number } | null>(null);
const runSplitRef = useRef<HTMLDivElement | null>(null);
const [materialEdit, setMaterialEdit] = useState<ProjectMaterial | null | 'new'>(null);
const onProjectNotice = useCallback(
(code: ProjectNoticeCode) => {
@@ -351,13 +358,17 @@ export function EditorApp() {
const runHelpTooltip = !licenseActive ? t('top.afterLicense') : t('top.setStartScene');
const launchFromGraphNode = useCallback(
(graphNodeId: GraphNodeId) => {
(graphNodeId: GraphNodeId, playerIds?: string[]) => {
if (!licenseActive || launching) return;
setLaunching(true);
setRunMenuOpen(false);
setLaunchPlayersOpen(false);
void (async () => {
try {
await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId });
await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {});
await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {
...(playerIds && playerIds.length > 0 ? { playerIds } : {}),
});
} catch {
setLaunching(false);
}
@@ -461,6 +472,23 @@ export function EditorApp() {
return () => window.removeEventListener('mousedown', onDown);
}, [aboutMenuOpen]);
useEffect(() => {
if (!runMenuOpen) return;
const r = runSplitRef.current?.getBoundingClientRect() ?? null;
queueMicrotask(() => {
if (r) setRunMenuPos({ left: r.right, top: r.bottom + 8 });
else setRunMenuPos(null);
});
const onDown = (e: MouseEvent) => {
const t = e.target as HTMLElement | null;
if (!t) return;
if (t.closest('[data-runmenu-root="1"]')) return;
setRunMenuOpen(false);
};
window.addEventListener('mousedown', onDown);
return () => window.removeEventListener('mousedown', onDown);
}, [runMenuOpen]);
useEffect(() => {
let off: (() => void) | null = null;
void (async () => {
@@ -546,12 +574,7 @@ export function EditorApp() {
sceneResolutions,
npcResolutions,
)
: await actions.mergeImportZip(
importPeek.filePath!,
selections,
sceneResolutions,
npcResolutions,
);
: await actions.mergeImportZip(importPeek.filePath!, selections, sceneResolutions, npcResolutions);
setImportReport(report);
setImportReportOpen(true);
clearImportFlow();
@@ -571,12 +594,7 @@ export function EditorApp() {
setImportNpcConflictsOpen(true);
return;
}
const npcResolutions = buildNpcResolutionsForImport(
state.project,
importPeek.sourceProject,
[],
[],
);
const npcResolutions = buildNpcResolutionsForImport(state.project, importPeek.sourceProject, [], []);
void runStorylineMerge(selections, sceneResolutions, npcResolutions);
},
[importPeek, runStorylineMerge, state.project],
@@ -724,9 +742,7 @@ export function EditorApp() {
{t('scenes.batchProgress')
.replace('{current}', String(state.sceneBatchImport.current))
.replace('{total}', String(state.sceneBatchImport.total))}
{state.sceneBatchImport.fileName
? `: ${state.sceneBatchImport.fileName}`
: ''}
{state.sceneBatchImport.fileName ? `: ${state.sceneBatchImport.fileName}` : ''}
</div>
<div>
{Math.round(
@@ -825,6 +841,23 @@ export function EditorApp() {
{t('top.file')}
</button>
) : null}
<button
type="button"
data-testid="players-header-btn"
className={styles.fileMenuTrigger}
disabled={!licenseActive}
title={!licenseActive ? t('top.afterLicense') : undefined}
onClick={() => {
if (!licenseActive) return;
setProjectMenuOpen(false);
setSettingsMenuOpen(false);
setAboutMenuOpen(false);
setFileMenuOpen(false);
setPlayersManagerOpen(true);
}}
>
{t('top.players')}
</button>
</div>
<div className={styles.flex1} />
{appVersionText ? (
@@ -835,16 +868,55 @@ export function EditorApp() {
<div className={styles.headerActions}>
{state.project ? (
<>
<Button
variant="primary"
disabled={runDisabled || launching}
onClick={() => {
if (!licenseActive || !graphStartGraphNodeId || launching) return;
launchFromGraphNode(graphStartGraphNodeId);
}}
<div
ref={runSplitRef}
className={styles.splitRun}
data-runmenu-root="1"
aria-disabled={runDisabled || launching ? true : undefined}
>
{t('top.run')}
</Button>
<button
type="button"
className={styles.splitRunMain}
disabled={runDisabled || launching}
data-testid="run-main-btn"
onClick={() => {
if (!licenseActive || !graphStartGraphNodeId || launching) return;
launchFromGraphNode(graphStartGraphNodeId);
}}
>
{t('top.run')}
</button>
<span className={styles.splitRunDivider} aria-hidden />
<button
type="button"
className={[
styles.splitRunChevron,
runMenuOpen ? styles.splitRunChevronOpen : '',
]
.filter(Boolean)
.join(' ')}
disabled={runDisabled || launching}
aria-label={t('top.runMenuAria')}
aria-haspopup="menu"
aria-expanded={runMenuOpen}
data-testid="run-menu-btn"
onClick={() => {
if (runDisabled || launching) return;
setRunMenuOpen((v) => !v);
}}
>
<svg className={styles.splitRunChevronSvg} viewBox="0 0 12 12" aria-hidden>
<path
d="M2.5 4.25 L6 7.75 L9.5 4.25"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
{runDisabled ? (
<Button
variant="ghost"
@@ -900,9 +972,7 @@ export function EditorApp() {
scene={s}
reorderEnabled={sceneListReorderEnabled}
listDragActive={draggingListSceneId !== null}
dropPlace={
sceneListDrop?.targetId === s.id ? sceneListDrop.place : null
}
dropPlace={sceneListDrop?.targetId === s.id ? sceneListDrop.place : null}
isDragging={draggingListSceneId === s.id}
onSelect={() => {
setSelectedGraphNodeId(null);
@@ -925,9 +995,7 @@ export function EditorApp() {
return;
}
setSceneListDrop((cur) =>
cur?.targetId === targetId && cur.place === place
? cur
: { targetId, place },
cur?.targetId === targetId && cur.place === place ? cur : { targetId, place },
);
}}
onDropListReorder={(draggedId, targetId, place) => {
@@ -1552,6 +1620,39 @@ export function EditorApp() {
}}
/>
<CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(false)} />
<PlayersManagerModal open={playersManagerOpen} onClose={() => setPlayersManagerOpen(false)} />
<LaunchPlayersModal
open={launchPlayersOpen}
onClose={() => setLaunchPlayersOpen(false)}
onConfirm={(playerIds) => {
if (!graphStartGraphNodeId) return;
launchFromGraphNode(graphStartGraphNodeId, playerIds);
}}
/>
{runMenuOpen && runMenuPos
? createPortal(
<div
data-runmenu-root="1"
className={styles.fileMenu}
style={{ left: runMenuPos.left, top: runMenuPos.top, transform: 'translateX(-100%)' }}
role="menu"
>
<button
type="button"
className={styles.fileMenuItem}
role="menuitem"
data-testid="run-with-players-item"
onClick={() => {
setRunMenuOpen(false);
setLaunchPlayersOpen(true);
}}
>
{t('top.runWithPlayers')}
</button>
</div>,
document.body,
)
: null}
<MaterialsManagerModal
open={materialsManagerOpen}
materials={state.project?.materials ?? []}
@@ -2379,9 +2480,7 @@ function CampaignInspector({
onDragOver={audioDrop.onDragOver}
onDrop={audioDrop.onDrop}
>
{audioDrop.dragOver ? (
<div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div>
) : null}
{audioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
<div className={styles.audioDropEmpty}>
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
@@ -2548,10 +2647,7 @@ function SceneInspector({
{sideStoryStartNodes.map((gn) => (
<div key={gn.id}>
<div className={styles.labelSm}>{t('scene.sideStoryLineTitle')}</div>
<Input
value={gn.sideStoryLineTitle}
onChange={(v) => onSideStoryLineTitleChange(gn.id, v)}
/>
<Input value={gn.sideStoryLineTitle} onChange={(v) => onSideStoryLineTitleChange(gn.id, v)} />
<div className={styles.spacer8} />
</div>
))}
@@ -2567,9 +2663,7 @@ function SceneInspector({
onDragOver={previewDrop.onDragOver}
onDrop={previewDrop.onDrop}
>
{previewDrop.dragOver ? (
<div className={styles.dropHintOverlay}>{t('drop.hintPreview')}</div>
) : null}
{previewDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintPreview')}</div> : null}
{previewUrl && previewAssetType === 'image' ? (
<div className={styles.previewFill}>
<RotatedImage
@@ -2668,9 +2762,7 @@ function SceneInspector({
onDragOver={sceneAudioDrop.onDragOver}
onDrop={sceneAudioDrop.onDrop}
>
{sceneAudioDrop.dragOver ? (
<div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div>
) : null}
{sceneAudioDrop.dragOver ? <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> : null}
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
<div className={styles.audioDropEmpty}>
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
@@ -2983,9 +3075,7 @@ function SceneListCard({
</button>
</div>
<div className={styles.fieldGrid}>
<div className={styles.muted}>
{t('confirmDeleteScene.body', { name: scene.title })}
</div>
<div className={styles.muted}>{t('confirmDeleteScene.body', { name: scene.title })}</div>
</div>
<div className={styles.modalFooter}>
<Button onClick={() => setPendingDelete(false)}>{t('common.cancel')}</Button>