feat(npcs): drop binding; select NPCs on storyline export

Remove storyline/scene NPC binding and export chosen NPCs with relations/groups. Harden export modal so a missing npcs payload no longer blacks out the editor.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-30 10:05:09 +08:00
parent 04c75cd725
commit e08f5ef550
17 changed files with 418 additions and 522 deletions
+3 -9
View File
@@ -562,11 +562,7 @@ export function EditorApp() {
const continueImportAfterScenes = useCallback(
(selections: StorylineSelection[], sceneResolutions: SceneImportResolution[]) => {
if (!importPeek || !state.project) return;
const npcConflicts = computeNpcImportConflicts(
state.project,
importPeek.sourceProject,
selections,
);
const npcConflicts = computeNpcImportConflicts(state.project, importPeek.sourceProject);
setPendingImportSelections(selections);
setPendingSceneResolutions(sceneResolutions);
setImportConflictsOpen(false);
@@ -578,7 +574,6 @@ export function EditorApp() {
const npcResolutions = buildNpcResolutionsForImport(
state.project,
importPeek.sourceProject,
selections,
[],
[],
);
@@ -1459,8 +1454,8 @@ export function EditorApp() {
storylineLabels={storylineLabels}
loadStorylines={loadProjectStorylines}
onClose={() => setExportModalOpen(false)}
onExport={async (projectId, selections) => {
await actions.exportProject(projectId, selections, storylineLabels);
onExport={async (projectId, selections, npcIds) => {
await actions.exportProject(projectId, selections, npcIds, storylineLabels);
}}
/>
<ImportSourceModal
@@ -1535,7 +1530,6 @@ export function EditorApp() {
const npcResolutions = buildNpcResolutionsForImport(
state.project,
importPeek.sourceProject,
pendingImportSelections,
importNpcConflicts,
userNpcResolutions,
);
+172 -82
View File
@@ -3,9 +3,9 @@ import { createPortal } from 'react-dom';
import {
collectSceneIdsForSelections,
filterNpcsForStorylineExport,
findNpcNameConflicts,
findSceneTitleConflicts,
listExportedNpcsFromBundle,
storylineSelectionKey,
type NpcImportResolution,
type NpcNameConflict,
@@ -22,29 +22,40 @@ import { Button, Select } from '../shared/ui/controls';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
export type ExportNpcOption = { id: string; name: string };
type ExportProjectModalProps = {
open: boolean;
projects: { id: ProjectId; name: string; fileName: string }[];
initialProjectId: ProjectId | null;
storylineLabels: StorylineLabels;
loadStorylines: (projectId: ProjectId) => Promise<StorylineListItem[]>;
loadStorylines: (
projectId: ProjectId,
) => Promise<{ storylines: StorylineListItem[]; npcs: ExportNpcOption[] }>;
onClose: () => void;
onExport: (projectId: ProjectId, selections: StorylineSelection[]) => Promise<void>;
onExport: (
projectId: ProjectId,
selections: StorylineSelection[],
npcIds: string[],
) => Promise<void>;
};
export function ExportProjectModal({
open,
projects,
initialProjectId,
storylineLabels,
storylineLabels: _storylineLabels,
loadStorylines,
onClose,
onExport,
}: ExportProjectModalProps) {
const { t } = useEditorI18n();
const [step, setStep] = useState<'storylines' | 'npcs'>('storylines');
const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId);
const [storylines, setStorylines] = useState<StorylineListItem[]>([]);
const [npcs, setNpcs] = useState<ExportNpcOption[]>([]);
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [selectedNpcIds, setSelectedNpcIds] = useState<Set<string>>(new Set());
const [loadingStorylines, setLoadingStorylines] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -55,23 +66,38 @@ export function ExportProjectModal({
setSaving(false);
setError(null);
setSelectedKeys(new Set());
setSelectedNpcIds(new Set());
setStep('storylines');
setNpcs([]);
}, [initialProjectId, open]);
useEffect(() => {
if (!open || !projectId) {
setStorylines([]);
setNpcs([]);
return;
}
let cancelled = false;
setLoadingStorylines(true);
void (async () => {
try {
const list = await loadStorylines(projectId);
const res = await loadStorylines(projectId);
if (cancelled) return;
const list = Array.isArray(res?.storylines) ? res.storylines : [];
const npcList = Array.isArray(res?.npcs) ? res.npcs : [];
setStorylines(list);
setNpcs(npcList);
setSelectedKeys(new Set(list.map((item) => storylineSelectionKey(item.selection))));
setSelectedNpcIds(new Set(npcList.map((n) => n.id)));
setStep('storylines');
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
if (!cancelled) {
setStorylines([]);
setNpcs([]);
setSelectedKeys(new Set());
setSelectedNpcIds(new Set());
setError(e instanceof Error ? e.message : String(e));
}
} finally {
if (!cancelled) setLoadingStorylines(false);
}
@@ -84,15 +110,18 @@ export function ExportProjectModal({
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'Escape') {
if (step === 'npcs') setStep('storylines');
else onClose();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]);
}, [onClose, open, step]);
if (!open) return null;
const canExport =
const canContinueStorylines =
projectId !== null &&
projects.some((p) => p.id === projectId) &&
selectedKeys.size > 0 &&
@@ -108,10 +137,44 @@ export function ExportProjectModal({
});
};
const toggleNpc = (id: string) => {
setSelectedNpcIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const selectedSelections = storylines
.filter((item) => selectedKeys.has(storylineSelectionKey(item.selection)))
.map((item) => item.selection);
const runExport = (npcIds: string[]) => {
if (!projectId || !canContinueStorylines) return;
void (async () => {
setSaving(true);
setError(null);
try {
await onExport(projectId, selectedSelections, npcIds);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
})();
};
const goNextFromStorylines = () => {
if (!canContinueStorylines) return;
if (npcs.length === 0) {
runExport([]);
return;
}
setStep('npcs');
};
return createPortal(
<>
<button
@@ -122,7 +185,9 @@ export function ExportProjectModal({
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('export.title')}</div>
<div className={styles.modalTitle}>
{step === 'storylines' ? t('export.title') : t('export.npcsTitle')}
</div>
<button
type="button"
aria-label={t('common.close')}
@@ -133,81 +198,108 @@ export function ExportProjectModal({
</button>
</div>
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>{t('export.project')}</div>
<Select
value={projectId ?? ''}
onChange={(next) => setProjectId((next as ProjectId) || null)}
disabled={projects.length === 0}
ariaLabel={t('export.project')}
options={projects.map((p) => ({
value: p.id,
label: `${p.name} (${p.fileName})`,
}))}
/>
{step === 'storylines' ? (
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>{t('export.project')}</div>
<Select
value={projectId ?? ''}
onChange={(next) => setProjectId((next as ProjectId) || null)}
disabled={projects.length === 0 || saving}
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 ? (
<div className={styles.muted}>{t('storyline.loading')}</div>
) : storylines.length === 0 ? (
<div className={styles.muted}>{t('storyline.empty')}</div>
) : (
<div className={styles.fieldLabel}>{t('storyline.section')}</div>
{loadingStorylines ? (
<div className={styles.muted}>{t('storyline.loading')}</div>
) : storylines.length === 0 ? (
<div className={styles.muted}>{t('storyline.empty')}</div>
) : (
<div className={styles.storylineChecklist}>
{storylines.map((item) => {
const key = storylineSelectionKey(item.selection);
const checked = selectedKeys.has(key);
const disabled = item.disabled === true;
return (
<label
key={key}
className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}
>
<input
type="checkbox"
checked={checked}
disabled={disabled || saving}
onChange={() => toggleKey(key, disabled)}
/>
<span>{item.label}</span>
{disabled && item.disabledReason === 'main_exists' ? (
<span className={styles.muted}> {t('storyline.mainExistsHint')}</span>
) : null}
</label>
);
})}
</div>
)}
<div className={styles.muted}>{t('export.hint')}</div>
</div>
) : (
<div className={styles.fieldGrid}>
<div className={styles.muted}>{t('export.npcsHint')}</div>
<div className={styles.storylineChecklist}>
{storylines.map((item) => {
const key = storylineSelectionKey(item.selection);
const checked = selectedKeys.has(key);
const disabled = item.disabled === true;
return (
<label
key={key}
className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}
>
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={() => toggleKey(key, disabled)}
/>
<span>{item.label}</span>
{disabled && item.disabledReason === 'main_exists' ? (
<span className={styles.muted}> {t('storyline.mainExistsHint')}</span>
) : null}
</label>
);
})}
{npcs.map((n) => (
<label key={n.id} className={styles.storylineCheck}>
<input
type="checkbox"
checked={selectedNpcIds.has(n.id)}
disabled={saving}
onChange={() => toggleNpc(n.id)}
/>
<span>{n.name}</span>
</label>
))}
</div>
)}
<div className={styles.muted}>{t('export.hint')}</div>
</div>
<Button
disabled={saving || npcs.length === 0}
onClick={() => setSelectedNpcIds(new Set(npcs.map((n) => n.id)))}
>
{t('export.selectAllNpcs')}
</Button>
</div>
)}
{error ? <div className={styles.fieldError}>{error}</div> : null}
<div className={styles.modalFooter}>
<Button onClick={onClose} disabled={saving} title={saving ? t('export.exporting') : undefined}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
disabled={!canExport || saving}
onClick={() => {
if (!projectId || !canExport) return;
void (async () => {
setSaving(true);
setError(null);
try {
await onExport(projectId, selectedSelections);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
})();
}}
>
{t('export.saveAs')}
</Button>
{step === 'npcs' ? (
<Button onClick={() => setStep('storylines')} disabled={saving}>
{t('export.back')}
</Button>
) : (
<Button onClick={onClose} disabled={saving} title={saving ? t('export.exporting') : undefined}>
{t('common.cancel')}
</Button>
)}
{step === 'storylines' ? (
<Button
variant="primary"
disabled={!canContinueStorylines || saving}
onClick={goNextFromStorylines}
>
{npcs.length > 0 ? t('export.next') : t('export.saveAs')}
</Button>
) : (
<Button
variant="primary"
disabled={saving}
onClick={() => runExport([...selectedNpcIds])}
>
{t('export.saveAs')}
</Button>
)}
</div>
</div>
</>,
@@ -831,11 +923,10 @@ export function computeImportConflicts(
export function buildNpcResolutionsForImport(
_targetProject: Project,
sourceProject: Project,
selections: StorylineSelection[],
conflicts: NpcNameConflict[],
userResolutions: NpcImportResolution[],
): NpcImportResolution[] {
const exported = filterNpcsForStorylineExport(sourceProject, selections);
const exported = listExportedNpcsFromBundle(sourceProject);
const conflictIds = new Set(conflicts.map((c) => c.sourceNpcId));
const bySource = new Map(userResolutions.map((r) => [r.sourceNpcId, r]));
const out: NpcImportResolution[] = [];
@@ -853,9 +944,8 @@ export function buildNpcResolutionsForImport(
export function computeNpcImportConflicts(
targetProject: Project,
sourceProject: Project,
selections: StorylineSelection[],
): NpcNameConflict[] {
const exported = filterNpcsForStorylineExport(sourceProject, selections);
const exported = listExportedNpcsFromBundle(sourceProject);
return findNpcNameConflicts(
targetProject,
sourceProject,
+14 -14
View File
@@ -298,7 +298,13 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'export.title': 'Экспорт проекта',
'export.project': 'ПРОЕКТ',
'export.hint':
'Выберите сюжетные линии для экспорта. В архив попадут только выбранные линии, их сцены и материалы. Далее откроется окно сохранения .ttrpg.zip.',
'Выберите сюжетные линии для экспорта. В архив попадут выбранные линии, их сцены и материалы. Если в проекте есть НПС, на следующем шаге можно отметить, кого включить.',
'export.npcsTitle': 'Экспорт НПС',
'export.npcsHint':
'Отметьте НПС для экспорта. Вместе с ними попадут связи между отмеченными и группы этих персонажей.',
'export.selectAllNpcs': 'Отметить всех',
'export.next': 'Далее',
'export.back': 'Назад',
'export.exporting': 'Экспорт…',
'export.saveAs': 'Сохранить как…',
@@ -475,12 +481,6 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'Удалить группу «{name}»? НПС станут без группы, вложенные группы будут подняты на уровень выше.',
'npcs.addSubgroup': 'Добавить подгруппу',
'npcs.group': 'ГРУППА',
'npcs.bindingEnable': 'Привязать…',
'npcs.bindingKind': 'ТИП ПРИВЯЗКИ',
'npcs.bindingStoryline': 'Сюжетная линия',
'npcs.bindingScene': 'Сцена',
'npcs.bindingMain': 'Основная линия',
'npcs.bindingSelect': 'ОБЪЕКТ',
'npcs.graphFilterAll': 'Все',
'npcs.graphFilterUngrouped': 'Без группы',
'npcs.graphFilter': 'Фильтр графа',
@@ -866,7 +866,13 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'export.title': 'Export project',
'export.project': 'PROJECT',
'export.hint':
'Select storylines to export. The archive will include only the chosen lines, their scenes, and assets. Then choose where to save the .ttrpg.zip file.',
'Select storylines to export. The archive will include the chosen lines, their scenes, and assets. If the project has NPCs, the next step lets you choose which ones to include.',
'export.npcsTitle': 'Export NPCs',
'export.npcsHint':
'Select NPCs to export. Relations between selected NPCs and their groups are included.',
'export.selectAllNpcs': 'Select all',
'export.next': 'Next',
'export.back': 'Back',
'export.exporting': 'Exporting…',
'export.saveAs': 'Save as…',
@@ -1044,12 +1050,6 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'Delete group “{name}”? NPCs will become ungrouped; child groups will move up one level.',
'npcs.addSubgroup': 'Add subgroup',
'npcs.group': 'GROUP',
'npcs.bindingEnable': 'Bind…',
'npcs.bindingKind': 'BINDING TYPE',
'npcs.bindingStoryline': 'Storyline',
'npcs.bindingScene': 'Scene',
'npcs.bindingMain': 'Main storyline',
'npcs.bindingSelect': 'TARGET',
'npcs.graphFilterAll': 'All',
'npcs.graphFilterUngrouped': 'Ungrouped',
'npcs.graphFilter': 'Graph filter',
+6 -3
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
import { EditorApp } from './EditorApp';
import { EditorI18nProvider } from './i18n/EditorI18nContext';
@@ -12,8 +13,10 @@ if (!rootEl) {
createRoot(rootEl).render(
<React.StrictMode>
<EditorI18nProvider>
<EditorApp />
</EditorI18nProvider>
<WindowErrorBoundary title="Редактор">
<EditorI18nProvider>
<EditorApp />
</EditorI18nProvider>
</WindowErrorBoundary>
</React.StrictMode>,
);
+11 -2
View File
@@ -148,10 +148,14 @@ type Actions = {
npcResolutions?: NpcImportResolution[],
) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
importProjectFromPath: (filePath: string) => Promise<void>;
getProjectStorylines: (projectId: ProjectId, labels: StorylineLabels) => Promise<StorylineListItem[]>;
getProjectStorylines: (
projectId: ProjectId,
labels: StorylineLabels,
) => Promise<{ storylines: StorylineListItem[]; npcs: { id: string; name: string }[] }>;
exportProject: (
projectId: ProjectId,
storylineSelections: StorylineSelection[],
npcIds: string[],
labels: StorylineLabels,
) => Promise<void>;
deleteProject: (projectId: ProjectId) => Promise<void>;
@@ -869,18 +873,23 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
const getProjectStorylines = async (projectId: ProjectId, labels: StorylineLabels) => {
const res = await api.invoke(ipcChannels.project.getProjectStorylines, { projectId, labels });
return res.storylines;
return {
storylines: Array.isArray(res?.storylines) ? res.storylines : [],
npcs: Array.isArray(res?.npcs) ? res.npcs : [],
};
};
const exportProject = async (
projectId: ProjectId,
storylineSelections: StorylineSelection[],
npcIds: string[],
labels: StorylineLabels,
) => {
try {
const res = await api.invoke(ipcChannels.project.exportZip, {
projectId,
storylineSelections,
npcIds,
labels,
});
if (res.canceled) return;