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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user