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;
-135
View File
@@ -1,135 +0,0 @@
import React, { useMemo } from 'react';
import { isNpcBindingNone, listStorylineOptionsForBinding, noneBinding } from '../../shared/npcs/npcBinding';
import type { GraphNodeId, NpcBinding, Project, SceneId } from '../../shared/types';
import editorStyles from '../editor/EditorApp.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { Select } from '../shared/ui/controls';
type NpcBindingFieldsProps = {
project: Project;
binding: NpcBinding;
onChange: (binding: NpcBinding) => void;
};
function defaultBinding(project: Project): NpcBinding {
const opts = listStorylineOptionsForBinding(project);
if (opts.main) return { kind: 'storyline', storyline: { kind: 'main' } };
if (opts.sides[0]) {
return {
kind: 'storyline',
storyline: { kind: 'side', startGraphNodeId: opts.sides[0].startGraphNodeId },
};
}
const firstScene = Object.keys(project.scenes)[0] as SceneId | undefined;
if (firstScene) return { kind: 'scene', sceneId: firstScene };
return noneBinding();
}
export function NpcBindingFields({ project, binding, onChange }: NpcBindingFieldsProps) {
const { t } = useEditorI18n();
const enabled = !isNpcBindingNone(binding);
const storylineOpts = useMemo(() => listStorylineOptionsForBinding(project), [project]);
const sceneOptions = useMemo(
() =>
Object.entries(project.scenes)
.map(([id, scene]) => ({ id: id as SceneId, title: scene.title.trim() || id }))
.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })),
[project.scenes],
);
const kind = binding.kind === 'none' ? 'storyline' : binding.kind;
const bindingTargetValue = useMemo(() => {
if (binding.kind === 'scene') return binding.sceneId;
if (binding.kind === 'storyline') {
if (binding.storyline.kind === 'main') return 'main';
return `side:${binding.storyline.startGraphNodeId}`;
}
return '';
}, [binding]);
return (
<div className={editorStyles.fieldGrid}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input
type="checkbox"
checked={enabled}
onChange={(e) => {
onChange(e.target.checked ? defaultBinding(project) : noneBinding());
}}
/>
<span>{t('npcs.bindingEnable')}</span>
</label>
{enabled ? (
<>
<div className={editorStyles.fieldLabel}>{t('npcs.bindingKind')}</div>
<Select
value={kind}
ariaLabel={t('npcs.bindingKind')}
options={[
{ value: 'storyline', label: t('npcs.bindingStoryline') },
{ value: 'scene', label: t('npcs.bindingScene') },
]}
onChange={(nextKind) => {
if (nextKind === 'scene') {
const first = sceneOptions[0];
onChange(first ? { kind: 'scene', sceneId: first.id } : noneBinding());
return;
}
if (storylineOpts.main) {
onChange({ kind: 'storyline', storyline: { kind: 'main' } });
} else if (storylineOpts.sides[0]) {
onChange({
kind: 'storyline',
storyline: { kind: 'side', startGraphNodeId: storylineOpts.sides[0].startGraphNodeId },
});
} else {
onChange(noneBinding());
}
}}
/>
<div className={editorStyles.fieldLabel}>{t('npcs.bindingSelect')}</div>
<Select
value={bindingTargetValue}
ariaLabel={t('npcs.bindingSelect')}
options={
kind === 'storyline'
? [
...(storylineOpts.main
? [{ value: 'main', label: t('npcs.bindingMain') }]
: []),
...storylineOpts.sides.map((s) => ({
value: `side:${s.startGraphNodeId}`,
label: s.label,
})),
]
: sceneOptions.map((s) => ({ value: s.id, label: s.title }))
}
onChange={(v) => {
if (kind === 'scene') {
onChange({ kind: 'scene', sceneId: v as SceneId });
return;
}
if (v === 'main') {
onChange({ kind: 'storyline', storyline: { kind: 'main' } });
return;
}
if (v.startsWith('side:')) {
onChange({
kind: 'storyline',
storyline: {
kind: 'side',
startGraphNodeId: v.slice(5) as GraphNodeId,
},
});
}
}}
/>
</>
) : null}
</div>
);
}
+2 -14
View File
@@ -2,9 +2,8 @@ import React, { useEffect, useMemo, useState } from 'react';
import { createPortal, flushSync } from 'react-dom';
import { ipcChannels } from '../../shared/ipc/contracts';
import { noneBinding } from '../../shared/npcs/npcBinding';
import { buildNpcGroupForest } from '../../shared/npcs/npcGroups';
import type { NpcBinding, NpcGroupId, Project, ProjectNpc, ProjectNpcGroup } from '../../shared/types';
import type { NpcGroupId, ProjectNpc, ProjectNpcGroup } from '../../shared/types';
import editorStyles from '../editor/EditorApp.module.css';
import {
filterMaterialImagePaths,
@@ -18,8 +17,6 @@ import { getDndApi } from '../shared/dndApi';
import { Button, Input, Select } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { NpcBindingFields } from './NpcBindingFields';
function normalizeName(input: string): string {
return input.trim().toLowerCase();
}
@@ -41,7 +38,6 @@ type NpcEditModalProps = {
open: boolean;
initial: ProjectNpc | null;
existingNames: string[];
project: Project | null;
npcGroups: ProjectNpcGroup[];
onClose: () => void;
onPickImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>;
@@ -49,7 +45,6 @@ type NpcEditModalProps = {
name: string;
filePath?: string;
groupId?: NpcGroupId | null;
binding?: NpcBinding;
}) => Promise<void>;
};
@@ -57,7 +52,6 @@ export function NpcEditModal({
open,
initial,
existingNames,
project,
npcGroups,
onClose,
onPickImage,
@@ -69,7 +63,6 @@ export function NpcEditModal({
const [filePath, setFilePath] = useState<string | null>(null);
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
const [groupId, setGroupId] = useState<NpcGroupId | ''>('');
const [binding, setBinding] = useState<NpcBinding>(noneBinding());
const [saving, setSaving] = useState(false);
const [saveProgress, setSaveProgress] = useState<{ percent: number; detail: string } | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -86,7 +79,6 @@ export function NpcEditModal({
setFilePath(null);
setLocalPreviewUrl(null);
setGroupId(initial?.groupId ?? '');
setBinding(initial?.binding ?? noneBinding());
setSaving(false);
setSaveProgress(null);
setError(null);
@@ -261,10 +253,6 @@ export function NpcEditModal({
{!hasImage ? <div className={editorStyles.fieldError}>{t('npcs.avatarRequired')}</div> : null}
</div>
{project && !initial ? (
<NpcBindingFields project={project} binding={binding} onChange={setBinding} />
) : null}
{error ? <div className={editorStyles.fieldError}>{error}</div> : null}
<div className={editorStyles.modalFooter}>
@@ -286,7 +274,7 @@ export function NpcEditModal({
await onSave({
name: trimmed,
...(filePath ? { filePath } : {}),
...(!initial ? { groupId: groupId || null, binding } : {}),
...(!initial ? { groupId: groupId || null } : {}),
});
onClose();
} catch (e) {
-18
View File
@@ -4,7 +4,6 @@ import { createPortal } from 'react-dom';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups';
import type {
NpcBinding,
NpcGroupId,
NpcId,
NpcRelationId,
@@ -18,7 +17,6 @@ import { getDndApi } from '../shared/dndApi';
import { Button, Input, Select } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { NpcBindingFields } from './NpcBindingFields';
import { NpcDescriptionField } from './NpcDescriptionField';
import { NpcEditModal } from './NpcEditModal';
import type { GraphGroupFilter } from './NpcGraph';
@@ -822,20 +820,6 @@ export function NpcsEditorApp() {
/>
</div>
<div>
<div className={styles.fieldLabel}>{t('npcs.bindingEnable')}</div>
<NpcBindingFields
project={project}
binding={selected.binding}
onChange={(binding: NpcBinding) => {
void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
binding,
});
}}
/>
</div>
{relationsForSelected.length > 0 ? (
<div>
<div className={styles.relationsTitle}>{t('npcs.relations')}</div>
@@ -860,7 +844,6 @@ export function NpcsEditorApp() {
open={editOpen}
initial={editInitial}
existingNames={npcs.map((n) => n.name)}
project={project}
npcGroups={npcGroups}
onClose={() => setEditOpen(false)}
onPickImage={pickAvatar}
@@ -870,7 +853,6 @@ export function NpcsEditorApp() {
name: input.name,
...(input.filePath ? { filePath: input.filePath } : {}),
...(input.groupId !== undefined ? { groupId: input.groupId } : {}),
...(input.binding !== undefined ? { binding: input.binding } : {}),
});
const created = res.project.npcs.find((n) => n.name === input.name.trim());
if (created) setSelectedId(created.id);
@@ -8,6 +8,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
const rendererRoot = path.resolve(here, '../..');
const SECONDARY_WINDOW_MAINS = [
'editor/main.tsx',
'npcs/npcsEditorMain.tsx',
'npcs/npcsMain.tsx',
'materials/main.tsx',
@@ -45,6 +46,24 @@ void test('NpcsEditorApp: no undefined controlStyles (inspector crash)', () => {
assert.doesNotMatch(src, /controlStyles/);
});
void test('NPC binding removed from editor UI', () => {
const editor = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcsEditorApp.tsx'), 'utf8');
const modal = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcEditModal.tsx'), 'utf8');
assert.doesNotMatch(editor, /NpcBindingFields|binding/);
assert.doesNotMatch(modal, /NpcBindingFields|binding|noneBinding/);
assert.ok(!fs.existsSync(path.join(rendererRoot, 'npcs/NpcBindingFields.tsx')));
assert.ok(!fs.existsSync(path.join(rendererRoot, '../shared/npcs/npcBinding.ts')));
});
void test('storyline export modal: NPC selection step', () => {
const src = fs.readFileSync(path.join(rendererRoot, 'editor/StorylineTransferModals.tsx'), 'utf8');
assert.match(src, /step === 'npcs'/);
assert.match(src, /export\.selectAllNpcs/);
assert.match(src, /npcIds/);
assert.match(src, /Array\.isArray\(res\?\.npcs\)/);
assert.doesNotMatch(src, /filterNpcsForStorylineExport/);
});
void test('WindowErrorBoundary component exists and catches errors', () => {
const src = fs.readFileSync(path.join(here, 'WindowErrorBoundary.tsx'), 'utf8');
assert.ok(src.includes('getDerivedStateFromError'));