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
-2
View File
@@ -18,7 +18,6 @@ import type {
FoundryPlaylistDoc, FoundryPlaylistDoc,
FoundrySceneDoc, FoundrySceneDoc,
} from '../../shared/foundry/foundryTypes'; } from '../../shared/foundry/foundryTypes';
import { noneBinding } from '../../shared/npcs/npcBinding';
import { DEFAULT_NPC_GROUP_COLOR, normalizeHexColor } from '../../shared/npcs/npcGroups'; import { DEFAULT_NPC_GROUP_COLOR, normalizeHexColor } from '../../shared/npcs/npcGroups';
import type { import type {
MediaAsset, MediaAsset,
@@ -447,7 +446,6 @@ export async function buildProjectFromFoundryDocuments(
x: 80 + (npcIndex % 4) * 220, x: 80 + (npcIndex % 4) * 220,
y: 80 + Math.floor(npcIndex / 4) * 200, y: 80 + Math.floor(npcIndex / 4) * 200,
groupId, groupId,
binding: noneBinding(),
}); });
npcIndex += 1; npcIndex += 1;
} }
+5 -7
View File
@@ -757,7 +757,7 @@ async function main() {
}); });
registerHandler( registerHandler(
ipcChannels.project.upsertNpc, ipcChannels.project.upsertNpc,
async ({ npcId, name, description, filePath: pathFromDrop, groupId, binding }) => { async ({ npcId, name, description, filePath: pathFromDrop, groupId }) => {
let filePath = pathFromDrop; let filePath = pathFromDrop;
if (!filePath && !npcId) { if (!filePath && !npcId) {
const { canceled, filePaths } = await dialog.showOpenDialog({ const { canceled, filePaths } = await dialog.showOpenDialog({
@@ -781,7 +781,6 @@ async function main() {
...(typeof description === 'string' ? { description } : {}), ...(typeof description === 'string' ? { description } : {}),
...(filePath ? { filePath } : {}), ...(filePath ? { filePath } : {}),
...(groupId !== undefined ? { groupId } : {}), ...(groupId !== undefined ? { groupId } : {}),
...(binding !== undefined ? { binding } : {}),
}, },
(p) => emitNpcUpsertProgress(p), (p) => emitNpcUpsertProgress(p),
); );
@@ -793,12 +792,11 @@ async function main() {
); );
registerHandler( registerHandler(
ipcChannels.project.updateNpcFields, ipcChannels.project.updateNpcFields,
async ({ npcId, name, description, groupId, binding }) => { async ({ npcId, name, description, groupId }) => {
const project = await projectStore.updateNpcFields(npcId, { const project = await projectStore.updateNpcFields(npcId, {
...(typeof name === 'string' ? { name } : {}), ...(typeof name === 'string' ? { name } : {}),
...(typeof description === 'string' ? { description } : {}), ...(typeof description === 'string' ? { description } : {}),
...(groupId !== undefined ? { groupId } : {}), ...(groupId !== undefined ? { groupId } : {}),
...(binding !== undefined ? { binding } : {}),
}); });
emitSessionState(); emitSessionState();
return { project }; return { project };
@@ -1017,8 +1015,7 @@ async function main() {
return { canceled: false as const, project }; return { canceled: false as const, project };
}); });
registerHandler(ipcChannels.project.getProjectStorylines, async ({ projectId, labels }) => { registerHandler(ipcChannels.project.getProjectStorylines, async ({ projectId, labels }) => {
const storylines = await projectStore.getProjectStorylines(projectId, labels); return projectStore.getProjectStorylines(projectId, labels);
return { storylines };
}); });
registerHandler(ipcChannels.project.peekImportZip, async ({ labels, targetHasMainStart }) => { registerHandler(ipcChannels.project.peekImportZip, async ({ labels, targetHasMainStart }) => {
const { canceled, filePaths } = await dialog.showOpenDialog({ const { canceled, filePaths } = await dialog.showOpenDialog({
@@ -1159,7 +1156,7 @@ async function main() {
throw e; throw e;
} }
}); });
registerHandler(ipcChannels.project.exportZip, async ({ projectId, storylineSelections, labels }) => { registerHandler(ipcChannels.project.exportZip, async ({ projectId, storylineSelections, npcIds, labels }) => {
const list = await projectStore.listProjects(); const list = await projectStore.listProjects();
const entry = list.find((p) => p.id === projectId); const entry = list.find((p) => p.id === projectId);
if (!entry) { if (!entry) {
@@ -1183,6 +1180,7 @@ async function main() {
await projectStore.exportStorylinesZipToPath( await projectStore.exportStorylinesZipToPath(
projectId, projectId,
storylineSelections, storylineSelections,
npcIds ?? [],
dest, dest,
labels, labels,
(p) => { (p) => {
+8 -85
View File
@@ -39,7 +39,6 @@ import type {
MaterialLegend, MaterialLegend,
MediaAsset, MediaAsset,
MediaAssetType, MediaAssetType,
NpcBinding,
Project, Project,
ProjectId, ProjectId,
ProjectNpc, ProjectNpc,
@@ -66,12 +65,6 @@ import {
asNpcRelationId, asNpcRelationId,
asProjectId, asProjectId,
} from '../../shared/types/ids'; } from '../../shared/types/ids';
import {
clearNpcBindingsForDeletedScene,
clearNpcBindingsForRemovedStoryline,
noneBinding,
normalizeNpcBinding,
} from '../../shared/npcs/npcBinding';
import { import {
DEFAULT_NPC_GROUP_COLOR, DEFAULT_NPC_GROUP_COLOR,
normalizeHexColor, normalizeHexColor,
@@ -712,25 +705,9 @@ export class ZipProjectStore {
currentSceneId = ids[0] ?? null; currentSceneId = ids[0] ?? null;
} }
const removedSideStarts = p.sceneGraphNodes.filter(
(n) => n.sceneId === sceneId && n.isSideStoryStart,
);
let npcs = clearNpcBindingsForDeletedScene(p.npcs ?? [], sceneId);
for (const side of removedSideStarts) {
npcs = clearNpcBindingsForRemovedStoryline(npcs, {
kind: 'side',
startGraphNodeId: side.id,
});
}
const hadMainOnScene = p.sceneGraphNodes.some((n) => n.sceneId === sceneId && n.isStartScene);
if (hadMainOnScene) {
npcs = clearNpcBindingsForRemovedStoryline(npcs, { kind: 'main' });
}
return { return {
...withGraph, ...withGraph,
scenes: nextScenes, scenes: nextScenes,
npcs,
sceneListOrder: removeFromSceneListOrder( sceneListOrder: removeFromSceneListOrder(
reconcileSceneListOrder(withGraph.scenes, p.sceneListOrder), reconcileSceneListOrder(withGraph.scenes, p.sceneListOrder),
sceneId, sceneId,
@@ -792,25 +769,9 @@ export class ZipProjectStore {
if (graphNodeId !== null && !open.project.sceneGraphNodes.some((n) => n.id === graphNodeId)) { if (graphNodeId !== null && !open.project.sceneGraphNodes.some((n) => n.id === graphNodeId)) {
throw new Error('Graph node not found'); throw new Error('Graph node not found');
} }
const prevMain = open.project.sceneGraphNodes.find((n) => n.isStartScene);
const clearingMain = graphNodeId === null || (prevMain && prevMain.id !== graphNodeId);
await this.updateProject((p) => { await this.updateProject((p) => {
let npcs = p.npcs ?? [];
if (clearingMain && prevMain) {
npcs = clearNpcBindingsForRemovedStoryline(npcs, { kind: 'main' });
}
const demotedSides = p.sceneGraphNodes.filter(
(n) => n.isSideStoryStart && graphNodeId !== null && n.id === graphNodeId,
);
for (const side of demotedSides) {
npcs = clearNpcBindingsForRemovedStoryline(npcs, {
kind: 'side',
startGraphNodeId: side.id,
});
}
return { return {
...p, ...p,
npcs,
sceneGraphNodes: p.sceneGraphNodes.map((n) => { sceneGraphNodes: p.sceneGraphNodes.map((n) => {
const isMain = graphNodeId !== null && n.id === graphNodeId; const isMain = graphNodeId !== null && n.id === graphNodeId;
if (isMain) { if (isMain) {
@@ -841,19 +802,8 @@ export class ZipProjectStore {
return open.project; return open.project;
} }
await this.updateProject((p) => { await this.updateProject((p) => {
let npcs = p.npcs ?? [];
if (!enabling) {
npcs = clearNpcBindingsForRemovedStoryline(npcs, {
kind: 'side',
startGraphNodeId: graphNodeId,
});
}
if (enabling && node.isStartScene) {
npcs = clearNpcBindingsForRemovedStoryline(npcs, { kind: 'main' });
}
return { return {
...p, ...p,
npcs,
sceneGraphNodes: p.sceneGraphNodes.map((n) => { sceneGraphNodes: p.sceneGraphNodes.map((n) => {
if (n.id !== graphNodeId) return n; if (n.id !== graphNodeId) return n;
if (enabling) { if (enabling) {
@@ -916,23 +866,7 @@ export class ZipProjectStore {
await this.updateProject((p) => { await this.updateProject((p) => {
const withGraph = { ...p, sceneGraphNodes: nextNodes, sceneGraphEdges: nextEdges }; const withGraph = { ...p, sceneGraphNodes: nextNodes, sceneGraphEdges: nextEdges };
const out = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges); const out = recomputeOutgoing(withGraph.sceneGraphNodes, withGraph.sceneGraphEdges);
const npcs = (p.npcs ?? []).map((n) => { return { ...withGraph, scenes: applyConnectionSets(withGraph.scenes, out) };
if (
n.binding?.kind === 'storyline' &&
n.binding.storyline.kind === 'side' &&
n.binding.storyline.startGraphNodeId === nodeId
) {
return {
...n,
binding: {
kind: 'storyline' as const,
storyline: { kind: 'side' as const, startGraphNodeId: newStartId },
},
};
}
return n;
});
return { ...withGraph, npcs, scenes: applyConnectionSets(withGraph.scenes, out) };
}); });
const latest = this.getOpenProject(); const latest = this.getOpenProject();
if (!latest) throw new Error('No open project'); if (!latest) throw new Error('No open project');
@@ -1309,7 +1243,6 @@ export class ZipProjectStore {
description?: string; description?: string;
filePath?: string; filePath?: string;
groupId?: NpcGroupId | null; groupId?: NpcGroupId | null;
binding?: NpcBinding;
}, },
onProgress?: (p: { percent: number; stage: string; detail?: string }) => void, onProgress?: (p: { percent: number; stage: string; detail?: string }) => void,
): Promise<Project> { ): Promise<Project> {
@@ -1384,7 +1317,6 @@ export class ZipProjectStore {
description: description:
typeof input.description === 'string' ? input.description : prev.description, typeof input.description === 'string' ? input.description : prev.description,
groupId: resolveGroup(input.groupId, prev.groupId), groupId: resolveGroup(input.groupId, prev.groupId),
binding: input.binding !== undefined ? input.binding : prev.binding,
}; };
} else { } else {
if (!nextAssetId) throw new Error('NPC avatar is required'); if (!nextAssetId) throw new Error('NPC avatar is required');
@@ -1397,7 +1329,6 @@ export class ZipProjectStore {
x: 80 + (count % 4) * 220, x: 80 + (count % 4) * 220,
y: 80 + Math.floor(count / 4) * 200, y: 80 + Math.floor(count / 4) * 200,
groupId: resolveGroup(input.groupId, null), groupId: resolveGroup(input.groupId, null),
binding: input.binding ?? noneBinding(),
}); });
} }
return { ...p, assets, npcs }; return { ...p, assets, npcs };
@@ -1415,7 +1346,6 @@ export class ZipProjectStore {
name?: string; name?: string;
description?: string; description?: string;
groupId?: NpcGroupId | null; groupId?: NpcGroupId | null;
binding?: NpcBinding;
}, },
): Promise<Project> { ): Promise<Project> {
const open = this.openProject; const open = this.openProject;
@@ -1447,7 +1377,6 @@ export class ZipProjectStore {
...(name !== undefined ? { name } : {}), ...(name !== undefined ? { name } : {}),
...(typeof patch.description === 'string' ? { description: patch.description } : {}), ...(typeof patch.description === 'string' ? { description: patch.description } : {}),
groupId, groupId,
...(patch.binding !== undefined ? { binding: patch.binding } : {}),
}; };
}); });
return { ...p, npcs }; return { ...p, npcs };
@@ -2000,10 +1929,13 @@ export class ZipProjectStore {
async getProjectStorylines( async getProjectStorylines(
projectId: ProjectId, projectId: ProjectId,
labels: StorylineLabels, labels: StorylineLabels,
): Promise<StorylineListItem[]> { ): Promise<{ storylines: StorylineListItem[]; npcs: { id: string; name: string }[] }> {
const snap = await this.loadProjectSnapshot(projectId); const snap = await this.loadProjectSnapshot(projectId);
try { try {
return listExportableStorylines(snap.project, labels); return {
storylines: listExportableStorylines(snap.project, labels),
npcs: (snap.project.npcs ?? []).map((n) => ({ id: n.id, name: n.name })),
};
} finally { } finally {
if (snap.ownsCache) { if (snap.ownsCache) {
await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined); await fs.rm(snap.cacheDir, { recursive: true, force: true }).catch(() => undefined);
@@ -2014,6 +1946,7 @@ export class ZipProjectStore {
async exportStorylinesZipToPath( async exportStorylinesZipToPath(
projectId: ProjectId, projectId: ProjectId,
selections: StorylineSelection[], selections: StorylineSelection[],
npcIds: string[],
destinationPath: string, destinationPath: string,
labels: StorylineLabels, labels: StorylineLabels,
onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void, onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void,
@@ -2030,6 +1963,7 @@ export class ZipProjectStore {
newProjectId: newExportBundleProjectId(), newProjectId: newExportBundleProjectId(),
exportTitle: entry?.name ?? snap.project.meta.name, exportTitle: entry?.name ?? snap.project.meta.name,
labels, labels,
npcIds,
}); });
await fs.mkdir(path.join(exportCache, 'assets'), { recursive: true }); await fs.mkdir(path.join(exportCache, 'assets'), { recursive: true });
const assetIds = Object.keys(partial.assets) as AssetId[]; const assetIds = Object.keys(partial.assets) as AssetId[];
@@ -2483,11 +2417,6 @@ function normalizeProject(p: Project): Project {
); );
const npcGroups = normalizeNpcGroups((p as unknown as { npcGroups?: unknown }).npcGroups); const npcGroups = normalizeNpcGroups((p as unknown as { npcGroups?: unknown }).npcGroups);
const groupIdSet = new Set(npcGroups.map((g) => g.id)); const groupIdSet = new Set(npcGroups.map((g) => g.id));
const sceneIdSet = new Set(Object.keys(scenes) as SceneId[]);
const sideStartIds = new Set(
sceneGraphNodes.filter((n) => n.isSideStoryStart).map((n) => n.id),
);
const hasMainStart = sceneGraphNodes.some((n) => n.isStartScene);
const rawNpcs = (p as unknown as { npcs?: unknown[] }).npcs; const rawNpcs = (p as unknown as { npcs?: unknown[] }).npcs;
const npcs: ProjectNpc[] = (Array.isArray(rawNpcs) ? rawNpcs : []) const npcs: ProjectNpc[] = (Array.isArray(rawNpcs) ? rawNpcs : [])
.map((n, index) => { .map((n, index) => {
@@ -2500,7 +2429,6 @@ function normalizeProject(p: Project): Project {
x?: number; x?: number;
y?: number; y?: number;
groupId?: string | null; groupId?: string | null;
binding?: unknown;
}; };
if (!obj.id || !obj.avatarAssetId || typeof obj.name !== 'string') return null; if (!obj.id || !obj.avatarAssetId || typeof obj.name !== 'string') return null;
const name = obj.name.trim(); const name = obj.name.trim();
@@ -2516,11 +2444,6 @@ function normalizeProject(p: Project): Project {
x, x,
y, y,
groupId: resolveNpcGroupId(obj.groupId, groupIdSet), groupId: resolveNpcGroupId(obj.groupId, groupIdSet),
binding: normalizeNpcBinding(obj.binding, {
sceneIds: sceneIdSet,
sideStartIds,
hasMainStart,
}),
}; };
}) })
.filter((x): x is ProjectNpc => Boolean(x)); .filter((x): x is ProjectNpc => Boolean(x));
+3 -9
View File
@@ -562,11 +562,7 @@ export function EditorApp() {
const continueImportAfterScenes = useCallback( const continueImportAfterScenes = useCallback(
(selections: StorylineSelection[], sceneResolutions: SceneImportResolution[]) => { (selections: StorylineSelection[], sceneResolutions: SceneImportResolution[]) => {
if (!importPeek || !state.project) return; if (!importPeek || !state.project) return;
const npcConflicts = computeNpcImportConflicts( const npcConflicts = computeNpcImportConflicts(state.project, importPeek.sourceProject);
state.project,
importPeek.sourceProject,
selections,
);
setPendingImportSelections(selections); setPendingImportSelections(selections);
setPendingSceneResolutions(sceneResolutions); setPendingSceneResolutions(sceneResolutions);
setImportConflictsOpen(false); setImportConflictsOpen(false);
@@ -578,7 +574,6 @@ export function EditorApp() {
const npcResolutions = buildNpcResolutionsForImport( const npcResolutions = buildNpcResolutionsForImport(
state.project, state.project,
importPeek.sourceProject, importPeek.sourceProject,
selections,
[], [],
[], [],
); );
@@ -1459,8 +1454,8 @@ export function EditorApp() {
storylineLabels={storylineLabels} storylineLabels={storylineLabels}
loadStorylines={loadProjectStorylines} loadStorylines={loadProjectStorylines}
onClose={() => setExportModalOpen(false)} onClose={() => setExportModalOpen(false)}
onExport={async (projectId, selections) => { onExport={async (projectId, selections, npcIds) => {
await actions.exportProject(projectId, selections, storylineLabels); await actions.exportProject(projectId, selections, npcIds, storylineLabels);
}} }}
/> />
<ImportSourceModal <ImportSourceModal
@@ -1535,7 +1530,6 @@ export function EditorApp() {
const npcResolutions = buildNpcResolutionsForImport( const npcResolutions = buildNpcResolutionsForImport(
state.project, state.project,
importPeek.sourceProject, importPeek.sourceProject,
pendingImportSelections,
importNpcConflicts, importNpcConflicts,
userNpcResolutions, userNpcResolutions,
); );
+122 -32
View File
@@ -3,9 +3,9 @@ import { createPortal } from 'react-dom';
import { import {
collectSceneIdsForSelections, collectSceneIdsForSelections,
filterNpcsForStorylineExport,
findNpcNameConflicts, findNpcNameConflicts,
findSceneTitleConflicts, findSceneTitleConflicts,
listExportedNpcsFromBundle,
storylineSelectionKey, storylineSelectionKey,
type NpcImportResolution, type NpcImportResolution,
type NpcNameConflict, type NpcNameConflict,
@@ -22,29 +22,40 @@ import { Button, Select } from '../shared/ui/controls';
import styles from './EditorApp.module.css'; import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext'; import { useEditorI18n } from './i18n/EditorI18nContext';
export type ExportNpcOption = { id: string; name: string };
type ExportProjectModalProps = { type ExportProjectModalProps = {
open: boolean; open: boolean;
projects: { id: ProjectId; name: string; fileName: string }[]; projects: { id: ProjectId; name: string; fileName: string }[];
initialProjectId: ProjectId | null; initialProjectId: ProjectId | null;
storylineLabels: StorylineLabels; storylineLabels: StorylineLabels;
loadStorylines: (projectId: ProjectId) => Promise<StorylineListItem[]>; loadStorylines: (
projectId: ProjectId,
) => Promise<{ storylines: StorylineListItem[]; npcs: ExportNpcOption[] }>;
onClose: () => void; onClose: () => void;
onExport: (projectId: ProjectId, selections: StorylineSelection[]) => Promise<void>; onExport: (
projectId: ProjectId,
selections: StorylineSelection[],
npcIds: string[],
) => Promise<void>;
}; };
export function ExportProjectModal({ export function ExportProjectModal({
open, open,
projects, projects,
initialProjectId, initialProjectId,
storylineLabels, storylineLabels: _storylineLabels,
loadStorylines, loadStorylines,
onClose, onClose,
onExport, onExport,
}: ExportProjectModalProps) { }: ExportProjectModalProps) {
const { t } = useEditorI18n(); const { t } = useEditorI18n();
const [step, setStep] = useState<'storylines' | 'npcs'>('storylines');
const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId); const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId);
const [storylines, setStorylines] = useState<StorylineListItem[]>([]); const [storylines, setStorylines] = useState<StorylineListItem[]>([]);
const [npcs, setNpcs] = useState<ExportNpcOption[]>([]);
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set()); const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [selectedNpcIds, setSelectedNpcIds] = useState<Set<string>>(new Set());
const [loadingStorylines, setLoadingStorylines] = useState(false); const [loadingStorylines, setLoadingStorylines] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -55,23 +66,38 @@ export function ExportProjectModal({
setSaving(false); setSaving(false);
setError(null); setError(null);
setSelectedKeys(new Set()); setSelectedKeys(new Set());
setSelectedNpcIds(new Set());
setStep('storylines');
setNpcs([]);
}, [initialProjectId, open]); }, [initialProjectId, open]);
useEffect(() => { useEffect(() => {
if (!open || !projectId) { if (!open || !projectId) {
setStorylines([]); setStorylines([]);
setNpcs([]);
return; return;
} }
let cancelled = false; let cancelled = false;
setLoadingStorylines(true); setLoadingStorylines(true);
void (async () => { void (async () => {
try { try {
const list = await loadStorylines(projectId); const res = await loadStorylines(projectId);
if (cancelled) return; if (cancelled) return;
const list = Array.isArray(res?.storylines) ? res.storylines : [];
const npcList = Array.isArray(res?.npcs) ? res.npcs : [];
setStorylines(list); setStorylines(list);
setNpcs(npcList);
setSelectedKeys(new Set(list.map((item) => storylineSelectionKey(item.selection)))); setSelectedKeys(new Set(list.map((item) => storylineSelectionKey(item.selection))));
setSelectedNpcIds(new Set(npcList.map((n) => n.id)));
setStep('storylines');
} catch (e) { } 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 { } finally {
if (!cancelled) setLoadingStorylines(false); if (!cancelled) setLoadingStorylines(false);
} }
@@ -84,15 +110,18 @@ export function ExportProjectModal({
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose(); if (e.key === 'Escape') {
if (step === 'npcs') setStep('storylines');
else onClose();
}
}; };
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]); }, [onClose, open, step]);
if (!open) return null; if (!open) return null;
const canExport = const canContinueStorylines =
projectId !== null && projectId !== null &&
projects.some((p) => p.id === projectId) && projects.some((p) => p.id === projectId) &&
selectedKeys.size > 0 && 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 const selectedSelections = storylines
.filter((item) => selectedKeys.has(storylineSelectionKey(item.selection))) .filter((item) => selectedKeys.has(storylineSelectionKey(item.selection)))
.map((item) => 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( return createPortal(
<> <>
<button <button
@@ -122,7 +185,9 @@ export function ExportProjectModal({
/> />
<div role="dialog" aria-modal="true" className={styles.modalDialog}> <div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}> <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 <button
type="button" type="button"
aria-label={t('common.close')} aria-label={t('common.close')}
@@ -133,12 +198,13 @@ export function ExportProjectModal({
</button> </button>
</div> </div>
{step === 'storylines' ? (
<div className={styles.fieldGrid}> <div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>{t('export.project')}</div> <div className={styles.fieldLabel}>{t('export.project')}</div>
<Select <Select
value={projectId ?? ''} value={projectId ?? ''}
onChange={(next) => setProjectId((next as ProjectId) || null)} onChange={(next) => setProjectId((next as ProjectId) || null)}
disabled={projects.length === 0} disabled={projects.length === 0 || saving}
ariaLabel={t('export.project')} ariaLabel={t('export.project')}
options={projects.map((p) => ({ options={projects.map((p) => ({
value: p.id, value: p.id,
@@ -165,7 +231,7 @@ export function ExportProjectModal({
<input <input
type="checkbox" type="checkbox"
checked={checked} checked={checked}
disabled={disabled} disabled={disabled || saving}
onChange={() => toggleKey(key, disabled)} onChange={() => toggleKey(key, disabled)}
/> />
<span>{item.label}</span> <span>{item.label}</span>
@@ -180,34 +246,60 @@ export function ExportProjectModal({
<div className={styles.muted}>{t('export.hint')}</div> <div className={styles.muted}>{t('export.hint')}</div>
</div> </div>
) : (
<div className={styles.fieldGrid}>
<div className={styles.muted}>{t('export.npcsHint')}</div>
<div className={styles.storylineChecklist}>
{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>
<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} {error ? <div className={styles.fieldError}>{error}</div> : null}
<div className={styles.modalFooter}> <div className={styles.modalFooter}>
{step === 'npcs' ? (
<Button onClick={() => setStep('storylines')} disabled={saving}>
{t('export.back')}
</Button>
) : (
<Button onClick={onClose} disabled={saving} title={saving ? t('export.exporting') : undefined}> <Button onClick={onClose} disabled={saving} title={saving ? t('export.exporting') : undefined}>
{t('common.cancel')} {t('common.cancel')}
</Button> </Button>
)}
{step === 'storylines' ? (
<Button <Button
variant="primary" variant="primary"
disabled={!canExport || saving} disabled={!canContinueStorylines || saving}
onClick={() => { onClick={goNextFromStorylines}
if (!projectId || !canExport) return; >
void (async () => { {npcs.length > 0 ? t('export.next') : t('export.saveAs')}
setSaving(true); </Button>
setError(null); ) : (
try { <Button
await onExport(projectId, selectedSelections); variant="primary"
onClose(); disabled={saving}
} catch (e) { onClick={() => runExport([...selectedNpcIds])}
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
})();
}}
> >
{t('export.saveAs')} {t('export.saveAs')}
</Button> </Button>
)}
</div> </div>
</div> </div>
</>, </>,
@@ -831,11 +923,10 @@ export function computeImportConflicts(
export function buildNpcResolutionsForImport( export function buildNpcResolutionsForImport(
_targetProject: Project, _targetProject: Project,
sourceProject: Project, sourceProject: Project,
selections: StorylineSelection[],
conflicts: NpcNameConflict[], conflicts: NpcNameConflict[],
userResolutions: NpcImportResolution[], userResolutions: NpcImportResolution[],
): NpcImportResolution[] { ): NpcImportResolution[] {
const exported = filterNpcsForStorylineExport(sourceProject, selections); const exported = listExportedNpcsFromBundle(sourceProject);
const conflictIds = new Set(conflicts.map((c) => c.sourceNpcId)); const conflictIds = new Set(conflicts.map((c) => c.sourceNpcId));
const bySource = new Map(userResolutions.map((r) => [r.sourceNpcId, r])); const bySource = new Map(userResolutions.map((r) => [r.sourceNpcId, r]));
const out: NpcImportResolution[] = []; const out: NpcImportResolution[] = [];
@@ -853,9 +944,8 @@ export function buildNpcResolutionsForImport(
export function computeNpcImportConflicts( export function computeNpcImportConflicts(
targetProject: Project, targetProject: Project,
sourceProject: Project, sourceProject: Project,
selections: StorylineSelection[],
): NpcNameConflict[] { ): NpcNameConflict[] {
const exported = filterNpcsForStorylineExport(sourceProject, selections); const exported = listExportedNpcsFromBundle(sourceProject);
return findNpcNameConflicts( return findNpcNameConflicts(
targetProject, targetProject,
sourceProject, sourceProject,
+14 -14
View File
@@ -298,7 +298,13 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'export.title': 'Экспорт проекта', 'export.title': 'Экспорт проекта',
'export.project': 'ПРОЕКТ', 'export.project': 'ПРОЕКТ',
'export.hint': 'export.hint':
'Выберите сюжетные линии для экспорта. В архив попадут только выбранные линии, их сцены и материалы. Далее откроется окно сохранения .ttrpg.zip.', 'Выберите сюжетные линии для экспорта. В архив попадут выбранные линии, их сцены и материалы. Если в проекте есть НПС, на следующем шаге можно отметить, кого включить.',
'export.npcsTitle': 'Экспорт НПС',
'export.npcsHint':
'Отметьте НПС для экспорта. Вместе с ними попадут связи между отмеченными и группы этих персонажей.',
'export.selectAllNpcs': 'Отметить всех',
'export.next': 'Далее',
'export.back': 'Назад',
'export.exporting': 'Экспорт…', 'export.exporting': 'Экспорт…',
'export.saveAs': 'Сохранить как…', 'export.saveAs': 'Сохранить как…',
@@ -475,12 +481,6 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'Удалить группу «{name}»? НПС станут без группы, вложенные группы будут подняты на уровень выше.', 'Удалить группу «{name}»? НПС станут без группы, вложенные группы будут подняты на уровень выше.',
'npcs.addSubgroup': 'Добавить подгруппу', 'npcs.addSubgroup': 'Добавить подгруппу',
'npcs.group': 'ГРУППА', 'npcs.group': 'ГРУППА',
'npcs.bindingEnable': 'Привязать…',
'npcs.bindingKind': 'ТИП ПРИВЯЗКИ',
'npcs.bindingStoryline': 'Сюжетная линия',
'npcs.bindingScene': 'Сцена',
'npcs.bindingMain': 'Основная линия',
'npcs.bindingSelect': 'ОБЪЕКТ',
'npcs.graphFilterAll': 'Все', 'npcs.graphFilterAll': 'Все',
'npcs.graphFilterUngrouped': 'Без группы', 'npcs.graphFilterUngrouped': 'Без группы',
'npcs.graphFilter': 'Фильтр графа', 'npcs.graphFilter': 'Фильтр графа',
@@ -866,7 +866,13 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'export.title': 'Export project', 'export.title': 'Export project',
'export.project': 'PROJECT', 'export.project': 'PROJECT',
'export.hint': '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.exporting': 'Exporting…',
'export.saveAs': 'Save as…', '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.', 'Delete group “{name}”? NPCs will become ungrouped; child groups will move up one level.',
'npcs.addSubgroup': 'Add subgroup', 'npcs.addSubgroup': 'Add subgroup',
'npcs.group': 'GROUP', '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.graphFilterAll': 'All',
'npcs.graphFilterUngrouped': 'Ungrouped', 'npcs.graphFilterUngrouped': 'Ungrouped',
'npcs.graphFilter': 'Graph filter', 'npcs.graphFilter': 'Graph filter',
+3
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css'; import '../shared/ui/globals.css';
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
import { EditorApp } from './EditorApp'; import { EditorApp } from './EditorApp';
import { EditorI18nProvider } from './i18n/EditorI18nContext'; import { EditorI18nProvider } from './i18n/EditorI18nContext';
@@ -12,8 +13,10 @@ if (!rootEl) {
createRoot(rootEl).render( createRoot(rootEl).render(
<React.StrictMode> <React.StrictMode>
<WindowErrorBoundary title="Редактор">
<EditorI18nProvider> <EditorI18nProvider>
<EditorApp /> <EditorApp />
</EditorI18nProvider> </EditorI18nProvider>
</WindowErrorBoundary>
</React.StrictMode>, </React.StrictMode>,
); );
+11 -2
View File
@@ -148,10 +148,14 @@ type Actions = {
npcResolutions?: NpcImportResolution[], npcResolutions?: NpcImportResolution[],
) => Promise<{ project: Project; report: StorylineImportMergeReport }>; ) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
importProjectFromPath: (filePath: string) => Promise<void>; 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: ( exportProject: (
projectId: ProjectId, projectId: ProjectId,
storylineSelections: StorylineSelection[], storylineSelections: StorylineSelection[],
npcIds: string[],
labels: StorylineLabels, labels: StorylineLabels,
) => Promise<void>; ) => Promise<void>;
deleteProject: (projectId: ProjectId) => 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 getProjectStorylines = async (projectId: ProjectId, labels: StorylineLabels) => {
const res = await api.invoke(ipcChannels.project.getProjectStorylines, { projectId, labels }); 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 ( const exportProject = async (
projectId: ProjectId, projectId: ProjectId,
storylineSelections: StorylineSelection[], storylineSelections: StorylineSelection[],
npcIds: string[],
labels: StorylineLabels, labels: StorylineLabels,
) => { ) => {
try { try {
const res = await api.invoke(ipcChannels.project.exportZip, { const res = await api.invoke(ipcChannels.project.exportZip, {
projectId, projectId,
storylineSelections, storylineSelections,
npcIds,
labels, labels,
}); });
if (res.canceled) return; 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 { createPortal, flushSync } from 'react-dom';
import { ipcChannels } from '../../shared/ipc/contracts'; import { ipcChannels } from '../../shared/ipc/contracts';
import { noneBinding } from '../../shared/npcs/npcBinding';
import { buildNpcGroupForest } from '../../shared/npcs/npcGroups'; 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 editorStyles from '../editor/EditorApp.module.css';
import { import {
filterMaterialImagePaths, filterMaterialImagePaths,
@@ -18,8 +17,6 @@ import { getDndApi } from '../shared/dndApi';
import { Button, Input, Select } from '../shared/ui/controls'; import { Button, Input, Select } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl'; import { useAssetUrl } from '../shared/useAssetImageUrl';
import { NpcBindingFields } from './NpcBindingFields';
function normalizeName(input: string): string { function normalizeName(input: string): string {
return input.trim().toLowerCase(); return input.trim().toLowerCase();
} }
@@ -41,7 +38,6 @@ type NpcEditModalProps = {
open: boolean; open: boolean;
initial: ProjectNpc | null; initial: ProjectNpc | null;
existingNames: string[]; existingNames: string[];
project: Project | null;
npcGroups: ProjectNpcGroup[]; npcGroups: ProjectNpcGroup[];
onClose: () => void; onClose: () => void;
onPickImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>; onPickImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>;
@@ -49,7 +45,6 @@ type NpcEditModalProps = {
name: string; name: string;
filePath?: string; filePath?: string;
groupId?: NpcGroupId | null; groupId?: NpcGroupId | null;
binding?: NpcBinding;
}) => Promise<void>; }) => Promise<void>;
}; };
@@ -57,7 +52,6 @@ export function NpcEditModal({
open, open,
initial, initial,
existingNames, existingNames,
project,
npcGroups, npcGroups,
onClose, onClose,
onPickImage, onPickImage,
@@ -69,7 +63,6 @@ export function NpcEditModal({
const [filePath, setFilePath] = useState<string | null>(null); const [filePath, setFilePath] = useState<string | null>(null);
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null); const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
const [groupId, setGroupId] = useState<NpcGroupId | ''>(''); const [groupId, setGroupId] = useState<NpcGroupId | ''>('');
const [binding, setBinding] = useState<NpcBinding>(noneBinding());
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveProgress, setSaveProgress] = useState<{ percent: number; detail: string } | null>(null); const [saveProgress, setSaveProgress] = useState<{ percent: number; detail: string } | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -86,7 +79,6 @@ export function NpcEditModal({
setFilePath(null); setFilePath(null);
setLocalPreviewUrl(null); setLocalPreviewUrl(null);
setGroupId(initial?.groupId ?? ''); setGroupId(initial?.groupId ?? '');
setBinding(initial?.binding ?? noneBinding());
setSaving(false); setSaving(false);
setSaveProgress(null); setSaveProgress(null);
setError(null); setError(null);
@@ -261,10 +253,6 @@ export function NpcEditModal({
{!hasImage ? <div className={editorStyles.fieldError}>{t('npcs.avatarRequired')}</div> : null} {!hasImage ? <div className={editorStyles.fieldError}>{t('npcs.avatarRequired')}</div> : null}
</div> </div>
{project && !initial ? (
<NpcBindingFields project={project} binding={binding} onChange={setBinding} />
) : null}
{error ? <div className={editorStyles.fieldError}>{error}</div> : null} {error ? <div className={editorStyles.fieldError}>{error}</div> : null}
<div className={editorStyles.modalFooter}> <div className={editorStyles.modalFooter}>
@@ -286,7 +274,7 @@ export function NpcEditModal({
await onSave({ await onSave({
name: trimmed, name: trimmed,
...(filePath ? { filePath } : {}), ...(filePath ? { filePath } : {}),
...(!initial ? { groupId: groupId || null, binding } : {}), ...(!initial ? { groupId: groupId || null } : {}),
}); });
onClose(); onClose();
} catch (e) { } catch (e) {
-18
View File
@@ -4,7 +4,6 @@ import { createPortal } from 'react-dom';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts'; import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups'; import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups';
import type { import type {
NpcBinding,
NpcGroupId, NpcGroupId,
NpcId, NpcId,
NpcRelationId, NpcRelationId,
@@ -18,7 +17,6 @@ import { getDndApi } from '../shared/dndApi';
import { Button, Input, Select } from '../shared/ui/controls'; import { Button, Input, Select } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl'; import { useAssetUrl } from '../shared/useAssetImageUrl';
import { NpcBindingFields } from './NpcBindingFields';
import { NpcDescriptionField } from './NpcDescriptionField'; import { NpcDescriptionField } from './NpcDescriptionField';
import { NpcEditModal } from './NpcEditModal'; import { NpcEditModal } from './NpcEditModal';
import type { GraphGroupFilter } from './NpcGraph'; import type { GraphGroupFilter } from './NpcGraph';
@@ -822,20 +820,6 @@ export function NpcsEditorApp() {
/> />
</div> </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 ? ( {relationsForSelected.length > 0 ? (
<div> <div>
<div className={styles.relationsTitle}>{t('npcs.relations')}</div> <div className={styles.relationsTitle}>{t('npcs.relations')}</div>
@@ -860,7 +844,6 @@ export function NpcsEditorApp() {
open={editOpen} open={editOpen}
initial={editInitial} initial={editInitial}
existingNames={npcs.map((n) => n.name)} existingNames={npcs.map((n) => n.name)}
project={project}
npcGroups={npcGroups} npcGroups={npcGroups}
onClose={() => setEditOpen(false)} onClose={() => setEditOpen(false)}
onPickImage={pickAvatar} onPickImage={pickAvatar}
@@ -870,7 +853,6 @@ export function NpcsEditorApp() {
name: input.name, name: input.name,
...(input.filePath ? { filePath: input.filePath } : {}), ...(input.filePath ? { filePath: input.filePath } : {}),
...(input.groupId !== undefined ? { groupId: input.groupId } : {}), ...(input.groupId !== undefined ? { groupId: input.groupId } : {}),
...(input.binding !== undefined ? { binding: input.binding } : {}),
}); });
const created = res.project.npcs.find((n) => n.name === input.name.trim()); const created = res.project.npcs.find((n) => n.name === input.name.trim());
if (created) setSelectedId(created.id); if (created) setSelectedId(created.id);
@@ -8,6 +8,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
const rendererRoot = path.resolve(here, '../..'); const rendererRoot = path.resolve(here, '../..');
const SECONDARY_WINDOW_MAINS = [ const SECONDARY_WINDOW_MAINS = [
'editor/main.tsx',
'npcs/npcsEditorMain.tsx', 'npcs/npcsEditorMain.tsx',
'npcs/npcsMain.tsx', 'npcs/npcsMain.tsx',
'materials/main.tsx', 'materials/main.tsx',
@@ -45,6 +46,24 @@ void test('NpcsEditorApp: no undefined controlStyles (inspector crash)', () => {
assert.doesNotMatch(src, /controlStyles/); 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', () => { void test('WindowErrorBoundary component exists and catches errors', () => {
const src = fs.readFileSync(path.join(here, 'WindowErrorBoundary.tsx'), 'utf8'); const src = fs.readFileSync(path.join(here, 'WindowErrorBoundary.tsx'), 'utf8');
assert.ok(src.includes('getDerivedStateFromError')); assert.ok(src.includes('getDerivedStateFromError'));
+155 -3
View File
@@ -1,17 +1,28 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import type { Project, Scene, SceneGraphEdge, SceneGraphNode } from '../types'; import type { Project, ProjectNpc, ProjectNpcGroup, ProjectNpcRelation, Scene, SceneGraphEdge, SceneGraphNode } from '../types';
import { asAssetId, asGraphNodeId, asProjectId, asSceneId } from '../types/ids'; import {
asAssetId,
asGraphNodeId,
asNpcGroupId,
asNpcId,
asNpcRelationId,
asProjectId,
asSceneId,
} from '../types/ids';
import { PROJECT_SCHEMA_VERSION } from '../types';
import { import {
buildPartialExportProject, buildPartialExportProject,
collectStorylineGraphNodeIds, collectStorylineGraphNodeIds,
computeGraphImportOffsetX, computeGraphImportOffsetX,
findSceneTitleConflicts, findSceneTitleConflicts,
listExportedNpcsFromBundle,
listImportableStorylines, listImportableStorylines,
mergeStorylinesIntoProject, mergeStorylinesIntoProject,
newExportBundleProjectId, newExportBundleProjectId,
selectNpcsByIds,
} from './storylineExportImport'; } from './storylineExportImport';
const LABELS = { main: 'Основная линия', untitled: 'Без названия' }; const LABELS = { main: 'Основная линия', untitled: 'Без названия' };
@@ -64,7 +75,7 @@ function minimalProject(overrides: Partial<Project> = {}): Project {
updatedAt: '2020-01-01T00:00:00.000Z', updatedAt: '2020-01-01T00:00:00.000Z',
createdWithAppVersion: '1', createdWithAppVersion: '1',
appVersion: '1', appVersion: '1',
schemaVersion: 9, schemaVersion: PROJECT_SCHEMA_VERSION,
}, },
scenes: {}, scenes: {},
sceneListOrder: [], sceneListOrder: [],
@@ -210,6 +221,147 @@ void test('mergeStorylinesIntoProject: offset X, create scene, rename side title
assert.equal(report.edgesAdded, 1); assert.equal(report.edgesAdded, 1);
}); });
void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations, ancestor groups', () => {
const gRoot = asNpcGroupId('g_root');
const gChild = asNpcGroupId('g_child');
const n1 = asNpcId('npc1');
const n2 = asNpcId('npc2');
const n3 = asNpcId('npc3');
const avatar = asAssetId('a1');
const groups: ProjectNpcGroup[] = [
{ id: gRoot, name: 'Root', color: '#ffffff', parentId: null },
{ id: gChild, name: 'Child', color: '#ff0000', parentId: gRoot },
{ id: asNpcGroupId('g_other'), name: 'Other', color: '#00ff00', parentId: null },
];
const npcs: ProjectNpc[] = [
{
id: n1,
name: 'A',
avatarAssetId: avatar,
description: '',
x: 0,
y: 0,
groupId: gChild,
},
{
id: n2,
name: 'B',
avatarAssetId: avatar,
description: '',
x: 10,
y: 0,
groupId: gChild,
},
{
id: n3,
name: 'C',
avatarAssetId: avatar,
description: '',
x: 20,
y: 0,
groupId: asNpcGroupId('g_other'),
},
];
const relations: ProjectNpcRelation[] = [
{ id: asNpcRelationId('r1'), sourceNpcId: n1, targetNpcId: n2, label: 'friends' },
{ id: asNpcRelationId('r2'), sourceNpcId: n1, targetNpcId: n3, label: 'rivals' },
];
const source = minimalProject({
scenes: { [asSceneId('s1')]: scene('s1', 'Start') },
sceneGraphNodes: [node('main', 's1', { isStartScene: true })],
assets: {
[avatar]: {
id: avatar,
type: 'image',
mime: 'image/png',
originalName: 'a.png',
relPath: 'assets/a.png',
sha256: 'abc',
sizeBytes: 1,
createdAt: '2020-01-01T00:00:00.000Z',
},
},
npcs,
npcGroups: groups,
npcRelations: relations,
});
assert.deepEqual(
selectNpcsByIds(source, [n1, n2]).map((n) => n.id),
[n1, n2],
);
const partial = buildPartialExportProject(source, [{ kind: 'main' }], {
newProjectId: newExportBundleProjectId(),
exportTitle: 'Export',
labels: LABELS,
npcIds: [n1, n2],
});
assert.equal(partial.npcs.length, 2);
assert.ok(partial.npcs.every((n) => n.id === n1 || n.id === n2));
assert.equal(partial.npcRelations.length, 1);
assert.equal(partial.npcRelations[0]!.label, 'friends');
assert.deepEqual(
[...partial.npcGroups.map((g) => g.id)].sort(),
[gRoot, gChild].sort(),
);
assert.ok(!partial.npcGroups.some((g) => g.id === asNpcGroupId('g_other')));
});
void test('mergeStorylinesIntoProject: imports all NPCs from export bundle', () => {
const avatar = asAssetId('a1');
const sourceNpcId = asNpcId('src_npc');
const source = minimalProject({
scenes: { [asSceneId('s1')]: scene('s1', 'Side') },
sceneGraphNodes: [node('side', 's1', { isSideStoryStart: true, sideStoryLineTitle: 'Side', x: 0 })],
assets: {
[avatar]: {
id: avatar,
type: 'image',
mime: 'image/png',
originalName: 'a.png',
relPath: 'assets/a.png',
sha256: 'npcsha',
sizeBytes: 1,
createdAt: '2020-01-01T00:00:00.000Z',
},
},
npcs: [
{
id: sourceNpcId,
name: 'Hero',
avatarAssetId: avatar,
description: '',
x: 0,
y: 0,
groupId: null,
},
],
});
const target = minimalProject({
scenes: { [asSceneId('t1')]: scene('t1', 'Existing') },
sceneGraphNodes: [node('tgn', 't1', { x: 0 })],
});
assert.equal(listExportedNpcsFromBundle(source).length, 1);
const { project: merged, report } = mergeStorylinesIntoProject(
target,
source,
[{ kind: 'side', startGraphNodeId: asGraphNodeId('side') }],
[{ sourceSceneId: asSceneId('s1'), mode: 'create' }],
{
graphOffsetX: 200,
npcResolutions: [{ sourceNpcId, mode: 'create' }],
},
);
assert.equal(report.npcsCreated, 1);
assert.equal(merged.npcs.length, 1);
assert.equal(merged.npcs[0]!.name, 'Hero');
assert.ok(!('binding' in (merged.npcs[0] as object)));
});
void test('mergeStorylinesIntoProject: reuse existing scene by resolution', () => { void test('mergeStorylinesIntoProject: reuse existing scene by resolution', () => {
const targetSceneId = asSceneId('t1'); const targetSceneId = asSceneId('t1');
const target = minimalProject({ const target = minimalProject({
+13 -51
View File
@@ -1,8 +1,6 @@
import { noneBinding } from '../npcs/npcBinding';
import type { import type {
ExportedStorylineRef, ExportedStorylineRef,
GraphNodeId, GraphNodeId,
NpcBinding,
Project, Project,
ProjectNpc, ProjectNpc,
ProjectNpcGroup, ProjectNpcGroup,
@@ -241,6 +239,8 @@ export function buildPartialExportProject(
newProjectId: ProjectId; newProjectId: ProjectId;
exportTitle: string; exportTitle: string;
labels: StorylineLabels; labels: StorylineLabels;
/** Явный список НПС; пустой — без НПС в архиве. */
npcIds?: readonly string[];
}, },
): Project { ): Project {
const nodeIds = collectSelectionsGraphNodeIds(source, selections); const nodeIds = collectSelectionsGraphNodeIds(source, selections);
@@ -278,7 +278,7 @@ export function buildPartialExportProject(
const outgoing = recomputeOutgoing(draft.sceneGraphNodes, draft.sceneGraphEdges); const outgoing = recomputeOutgoing(draft.sceneGraphNodes, draft.sceneGraphEdges);
draft = { ...draft, scenes: applyConnectionSets(draft.scenes, outgoing) }; draft = { ...draft, scenes: applyConnectionSets(draft.scenes, outgoing) };
const exportedNpcs = filterNpcsForStorylineExport(source, selections); const exportedNpcs = selectNpcsByIds(source, opts.npcIds ?? []);
const exportedNpcIds = new Set(exportedNpcs.map((n) => n.id)); const exportedNpcIds = new Set(exportedNpcs.map((n) => n.id));
const exportedRelations = (source.npcRelations ?? []).filter( const exportedRelations = (source.npcRelations ?? []).filter(
(r) => exportedNpcIds.has(r.sourceNpcId) && exportedNpcIds.has(r.targetNpcId), (r) => exportedNpcIds.has(r.sourceNpcId) && exportedNpcIds.has(r.targetNpcId),
@@ -312,33 +312,16 @@ export function buildPartialExportProject(
}; };
} }
/** НПС для экспорта выбранных линий (main + unbound; side только свои). */ /** НПС по явному списку id (порядок — как в проекте). */
export function filterNpcsForStorylineExport( export function selectNpcsByIds(project: Project, npcIds: readonly string[]): ProjectNpc[] {
project: Project, if (npcIds.length === 0) return [];
selections: StorylineSelection[], const wanted = new Set(npcIds);
): ProjectNpc[] { return (project.npcs ?? []).filter((n) => wanted.has(n.id));
const npcs = project.npcs ?? [];
if (selections.length === 0) return [];
const includeUnbound = selections.some((s) => s.kind === 'main');
const hasMain = selections.some((s) => s.kind === 'main');
const sideRefs = new Set(
selections
.filter((s): s is { kind: 'side'; startGraphNodeId: GraphNodeId } => s.kind === 'side')
.map((s) => s.startGraphNodeId),
);
const sceneIdsInExport = new Set(collectSceneIdsForSelections(project, selections));
return npcs.filter((npc) => {
const b: NpcBinding = npc.binding ?? noneBinding();
if (b.kind === 'none') return includeUnbound;
if (b.kind === 'storyline') {
if (b.storyline.kind === 'main') return hasMain;
return sideRefs.has(b.storyline.startGraphNodeId);
} }
if (b.kind === 'scene') return sceneIdsInExport.has(b.sceneId);
return false; /** НПС из экспортного пакета — импортируем всех, кто в нём лежит. */
}); export function listExportedNpcsFromBundle(source: Project): ProjectNpc[] {
return [...(source.npcs ?? [])];
} }
function collectNpcGroupIdsForNpcs(groups: ProjectNpcGroup[], npcs: ProjectNpc[]): Set<NpcGroupId> { function collectNpcGroupIdsForNpcs(groups: ProjectNpcGroup[], npcs: ProjectNpc[]): Set<NpcGroupId> {
@@ -493,7 +476,7 @@ export function mergeStorylinesIntoProject(
for (const au of source.campaignAudios) neededAssetIds.add(au.assetId); for (const au of source.campaignAudios) neededAssetIds.add(au.assetId);
for (const m of source.materials ?? []) neededAssetIds.add(m.assetId); for (const m of source.materials ?? []) neededAssetIds.add(m.assetId);
const exportedNpcs = filterNpcsForStorylineExport(source, selections); const exportedNpcs = listExportedNpcsFromBundle(source);
for (const n of exportedNpcs) { for (const n of exportedNpcs) {
const res = npcResolutionBySource.get(n.id); const res = npcResolutionBySource.get(n.id);
if (res?.mode === 'use') continue; if (res?.mode === 'use') continue;
@@ -695,7 +678,6 @@ export function mergeStorylinesIntoProject(
const mappedGroupId = n.groupId const mappedGroupId = n.groupId
? (asNpcGroupId(groupIdMap.get(n.groupId) ?? n.groupId) as typeof n.groupId) ? (asNpcGroupId(groupIdMap.get(n.groupId) ?? n.groupId) as typeof n.groupId)
: null; : null;
const binding = remapNpcBinding(n.binding ?? noneBinding(), sceneIdMap, graphNodeIdMap);
npcs.push({ npcs.push({
id: newId, id: newId,
name, name,
@@ -704,7 +686,6 @@ export function mergeStorylinesIntoProject(
x: n.x, x: n.x,
y: n.y, y: n.y,
groupId: mappedGroupId && npcGroups.some((g) => g.id === mappedGroupId) ? mappedGroupId : null, groupId: mappedGroupId && npcGroups.some((g) => g.id === mappedGroupId) ? mappedGroupId : null,
binding,
}); });
npcNameKeys.add(name.toLowerCase()); npcNameKeys.add(name.toLowerCase());
npcsCreated += 1; npcsCreated += 1;
@@ -791,25 +772,6 @@ function topologicalNpcGroups(groups: ProjectNpcGroup[]): ProjectNpcGroup[] {
return out; return out;
} }
function remapNpcBinding(
binding: NpcBinding,
sceneIdMap: Map<SceneId, SceneId>,
graphNodeIdMap: Map<GraphNodeId, GraphNodeId>,
): NpcBinding {
if (binding.kind === 'none') return noneBinding();
if (binding.kind === 'scene') {
const mapped = sceneIdMap.get(binding.sceneId);
return mapped ? { kind: 'scene', sceneId: mapped } : noneBinding();
}
if (binding.storyline.kind === 'main') {
return { kind: 'storyline', storyline: { kind: 'main' } };
}
const mappedGn = graphNodeIdMap.get(binding.storyline.startGraphNodeId);
return mappedGn
? { kind: 'storyline', storyline: { kind: 'side', startGraphNodeId: mappedGn } }
: noneBinding();
}
export function newExportBundleProjectId(): ProjectId { export function newExportBundleProjectId(): ProjectId {
return asProjectId(`p_${generateId()}`); return asProjectId(`p_${generateId()}`);
} }
+10 -5
View File
@@ -12,7 +12,6 @@ import type {
NpcId, NpcId,
NpcRelationId, NpcRelationId,
NpcGroupId, NpcGroupId,
NpcBinding,
NpcsOverlayEvent, NpcsOverlayEvent,
NpcsOverlayState, NpcsOverlayState,
Project, Project,
@@ -369,7 +368,6 @@ export type IpcInvokeMap = {
description?: string; description?: string;
filePath?: string; filePath?: string;
groupId?: NpcGroupId | null; groupId?: NpcGroupId | null;
binding?: NpcBinding;
}; };
res: { project: Project }; res: { project: Project };
}; };
@@ -379,7 +377,6 @@ export type IpcInvokeMap = {
name?: string; name?: string;
description?: string; description?: string;
groupId?: NpcGroupId | null; groupId?: NpcGroupId | null;
binding?: NpcBinding;
}; };
res: { project: Project }; res: { project: Project };
}; };
@@ -493,7 +490,10 @@ export type IpcInvokeMap = {
}; };
[ipcChannels.project.getProjectStorylines]: { [ipcChannels.project.getProjectStorylines]: {
req: { projectId: ProjectId; labels: StorylineLabels }; req: { projectId: ProjectId; labels: StorylineLabels };
res: { storylines: StorylineListItem[] }; res: {
storylines: StorylineListItem[];
npcs: { id: string; name: string }[];
};
}; };
[ipcChannels.project.peekImportZip]: { [ipcChannels.project.peekImportZip]: {
req: { labels: StorylineLabels; targetHasMainStart: boolean }; req: { labels: StorylineLabels; targetHasMainStart: boolean };
@@ -560,7 +560,12 @@ export type IpcInvokeMap = {
res: { project: Project }; res: { project: Project };
}; };
[ipcChannels.project.exportZip]: { [ipcChannels.project.exportZip]: {
req: { projectId: ProjectId; storylineSelections: StorylineSelection[]; labels: StorylineLabels }; req: {
projectId: ProjectId;
storylineSelections: StorylineSelection[];
npcIds: string[];
labels: StorylineLabels;
};
res: { canceled: true } | { canceled: false }; res: { canceled: true } | { canceled: false };
}; };
[ipcChannels.project.deleteProject]: { [ipcChannels.project.deleteProject]: {
-80
View File
@@ -1,80 +0,0 @@
import { listSideStoryStarts } from '../graph/sceneGraphLineage';
import type { GraphNodeId, NpcBinding, NpcStorylineRef, Project, ProjectNpc, SceneId } from '../types';
export function noneBinding(): NpcBinding {
return { kind: 'none' };
}
export function isNpcBindingNone(b: NpcBinding | undefined | null): boolean {
return !b || b.kind === 'none';
}
export function storylineRefKey(ref: NpcStorylineRef): string {
return ref.kind === 'main' ? 'main' : `side:${ref.startGraphNodeId}`;
}
export function normalizeNpcBinding(
raw: unknown,
ctx: { sceneIds: Set<SceneId>; sideStartIds: Set<GraphNodeId>; hasMainStart: boolean },
): NpcBinding {
if (!raw || typeof raw !== 'object') return noneBinding();
const obj = raw as {
kind?: string;
sceneId?: string;
storyline?: { kind?: string; startGraphNodeId?: string };
};
if (obj.kind === 'scene' && typeof obj.sceneId === 'string') {
const sceneId = obj.sceneId as SceneId;
if (ctx.sceneIds.has(sceneId)) return { kind: 'scene', sceneId };
return noneBinding();
}
if (obj.kind === 'storyline' && obj.storyline && typeof obj.storyline === 'object') {
if (obj.storyline.kind === 'main') {
return ctx.hasMainStart ? { kind: 'storyline', storyline: { kind: 'main' } } : noneBinding();
}
if (obj.storyline.kind === 'side' && typeof obj.storyline.startGraphNodeId === 'string') {
const id = obj.storyline.startGraphNodeId as GraphNodeId;
if (ctx.sideStartIds.has(id)) {
return { kind: 'storyline', storyline: { kind: 'side', startGraphNodeId: id } };
}
}
}
return noneBinding();
}
export function clearNpcBindingsForDeletedScene(npcs: ProjectNpc[], sceneId: SceneId): ProjectNpc[] {
return npcs.map((n) => {
if (n.binding?.kind === 'scene' && n.binding.sceneId === sceneId) {
return { ...n, binding: noneBinding() };
}
return n;
});
}
export function clearNpcBindingsForRemovedStoryline(
npcs: ProjectNpc[],
removed: NpcStorylineRef,
): ProjectNpc[] {
return npcs.map((n) => {
if (n.binding?.kind !== 'storyline') return n;
const ref = n.binding.storyline;
if (removed.kind === 'main' && ref.kind === 'main') return { ...n, binding: noneBinding() };
if (removed.kind === 'side' && ref.kind === 'side' && ref.startGraphNodeId === removed.startGraphNodeId) {
return { ...n, binding: noneBinding() };
}
return n;
});
}
export function listStorylineOptionsForBinding(project: Project): {
main: boolean;
sides: { startGraphNodeId: GraphNodeId; label: string }[];
} {
const hasMain = project.sceneGraphNodes.some((n) => n.isStartScene);
const sides = listSideStoryStarts(project.sceneGraphNodes).map((n) => {
const scene = project.scenes[n.sceneId];
const label = n.sideStoryLineTitle.trim() || scene?.title.trim() || String(n.id);
return { startGraphNodeId: n.id, label };
});
return { main: hasMain, sides };
}
-12
View File
@@ -34,17 +34,6 @@ export type ProjectNpcGroup = {
parentId: NpcGroupId | null; parentId: NpcGroupId | null;
}; };
/** Привязка НПС к сюжетной линии. */
export type NpcStorylineRef =
| { kind: 'main' }
| { kind: 'side'; startGraphNodeId: GraphNodeId };
/** Привязка НПС к линии или сцене; `none` — без привязки. */
export type NpcBinding =
| { kind: 'none' }
| { kind: 'storyline'; storyline: NpcStorylineRef }
| { kind: 'scene'; sceneId: SceneId };
/** НПС кампании: персонаж с аватаром, описанием и связями на графе. */ /** НПС кампании: персонаж с аватаром, описанием и связями на графе. */
export type ProjectNpc = { export type ProjectNpc = {
id: NpcId; id: NpcId;
@@ -57,7 +46,6 @@ export type ProjectNpc = {
y: number; y: number;
/** `null` — системная секция «Без группы». */ /** `null` — системная секция «Без группы». */
groupId: NpcGroupId | null; groupId: NpcGroupId | null;
binding: NpcBinding;
}; };
/** Однонаправленная связь: от `sourceNpcId` к `targetNpcId`; подпись на линии. */ /** Однонаправленная связь: от `sourceNpcId` к `targetNpcId`; подпись на линии. */