Files
DndGamePlayer/app/renderer/editor/StorylineTransferModals.tsx
T
Ivan Fontosh f270812219 feat(tokens): app-local non-player tokens with session moves and UI polish
Add token library/placements, keep play-time moves for the session, lock presentation interactions, and fix export/import modal layout plus freeform trap label.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 11:04:12 +08:00

876 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import {
collectSceneIdsForSelections,
filterNpcsForStorylineExport,
findNpcNameConflicts,
findSceneTitleConflicts,
storylineSelectionKey,
type NpcImportResolution,
type NpcNameConflict,
type SceneImportResolution,
type SceneTitleConflict,
type StorylineImportMergeReport,
type StorylineLabels,
type StorylineListItem,
type StorylineSelection,
} from '../../shared/graph/storylineExportImport';
import type { Project, ProjectId, SceneId } from '../../shared/types';
import { Button, Select } from '../shared/ui/controls';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
type ExportProjectModalProps = {
open: boolean;
projects: { id: ProjectId; name: string; fileName: string }[];
initialProjectId: ProjectId | null;
storylineLabels: StorylineLabels;
loadStorylines: (projectId: ProjectId) => Promise<StorylineListItem[]>;
onClose: () => void;
onExport: (projectId: ProjectId, selections: StorylineSelection[]) => Promise<void>;
};
export function ExportProjectModal({
open,
projects,
initialProjectId,
storylineLabels,
loadStorylines,
onClose,
onExport,
}: ExportProjectModalProps) {
const { t } = useEditorI18n();
const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId);
const [storylines, setStorylines] = useState<StorylineListItem[]>([]);
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [loadingStorylines, setLoadingStorylines] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setProjectId(initialProjectId);
setSaving(false);
setError(null);
setSelectedKeys(new Set());
}, [initialProjectId, open]);
useEffect(() => {
if (!open || !projectId) {
setStorylines([]);
return;
}
let cancelled = false;
setLoadingStorylines(true);
void (async () => {
try {
const list = await loadStorylines(projectId);
if (cancelled) return;
setStorylines(list);
setSelectedKeys(new Set(list.map((item) => storylineSelectionKey(item.selection))));
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
} finally {
if (!cancelled) setLoadingStorylines(false);
}
})();
return () => {
cancelled = true;
};
}, [loadStorylines, open, projectId]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]);
if (!open) return null;
const canExport =
projectId !== null &&
projects.some((p) => p.id === projectId) &&
selectedKeys.size > 0 &&
!loadingStorylines;
const toggleKey = (key: string, disabled?: boolean) => {
if (disabled) return;
setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const selectedSelections = storylines
.filter((item) => selectedKeys.has(storylineSelectionKey(item.selection)))
.map((item) => item.selection);
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('export.title')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</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})`,
}))}
/>
<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}
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>
{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>
</div>
</div>
</>,
document.body,
);
}
export type ImportPeekResult = {
kind: 'file' | 'project';
filePath?: string;
sourceProjectId?: ProjectId;
projectName: string;
storylines: StorylineListItem[];
sourceProject: Project;
};
export type ImportSourceSelection =
| { kind: 'file'; filePath: string; fileName: string }
| { kind: 'project'; sourceProjectId: ProjectId };
type ImportSourceModalProps = {
open: boolean;
/** false — только импорт из файла (полный импорт проекта). */
canImportFromProject: boolean;
projects: { id: ProjectId; name: string; fileName: string }[];
currentProjectId: ProjectId | null;
pickFile: () => Promise<{ canceled: true } | { canceled: false; filePath: string }>;
onClose: () => void;
onContinue: (selection: ImportSourceSelection) => Promise<void>;
};
export function ImportSourceModal({
open,
canImportFromProject,
projects,
currentProjectId,
pickFile,
onClose,
onContinue,
}: ImportSourceModalProps) {
const { t } = useEditorI18n();
const [importKind, setImportKind] = useState<'project' | 'file'>('file');
const [sourceProjectId, setSourceProjectId] = useState<ProjectId | null>(null);
const [pickedFile, setPickedFile] = useState<{ path: string; name: string } | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const availableProjects = useMemo(
() => projects.filter((p) => p.id !== currentProjectId),
[currentProjectId, projects],
);
useEffect(() => {
if (!open) return;
setImportKind(canImportFromProject && availableProjects.length > 0 ? 'project' : 'file');
setSourceProjectId(availableProjects[0]?.id ?? null);
setPickedFile(null);
setSubmitting(false);
setError(null);
}, [availableProjects, canImportFromProject, open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]);
if (!open) return null;
const canContinue =
!submitting &&
(importKind === 'project' ? canImportFromProject && sourceProjectId !== null : pickedFile !== null);
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('importSource.title')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div className={styles.fieldGrid}>
{canImportFromProject ? (
<div className={styles.fieldGroup}>
<div className={styles.fieldLabel}>{t('importSource.type')}</div>
<Select
value={importKind}
onChange={(next) => setImportKind(next as 'project' | 'file')}
disabled={submitting}
ariaLabel={t('importSource.type')}
options={[
{ value: 'project', label: t('importSource.fromProject') },
{ value: 'file', label: t('importSource.fromFile') },
]}
/>
</div>
) : (
<div className={styles.muted}>{t('importSource.fileOnlyHint')}</div>
)}
{importKind === 'project' && canImportFromProject ? (
<div className={[styles.fieldGroup, canImportFromProject ? styles.fieldGroupSpaced : ''].filter(Boolean).join(' ')}>
<div className={styles.fieldLabel}>{t('importSource.project')}</div>
<Select
value={sourceProjectId ?? ''}
onChange={(next) => setSourceProjectId((next as ProjectId) || null)}
disabled={availableProjects.length === 0 || submitting}
ariaLabel={t('importSource.project')}
options={availableProjects.map((p) => ({
value: p.id,
label: `${p.name} (${p.fileName})`,
}))}
/>
{availableProjects.length === 0 ? (
<div className={styles.muted}>{t('importSource.noOtherProjects')}</div>
) : null}
</div>
) : (
<div
className={[styles.fieldGroup, canImportFromProject ? styles.fieldGroupSpaced : '']
.filter(Boolean)
.join(' ')}
>
<div className={styles.fieldLabel}>{t('importSource.file')}</div>
<div className={styles.importFileRow}>
<Button
disabled={submitting}
onClick={() => {
void (async () => {
setError(null);
const res = await pickFile();
if (res.canceled) return;
const name = res.filePath.split(/[/\\]/).pop() ?? res.filePath;
setPickedFile({ path: res.filePath, name });
})();
}}
>
{t('importSource.chooseFile')}
</Button>
<span className={styles.muted}>
{pickedFile ? pickedFile.name : t('importSource.noFileSelected')}
</span>
</div>
</div>
)}
</div>
{error ? <div className={styles.fieldError}>{error}</div> : null}
<div className={styles.modalFooter}>
<Button onClick={onClose} disabled={submitting}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
disabled={!canContinue}
onClick={() => {
void (async () => {
setSubmitting(true);
setError(null);
try {
if (importKind === 'project' && canImportFromProject && sourceProjectId) {
await onContinue({ kind: 'project', sourceProjectId });
} else if (pickedFile) {
await onContinue({ kind: 'file', filePath: pickedFile.path, fileName: pickedFile.name });
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSubmitting(false);
}
})();
}}
>
{t('importStoryline.continue')}
</Button>
</div>
</div>
</>,
document.body,
);
}
type ImportStorylinesModalProps = {
open: boolean;
sourceName: string;
storylines: StorylineListItem[];
onClose: () => void;
onContinue: (selections: StorylineSelection[]) => void;
};
export function ImportStorylinesModal({
open,
sourceName,
storylines,
onClose,
onContinue,
}: ImportStorylinesModalProps) {
const { t } = useEditorI18n();
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
useEffect(() => {
if (!open) return;
setSelectedKeys(new Set());
}, [open, sourceName]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]);
if (!open) return null;
const toggleKey = (key: string, disabled?: boolean) => {
if (disabled) return;
setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const selectedSelections = storylines
.filter((item) => selectedKeys.has(storylineSelectionKey(item.selection)))
.map((item) => item.selection);
const canContinue = selectedKeys.size > 0;
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('importStoryline.title')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>{t('importStoryline.source')}</div>
<div>{sourceName}</div>
<div className={styles.fieldLabel}>{t('storyline.section')}</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 disabled = item.disabled === true;
return (
<label
key={key}
className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}
>
<input
type="checkbox"
checked={selectedKeys.has(key)}
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>
);
})}
</div>
)}
</div>
<div className={styles.modalFooter}>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button variant="primary" disabled={!canContinue} onClick={() => onContinue(selectedSelections)}>
{t('importStoryline.continue')}
</Button>
</div>
</div>
</>,
document.body,
);
}
type SceneConflictModalProps = {
open: boolean;
conflicts: SceneTitleConflict[];
onClose: () => void;
onConfirm: (resolutions: SceneImportResolution[]) => void;
};
export function SceneConflictModal({ open, conflicts, onClose, onConfirm }: SceneConflictModalProps) {
const { t } = useEditorI18n();
const [choices, setChoices] = useState<Record<string, 'create' | SceneId>>({});
useEffect(() => {
if (!open) return;
const init: Record<string, 'create' | SceneId> = {};
for (const c of conflicts) {
init[c.sourceSceneId] = c.matches[0]?.sceneId ?? 'create';
}
setChoices(init);
}, [conflicts, open]);
if (!open) return null;
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={`${styles.modalDialog} ${styles.modalDialogWide}`}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('importStoryline.conflictsTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<p className={styles.muted}>{t('importStoryline.conflictsHint')}</p>
<div className={styles.conflictList}>
{conflicts.map((c) => (
<div key={c.sourceSceneId} className={styles.conflictRow}>
<div className={styles.conflictTitle}>{c.sourceTitle}</div>
<Select
value={choices[c.sourceSceneId] ?? 'create'}
onChange={(v) => {
setChoices((prev) => ({
...prev,
[c.sourceSceneId]: v === 'create' ? 'create' : (v as SceneId),
}));
}}
ariaLabel={c.sourceTitle}
options={[
{ value: 'create', label: t('importStoryline.createNewScene') },
...c.matches.map((m) => ({
value: m.sceneId,
label: t('importStoryline.useExistingScene', { title: m.title }),
})),
]}
/>
</div>
))}
</div>
<div className={styles.modalFooter}>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
const resolutions: SceneImportResolution[] = conflicts.map((c) => {
const choice = choices[c.sourceSceneId] ?? 'create';
if (choice === 'create') return { sourceSceneId: c.sourceSceneId, mode: 'create' };
return { sourceSceneId: c.sourceSceneId, mode: 'use', targetSceneId: choice };
});
onConfirm(resolutions);
}}
>
{t('importStoryline.import')}
</Button>
</div>
</div>
</>,
document.body,
);
}
type NpcConflictModalProps = {
open: boolean;
conflicts: NpcNameConflict[];
onClose: () => void;
onConfirm: (resolutions: NpcImportResolution[]) => void;
};
function defaultNpcConflictChoices(conflicts: NpcNameConflict[]): Record<string, 'create' | string> {
const init: Record<string, 'create' | string> = {};
for (const c of conflicts) {
init[c.sourceNpcId] = c.matches[0]?.npcId ?? 'create';
}
return init;
}
export function NpcConflictModal({ open, conflicts, onClose, onConfirm }: NpcConflictModalProps) {
if (!open) return null;
const remountKey = conflicts.map((c) => c.sourceNpcId).join('|');
return (
<NpcConflictModalBody
key={remountKey}
conflicts={conflicts}
onClose={onClose}
onConfirm={onConfirm}
/>
);
}
function NpcConflictModalBody({
conflicts,
onClose,
onConfirm,
}: {
conflicts: NpcNameConflict[];
onClose: () => void;
onConfirm: (resolutions: NpcImportResolution[]) => void;
}) {
const { t } = useEditorI18n();
const [choices, setChoices] = useState(() => defaultNpcConflictChoices(conflicts));
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div
role="dialog"
aria-modal="true"
className={[styles.modalDialog, styles.modalDialogWide].filter(Boolean).join(' ')}
>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('importStoryline.npcConflictsTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<p className={styles.muted}>{t('importStoryline.npcConflictsHint')}</p>
<div className={styles.conflictList}>
{conflicts.map((c) => (
<div key={c.sourceNpcId} className={styles.conflictRow}>
<div className={styles.conflictTitle}>{c.sourceName}</div>
<Select
value={choices[c.sourceNpcId] ?? 'create'}
onChange={(v) => {
setChoices((prev) => ({
...prev,
[c.sourceNpcId]: v === 'create' ? 'create' : v,
}));
}}
ariaLabel={c.sourceName}
options={[
{ value: 'create', label: t('importStoryline.createNewNpc') },
...c.matches.map((m) => ({
value: m.npcId,
label: t('importStoryline.useExistingNpc', { name: m.name }),
})),
]}
/>
</div>
))}
</div>
<div className={styles.modalFooter}>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
const resolutions: NpcImportResolution[] = conflicts.map((c) => {
const choice = choices[c.sourceNpcId] ?? 'create';
if (choice === 'create') return { sourceNpcId: c.sourceNpcId, mode: 'create' };
return { sourceNpcId: c.sourceNpcId, mode: 'use', targetNpcId: choice };
});
onConfirm(resolutions);
}}
>
{t('importStoryline.import')}
</Button>
</div>
</div>
</>,
document.body,
);
}
type ImportReportModalProps = {
open: boolean;
report: StorylineImportMergeReport | null;
onClose: () => void;
};
export function ImportReportModal({ open, report, onClose }: ImportReportModalProps) {
const { t } = useEditorI18n();
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]);
if (!open || !report) return null;
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('importStoryline.reportTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<ul className={styles.reportList}>
<li>{t('importStoryline.reportLines', { count: report.storylinesImported })}</li>
<li>{t('importStoryline.reportScenesCreated', { count: report.scenesCreated })}</li>
<li>{t('importStoryline.reportScenesReused', { count: report.scenesReused })}</li>
<li>{t('importStoryline.reportNpcsCreated', { count: report.npcsCreated })}</li>
<li>{t('importStoryline.reportNpcsReused', { count: report.npcsReused })}</li>
<li>{t('importStoryline.reportNodes', { count: report.graphNodesAdded })}</li>
<li>{t('importStoryline.reportEdges', { count: report.edgesAdded })}</li>
<li>{t('importStoryline.reportAssetsCopied', { count: report.assetsCopied })}</li>
<li>{t('importStoryline.reportAssetsReused', { count: report.assetsReused })}</li>
{report.renamedSideTitles.length > 0 ? (
<li>{t('importStoryline.reportRenamedSides', { names: report.renamedSideTitles.join(', ') })}</li>
) : null}
</ul>
<div className={styles.modalFooter}>
<Button variant="primary" onClick={onClose}>
{t('common.close')}
</Button>
</div>
</div>
</>,
document.body,
);
}
export function buildSceneResolutionsForImport(
targetProject: Project,
sourceProject: Project,
selections: StorylineSelection[],
conflicts: SceneTitleConflict[],
userResolutions: SceneImportResolution[],
): SceneImportResolution[] {
const sceneIds = collectSceneIdsForSelections(sourceProject, selections);
const conflictIds = new Set(conflicts.map((c) => c.sourceSceneId));
const bySource = new Map(userResolutions.map((r) => [r.sourceSceneId, r]));
const out: SceneImportResolution[] = [];
for (const sid of sceneIds) {
if (conflictIds.has(sid)) {
const r = bySource.get(sid);
if (r) out.push(r);
continue;
}
out.push({ sourceSceneId: sid, mode: 'create' });
}
return out;
}
export function computeImportConflicts(
targetProject: Project,
sourceProject: Project,
selections: StorylineSelection[],
): SceneTitleConflict[] {
const sceneIds = collectSceneIdsForSelections(sourceProject, selections);
return findSceneTitleConflicts(targetProject, sourceProject, sceneIds);
}
export function buildNpcResolutionsForImport(
_targetProject: Project,
sourceProject: Project,
selections: StorylineSelection[],
conflicts: NpcNameConflict[],
userResolutions: NpcImportResolution[],
): NpcImportResolution[] {
const exported = filterNpcsForStorylineExport(sourceProject, selections);
const conflictIds = new Set(conflicts.map((c) => c.sourceNpcId));
const bySource = new Map(userResolutions.map((r) => [r.sourceNpcId, r]));
const out: NpcImportResolution[] = [];
for (const n of exported) {
if (conflictIds.has(n.id)) {
const r = bySource.get(n.id);
if (r) out.push(r);
continue;
}
out.push({ sourceNpcId: n.id, mode: 'create' });
}
return out;
}
export function computeNpcImportConflicts(
targetProject: Project,
sourceProject: Project,
selections: StorylineSelection[],
): NpcNameConflict[] {
const exported = filterNpcsForStorylineExport(sourceProject, selections);
return findNpcNameConflicts(
targetProject,
sourceProject,
exported.map((n) => n.id),
);
}
export function useStorylineLabels(): StorylineLabels {
const { t } = useEditorI18n();
return useMemo(
() => ({
main: t('storyline.main'),
untitled: t('graph.untitled'),
}),
[t],
);
}