feat(editor): побочные сюжетные линии и импорт/экспорт линий
Добавлена полноценная поддержка побочных сюжетных линий в редакторе и на пульте: визуальное выделение компонент графа, запрет недопустимых связей между основной и побочными линиями, метки «ПОБОЧНАЯ» и названия линий. Реализован partial export/import сюжетных линий: - экспорт выбранных линий в урезанный .ttrpg.zip с manifest в project.json; - импорт в открытый проект с выбором линий, разрешением конфликтов названий сцен (одна модалка на операцию) и отчётом о результате; - новое окно источника импорта: «Из проекта» (dropdown, без текущего) или «Из файла»; на главном экране — только полный импорт из файла. Исправлены гонки при открытии/закрытии проекта (сериализация open/close в main, сброс зависших состояний UI), залипание оверлея прогресса экспорта и старт Electron в dev после готовности Vite. Добавлены unit-тесты для lineage, export/import и контрактов zipStore. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -941,3 +941,77 @@
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modalDialogWide {
|
||||
width: 640px;
|
||||
}
|
||||
|
||||
.storylineChecklist {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.storylineCheck {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.storylineCheck input {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.storylineCheckDisabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.conflictList {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.conflictRow {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.conflictRowTitle {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.conflictRowOptions {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.importFileRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.reportList {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
+221
-122
@@ -25,6 +25,19 @@ import { Button, Input } from '../shared/ui/controls';
|
||||
import { LayoutShell } from '../shared/ui/LayoutShell';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import type { SceneImportResolution, StorylineImportMergeReport, StorylineSelection } from '../../shared/graph/storylineExportImport';
|
||||
import {
|
||||
buildSceneResolutionsForImport,
|
||||
computeImportConflicts,
|
||||
ExportProjectModal,
|
||||
ImportReportModal,
|
||||
ImportSourceModal,
|
||||
ImportStorylinesModal,
|
||||
SceneConflictModal,
|
||||
useStorylineLabels,
|
||||
type ImportPeekResult,
|
||||
type ImportSourceSelection,
|
||||
} from './StorylineTransferModals';
|
||||
import { AppAboutModal, InstructionsModal } from './about/EditorAboutModals';
|
||||
import styles from './EditorApp.module.css';
|
||||
import { buildNextSceneCardById } from './graph/sceneCardById';
|
||||
@@ -92,6 +105,14 @@ export function EditorApp() {
|
||||
const [instructionsSection, setInstructionsSection] = useState<HelpSectionId>('overview');
|
||||
const [renameOpen, setRenameOpen] = useState(false);
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||
const [importSourceOpen, setImportSourceOpen] = useState(false);
|
||||
const [importPeek, setImportPeek] = useState<ImportPeekResult | null>(null);
|
||||
const [importStorylinesOpen, setImportStorylinesOpen] = useState(false);
|
||||
const [importConflictsOpen, setImportConflictsOpen] = useState(false);
|
||||
const [importConflicts, setImportConflicts] = useState<ReturnType<typeof computeImportConflicts>>([]);
|
||||
const [pendingImportSelections, setPendingImportSelections] = useState<StorylineSelection[]>([]);
|
||||
const [importReportOpen, setImportReportOpen] = useState(false);
|
||||
const [importReport, setImportReport] = useState<StorylineImportMergeReport | null>(null);
|
||||
const [previewDialogSceneId, setPreviewDialogSceneId] = useState<SceneId | null>(null);
|
||||
const [presentationOpen, setPresentationOpen] = useState(false);
|
||||
const [licenseSnap, setLicenseSnap] = useState<LicenseSnapshot | null>(null);
|
||||
@@ -119,6 +140,7 @@ export function EditorApp() {
|
||||
const graphUi = useMemo<SceneGraphUiStrings>(
|
||||
() => ({
|
||||
badgeStart: t('graph.badgeStart'),
|
||||
badgeSideStory: t('graph.badgeSideStory'),
|
||||
untitled: t('graph.untitled'),
|
||||
videoBadge: t('graph.videoBadge'),
|
||||
audioBadge: t('graph.audioBadge'),
|
||||
@@ -133,6 +155,8 @@ export function EditorApp() {
|
||||
closeMenu: t('common.closeMenu'),
|
||||
startScene: t('graph.startScene'),
|
||||
unsetStartScene: t('graph.unsetStartScene'),
|
||||
sideStoryStartScene: t('graph.sideStoryStartScene'),
|
||||
unsetSideStoryStartScene: t('graph.unsetSideStoryStartScene'),
|
||||
runFromScene: t('graph.runFromScene'),
|
||||
delete: t('common.delete'),
|
||||
}),
|
||||
@@ -146,6 +170,19 @@ export function EditorApp() {
|
||||
const [projectMenuPos, setProjectMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||||
const [settingsMenuPos, setSettingsMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||||
const [aboutMenuPos, setAboutMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||||
const [selectedGraphNodeId, setSelectedGraphNodeId] = useState<GraphNodeId | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedGraphNodeId(null);
|
||||
}, [state.project?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.project || !selectedGraphNodeId) return;
|
||||
if (!state.project.sceneGraphNodes.some((n) => n.id === selectedGraphNodeId)) {
|
||||
setSelectedGraphNodeId(null);
|
||||
}
|
||||
}, [selectedGraphNodeId, state.project?.sceneGraphNodes]);
|
||||
|
||||
const scenes = useMemo<SceneCard[]>(() => {
|
||||
const p = state.project;
|
||||
if (!p) return [];
|
||||
@@ -367,6 +404,79 @@ export function EditorApp() {
|
||||
}, []);
|
||||
|
||||
const exportModalInitialProjectId = state.project?.id ?? state.projects[0]?.id ?? null;
|
||||
const storylineLabels = useStorylineLabels();
|
||||
const targetHasMainStart = state.project?.sceneGraphNodes.some((n) => n.isStartScene) ?? false;
|
||||
|
||||
const loadProjectStorylines = useCallback(
|
||||
(projectId: ProjectId) => actions.getProjectStorylines(projectId, storylineLabels),
|
||||
[actions, storylineLabels],
|
||||
);
|
||||
|
||||
const runStorylineMerge = useCallback(
|
||||
async (selections: StorylineSelection[], resolutions: SceneImportResolution[]) => {
|
||||
if (!importPeek) return;
|
||||
const { report } =
|
||||
importPeek.kind === 'project' && importPeek.sourceProjectId
|
||||
? await actions.mergeImportFromProject(importPeek.sourceProjectId, selections, resolutions)
|
||||
: await actions.mergeImportZip(importPeek.filePath!, selections, resolutions);
|
||||
setImportReport(report);
|
||||
setImportReportOpen(true);
|
||||
setImportPeek(null);
|
||||
setImportStorylinesOpen(false);
|
||||
setImportConflictsOpen(false);
|
||||
setPendingImportSelections([]);
|
||||
setImportConflicts([]);
|
||||
},
|
||||
[actions, importPeek],
|
||||
);
|
||||
|
||||
const handleImportSourceContinue = useCallback(
|
||||
async (selection: ImportSourceSelection) => {
|
||||
if (!state.project) {
|
||||
if (selection.kind === 'file') {
|
||||
await actions.importProjectFromPath(selection.filePath);
|
||||
setImportSourceOpen(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const peek =
|
||||
selection.kind === 'project'
|
||||
? await actions.peekImportFromProject(
|
||||
selection.sourceProjectId,
|
||||
storylineLabels,
|
||||
targetHasMainStart,
|
||||
)
|
||||
: await actions.peekImportZipPath(selection.filePath, storylineLabels, targetHasMainStart);
|
||||
setImportPeek({
|
||||
kind: selection.kind,
|
||||
...(selection.kind === 'file'
|
||||
? { filePath: selection.filePath }
|
||||
: { sourceProjectId: selection.sourceProjectId }),
|
||||
projectName: peek.projectName,
|
||||
storylines: peek.storylines,
|
||||
sourceProject: peek.sourceProject,
|
||||
});
|
||||
setImportSourceOpen(false);
|
||||
setImportStorylinesOpen(true);
|
||||
},
|
||||
[actions, state.project, storylineLabels, targetHasMainStart],
|
||||
);
|
||||
|
||||
const handleImportProject = useCallback(() => {
|
||||
setProjectMenuOpen(false);
|
||||
setImportSourceOpen(true);
|
||||
}, []);
|
||||
|
||||
const goHome = useCallback(() => {
|
||||
setProjectMenuOpen(false);
|
||||
setExportModalOpen(false);
|
||||
setImportSourceOpen(false);
|
||||
setImportStorylinesOpen(false);
|
||||
setImportConflictsOpen(false);
|
||||
setImportReportOpen(false);
|
||||
setImportPeek(null);
|
||||
void actions.closeProject();
|
||||
}, [actions]);
|
||||
|
||||
const bodyOverlay =
|
||||
licenseSnap === null ? (
|
||||
@@ -424,7 +534,7 @@ export function EditorApp() {
|
||||
type="button"
|
||||
className={styles.brandButton}
|
||||
onClick={() => {
|
||||
void actions.closeProject();
|
||||
goHome();
|
||||
}}
|
||||
title={t('top.backToProjects')}
|
||||
>
|
||||
@@ -562,7 +672,10 @@ export function EditorApp() {
|
||||
<SceneListCard
|
||||
key={s.id}
|
||||
scene={s}
|
||||
onSelect={() => void actions.selectScene(s.id)}
|
||||
onSelect={() => {
|
||||
setSelectedGraphNodeId(null);
|
||||
void actions.selectScene(s.id);
|
||||
}}
|
||||
onDeleteScene={(id) => void actions.deleteScene(id)}
|
||||
/>
|
||||
))}
|
||||
@@ -604,8 +717,11 @@ export function EditorApp() {
|
||||
sceneGraphNodes={state.project.sceneGraphNodes}
|
||||
sceneGraphEdges={state.project.sceneGraphEdges}
|
||||
sceneCardById={sceneCardById}
|
||||
currentSceneId={state.selectedSceneId}
|
||||
onCurrentSceneChange={(id) => void actions.selectScene(id)}
|
||||
selectedGraphNodeId={selectedGraphNodeId}
|
||||
onGraphNodeSelect={(graphNodeId, sceneId) => {
|
||||
setSelectedGraphNodeId(graphNodeId);
|
||||
void actions.selectScene(sceneId);
|
||||
}}
|
||||
onConnect={(sourceGn, targetGn) => void actions.addSceneGraphEdge(sourceGn, targetGn)}
|
||||
onDisconnect={(edgeId) => void actions.removeSceneGraphEdge(edgeId)}
|
||||
onNodePositionCommit={(nodeId, x, y) =>
|
||||
@@ -616,6 +732,9 @@ export function EditorApp() {
|
||||
}}
|
||||
onRemoveGraphNode={(id) => void actions.removeSceneGraphNode(id)}
|
||||
onSetGraphNodeStart={(graphNodeId) => void actions.setSceneGraphNodeStart(graphNodeId)}
|
||||
onSetGraphNodeSideStoryStart={(graphNodeId) =>
|
||||
void actions.setSceneGraphNodeSideStoryStart(graphNodeId)
|
||||
}
|
||||
onRunFromGraphNode={launchFromGraphNode}
|
||||
onDropSceneFromList={(sceneId, x, y) => void actions.addSceneGraphNode(sceneId, x, y)}
|
||||
/>
|
||||
@@ -666,10 +785,14 @@ export function EditorApp() {
|
||||
: previewImport !== null
|
||||
? t('scene.previewOptimizing')
|
||||
: t('scene.previewBusy');
|
||||
const sideStoryStartNodes = proj.sceneGraphNodes.filter(
|
||||
(n) => n.sceneId === sid && n.isSideStoryStart,
|
||||
);
|
||||
return (
|
||||
<SceneInspector
|
||||
title={sc?.title ?? ''}
|
||||
description={sc?.description ?? ''}
|
||||
sideStoryStartNodes={sideStoryStartNodes}
|
||||
previewAssetId={sc?.previewAssetId ?? null}
|
||||
previewAssetType={sc?.previewAssetType ?? null}
|
||||
previewVideoAutostart={sc?.previewVideoAutostart ?? false}
|
||||
@@ -711,6 +834,9 @@ export function EditorApp() {
|
||||
void actions.updateScene(sid, { previewRotationDeg })
|
||||
}
|
||||
onUploadMedia={() => void actions.importMediaToScene(sid)}
|
||||
onSideStoryLineTitleChange={(graphNodeId, title) =>
|
||||
void actions.updateSideStoryLineTitle(graphNodeId, title)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
@@ -910,8 +1036,7 @@ export function EditorApp() {
|
||||
role="menuitem"
|
||||
className={styles.fileMenuItem}
|
||||
onClick={() => {
|
||||
setProjectMenuOpen(false);
|
||||
void actions.closeProject();
|
||||
goHome();
|
||||
}}
|
||||
>
|
||||
{t('projectMenu.home')}
|
||||
@@ -921,8 +1046,7 @@ export function EditorApp() {
|
||||
role="menuitem"
|
||||
className={styles.fileMenuItem}
|
||||
onClick={() => {
|
||||
setProjectMenuOpen(false);
|
||||
void actions.importProject();
|
||||
void handleImportProject();
|
||||
}}
|
||||
>
|
||||
{t('projectMenu.import')}
|
||||
@@ -990,9 +1114,77 @@ export function EditorApp() {
|
||||
open={exportModalOpen}
|
||||
projects={state.projects}
|
||||
initialProjectId={exportModalInitialProjectId}
|
||||
storylineLabels={storylineLabels}
|
||||
loadStorylines={loadProjectStorylines}
|
||||
onClose={() => setExportModalOpen(false)}
|
||||
onExport={async (projectId) => {
|
||||
await actions.exportProject(projectId);
|
||||
onExport={async (projectId, selections) => {
|
||||
await actions.exportProject(projectId, selections, storylineLabels);
|
||||
}}
|
||||
/>
|
||||
<ImportSourceModal
|
||||
open={importSourceOpen}
|
||||
canImportFromProject={state.project !== null}
|
||||
projects={state.projects}
|
||||
currentProjectId={state.project?.id ?? null}
|
||||
pickFile={actions.pickImportZipFile}
|
||||
onClose={() => setImportSourceOpen(false)}
|
||||
onContinue={handleImportSourceContinue}
|
||||
/>
|
||||
<ImportStorylinesModal
|
||||
open={importStorylinesOpen}
|
||||
sourceName={importPeek?.projectName ?? ''}
|
||||
storylines={importPeek?.storylines ?? []}
|
||||
onClose={() => {
|
||||
setImportStorylinesOpen(false);
|
||||
setImportPeek(null);
|
||||
}}
|
||||
onContinue={(selections) => {
|
||||
if (!importPeek || !state.project) return;
|
||||
const conflicts = computeImportConflicts(state.project, importPeek.sourceProject, selections);
|
||||
setPendingImportSelections(selections);
|
||||
if (conflicts.length > 0) {
|
||||
setImportConflicts(conflicts);
|
||||
setImportConflictsOpen(true);
|
||||
setImportStorylinesOpen(false);
|
||||
return;
|
||||
}
|
||||
const resolutions = buildSceneResolutionsForImport(
|
||||
state.project,
|
||||
importPeek.sourceProject,
|
||||
selections,
|
||||
[],
|
||||
[],
|
||||
);
|
||||
void runStorylineMerge(selections, resolutions);
|
||||
}}
|
||||
/>
|
||||
<SceneConflictModal
|
||||
open={importConflictsOpen}
|
||||
conflicts={importConflicts}
|
||||
onClose={() => {
|
||||
setImportConflictsOpen(false);
|
||||
setImportPeek(null);
|
||||
setPendingImportSelections([]);
|
||||
setImportConflicts([]);
|
||||
}}
|
||||
onConfirm={(userResolutions) => {
|
||||
if (!importPeek || !state.project) return;
|
||||
const resolutions = buildSceneResolutionsForImport(
|
||||
state.project,
|
||||
importPeek.sourceProject,
|
||||
pendingImportSelections,
|
||||
importConflicts,
|
||||
userResolutions,
|
||||
);
|
||||
void runStorylineMerge(pendingImportSelections, resolutions);
|
||||
}}
|
||||
/>
|
||||
<ImportReportModal
|
||||
open={importReportOpen}
|
||||
report={importReport}
|
||||
onClose={() => {
|
||||
setImportReportOpen(false);
|
||||
setImportReport(null);
|
||||
}}
|
||||
/>
|
||||
<CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(false)} />
|
||||
@@ -1006,118 +1198,6 @@ export function EditorApp() {
|
||||
);
|
||||
}
|
||||
|
||||
type ExportProjectModalProps = {
|
||||
open: boolean;
|
||||
projects: { id: ProjectId; name: string; fileName: string }[];
|
||||
initialProjectId: ProjectId | null;
|
||||
onClose: () => void;
|
||||
onExport: (projectId: ProjectId) => Promise<void>;
|
||||
};
|
||||
|
||||
function ExportProjectModal({
|
||||
open,
|
||||
projects,
|
||||
initialProjectId,
|
||||
onClose,
|
||||
onExport,
|
||||
}: ExportProjectModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setProjectId(initialProjectId);
|
||||
setSaving(false);
|
||||
setError(null);
|
||||
}, [initialProjectId, 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 canExport = projectId !== null && projects.some((p) => p.id === projectId);
|
||||
|
||||
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
|
||||
className={styles.selectInput}
|
||||
value={projectId ?? ''}
|
||||
onChange={(e) => setProjectId((e.target.value as ProjectId) || null)}
|
||||
disabled={projects.length === 0}
|
||||
>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.fileName})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<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);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('export.saveAs')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type CheckUpdatesModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -1787,6 +1867,7 @@ function ProjectPicker({
|
||||
type SceneInspectorProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
sideStoryStartNodes: { id: GraphNodeId; sideStoryLineTitle: string }[];
|
||||
previewAssetId: AssetId | null;
|
||||
previewAssetType: 'image' | 'video' | null;
|
||||
previewVideoAutostart: boolean;
|
||||
@@ -1805,6 +1886,7 @@ type SceneInspectorProps = {
|
||||
onClearPreview: () => void;
|
||||
onRotatePreview: (deg: 0 | 90 | 180 | 270) => void;
|
||||
onUploadMedia: () => void;
|
||||
onSideStoryLineTitleChange: (graphNodeId: GraphNodeId, title: string) => void;
|
||||
};
|
||||
|
||||
type CampaignInspectorProps = {
|
||||
@@ -1897,6 +1979,7 @@ function CampaignInspector({
|
||||
function SceneInspector({
|
||||
title,
|
||||
description,
|
||||
sideStoryStartNodes,
|
||||
previewAssetId,
|
||||
previewAssetType,
|
||||
previewVideoAutostart,
|
||||
@@ -1915,6 +1998,7 @@ function SceneInspector({
|
||||
onClearPreview,
|
||||
onRotatePreview,
|
||||
onUploadMedia,
|
||||
onSideStoryLineTitleChange,
|
||||
}: SceneInspectorProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const previewUrl = useAssetUrl(previewAssetId);
|
||||
@@ -1930,6 +2014,21 @@ function SceneInspector({
|
||||
value={description}
|
||||
onChange={(e) => onDescriptionChange(e.target.value)}
|
||||
/>
|
||||
{sideStoryStartNodes.length > 0 ? (
|
||||
<>
|
||||
<div className={styles.spacer8} />
|
||||
{sideStoryStartNodes.map((gn) => (
|
||||
<div key={gn.id}>
|
||||
<div className={styles.labelSm}>{t('scene.sideStoryLineTitle')}</div>
|
||||
<Input
|
||||
value={gn.sideStoryLineTitle}
|
||||
onChange={(v) => onSideStoryLineTitleChange(gn.id, v)}
|
||||
/>
|
||||
<div className={styles.spacer8} />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
<div className={styles.spacer6} />
|
||||
<div className={styles.labelSm}>{t('scene.preview')}</div>
|
||||
<div className={styles.hint}>{t('scene.previewHint')}</div>
|
||||
|
||||
@@ -0,0 +1,671 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import {
|
||||
collectSceneIdsForSelections,
|
||||
findSceneTitleConflicts,
|
||||
storylineSelectionKey,
|
||||
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 } from '../shared/ui/controls';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
import styles from './EditorApp.module.css';
|
||||
|
||||
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
|
||||
className={styles.selectInput}
|
||||
value={projectId ?? ''}
|
||||
onChange={(e) => setProjectId((e.target.value as ProjectId) || null)}
|
||||
disabled={projects.length === 0}
|
||||
>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.fileName})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<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.fieldLabel}>{t('importSource.type')}</div>
|
||||
<select
|
||||
className={styles.selectInput}
|
||||
value={importKind}
|
||||
onChange={(e) => setImportKind(e.target.value as 'project' | 'file')}
|
||||
disabled={submitting}
|
||||
>
|
||||
<option value="project">{t('importSource.fromProject')}</option>
|
||||
<option value="file">{t('importSource.fromFile')}</option>
|
||||
</select>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.muted}>{t('importSource.fileOnlyHint')}</div>
|
||||
)}
|
||||
|
||||
{importKind === 'project' && canImportFromProject ? (
|
||||
<>
|
||||
<div className={styles.fieldLabel}>{t('importSource.project')}</div>
|
||||
<select
|
||||
className={styles.selectInput}
|
||||
value={sourceProjectId ?? ''}
|
||||
onChange={(e) => setSourceProjectId((e.target.value as ProjectId) || null)}
|
||||
disabled={availableProjects.length === 0 || submitting}
|
||||
>
|
||||
{availableProjects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.fileName})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{availableProjects.length === 0 ? (
|
||||
<div className={styles.muted}>{t('importSource.noOtherProjects')}</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
|
||||
{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
|
||||
className={styles.selectInput}
|
||||
value={choices[c.sourceSceneId] ?? 'create'}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setChoices((prev) => ({
|
||||
...prev,
|
||||
[c.sourceSceneId]: v === 'create' ? 'create' : (v as SceneId),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<option value="create">{t('importStoryline.createNewScene')}</option>
|
||||
{c.matches.map((m) => (
|
||||
<option key={m.sceneId} value={m.sceneId}>
|
||||
{t('importStoryline.useExistingScene', { title: m.title })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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 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.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 useStorylineLabels(): StorylineLabels {
|
||||
const { t } = useEditorI18n();
|
||||
return useMemo(
|
||||
() => ({
|
||||
main: t('storyline.main'),
|
||||
untitled: t('graph.untitled'),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,12 @@
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.handleSide {
|
||||
background: var(--side-story-handle);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.card {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
@@ -26,6 +32,13 @@
|
||||
0 25px 50px -12px rgba(167, 139, 250, 0.12);
|
||||
}
|
||||
|
||||
.cardActiveSide {
|
||||
border-color: rgba(0, 120, 212, 0.95);
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(0, 120, 212, 0.35),
|
||||
0 25px 50px -12px rgba(0, 120, 212, 0.12);
|
||||
}
|
||||
|
||||
.previewShell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -65,6 +78,21 @@
|
||||
box-shadow: var(--shadow-start-badge);
|
||||
}
|
||||
|
||||
.badgeSideStory {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 2;
|
||||
font-size: 8.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--side-story-fill-solid);
|
||||
color: var(--text-on-accent);
|
||||
box-shadow: var(--shadow-side-story-badge);
|
||||
}
|
||||
|
||||
.cornerBadges {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useS
|
||||
import { createPortal } from 'react-dom';
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
ConnectionMode,
|
||||
Handle,
|
||||
MarkerType,
|
||||
Panel,
|
||||
@@ -19,6 +20,11 @@ import ReactFlow, {
|
||||
import 'reactflow/dist/style.css';
|
||||
|
||||
import { isSceneGraphEdgeRejected } from '../../../shared/graph/sceneGraphEdgeRules';
|
||||
import {
|
||||
canSetSideStoryStart,
|
||||
isNodeInSideStoryline,
|
||||
isSideStoryEdge,
|
||||
} from '../../../shared/graph/sceneGraphLineage';
|
||||
import type { AssetId, GraphNodeId, SceneGraphEdge, SceneGraphNode, SceneId } from '../../../shared/types';
|
||||
import { RotatedImage } from '../../shared/RotatedImage';
|
||||
import { useAssetUrl } from '../../shared/useAssetImageUrl';
|
||||
@@ -53,6 +59,7 @@ const SCENE_CARD_H = 248;
|
||||
/** UI strings for the scene graph (passed from editor i18n). */
|
||||
export type SceneGraphUiStrings = {
|
||||
badgeStart: string;
|
||||
badgeSideStory: string;
|
||||
untitled: string;
|
||||
videoBadge: string;
|
||||
audioBadge: string;
|
||||
@@ -67,12 +74,15 @@ export type SceneGraphUiStrings = {
|
||||
closeMenu: string;
|
||||
startScene: string;
|
||||
unsetStartScene: string;
|
||||
sideStoryStartScene: string;
|
||||
unsetSideStoryStartScene: string;
|
||||
runFromScene: string;
|
||||
delete: string;
|
||||
};
|
||||
|
||||
const DEFAULT_SCENE_GRAPH_UI: SceneGraphUiStrings = {
|
||||
badgeStart: 'НАЧАЛО',
|
||||
badgeSideStory: 'ПОБОЧНАЯ',
|
||||
untitled: 'Без названия',
|
||||
videoBadge: 'Видео',
|
||||
audioBadge: 'Аудио',
|
||||
@@ -87,6 +97,8 @@ const DEFAULT_SCENE_GRAPH_UI: SceneGraphUiStrings = {
|
||||
closeMenu: 'Закрыть меню',
|
||||
startScene: 'Начальная сцена',
|
||||
unsetStartScene: 'Снять метку «Начальная сцена»',
|
||||
sideStoryStartScene: 'Начальная сцена побочной линии',
|
||||
unsetSideStoryStartScene: 'Снять метку «Начальная сцена побочной линии»',
|
||||
runFromScene: 'Запустить с этой сцены',
|
||||
delete: 'Удалить',
|
||||
};
|
||||
@@ -97,15 +109,17 @@ export type SceneGraphProps = {
|
||||
sceneGraphNodes: SceneGraphNode[];
|
||||
sceneGraphEdges: SceneGraphEdge[];
|
||||
sceneCardById: Record<SceneId, SceneGraphSceneCard>;
|
||||
currentSceneId: SceneId | null;
|
||||
/** Выделенная карточка на графе (одна нода, не все копии сцены). */
|
||||
selectedGraphNodeId: GraphNodeId | null;
|
||||
graphUi?: SceneGraphUiStrings;
|
||||
onCurrentSceneChange: (id: SceneId) => void;
|
||||
onGraphNodeSelect: (graphNodeId: GraphNodeId, sceneId: SceneId) => void;
|
||||
onConnect: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => void;
|
||||
onDisconnect: (edgeId: string) => void;
|
||||
onNodePositionCommit: (nodeId: GraphNodeId, x: number, y: number) => void;
|
||||
onRemoveGraphNodes: (nodeIds: GraphNodeId[]) => void;
|
||||
onRemoveGraphNode: (graphNodeId: GraphNodeId) => void;
|
||||
onSetGraphNodeStart: (graphNodeId: GraphNodeId | null) => void;
|
||||
onSetGraphNodeSideStoryStart: (graphNodeId: GraphNodeId) => void;
|
||||
onRunFromGraphNode?: (graphNodeId: GraphNodeId) => void;
|
||||
onDropSceneFromList: (sceneId: SceneId, x: number, y: number) => void;
|
||||
};
|
||||
@@ -120,6 +134,8 @@ type SceneCardData = {
|
||||
previewVideoAutostart: boolean;
|
||||
previewRotationDeg: 0 | 90 | 180 | 270;
|
||||
isStartScene: boolean;
|
||||
isSideStoryStart: boolean;
|
||||
isSideStoryNode: boolean;
|
||||
hasSceneAudio: boolean;
|
||||
previewIsVideo: boolean;
|
||||
hasAnyAudioLoop: boolean;
|
||||
@@ -179,15 +195,24 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
const ui = useContext(GraphUiContext);
|
||||
const thumbUrl = useAssetUrl(data.previewThumbAssetId);
|
||||
const previewUrl = useAssetUrl(data.previewAssetId);
|
||||
const cardClass = [styles.card, data.active ? styles.cardActive : ''].filter(Boolean).join(' ');
|
||||
const cardClass = [
|
||||
styles.card,
|
||||
data.active ? (data.isSideStoryNode ? styles.cardActiveSide : styles.cardActive) : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const handleClass = data.isSideStoryNode ? styles.handleSide : styles.handle;
|
||||
const showCornerVideo = data.previewIsVideo;
|
||||
const showCornerAudio = data.hasSceneAudio;
|
||||
return (
|
||||
<div className={styles.nodeWrap}>
|
||||
<Handle type="target" position={Position.Top} className={styles.handle} />
|
||||
<Handle type="target" position={Position.Top} className={handleClass} />
|
||||
<div className={cardClass}>
|
||||
<div className={styles.previewShell}>
|
||||
{data.isStartScene ? <div className={styles.badgeStart}>{ui.badgeStart}</div> : null}
|
||||
{data.isSideStoryStart ? (
|
||||
<div className={styles.badgeSideStory}>{ui.badgeSideStory}</div>
|
||||
) : null}
|
||||
{thumbUrl ? (
|
||||
<div className={styles.previewFill}>
|
||||
{data.previewRotationDeg === 0 ? (
|
||||
@@ -303,7 +328,7 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} className={styles.handle} />
|
||||
<Handle type="source" position={Position.Bottom} className={handleClass} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -353,15 +378,16 @@ function SceneGraphCanvas({
|
||||
sceneGraphNodes,
|
||||
sceneGraphEdges,
|
||||
sceneCardById,
|
||||
currentSceneId,
|
||||
selectedGraphNodeId,
|
||||
graphUi,
|
||||
onCurrentSceneChange,
|
||||
onGraphNodeSelect,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onNodePositionCommit,
|
||||
onRemoveGraphNodes,
|
||||
onRemoveGraphNode,
|
||||
onSetGraphNodeStart,
|
||||
onSetGraphNodeSideStoryStart,
|
||||
onRunFromGraphNode,
|
||||
onDropSceneFromList,
|
||||
}: SceneGraphProps) {
|
||||
@@ -387,10 +413,31 @@ function SceneGraphCanvas({
|
||||
return sceneGraphNodes.some((n) => n.id === menu.graphNodeId && n.isStartScene);
|
||||
}, [menu, sceneGraphNodes]);
|
||||
|
||||
const menuNodeIsSideStoryStart = useMemo(() => {
|
||||
if (!menu) return false;
|
||||
return sceneGraphNodes.some((n) => n.id === menu.graphNodeId && n.isSideStoryStart);
|
||||
}, [menu, sceneGraphNodes]);
|
||||
|
||||
const menuCanSetSideStoryStart = useMemo(() => {
|
||||
if (!menu) return false;
|
||||
return canSetSideStoryStart(sceneGraphNodes, sceneGraphEdges, menu.graphNodeId);
|
||||
}, [menu, sceneGraphEdges, sceneGraphNodes]);
|
||||
|
||||
const menuNodeIsSideBranch = useMemo(() => {
|
||||
if (!menu) return false;
|
||||
return isNodeInSideStoryline(sceneGraphNodes, sceneGraphEdges, menu.graphNodeId);
|
||||
}, [menu, sceneGraphEdges, sceneGraphNodes]);
|
||||
|
||||
const sideStoryEdgeStroke = 'rgba(0,120,212,0.95)';
|
||||
const sideStoryEdgeStrokeDim = 'rgba(0,120,212,0.55)';
|
||||
const mainEdgeStroke = 'rgba(167,139,250,0.95)';
|
||||
const mainEdgeStrokeDim = 'rgba(167,139,250,0.55)';
|
||||
|
||||
const desiredNodes = useMemo<Node<SceneCardData>[]>(() => {
|
||||
return sceneGraphNodes.map((gn) => {
|
||||
const c = sceneCardById[gn.sceneId];
|
||||
const active = gn.sceneId === currentSceneId;
|
||||
const active = selectedGraphNodeId === gn.id;
|
||||
const isSideStoryNode = isNodeInSideStoryline(sceneGraphNodes, sceneGraphEdges, gn.id);
|
||||
const audios = c?.audios ?? [];
|
||||
return {
|
||||
id: gn.id,
|
||||
@@ -406,6 +453,8 @@ function SceneGraphCanvas({
|
||||
previewVideoAutostart: c?.previewVideoAutostart ?? false,
|
||||
previewRotationDeg: c?.previewRotationDeg ?? 0,
|
||||
isStartScene: gn.isStartScene,
|
||||
isSideStoryStart: gn.isSideStoryStart,
|
||||
isSideStoryNode,
|
||||
hasSceneAudio: audios.length >= 1,
|
||||
previewIsVideo: c?.previewAssetType === 'video',
|
||||
hasAnyAudioLoop: audios.some((a) => a.loop),
|
||||
@@ -416,40 +465,46 @@ function SceneGraphCanvas({
|
||||
style: { padding: 0, background: 'transparent', border: 'none' },
|
||||
};
|
||||
});
|
||||
}, [currentSceneId, sceneCardById, sceneGraphNodes]);
|
||||
}, [sceneCardById, sceneGraphEdges, sceneGraphNodes, selectedGraphNodeId]);
|
||||
|
||||
const desiredEdges = useMemo<Edge[]>(() => {
|
||||
const selectedGraphNodeIds = new Set<GraphNodeId>();
|
||||
if (currentSceneId) {
|
||||
for (const gn of sceneGraphNodes) {
|
||||
if (gn.sceneId === currentSceneId) selectedGraphNodeIds.add(gn.id);
|
||||
}
|
||||
}
|
||||
const hasSelection = selectedGraphNodeIds.size > 0;
|
||||
return sceneGraphEdges.map((e) => ({
|
||||
...(hasSelection
|
||||
? {
|
||||
style:
|
||||
selectedGraphNodeIds.has(e.sourceGraphNodeId) || selectedGraphNodeIds.has(e.targetGraphNodeId)
|
||||
? { stroke: 'rgba(167,139,250,0.95)', strokeWidth: 3 }
|
||||
: { stroke: 'rgba(255,255,255,0.10)', strokeWidth: 2 },
|
||||
markerEnd:
|
||||
selectedGraphNodeIds.has(e.sourceGraphNodeId) || selectedGraphNodeIds.has(e.targetGraphNodeId)
|
||||
? { type: MarkerType.ArrowClosed, color: 'rgba(167,139,250,0.95)', strokeWidth: 2 }
|
||||
: { type: MarkerType.ArrowClosed, color: 'rgba(255,255,255,0.18)', strokeWidth: 2 },
|
||||
}
|
||||
: {
|
||||
style: { stroke: 'rgba(167,139,250,0.55)', strokeWidth: 2 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: 'rgba(167,139,250,0.85)', strokeWidth: 2 },
|
||||
}),
|
||||
id: e.id,
|
||||
source: e.sourceGraphNodeId,
|
||||
target: e.targetGraphNodeId,
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
selectable: false,
|
||||
}));
|
||||
}, [currentSceneId, sceneGraphEdges, sceneGraphNodes]);
|
||||
const hasSelection = selectedGraphNodeId != null;
|
||||
return sceneGraphEdges.map((e) => {
|
||||
const isSide = isSideStoryEdge(sceneGraphNodes, sceneGraphEdges, e);
|
||||
const strokeActive = isSide ? sideStoryEdgeStroke : mainEdgeStroke;
|
||||
const strokeIdle = isSide ? sideStoryEdgeStrokeDim : mainEdgeStrokeDim;
|
||||
const strokeDim = 'rgba(255,255,255,0.10)';
|
||||
const markerDim = 'rgba(255,255,255,0.18)';
|
||||
const touchesSelection =
|
||||
selectedGraphNodeId != null &&
|
||||
(e.sourceGraphNodeId === selectedGraphNodeId || e.targetGraphNodeId === selectedGraphNodeId);
|
||||
return {
|
||||
...(hasSelection
|
||||
? {
|
||||
style: touchesSelection
|
||||
? { stroke: strokeActive, strokeWidth: 3 }
|
||||
: { stroke: strokeDim, strokeWidth: 2 },
|
||||
markerEnd: touchesSelection
|
||||
? { type: MarkerType.ArrowClosed, color: strokeActive, strokeWidth: 2 }
|
||||
: { type: MarkerType.ArrowClosed, color: markerDim, strokeWidth: 2 },
|
||||
}
|
||||
: {
|
||||
style: { stroke: strokeIdle, strokeWidth: 2 },
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: isSide ? 'rgba(0,120,212,0.85)' : 'rgba(167,139,250,0.85)',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
}),
|
||||
id: e.id,
|
||||
source: e.sourceGraphNodeId,
|
||||
target: e.targetGraphNodeId,
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
selectable: false,
|
||||
};
|
||||
});
|
||||
}, [sceneGraphEdges, sceneGraphNodes, selectedGraphNodeId]);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node<SceneCardData>>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
@@ -494,7 +549,7 @@ function SceneGraphCanvas({
|
||||
if (!menu) return null;
|
||||
const pad = 8;
|
||||
const mw = 220;
|
||||
const mh = 168;
|
||||
const mh = 210;
|
||||
const x = Math.max(pad, Math.min(menu.x, window.innerWidth - mw - pad));
|
||||
const y = Math.max(pad, Math.min(menu.y, window.innerHeight - mh - pad));
|
||||
return { x, y };
|
||||
@@ -523,6 +578,7 @@ function SceneGraphCanvas({
|
||||
}}
|
||||
onEdgesChange={onEdgesChange}
|
||||
isValidConnection={isValidConnection}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
onConnect={onConnectInternal}
|
||||
onEdgeContextMenu={(e, edge) => {
|
||||
e.preventDefault();
|
||||
@@ -536,7 +592,7 @@ function SceneGraphCanvas({
|
||||
setMenu(null);
|
||||
setEdgeMenu(null);
|
||||
const d = node.data as SceneCardData;
|
||||
onCurrentSceneChange(d.sceneId);
|
||||
onGraphNodeSelect(node.id as GraphNodeId, d.sceneId);
|
||||
}}
|
||||
onNodeContextMenu={(e, node) => {
|
||||
e.preventDefault();
|
||||
@@ -599,18 +655,33 @@ function SceneGraphCanvas({
|
||||
>
|
||||
{menuNodeIsStart ? ui.unsetStartScene : ui.startScene}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.ctxItem}
|
||||
disabled={!onRunFromGraphNode}
|
||||
onClick={() => {
|
||||
onRunFromGraphNode?.(menu.graphNodeId);
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
{ui.runFromScene}
|
||||
</button>
|
||||
{menuNodeIsSideStoryStart || menuCanSetSideStoryStart ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.ctxItem}
|
||||
onClick={() => {
|
||||
onSetGraphNodeSideStoryStart(menu.graphNodeId);
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
{menuNodeIsSideStoryStart ? ui.unsetSideStoryStartScene : ui.sideStoryStartScene}
|
||||
</button>
|
||||
) : null}
|
||||
{!menuNodeIsSideBranch ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.ctxItem}
|
||||
disabled={!onRunFromGraphNode}
|
||||
onClick={() => {
|
||||
onRunFromGraphNode?.(menu.graphNodeId);
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
{ui.runFromScene}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
|
||||
@@ -16,3 +16,11 @@ void test('SceneGraph: контекстное меню узла — «Запус
|
||||
assert.ok(src.includes('onRunFromGraphNode'));
|
||||
assert.ok(src.includes('onRunFromGraphNode?.(menu.graphNodeId)'));
|
||||
});
|
||||
|
||||
void test('SceneGraph: побочная линия — меню старта и скрытие Run', () => {
|
||||
const src = readSceneGraph();
|
||||
assert.ok(src.includes('sideStoryStartScene'));
|
||||
assert.ok(src.includes('onSetGraphNodeSideStoryStart'));
|
||||
assert.ok(src.includes('menuNodeIsSideBranch'));
|
||||
assert.ok(src.includes('badgeSideStory'));
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ export const HELP_SECTION_IDS = [
|
||||
'projects',
|
||||
'scenes',
|
||||
'graph',
|
||||
'sideStorylines',
|
||||
'sceneProps',
|
||||
'campaignAudio',
|
||||
'session',
|
||||
|
||||
@@ -160,11 +160,15 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.graph.title': 'Граф сцен',
|
||||
'help.section.graph.body':
|
||||
'Карта в центре показывает, как эпизоды связаны. Каждый прямоугольник — место на схеме; одна сцена может встретиться несколько раз (например, игроки снова возвращаются в таверну).\n\nДобавить сцену на карту:\n\n1) Возьмите сцену в левом списке.\n\n2) Перетащите на свободное место на карте.\n\nСделать переход между сценами:\n\n1) Наведите на нижнюю точку первой карточки.\n\n2) Потяните линию к верхней точке второй и отпустите — появится стрелка.\n\nИз одной сцены может выходить несколько стрелок — так вы делаете ветвление сюжета. Вторую стрелку к той же паре сцен провести нельзя.\n\nУдалить стрелку: правый клик по линии → «Удалить». Обычный клик по линии ничего не делает.\n\nС чего начинается игра: правый клик по карточке → «Начальная сцена» (появится метка «НАЧАЛО»), затем «Запустить» в шапке. Можно начать с любой карточки: ПКМ → «Запустить с этой сцены» — откроются презентация и пульт с выбранного места (метку «НАЧАЛО» ставить не обязательно).\n\nУбрать карточку с карты, не удаляя сцену из списка: ПКМ → «Удалить».\n\nМасштаб — кнопки внизу или колёсико мыши. «Показать всё» вместит всю схему на экран.',
|
||||
'Карта в центре показывает, как эпизоды связаны. Каждый прямоугольник — место на схеме; одна сцена может встретиться несколько раз (например, игроки снова возвращаются в таверну).\n\nДобавить сцену на карту:\n\n1) Возьмите сцену в левом списке.\n\n2) Перетащите на свободное место на карте.\n\nСделать переход между сценами:\n\n1) Наведите на нижнюю точку первой карточки.\n\n2) Потяните линию к верхней точке второй и отпустите — появится стрелка.\n\nИз одной сцены может выходить несколько стрелок — так вы делаете ветвление сюжета. Вторую стрелку к той же паре сцен провести нельзя.\n\nУдалить стрелку: правый клик по линии → «Удалить». Обычный клик по линии ничего не делает.\n\nС чего начинается игра: правый клик по карточке → «Начальная сцена» (появится метка «НАЧАЛО»), затем «Запустить» в шапке. Можно начать с любой карточки основного сюжета: ПКМ → «Запустить с этой сцены» — откроются презентация и пульт с выбранного места (метку «НАЧАЛО» ставить не обязательно). Для карточек побочных линий этот пункт недоступен.\n\nУбрать карточку с карты, не удаляя сцену из списка: ПКМ → «Удалить».\n\nМасштаб — кнопки внизу или колёсико мыши. «Показать всё» вместит всю схему на экран.',
|
||||
|
||||
'help.section.sideStorylines.title': 'Побочные сюжетные линии',
|
||||
'help.section.sideStorylines.body':
|
||||
'Побочная линия — отдельная ветка сюжета, не связанная с основным сюжетом. Она нужна для ответвлений, флешбэков, побочных квестов и сцен «вне основного пути».\n\nСоздать побочную линию в редакторе:\n\n1) Добавьте сцены на карту и соедините их стрелками в отдельной группе — она не должна касаться основного сюжета (фиолетовые связи) и других побочных линий.\n\n2) Правый клик по стартовой карточке побочной ветки → «Начальная сцена побочной линии». Появится синяя метка «ПОБОЧНАЯ».\n\n3) В свойствах сцены задайте «Название побочной линии» — оно будет видно на пульте.\n\nПункт «Начальная сцена побочной линии» скрыт, если карточка уже связана с основным сюжетом (где есть фиолетовое «НАЧАЛО») или с другой побочной линией (где есть синее «ПОБОЧНАЯ»).\n\nСвязи внутри побочной линии и выделение её карточек — синего цвета (#0078d4). Между основным и побочным сюжетом, а также между разными побочными линиями, стрелки провести нельзя.\n\nСнять метку: ПКМ → «Снять метку «Начальная сцена побочной линии»». Название очистится, плитка исчезнет с пульта.\n\nУдалить стартовую карточку: если есть следующая сцена по стрелке — метка переносится на неё; если нет — вся побочная линия удаляется с карты.\n\nВо время игры на пульте под блоком «Музыка» появляется «Побочные сюжетные линии» — плитки с превью и названием. Клик переносит партию на первую сцену линии. Программа запоминает, с какой сцены основного сюжета вы ушли.\n\nПока идёт побочная линия, в «Варианты ветвления» первой опцией всегда «Вернуться в основной сюжет» — возврат на запомненную сцену. История «Сюжетная линия» продолжает записывать все шаги, включая побочную ветку.\n\nЗапустить побочную линию из редактора нельзя — только с пульта во время сессии.',
|
||||
|
||||
'help.section.sceneProps.title': 'Свойства сцены',
|
||||
'help.section.sceneProps.body':
|
||||
'Выберите сцену в списке слева — справа откроются её свойства.\n\n«Название сцены» и «Описание» — для мастера. Описание видно на пульте в блоке сюжетной линии.\n\nКартинка или видео для игроков:\n\n1) В «Превью сцены» нажмите «Загрузить» или «Изменить».\n\n2) Выберите файл (PNG, JPG, WebP, GIF и др.).\n\n3) Для картинки можно «Повернуть» (шаг 90°). «Очистить» — убрать превью.\n\n4) Для картинки можно включить «Затемнить сцену»: при показе игроки сначала увидят карту в полной темноте, а вы снимете её с пульта «Кистью Открытия» (см. «Эффекты»).\n\nДля видео включите «Автостарт», если ролик должен сам начаться на экране игроков. На видео-сценах эффекты кистью недоступны.\n\n«Аудио сцены» — музыка и звуки этого эпизода. «Загрузить» добавляет файлы. У каждого трека: «Авто» (играть при входе в сцену) и «Цикл». Корзина удаляет трек.\n\nСвязи между сценами задаются на карте, а не здесь — см. «Граф сцен».',
|
||||
'Выберите сцену в списке слева — справа откроются её свойства.\n\n«Название сцены» и «Описание» — для мастера. Описание видно на пульте в блоке сюжетной линии.\n\nЕсли у сцены на карте есть карточка с синей меткой «ПОБОЧНАЯ», появится поле «Название побочной линии».\n\nКартинка или видео для игроков:\n\n1) В «Превью сцены» нажмите «Загрузить» или «Изменить».\n\n2) Выберите файл (PNG, JPG, WebP, GIF и др.).\n\n3) Для картинки можно «Повернуть» (шаг 90°). «Очистить» — убрать превью.\n\n4) Для картинки можно включить «Затемнить сцену»: при показе игроки сначала увидят карту в полной темноте, а вы снимете её с пульта «Кистью Открытия» (см. «Эффекты»).\n\nДля видео включите «Автостарт», если ролик должен сам начаться на экране игроков. На видео-сценах эффекты кистью недоступны.\n\n«Аудио сцены» — музыка и звуки этого эпизода. «Загрузить» добавляет файлы. У каждого трека: «Авто» (играть при входе в сцену) и «Цикл». Корзина удаляет трек.\n\nСвязи между сценами задаются на карте, а не здесь — см. «Граф сцен».',
|
||||
|
||||
'help.section.campaignAudio.title': 'Аудио игры',
|
||||
'help.section.campaignAudio.body':
|
||||
@@ -247,10 +251,46 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'export.title': 'Экспорт проекта',
|
||||
'export.project': 'ПРОЕКТ',
|
||||
'export.hint':
|
||||
'Далее откроется окно сохранения: укажите имя и папку для файла .ttrpg.zip — будет создана копия архива проекта.',
|
||||
'Выберите сюжетные линии для экспорта. В архив попадут только выбранные линии, их сцены и материалы. Далее откроется окно сохранения .ttrpg.zip.',
|
||||
'export.exporting': 'Экспорт…',
|
||||
'export.saveAs': 'Сохранить как…',
|
||||
|
||||
'storyline.section': 'СЮЖЕТНАЯ ЛИНИЯ',
|
||||
'storyline.main': 'Основная линия',
|
||||
'storyline.loading': 'Загрузка линий…',
|
||||
'storyline.empty': 'В проекте нет сюжетных линий с метками «НАЧАЛО» или «ПОБОЧНАЯ».',
|
||||
'storyline.mainExistsHint': 'в проекте уже есть основная линия',
|
||||
|
||||
'importSource.title': 'Импорт',
|
||||
'importSource.type': 'ТИП ИМПОРТА',
|
||||
'importSource.fromProject': 'Из проекта',
|
||||
'importSource.fromFile': 'Из файла',
|
||||
'importSource.project': 'ПРОЕКТ',
|
||||
'importSource.file': 'ФАЙЛ',
|
||||
'importSource.chooseFile': 'Выбрать файл',
|
||||
'importSource.noFileSelected': 'Файл не выбран',
|
||||
'importSource.noOtherProjects': 'Нет других проектов для импорта.',
|
||||
'importSource.fileOnlyHint': 'Выберите файл проекта (.ttrpg.zip) для полного импорта.',
|
||||
|
||||
'importStoryline.title': 'Импорт сюжетных линий',
|
||||
'importStoryline.source': 'ИСТОЧНИК',
|
||||
'importStoryline.continue': 'Далее',
|
||||
'importStoryline.import': 'Импортировать',
|
||||
'importStoryline.conflictsTitle': 'Совпадение названий сцен',
|
||||
'importStoryline.conflictsHint':
|
||||
'В импортируемых линиях есть сцены с такими же названиями, как в текущем проекте. Выберите действие для каждой.',
|
||||
'importStoryline.createNewScene': 'Создать новую сцену',
|
||||
'importStoryline.useExistingScene': 'Использовать «{title}»',
|
||||
'importStoryline.reportTitle': 'Импорт завершён',
|
||||
'importStoryline.reportLines': 'Импортировано линий: {count}',
|
||||
'importStoryline.reportScenesCreated': 'Создано новых сцен: {count}',
|
||||
'importStoryline.reportScenesReused': 'Использовано существующих сцен: {count}',
|
||||
'importStoryline.reportNodes': 'Добавлено карточек на граф: {count}',
|
||||
'importStoryline.reportEdges': 'Добавлено связей: {count}',
|
||||
'importStoryline.reportAssetsCopied': 'Скопировано файлов материалов: {count}',
|
||||
'importStoryline.reportAssetsReused': 'Повторно использовано материалов: {count}',
|
||||
'importStoryline.reportRenamedSides': 'Переименованы побочные линии: {names}',
|
||||
|
||||
'confirmDelete.title': 'Удаление проекта',
|
||||
'confirmDelete.body': 'Удалить проект «{name}» безвозвратно? Файл и кэш будут стёрты с диска.',
|
||||
'confirmDelete.failedTitle': 'Не удалось удалить',
|
||||
@@ -299,6 +339,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'sceneCard.menu': 'Меню сцены',
|
||||
|
||||
'graph.badgeStart': 'НАЧАЛО',
|
||||
'graph.badgeSideStory': 'ПОБОЧНАЯ',
|
||||
'graph.untitled': 'Без названия',
|
||||
'graph.videoBadge': 'Видео',
|
||||
'graph.audioBadge': 'Аудио',
|
||||
@@ -312,8 +353,12 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'graph.fitAll': 'Показать всё',
|
||||
'graph.startScene': 'Начальная сцена',
|
||||
'graph.unsetStartScene': 'Снять метку «Начальная сцена»',
|
||||
'graph.sideStoryStartScene': 'Начальная сцена побочной линии',
|
||||
'graph.unsetSideStoryStartScene': 'Снять метку «Начальная сцена побочной линии»',
|
||||
'graph.runFromScene': 'Запустить с этой сцены',
|
||||
|
||||
'scene.sideStoryLineTitle': 'Название побочной линии',
|
||||
|
||||
'control.remoteTitle': 'ПУЛЬТ УПРАВЛЕНИЯ',
|
||||
'control.effects': 'ЭФФЕКТЫ',
|
||||
'control.tools': 'Инструменты',
|
||||
@@ -343,6 +388,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.videoBrushHint':
|
||||
'Видео-превью: кисть эффектов отключена (как на экране демонстрации — оверлей только для изображения).',
|
||||
'control.branches': 'Варианты ветвления',
|
||||
'control.returnToMainStory': 'Вернуться в основной сюжет',
|
||||
'control.sideStoryLines': 'Побочные сюжетные линии',
|
||||
'control.option': 'ОПЦИЯ {n}',
|
||||
'control.unnamed': 'Без названия',
|
||||
'control.switchScene': 'Переключить',
|
||||
@@ -488,11 +535,15 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'help.section.graph.title': 'Scene graph',
|
||||
'help.section.graph.body':
|
||||
'The map in the center shows how episodes connect. Each box is a spot on your story; the same scene can appear more than once (for example, players return to the tavern).\n\nPlace a scene on the map:\n\n1) Grab a scene in the left list.\n\n2) Drag it onto empty space on the map.\n\nConnect two scenes:\n\n1) Point at the bottom dot on the first card.\n\n2) Drag a line to the top dot on the second and release — an arrow appears.\n\nOne scene can have several outgoing arrows — that is how you branch the story. You cannot draw a second arrow between the same pair.\n\nRemove an arrow: right-click the line → Delete. A normal click on the line does nothing.\n\nSet where the game starts: right-click a card → Start scene (a START badge appears), then click Run in the header. You can also start from any card: right-click → Start from this scene — presentation and the control panel open at that spot (no START badge required).\n\nRemove a card from the map without deleting the scene from the list: right-click → Delete.\n\nZoom with the buttons at the bottom or the mouse wheel. Fit view shows the whole map.',
|
||||
'The map in the center shows how episodes connect. Each box is a spot on your story; the same scene can appear more than once (for example, players return to the tavern).\n\nPlace a scene on the map:\n\n1) Grab a scene in the left list.\n\n2) Drag it onto empty space on the map.\n\nConnect two scenes:\n\n1) Point at the bottom dot on the first card.\n\n2) Drag a line to the top dot on the second and release — an arrow appears.\n\nOne scene can have several outgoing arrows — that is how you branch the story. You cannot draw a second arrow between the same pair.\n\nRemove an arrow: right-click the line → Delete. A normal click on the line does nothing.\n\nSet where the game starts: right-click a card → Start scene (a START badge appears), then click Run in the header. You can also start from any main-story card: right-click → Start from this scene — presentation and the control panel open at that spot (no START badge required). Side-story cards do not offer this menu item.\n\nRemove a card from the map without deleting the scene from the list: right-click → Delete.\n\nZoom with the buttons at the bottom or the mouse wheel. Fit view shows the whole map.',
|
||||
|
||||
'help.section.sideStorylines.title': 'Side storylines',
|
||||
'help.section.sideStorylines.body':
|
||||
'A side storyline is a separate branch not connected to the main plot — for detours, flashbacks, side quests, and scenes off the main path.\n\nCreate one in the editor:\n\n1) Place scenes on the map and link them in an isolated group — it must not touch the main story (purple links) or other side storylines.\n\n2) Right-click the starting card → Side storyline start scene. A blue SIDE badge appears.\n\n3) In scene properties, set Side storyline title — it appears on the control panel.\n\nThe menu item is hidden if the card is already linked to the main story (purple START anywhere in the group) or another side storyline (blue SIDE in the group).\n\nLinks inside a side storyline and card selection use blue (#0078d4). You cannot link main to side, or one side storyline to another.\n\nClear the mark: right-click → Clear side storyline start mark. The title is cleared and the tile disappears from the control panel.\n\nDeleting the start card: if there is a next scene along an arrow, the mark moves to it; otherwise the whole side storyline is removed from the map.\n\nDuring play, Side storylines appears under Music on the control panel — tiles with preview and title. Clicking jumps to the first scene. The app remembers which main-story scene you left from.\n\nWhile in a side storyline, Branch options always lists Return to main story first — back to the remembered scene. Storyline history keeps recording all steps, including inside side branches.\n\nYou cannot launch a side storyline from the editor — only from the control panel during a session.',
|
||||
|
||||
'help.section.sceneProps.title': 'Scene properties',
|
||||
'help.section.sceneProps.body':
|
||||
'Select a scene in the left list — its properties open on the right.\n\nScene title and Description are for the GM. The description appears in the storyline on the control panel.\n\nImage or video for players:\n\n1) Under Scene preview, click Upload or Change.\n\n2) Pick a file (PNG, JPG, WebP, GIF, etc.).\n\n3) For images, use Rotate (90° steps). Clear removes the preview.\n\n4) For images, enable Darken scene so players start in full darkness and you reveal the map with the Opening brush on the control panel (see Effects).\n\nFor video, enable Autostart if the clip should start on its own on the player screen. Brush effects are not available on video scenes.\n\nScene audio — music and sounds for this episode. Upload adds files. Per track: Auto (play when entering the scene) and Loop. The trash icon removes a track.\n\nLinks between scenes are drawn on the map, not here — see Scene graph.',
|
||||
'Select a scene in the left list — its properties open on the right.\n\nScene title and Description are for the GM. The description appears in the storyline on the control panel.\n\nIf the scene has a graph card with a blue SIDE badge, a Side storyline title field appears.\n\nImage or video for players:\n\n1) Under Scene preview, click Upload or Change.\n\n2) Pick a file (PNG, JPG, WebP, GIF, etc.).\n\n3) For images, use Rotate (90° steps). Clear removes the preview.\n\n4) For images, enable Darken scene so players start in full darkness and you reveal the map with the Opening brush on the control panel (see Effects).\n\nFor video, enable Autostart if the clip should start on its own on the player screen. Brush effects are not available on video scenes.\n\nScene audio — music and sounds for this episode. Upload adds files. Per track: Auto (play when entering the scene) and Loop. The trash icon removes a track.\n\nLinks between scenes are drawn on the map, not here — see Scene graph.',
|
||||
|
||||
'help.section.campaignAudio.title': 'Game audio',
|
||||
'help.section.campaignAudio.body':
|
||||
@@ -575,10 +626,46 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'export.title': 'Export project',
|
||||
'export.project': 'PROJECT',
|
||||
'export.hint':
|
||||
'A save dialog will open: choose a name and folder for the .ttrpg.zip file — a copy of the project archive will be created.',
|
||||
'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.',
|
||||
'export.exporting': 'Exporting…',
|
||||
'export.saveAs': 'Save as…',
|
||||
|
||||
'storyline.section': 'STORYLINE',
|
||||
'storyline.main': 'Main storyline',
|
||||
'storyline.loading': 'Loading storylines…',
|
||||
'storyline.empty': 'This project has no storylines marked with START or SIDE badges.',
|
||||
'storyline.mainExistsHint': 'main storyline already exists in this project',
|
||||
|
||||
'importSource.title': 'Import',
|
||||
'importSource.type': 'IMPORT TYPE',
|
||||
'importSource.fromProject': 'From project',
|
||||
'importSource.fromFile': 'From file',
|
||||
'importSource.project': 'PROJECT',
|
||||
'importSource.file': 'FILE',
|
||||
'importSource.chooseFile': 'Choose file',
|
||||
'importSource.noFileSelected': 'No file selected',
|
||||
'importSource.noOtherProjects': 'No other projects available to import from.',
|
||||
'importSource.fileOnlyHint': 'Choose a project file (.ttrpg.zip) for a full import.',
|
||||
|
||||
'importStoryline.title': 'Import storylines',
|
||||
'importStoryline.source': 'SOURCE',
|
||||
'importStoryline.continue': 'Continue',
|
||||
'importStoryline.import': 'Import',
|
||||
'importStoryline.conflictsTitle': 'Duplicate scene titles',
|
||||
'importStoryline.conflictsHint':
|
||||
'Imported storylines contain scenes with the same titles as in the current project. Choose what to do for each.',
|
||||
'importStoryline.createNewScene': 'Create new scene',
|
||||
'importStoryline.useExistingScene': 'Use existing «{title}»',
|
||||
'importStoryline.reportTitle': 'Import complete',
|
||||
'importStoryline.reportLines': 'Storylines imported: {count}',
|
||||
'importStoryline.reportScenesCreated': 'New scenes created: {count}',
|
||||
'importStoryline.reportScenesReused': 'Existing scenes reused: {count}',
|
||||
'importStoryline.reportNodes': 'Graph cards added: {count}',
|
||||
'importStoryline.reportEdges': 'Connections added: {count}',
|
||||
'importStoryline.reportAssetsCopied': 'Asset files copied: {count}',
|
||||
'importStoryline.reportAssetsReused': 'Assets reused: {count}',
|
||||
'importStoryline.reportRenamedSides': 'Renamed side storylines: {names}',
|
||||
|
||||
'confirmDelete.title': 'Delete project',
|
||||
'confirmDelete.body':
|
||||
'Permanently delete project “{name}”? The file and cache will be removed from disk.',
|
||||
@@ -619,6 +706,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'scene.darkenScene': 'Darken scene',
|
||||
'scene.rotate': 'Rotate',
|
||||
'scene.audio': 'SCENE AUDIO',
|
||||
'scene.sideStoryLineTitle': 'Side storyline title',
|
||||
'scene.removeTitle': 'Remove from scene',
|
||||
'scene.branching': 'BRANCHING',
|
||||
'scene.branchingHint':
|
||||
@@ -628,6 +716,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'sceneCard.menu': 'Scene menu',
|
||||
|
||||
'graph.badgeStart': 'START',
|
||||
'graph.badgeSideStory': 'SIDE',
|
||||
'graph.untitled': 'Untitled',
|
||||
'graph.videoBadge': 'Video',
|
||||
'graph.audioBadge': 'Audio',
|
||||
@@ -641,6 +730,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'graph.fitAll': 'Fit view',
|
||||
'graph.startScene': 'Start scene',
|
||||
'graph.unsetStartScene': 'Clear start scene mark',
|
||||
'graph.sideStoryStartScene': 'Side storyline start scene',
|
||||
'graph.unsetSideStoryStartScene': 'Clear side storyline start mark',
|
||||
'graph.runFromScene': 'Start from this scene',
|
||||
|
||||
'control.remoteTitle': 'CONTROL PANEL',
|
||||
@@ -672,6 +763,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.videoBrushHint':
|
||||
'Video preview: effect brush is disabled (like on the presentation screen — overlay is for images only).',
|
||||
'control.branches': 'Branch options',
|
||||
'control.returnToMainStory': 'Return to main story',
|
||||
'control.sideStoryLines': 'Side storylines',
|
||||
'control.option': 'OPTION {n}',
|
||||
'control.unnamed': 'Untitled',
|
||||
'control.switchScene': 'Switch',
|
||||
|
||||
@@ -17,8 +17,9 @@ void test('projectState: list/get after delete invalidates in-flight initial loa
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/const openProject = async[\s\S]+?openInFlightRef\.current[\s\S]+?projectDataEpochRef\.current \+= 1[\s\S]+?await api\.invoke/,
|
||||
/const openProject = async[\s\S]+?projectDataEpochRef\.current \+= 1[\s\S]+?const epoch = projectDataEpochRef\.current[\s\S]+?openInFlightRef\.current = null[\s\S]+?if \(projectDataEpochRef\.current !== epoch\)/,
|
||||
);
|
||||
assert.match(src, /openInFlightRef\.current = null[\s\S]+?openingProjectId: null/);
|
||||
assert.match(src, /const refreshProjects = async \(\) => \{[\s\S]+?projectDataEpochRef\.current \+= 1/);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { ipcChannels, type ScenePreviewImportEvent } from '../../../shared/ipc/contracts';
|
||||
import type {
|
||||
SceneImportResolution,
|
||||
StorylineImportMergeReport,
|
||||
StorylineLabels,
|
||||
StorylineListItem,
|
||||
StorylineSelection,
|
||||
} from '../../../shared/graph/storylineExportImport';
|
||||
import type { AssetId, GraphNodeId, Project, ProjectId, Scene, SceneId } from '../../../shared/types';
|
||||
import { getDndApi } from '../../shared/dndApi';
|
||||
|
||||
@@ -54,10 +61,59 @@ type Actions = {
|
||||
addSceneGraphEdge: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => Promise<void>;
|
||||
removeSceneGraphEdge: (edgeId: string) => Promise<void>;
|
||||
setSceneGraphNodeStart: (graphNodeId: GraphNodeId | null) => Promise<void>;
|
||||
setSceneGraphNodeSideStoryStart: (graphNodeId: GraphNodeId) => Promise<void>;
|
||||
updateSideStoryLineTitle: (graphNodeId: GraphNodeId, title: string) => Promise<void>;
|
||||
deleteScene: (sceneId: SceneId) => Promise<void>;
|
||||
renameProject: (name: string, fileBaseName: string) => Promise<void>;
|
||||
importProject: () => Promise<void>;
|
||||
exportProject: (projectId: ProjectId) => Promise<void>;
|
||||
peekImportZip: (labels: StorylineLabels, targetHasMainStart: boolean) => Promise<
|
||||
| { canceled: true }
|
||||
| {
|
||||
canceled: false;
|
||||
filePath: string;
|
||||
projectName: string;
|
||||
storylines: StorylineListItem[];
|
||||
sourceProject: Project;
|
||||
}
|
||||
>;
|
||||
pickImportZipFile: () => Promise<{ canceled: true } | { canceled: false; filePath: string }>;
|
||||
peekImportZipPath: (
|
||||
filePath: string,
|
||||
labels: StorylineLabels,
|
||||
targetHasMainStart: boolean,
|
||||
) => Promise<{
|
||||
filePath: string;
|
||||
projectName: string;
|
||||
storylines: StorylineListItem[];
|
||||
sourceProject: Project;
|
||||
}>;
|
||||
peekImportFromProject: (
|
||||
sourceProjectId: ProjectId,
|
||||
labels: StorylineLabels,
|
||||
targetHasMainStart: boolean,
|
||||
) => Promise<{
|
||||
sourceProjectId: ProjectId;
|
||||
projectName: string;
|
||||
storylines: StorylineListItem[];
|
||||
sourceProject: Project;
|
||||
}>;
|
||||
mergeImportZip: (
|
||||
filePath: string,
|
||||
storylineSelections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
|
||||
mergeImportFromProject: (
|
||||
sourceProjectId: ProjectId,
|
||||
storylineSelections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
|
||||
importProjectFromPath: (filePath: string) => Promise<void>;
|
||||
getProjectStorylines: (projectId: ProjectId, labels: StorylineLabels) => Promise<StorylineListItem[]>;
|
||||
exportProject: (
|
||||
projectId: ProjectId,
|
||||
storylineSelections: StorylineSelection[],
|
||||
labels: StorylineLabels,
|
||||
) => Promise<void>;
|
||||
deleteProject: (projectId: ProjectId) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -110,8 +166,9 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
...(e.detail ? { detail: e.detail } : null),
|
||||
},
|
||||
}));
|
||||
if (e.stage === 'done' || e.percent >= 100) {
|
||||
setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), 450);
|
||||
if (e.stage === 'done' || e.stage === 'error' || e.percent >= 100) {
|
||||
const delay = e.stage === 'error' ? 0 : 450;
|
||||
setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), delay);
|
||||
}
|
||||
});
|
||||
const offExport = api.on(ipcChannels.project.exportZipProgress, (evt) => {
|
||||
@@ -125,8 +182,9 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
...(e.detail ? { detail: e.detail } : null),
|
||||
},
|
||||
}));
|
||||
if (e.stage === 'done' || e.percent >= 100) {
|
||||
setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), 450);
|
||||
if (e.stage === 'done' || e.stage === 'error' || e.percent >= 100) {
|
||||
const delay = e.stage === 'error' ? 0 : 450;
|
||||
setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), delay);
|
||||
}
|
||||
});
|
||||
const offPreview = api.on(ipcChannels.project.scenePreviewImportProgress, (evt) => {
|
||||
@@ -180,12 +238,18 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
};
|
||||
|
||||
const openProject = async (id: ProjectId) => {
|
||||
if (openInFlightRef.current) return openInFlightRef.current;
|
||||
projectDataEpochRef.current += 1;
|
||||
const epoch = projectDataEpochRef.current;
|
||||
openInFlightRef.current = null;
|
||||
|
||||
const job = (async () => {
|
||||
setState((s) => ({ ...s, openingProjectId: id }));
|
||||
try {
|
||||
projectDataEpochRef.current += 1;
|
||||
const res = await api.invoke(ipcChannels.project.open, { projectId: id });
|
||||
if (projectDataEpochRef.current !== epoch) {
|
||||
setState((s) => ({ ...s, openingProjectId: null }));
|
||||
return;
|
||||
}
|
||||
setState((s) => ({
|
||||
...s,
|
||||
project: res.project,
|
||||
@@ -193,7 +257,9 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
openingProjectId: null,
|
||||
}));
|
||||
} catch {
|
||||
setState((s) => ({ ...s, openingProjectId: null }));
|
||||
if (projectDataEpochRef.current === epoch) {
|
||||
setState((s) => ({ ...s, openingProjectId: null }));
|
||||
}
|
||||
}
|
||||
})();
|
||||
openInFlightRef.current = job.finally(() => {
|
||||
@@ -203,9 +269,20 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
};
|
||||
|
||||
const closeProject = async () => {
|
||||
await api.invoke(ipcChannels.project.close, {});
|
||||
setState((s) => ({ ...s, project: null, selectedSceneId: null }));
|
||||
await refreshProjects();
|
||||
projectDataEpochRef.current += 1;
|
||||
openInFlightRef.current = null;
|
||||
try {
|
||||
await api.invoke(ipcChannels.project.close, {});
|
||||
} finally {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
project: null,
|
||||
selectedSceneId: null,
|
||||
openingProjectId: null,
|
||||
zipProgress: null,
|
||||
}));
|
||||
await refreshProjects();
|
||||
}
|
||||
};
|
||||
|
||||
const createScene = async () => {
|
||||
@@ -411,6 +488,16 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
};
|
||||
|
||||
const setSceneGraphNodeSideStoryStart = async (graphNodeId: GraphNodeId) => {
|
||||
const res = await api.invoke(ipcChannels.project.setSceneGraphNodeSideStoryStart, { graphNodeId });
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
};
|
||||
|
||||
const updateSideStoryLineTitle = async (graphNodeId: GraphNodeId, title: string) => {
|
||||
const res = await api.invoke(ipcChannels.project.updateSideStoryLineTitle, { graphNodeId, title });
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
};
|
||||
|
||||
const deleteScene = async (sceneId: SceneId) => {
|
||||
const res = await api.invoke(ipcChannels.project.deleteScene, { sceneId });
|
||||
setState((s) => ({
|
||||
@@ -428,19 +515,118 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
};
|
||||
|
||||
const importProject = async () => {
|
||||
const res = await api.invoke(ipcChannels.project.importZip, {});
|
||||
if (res.canceled) return;
|
||||
try {
|
||||
const res = await api.invoke(ipcChannels.project.importZip, {});
|
||||
if (res.canceled) return;
|
||||
setState((s) => ({
|
||||
...s,
|
||||
project: res.project,
|
||||
selectedSceneId: res.project.currentSceneId,
|
||||
}));
|
||||
await refreshProjects();
|
||||
} finally {
|
||||
setState((s) => ({ ...s, zipProgress: null }));
|
||||
}
|
||||
};
|
||||
|
||||
const importProjectFromPath = async (filePath: string) => {
|
||||
try {
|
||||
const res = await api.invoke(ipcChannels.project.importZipFromPath, { filePath });
|
||||
setState((s) => ({
|
||||
...s,
|
||||
project: res.project,
|
||||
selectedSceneId: res.project.currentSceneId,
|
||||
}));
|
||||
await refreshProjects();
|
||||
} finally {
|
||||
setState((s) => ({ ...s, zipProgress: null }));
|
||||
}
|
||||
};
|
||||
|
||||
const peekImportZip = async (labels: StorylineLabels, targetHasMainStart: boolean) => {
|
||||
return api.invoke(ipcChannels.project.peekImportZip, { labels, targetHasMainStart });
|
||||
};
|
||||
|
||||
const pickImportZipFile = async () => {
|
||||
return api.invoke(ipcChannels.project.pickImportZipFile, {});
|
||||
};
|
||||
|
||||
const peekImportZipPath = async (
|
||||
filePath: string,
|
||||
labels: StorylineLabels,
|
||||
targetHasMainStart: boolean,
|
||||
) => {
|
||||
return api.invoke(ipcChannels.project.peekImportZipPath, { filePath, labels, targetHasMainStart });
|
||||
};
|
||||
|
||||
const peekImportFromProject = async (
|
||||
sourceProjectId: ProjectId,
|
||||
labels: StorylineLabels,
|
||||
targetHasMainStart: boolean,
|
||||
) => {
|
||||
return api.invoke(ipcChannels.project.peekImportFromProject, {
|
||||
sourceProjectId,
|
||||
labels,
|
||||
targetHasMainStart,
|
||||
});
|
||||
};
|
||||
|
||||
const mergeImportZip = async (
|
||||
filePath: string,
|
||||
storylineSelections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
) => {
|
||||
const res = await api.invoke(ipcChannels.project.mergeImportZip, {
|
||||
filePath,
|
||||
storylineSelections,
|
||||
sceneResolutions,
|
||||
});
|
||||
setState((s) => ({
|
||||
...s,
|
||||
project: res.project,
|
||||
selectedSceneId: res.project.currentSceneId,
|
||||
selectedSceneId: res.project.currentSceneId ?? s.selectedSceneId,
|
||||
}));
|
||||
await refreshProjects();
|
||||
return res;
|
||||
};
|
||||
|
||||
const exportProject = async (projectId: ProjectId) => {
|
||||
const res = await api.invoke(ipcChannels.project.exportZip, { projectId });
|
||||
if (res.canceled) return;
|
||||
const mergeImportFromProject = async (
|
||||
sourceProjectId: ProjectId,
|
||||
storylineSelections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
) => {
|
||||
const res = await api.invoke(ipcChannels.project.mergeImportFromProject, {
|
||||
sourceProjectId,
|
||||
storylineSelections,
|
||||
sceneResolutions,
|
||||
});
|
||||
setState((s) => ({
|
||||
...s,
|
||||
project: res.project,
|
||||
selectedSceneId: res.project.currentSceneId ?? s.selectedSceneId,
|
||||
}));
|
||||
return res;
|
||||
};
|
||||
|
||||
const getProjectStorylines = async (projectId: ProjectId, labels: StorylineLabels) => {
|
||||
const res = await api.invoke(ipcChannels.project.getProjectStorylines, { projectId, labels });
|
||||
return res.storylines;
|
||||
};
|
||||
|
||||
const exportProject = async (
|
||||
projectId: ProjectId,
|
||||
storylineSelections: StorylineSelection[],
|
||||
labels: StorylineLabels,
|
||||
) => {
|
||||
try {
|
||||
const res = await api.invoke(ipcChannels.project.exportZip, {
|
||||
projectId,
|
||||
storylineSelections,
|
||||
labels,
|
||||
});
|
||||
if (res.canceled) return;
|
||||
} finally {
|
||||
setState((s) => ({ ...s, zipProgress: null }));
|
||||
}
|
||||
};
|
||||
|
||||
const deleteProject = async (projectId: ProjectId) => {
|
||||
@@ -476,9 +662,19 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
addSceneGraphEdge,
|
||||
removeSceneGraphEdge,
|
||||
setSceneGraphNodeStart,
|
||||
setSceneGraphNodeSideStoryStart,
|
||||
updateSideStoryLineTitle,
|
||||
deleteScene,
|
||||
renameProject,
|
||||
importProject,
|
||||
importProjectFromPath,
|
||||
peekImportZip,
|
||||
pickImportZipFile,
|
||||
peekImportZipPath,
|
||||
peekImportFromProject,
|
||||
mergeImportZip,
|
||||
mergeImportFromProject,
|
||||
getProjectStorylines,
|
||||
exportProject,
|
||||
deleteProject,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user