feat(npcs): add groups, storyline bindings, and Foundry import
Nested NPC groups with color, graph filter, and scene/storyline binding; Foundry worlds/modules import actors into groups; storyline merge asks on NPC name conflicts and reports NPC counts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -153,7 +153,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
/* Выше модалок (20001), чтобы прогресс импорта не уходил под диалог выбора. */
|
||||
z-index: 30000;
|
||||
}
|
||||
|
||||
.editorLockOverlay {
|
||||
@@ -380,9 +381,14 @@
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
padding-right: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--bg0);
|
||||
background-color: var(--bg0);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.25L6 7.75L9.5 4.25' stroke='rgba(255,255,255,0.72)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
background-size: 12px 12px;
|
||||
color: var(--text0);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
|
||||
import { moveSceneInListOrder, reconcileSceneListOrder } from '../../shared/graph/sceneListOrder';
|
||||
import type {
|
||||
NpcImportResolution,
|
||||
SceneImportResolution,
|
||||
StorylineImportMergeReport,
|
||||
StorylineSelection,
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
sceneTitleFromMediaPath,
|
||||
useFileDropZone,
|
||||
} from './fileDrop';
|
||||
import { FoundryImportModal } from './FoundryImportModal';
|
||||
import { buildNextSceneCardById } from './graph/sceneCardById';
|
||||
import {
|
||||
DND_SCENE_ID_MIME,
|
||||
@@ -58,12 +60,15 @@ import { SceneDescriptionModal } from './SceneDescriptionModal';
|
||||
import type { ProjectNoticeCode } from './state/projectState';
|
||||
import { useProjectState } from './state/projectState';
|
||||
import {
|
||||
buildNpcResolutionsForImport,
|
||||
buildSceneResolutionsForImport,
|
||||
computeImportConflicts,
|
||||
computeNpcImportConflicts,
|
||||
ExportProjectModal,
|
||||
ImportReportModal,
|
||||
ImportSourceModal,
|
||||
ImportStorylinesModal,
|
||||
NpcConflictModal,
|
||||
SceneConflictModal,
|
||||
useStorylineLabels,
|
||||
type ImportPeekResult,
|
||||
@@ -123,11 +128,17 @@ export function EditorApp() {
|
||||
const [renameOpen, setRenameOpen] = useState(false);
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||
const [importSourceOpen, setImportSourceOpen] = useState(false);
|
||||
const [foundryImportOpen, setFoundryImportOpen] = 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 [importNpcConflictsOpen, setImportNpcConflictsOpen] = useState(false);
|
||||
const [importNpcConflicts, setImportNpcConflicts] = useState<
|
||||
ReturnType<typeof computeNpcImportConflicts>
|
||||
>([]);
|
||||
const [pendingImportSelections, setPendingImportSelections] = useState<StorylineSelection[]>([]);
|
||||
const [pendingSceneResolutions, setPendingSceneResolutions] = useState<SceneImportResolution[]>([]);
|
||||
const [importReportOpen, setImportReportOpen] = useState(false);
|
||||
const [importReport, setImportReport] = useState<StorylineImportMergeReport | null>(null);
|
||||
const [previewDialogSceneId, setPreviewDialogSceneId] = useState<SceneId | null>(null);
|
||||
@@ -499,22 +510,71 @@ export function EditorApp() {
|
||||
[actions, storylineLabels],
|
||||
);
|
||||
|
||||
const clearImportFlow = useCallback(() => {
|
||||
setImportPeek(null);
|
||||
setImportStorylinesOpen(false);
|
||||
setImportConflictsOpen(false);
|
||||
setImportNpcConflictsOpen(false);
|
||||
setPendingImportSelections([]);
|
||||
setPendingSceneResolutions([]);
|
||||
setImportConflicts([]);
|
||||
setImportNpcConflicts([]);
|
||||
}, []);
|
||||
|
||||
const runStorylineMerge = useCallback(
|
||||
async (selections: StorylineSelection[], resolutions: SceneImportResolution[]) => {
|
||||
async (
|
||||
selections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
npcResolutions: NpcImportResolution[],
|
||||
) => {
|
||||
if (!importPeek) return;
|
||||
const { report } =
|
||||
importPeek.kind === 'project' && importPeek.sourceProjectId
|
||||
? await actions.mergeImportFromProject(importPeek.sourceProjectId, selections, resolutions)
|
||||
: await actions.mergeImportZip(importPeek.filePath!, selections, resolutions);
|
||||
? await actions.mergeImportFromProject(
|
||||
importPeek.sourceProjectId,
|
||||
selections,
|
||||
sceneResolutions,
|
||||
npcResolutions,
|
||||
)
|
||||
: await actions.mergeImportZip(
|
||||
importPeek.filePath!,
|
||||
selections,
|
||||
sceneResolutions,
|
||||
npcResolutions,
|
||||
);
|
||||
setImportReport(report);
|
||||
setImportReportOpen(true);
|
||||
setImportPeek(null);
|
||||
setImportStorylinesOpen(false);
|
||||
setImportConflictsOpen(false);
|
||||
setPendingImportSelections([]);
|
||||
setImportConflicts([]);
|
||||
clearImportFlow();
|
||||
},
|
||||
[actions, importPeek],
|
||||
[actions, clearImportFlow, importPeek],
|
||||
);
|
||||
|
||||
const continueImportAfterScenes = useCallback(
|
||||
(selections: StorylineSelection[], sceneResolutions: SceneImportResolution[]) => {
|
||||
if (!importPeek || !state.project) return;
|
||||
const npcConflicts = computeNpcImportConflicts(
|
||||
state.project,
|
||||
importPeek.sourceProject,
|
||||
selections,
|
||||
);
|
||||
setPendingImportSelections(selections);
|
||||
setPendingSceneResolutions(sceneResolutions);
|
||||
setImportConflictsOpen(false);
|
||||
if (npcConflicts.length > 0) {
|
||||
setImportNpcConflicts(npcConflicts);
|
||||
setImportNpcConflictsOpen(true);
|
||||
return;
|
||||
}
|
||||
const npcResolutions = buildNpcResolutionsForImport(
|
||||
state.project,
|
||||
importPeek.sourceProject,
|
||||
selections,
|
||||
[],
|
||||
[],
|
||||
);
|
||||
void runStorylineMerge(selections, sceneResolutions, npcResolutions);
|
||||
},
|
||||
[importPeek, runStorylineMerge, state.project],
|
||||
);
|
||||
|
||||
const handleImportSourceContinue = useCallback(
|
||||
@@ -554,10 +614,16 @@ export function EditorApp() {
|
||||
setImportSourceOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleImportFoundry = useCallback(() => {
|
||||
setProjectMenuOpen(false);
|
||||
setFoundryImportOpen(true);
|
||||
}, []);
|
||||
|
||||
const goHome = useCallback(() => {
|
||||
setProjectMenuOpen(false);
|
||||
setExportModalOpen(false);
|
||||
setImportSourceOpen(false);
|
||||
setFoundryImportOpen(false);
|
||||
setImportStorylinesOpen(false);
|
||||
setImportConflictsOpen(false);
|
||||
setImportReportOpen(false);
|
||||
@@ -1294,6 +1360,18 @@ export function EditorApp() {
|
||||
>
|
||||
{t('projectMenu.import')}
|
||||
</button>
|
||||
{!state.project ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.fileMenuItem}
|
||||
onClick={() => {
|
||||
handleImportFoundry();
|
||||
}}
|
||||
>
|
||||
{t('projectMenu.importFoundry')}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@@ -1373,6 +1451,16 @@ export function EditorApp() {
|
||||
onClose={() => setImportSourceOpen(false)}
|
||||
onContinue={handleImportSourceContinue}
|
||||
/>
|
||||
<FoundryImportModal
|
||||
open={foundryImportOpen}
|
||||
pickSource={actions.pickFoundrySource}
|
||||
onClose={() => setFoundryImportOpen(false)}
|
||||
onImport={async (selection) => {
|
||||
// Сразу закрываем диалог, чтобы прогресс импорта не оказался под ним.
|
||||
setFoundryImportOpen(false);
|
||||
await actions.importFoundryProject(selection.sourcePath);
|
||||
}}
|
||||
/>
|
||||
<ImportStorylinesModal
|
||||
open={importStorylinesOpen}
|
||||
sourceName={importPeek?.projectName ?? ''}
|
||||
@@ -1385,10 +1473,10 @@ export function EditorApp() {
|
||||
if (!importPeek || !state.project) return;
|
||||
const conflicts = computeImportConflicts(state.project, importPeek.sourceProject, selections);
|
||||
setPendingImportSelections(selections);
|
||||
setImportStorylinesOpen(false);
|
||||
if (conflicts.length > 0) {
|
||||
setImportConflicts(conflicts);
|
||||
setImportConflictsOpen(true);
|
||||
setImportStorylinesOpen(false);
|
||||
return;
|
||||
}
|
||||
const resolutions = buildSceneResolutionsForImport(
|
||||
@@ -1398,18 +1486,13 @@ export function EditorApp() {
|
||||
[],
|
||||
[],
|
||||
);
|
||||
void runStorylineMerge(selections, resolutions);
|
||||
continueImportAfterScenes(selections, resolutions);
|
||||
}}
|
||||
/>
|
||||
<SceneConflictModal
|
||||
open={importConflictsOpen}
|
||||
conflicts={importConflicts}
|
||||
onClose={() => {
|
||||
setImportConflictsOpen(false);
|
||||
setImportPeek(null);
|
||||
setPendingImportSelections([]);
|
||||
setImportConflicts([]);
|
||||
}}
|
||||
onClose={clearImportFlow}
|
||||
onConfirm={(userResolutions) => {
|
||||
if (!importPeek || !state.project) return;
|
||||
const resolutions = buildSceneResolutionsForImport(
|
||||
@@ -1419,7 +1502,23 @@ export function EditorApp() {
|
||||
importConflicts,
|
||||
userResolutions,
|
||||
);
|
||||
void runStorylineMerge(pendingImportSelections, resolutions);
|
||||
continueImportAfterScenes(pendingImportSelections, resolutions);
|
||||
}}
|
||||
/>
|
||||
<NpcConflictModal
|
||||
open={importNpcConflictsOpen}
|
||||
conflicts={importNpcConflicts}
|
||||
onClose={clearImportFlow}
|
||||
onConfirm={(userNpcResolutions) => {
|
||||
if (!importPeek || !state.project) return;
|
||||
const npcResolutions = buildNpcResolutionsForImport(
|
||||
state.project,
|
||||
importPeek.sourceProject,
|
||||
pendingImportSelections,
|
||||
importNpcConflicts,
|
||||
userNpcResolutions,
|
||||
);
|
||||
void runStorylineMerge(pendingImportSelections, pendingSceneResolutions, npcResolutions);
|
||||
}}
|
||||
/>
|
||||
<ImportReportModal
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { Button } from '../shared/ui/controls';
|
||||
|
||||
import styles from './EditorApp.module.css';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
|
||||
export type FoundryImportSourceSelection =
|
||||
| { kind: 'folder'; sourcePath: string }
|
||||
| { kind: 'archive'; sourcePath: string };
|
||||
|
||||
type FoundryImportModalProps = {
|
||||
open: boolean;
|
||||
pickSource: (
|
||||
mode: 'folder' | 'archive',
|
||||
) => Promise<{ canceled: true } | { canceled: false; sourcePath: string }>;
|
||||
onClose: () => void;
|
||||
onImport: (selection: FoundryImportSourceSelection) => Promise<void>;
|
||||
};
|
||||
|
||||
export function FoundryImportModal({ open, pickSource, onClose, onImport }: FoundryImportModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const [mode, setMode] = useState<'folder' | 'archive'>('folder');
|
||||
const [picked, setPicked] = useState<{ path: string; name: string } | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setMode('folder');
|
||||
setPicked(null);
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !submitting) onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, open, submitting]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={() => {
|
||||
if (!submitting) onClose();
|
||||
}}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('foundryImport.title')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={() => {
|
||||
if (!submitting) onClose();
|
||||
}}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.muted}>{t('foundryImport.hint')}</div>
|
||||
|
||||
<div className={styles.fieldLabel}>{t('foundryImport.sourceType')}</div>
|
||||
<select
|
||||
className={styles.selectInput}
|
||||
value={mode}
|
||||
disabled={submitting}
|
||||
onChange={(e) => {
|
||||
setMode(e.target.value as 'folder' | 'archive');
|
||||
setPicked(null);
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
<option value="folder">{t('foundryImport.folder')}</option>
|
||||
<option value="archive">{t('foundryImport.archive')}</option>
|
||||
</select>
|
||||
|
||||
<div className={styles.fieldLabel}>{t('foundryImport.source')}</div>
|
||||
<div className={styles.importFileRow}>
|
||||
<Button
|
||||
disabled={submitting}
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setError(null);
|
||||
const res = await pickSource(mode);
|
||||
if (res.canceled) return;
|
||||
const name = res.sourcePath.split(/[/\\]/).pop() ?? res.sourcePath;
|
||||
setPicked({ path: res.sourcePath, name });
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{mode === 'folder' ? t('foundryImport.chooseFolder') : t('foundryImport.chooseArchive')}
|
||||
</Button>
|
||||
<span className={styles.muted}>{picked ? picked.name : t('foundryImport.noSourceSelected')}</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={!picked || submitting}
|
||||
onClick={() => {
|
||||
if (!picked) return;
|
||||
void (async () => {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onImport({
|
||||
kind: mode,
|
||||
sourcePath: picked.path,
|
||||
});
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('foundryImport.import')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,12 @@ import { createPortal } from 'react-dom';
|
||||
|
||||
import {
|
||||
collectSceneIdsForSelections,
|
||||
filterNpcsForStorylineExport,
|
||||
findNpcNameConflicts,
|
||||
findSceneTitleConflicts,
|
||||
storylineSelectionKey,
|
||||
type NpcImportResolution,
|
||||
type NpcNameConflict,
|
||||
type SceneImportResolution,
|
||||
type SceneTitleConflict,
|
||||
type StorylineImportMergeReport,
|
||||
@@ -14,8 +18,9 @@ import {
|
||||
} 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';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
|
||||
type ExportProjectModalProps = {
|
||||
open: boolean;
|
||||
@@ -109,11 +114,21 @@ export function ExportProjectModal({
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalBackdrop} />
|
||||
<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
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -145,7 +160,10 @@ export function ExportProjectModal({
|
||||
const checked = selectedKeys.has(key);
|
||||
const disabled = item.disabled === true;
|
||||
return (
|
||||
<label key={key} className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}>
|
||||
<label
|
||||
key={key}
|
||||
className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
@@ -266,17 +284,25 @@ export function ImportSourceModal({
|
||||
|
||||
const canContinue =
|
||||
!submitting &&
|
||||
(importKind === 'project'
|
||||
? canImportFromProject && sourceProjectId !== null
|
||||
: pickedFile !== null);
|
||||
(importKind === 'project' ? canImportFromProject && sourceProjectId !== null : pickedFile !== null);
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalBackdrop} />
|
||||
<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
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -432,11 +458,21 @@ export function ImportStorylinesModal({
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalBackdrop} />
|
||||
<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
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -454,7 +490,10 @@ export function ImportStorylinesModal({
|
||||
const key = storylineSelectionKey(item.selection);
|
||||
const disabled = item.disabled === true;
|
||||
return (
|
||||
<label key={key} className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}>
|
||||
<label
|
||||
key={key}
|
||||
className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedKeys.has(key)}
|
||||
@@ -474,11 +513,7 @@ export function ImportStorylinesModal({
|
||||
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!canContinue}
|
||||
onClick={() => onContinue(selectedSelections)}
|
||||
>
|
||||
<Button variant="primary" disabled={!canContinue} onClick={() => onContinue(selectedSelections)}>
|
||||
{t('importStoryline.continue')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -512,11 +547,21 @@ export function SceneConflictModal({ open, conflicts, onClose, onConfirm }: Scen
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalBackdrop} />
|
||||
<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
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -571,6 +616,121 @@ export function SceneConflictModal({ open, conflicts, onClose, onConfirm }: Scen
|
||||
);
|
||||
}
|
||||
|
||||
type NpcConflictModalProps = {
|
||||
open: boolean;
|
||||
conflicts: NpcNameConflict[];
|
||||
onClose: () => void;
|
||||
onConfirm: (resolutions: NpcImportResolution[]) => void;
|
||||
};
|
||||
|
||||
function defaultNpcConflictChoices(conflicts: NpcNameConflict[]): Record<string, 'create' | string> {
|
||||
const init: Record<string, 'create' | string> = {};
|
||||
for (const c of conflicts) {
|
||||
init[c.sourceNpcId] = c.matches[0]?.npcId ?? 'create';
|
||||
}
|
||||
return init;
|
||||
}
|
||||
|
||||
export function NpcConflictModal({ open, conflicts, onClose, onConfirm }: NpcConflictModalProps) {
|
||||
if (!open) return null;
|
||||
const remountKey = conflicts.map((c) => c.sourceNpcId).join('|');
|
||||
return (
|
||||
<NpcConflictModalBody
|
||||
key={remountKey}
|
||||
conflicts={conflicts}
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NpcConflictModalBody({
|
||||
conflicts,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
conflicts: NpcNameConflict[];
|
||||
onClose: () => void;
|
||||
onConfirm: (resolutions: NpcImportResolution[]) => void;
|
||||
}) {
|
||||
const { t } = useEditorI18n();
|
||||
const [choices, setChoices] = useState(() => defaultNpcConflictChoices(conflicts));
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalBackdrop}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={[styles.modalDialog, styles.modalDialogWide].filter(Boolean).join(' ')}
|
||||
>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('importStoryline.npcConflictsTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className={styles.muted}>{t('importStoryline.npcConflictsHint')}</p>
|
||||
|
||||
<div className={styles.conflictList}>
|
||||
{conflicts.map((c) => (
|
||||
<div key={c.sourceNpcId} className={styles.conflictRow}>
|
||||
<div className={styles.conflictTitle}>{c.sourceName}</div>
|
||||
<select
|
||||
className={styles.selectInput}
|
||||
value={choices[c.sourceNpcId] ?? 'create'}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setChoices((prev) => ({
|
||||
...prev,
|
||||
[c.sourceNpcId]: v === 'create' ? 'create' : v,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<option value="create">{t('importStoryline.createNewNpc')}</option>
|
||||
{c.matches.map((m) => (
|
||||
<option key={m.npcId} value={m.npcId}>
|
||||
{t('importStoryline.useExistingNpc', { name: m.name })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const resolutions: NpcImportResolution[] = conflicts.map((c) => {
|
||||
const choice = choices[c.sourceNpcId] ?? 'create';
|
||||
if (choice === 'create') return { sourceNpcId: c.sourceNpcId, mode: 'create' };
|
||||
return { sourceNpcId: c.sourceNpcId, mode: 'use', targetNpcId: choice };
|
||||
});
|
||||
onConfirm(resolutions);
|
||||
}}
|
||||
>
|
||||
{t('importStoryline.import')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type ImportReportModalProps = {
|
||||
open: boolean;
|
||||
report: StorylineImportMergeReport | null;
|
||||
@@ -593,11 +753,21 @@ export function ImportReportModal({ open, report, onClose }: ImportReportModalPr
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalBackdrop} />
|
||||
<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
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -606,14 +776,14 @@ export function ImportReportModal({ open, report, onClose }: ImportReportModalPr
|
||||
<li>{t('importStoryline.reportLines', { count: report.storylinesImported })}</li>
|
||||
<li>{t('importStoryline.reportScenesCreated', { count: report.scenesCreated })}</li>
|
||||
<li>{t('importStoryline.reportScenesReused', { count: report.scenesReused })}</li>
|
||||
<li>{t('importStoryline.reportNpcsCreated', { count: report.npcsCreated })}</li>
|
||||
<li>{t('importStoryline.reportNpcsReused', { count: report.npcsReused })}</li>
|
||||
<li>{t('importStoryline.reportNodes', { count: report.graphNodesAdded })}</li>
|
||||
<li>{t('importStoryline.reportEdges', { count: report.edgesAdded })}</li>
|
||||
<li>{t('importStoryline.reportAssetsCopied', { count: report.assetsCopied })}</li>
|
||||
<li>{t('importStoryline.reportAssetsReused', { count: report.assetsReused })}</li>
|
||||
{report.renamedSideTitles.length > 0 ? (
|
||||
<li>
|
||||
{t('importStoryline.reportRenamedSides', { names: report.renamedSideTitles.join(', ') })}
|
||||
</li>
|
||||
<li>{t('importStoryline.reportRenamedSides', { names: report.renamedSideTitles.join(', ') })}</li>
|
||||
) : null}
|
||||
</ul>
|
||||
|
||||
@@ -659,6 +829,41 @@ export function computeImportConflicts(
|
||||
return findSceneTitleConflicts(targetProject, sourceProject, sceneIds);
|
||||
}
|
||||
|
||||
export function buildNpcResolutionsForImport(
|
||||
_targetProject: Project,
|
||||
sourceProject: Project,
|
||||
selections: StorylineSelection[],
|
||||
conflicts: NpcNameConflict[],
|
||||
userResolutions: NpcImportResolution[],
|
||||
): NpcImportResolution[] {
|
||||
const exported = filterNpcsForStorylineExport(sourceProject, selections);
|
||||
const conflictIds = new Set(conflicts.map((c) => c.sourceNpcId));
|
||||
const bySource = new Map(userResolutions.map((r) => [r.sourceNpcId, r]));
|
||||
const out: NpcImportResolution[] = [];
|
||||
for (const n of exported) {
|
||||
if (conflictIds.has(n.id)) {
|
||||
const r = bySource.get(n.id);
|
||||
if (r) out.push(r);
|
||||
continue;
|
||||
}
|
||||
out.push({ sourceNpcId: n.id, mode: 'create' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function computeNpcImportConflicts(
|
||||
targetProject: Project,
|
||||
sourceProject: Project,
|
||||
selections: StorylineSelection[],
|
||||
): NpcNameConflict[] {
|
||||
const exported = filterNpcsForStorylineExport(sourceProject, selections);
|
||||
return findNpcNameConflicts(
|
||||
targetProject,
|
||||
sourceProject,
|
||||
exported.map((n) => n.id),
|
||||
);
|
||||
}
|
||||
|
||||
export function useStorylineLabels(): StorylineLabels {
|
||||
const { t } = useEditorI18n();
|
||||
return useMemo(
|
||||
|
||||
@@ -235,9 +235,22 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'projectMenu.home': 'Начальный экран',
|
||||
'projectMenu.import': 'Импорт',
|
||||
'projectMenu.importFoundry': 'Импорт из Foundry',
|
||||
'projectMenu.export': 'Экспорт',
|
||||
'projectMenu.noProjects': 'Нет сохранённых проектов',
|
||||
|
||||
'foundryImport.title': 'Импорт из Foundry',
|
||||
'foundryImport.hint':
|
||||
'Выберите папку или архив мира (.world) либо модуля Foundry VTT (версии 11+). Будет создан новый проект.',
|
||||
'foundryImport.sourceType': 'ТИП ИСТОЧНИКА',
|
||||
'foundryImport.folder': 'Папка',
|
||||
'foundryImport.archive': 'Архив (.zip / .fvtt)',
|
||||
'foundryImport.source': 'ИСТОЧНИК',
|
||||
'foundryImport.chooseFolder': 'Выбрать папку',
|
||||
'foundryImport.chooseArchive': 'Выбрать архив',
|
||||
'foundryImport.noSourceSelected': 'Не выбрано',
|
||||
'foundryImport.import': 'Импортировать',
|
||||
|
||||
'fileMenu.rename': 'Переименовать проект',
|
||||
|
||||
'scenes.search': 'Поиск сцен…',
|
||||
@@ -301,11 +314,18 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'importStoryline.reportLines': 'Импортировано линий: {count}',
|
||||
'importStoryline.reportScenesCreated': 'Создано новых сцен: {count}',
|
||||
'importStoryline.reportScenesReused': 'Использовано существующих сцен: {count}',
|
||||
'importStoryline.reportNpcsCreated': 'Создано новых НПС: {count}',
|
||||
'importStoryline.reportNpcsReused': 'Использовано существующих НПС: {count}',
|
||||
'importStoryline.reportNodes': 'Добавлено карточек на граф: {count}',
|
||||
'importStoryline.reportEdges': 'Добавлено связей: {count}',
|
||||
'importStoryline.reportAssetsCopied': 'Скопировано файлов материалов: {count}',
|
||||
'importStoryline.reportAssetsReused': 'Повторно использовано материалов: {count}',
|
||||
'importStoryline.reportRenamedSides': 'Переименованы побочные линии: {names}',
|
||||
'importStoryline.npcConflictsTitle': 'Совпадение имён НПС',
|
||||
'importStoryline.npcConflictsHint':
|
||||
'В импортируемых линиях есть НПС с такими же именами, как в текущем проекте. Выберите действие для каждого.',
|
||||
'importStoryline.createNewNpc': 'Создать нового НПС',
|
||||
'importStoryline.useExistingNpc': 'Использовать «{name}»',
|
||||
|
||||
'confirmDelete.title': 'Удаление проекта',
|
||||
'confirmDelete.body': 'Удалить проект «{name}» безвозвратно? Файл и кэш будут стёрты с диска.',
|
||||
@@ -411,6 +431,28 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'npcs.zoomInHint': 'Кликните по аватару в предпросмотре пульта, чтобы увеличить.',
|
||||
'npcs.zoomOutHint': 'Кликните по аватару в предпросмотре пульта, чтобы уменьшить.',
|
||||
'npcs.zoomIdleHint': 'Выберите лупу, затем кликните по аватару в предпросмотре пульта.',
|
||||
'npcs.ungrouped': 'Без группы',
|
||||
'npcs.addGroup': 'Новая группа',
|
||||
'npcs.editGroup': 'Изменить группу',
|
||||
'npcs.deleteGroup': 'Удалить группу',
|
||||
'npcs.groupName': 'Название группы',
|
||||
'npcs.groupColor': 'Цвет',
|
||||
'npcs.groupNameRequired': 'Укажите название группы.',
|
||||
'npcs.groupNameDup': 'Группа с таким названием уже есть.',
|
||||
'npcs.deleteGroupTitle': 'Удаление группы',
|
||||
'npcs.deleteGroupConfirm':
|
||||
'Удалить группу «{name}»? НПС станут без группы, вложенные группы будут подняты на уровень выше.',
|
||||
'npcs.addSubgroup': 'Добавить подгруппу',
|
||||
'npcs.group': 'ГРУППА',
|
||||
'npcs.bindingEnable': 'Привязать…',
|
||||
'npcs.bindingKind': 'ТИП ПРИВЯЗКИ',
|
||||
'npcs.bindingStoryline': 'Сюжетная линия',
|
||||
'npcs.bindingScene': 'Сцена',
|
||||
'npcs.bindingMain': 'Основная линия',
|
||||
'npcs.bindingSelect': 'ОБЪЕКТ',
|
||||
'npcs.graphFilterAll': 'Все',
|
||||
'npcs.graphFilterUngrouped': 'Без группы',
|
||||
'npcs.graphFilter': 'Фильтр графа',
|
||||
|
||||
'scene.title': 'НАЗВАНИЕ СЦЕНЫ',
|
||||
'scene.description': 'ОПИСАНИЕ',
|
||||
@@ -727,9 +769,22 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
|
||||
'projectMenu.home': 'Home',
|
||||
'projectMenu.import': 'Import',
|
||||
'projectMenu.importFoundry': 'Import from Foundry',
|
||||
'projectMenu.export': 'Export',
|
||||
'projectMenu.noProjects': 'No saved projects',
|
||||
|
||||
'foundryImport.title': 'Import from Foundry',
|
||||
'foundryImport.hint':
|
||||
'Choose a Foundry VTT world or module folder or archive (version 11+). A new project will be created.',
|
||||
'foundryImport.sourceType': 'SOURCE TYPE',
|
||||
'foundryImport.folder': 'Folder',
|
||||
'foundryImport.archive': 'Archive (.zip / .fvtt)',
|
||||
'foundryImport.source': 'SOURCE',
|
||||
'foundryImport.chooseFolder': 'Choose folder',
|
||||
'foundryImport.chooseArchive': 'Choose archive',
|
||||
'foundryImport.noSourceSelected': 'Nothing selected',
|
||||
'foundryImport.import': 'Import',
|
||||
|
||||
'fileMenu.rename': 'Rename project',
|
||||
|
||||
'scenes.search': 'Search scenes…',
|
||||
@@ -793,11 +848,18 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'importStoryline.reportLines': 'Storylines imported: {count}',
|
||||
'importStoryline.reportScenesCreated': 'New scenes created: {count}',
|
||||
'importStoryline.reportScenesReused': 'Existing scenes reused: {count}',
|
||||
'importStoryline.reportNpcsCreated': 'New NPCs created: {count}',
|
||||
'importStoryline.reportNpcsReused': 'Existing NPCs 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}',
|
||||
'importStoryline.npcConflictsTitle': 'Duplicate NPC names',
|
||||
'importStoryline.npcConflictsHint':
|
||||
'Imported storylines contain NPCs with the same names as in the current project. Choose what to do for each.',
|
||||
'importStoryline.createNewNpc': 'Create new NPC',
|
||||
'importStoryline.useExistingNpc': 'Use existing «{name}»',
|
||||
|
||||
'confirmDelete.title': 'Delete project',
|
||||
'confirmDelete.body':
|
||||
@@ -904,6 +966,28 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'npcs.zoomInHint': 'Click the avatar on the control preview to zoom in.',
|
||||
'npcs.zoomOutHint': 'Click the avatar on the control preview to zoom out.',
|
||||
'npcs.zoomIdleHint': 'Pick a magnifier, then click the avatar on the control preview.',
|
||||
'npcs.ungrouped': 'Ungrouped',
|
||||
'npcs.addGroup': 'New group',
|
||||
'npcs.editGroup': 'Edit group',
|
||||
'npcs.deleteGroup': 'Delete group',
|
||||
'npcs.groupName': 'Group name',
|
||||
'npcs.groupColor': 'Color',
|
||||
'npcs.groupNameRequired': 'Group name is required.',
|
||||
'npcs.groupNameDup': 'A group with this name already exists.',
|
||||
'npcs.deleteGroupTitle': 'Delete group',
|
||||
'npcs.deleteGroupConfirm':
|
||||
'Delete group “{name}”? NPCs will become ungrouped; child groups will move up one level.',
|
||||
'npcs.addSubgroup': 'Add subgroup',
|
||||
'npcs.group': 'GROUP',
|
||||
'npcs.bindingEnable': 'Bind…',
|
||||
'npcs.bindingKind': 'BINDING TYPE',
|
||||
'npcs.bindingStoryline': 'Storyline',
|
||||
'npcs.bindingScene': 'Scene',
|
||||
'npcs.bindingMain': 'Main storyline',
|
||||
'npcs.bindingSelect': 'TARGET',
|
||||
'npcs.graphFilterAll': 'All',
|
||||
'npcs.graphFilterUngrouped': 'Ungrouped',
|
||||
'npcs.graphFilter': 'Graph filter',
|
||||
|
||||
'scene.title': 'SCENE TITLE',
|
||||
'scene.description': 'DESCRIPTION',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { ipcChannels, type ScenePreviewImportEvent } from '../../../shared/ipc/contracts';
|
||||
import type {
|
||||
NpcImportResolution,
|
||||
SceneImportResolution,
|
||||
StorylineImportMergeReport,
|
||||
StorylineLabels,
|
||||
@@ -94,6 +95,10 @@ type Actions = {
|
||||
setSceneListOrder: (sceneListOrder: SceneId[]) => Promise<void>;
|
||||
renameProject: (name: string, fileBaseName: string) => Promise<void>;
|
||||
importProject: () => Promise<void>;
|
||||
pickFoundrySource: (
|
||||
mode: 'folder' | 'archive',
|
||||
) => Promise<{ canceled: true } | { canceled: false; sourcePath: string }>;
|
||||
importFoundryProject: (sourcePath: string) => Promise<void>;
|
||||
peekImportZip: (labels: StorylineLabels, targetHasMainStart: boolean) => Promise<
|
||||
| { canceled: true }
|
||||
| {
|
||||
@@ -129,11 +134,13 @@ type Actions = {
|
||||
filePath: string,
|
||||
storylineSelections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
npcResolutions?: NpcImportResolution[],
|
||||
) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
|
||||
mergeImportFromProject: (
|
||||
sourceProjectId: ProjectId,
|
||||
storylineSelections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
npcResolutions?: NpcImportResolution[],
|
||||
) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
|
||||
importProjectFromPath: (filePath: string) => Promise<void>;
|
||||
getProjectStorylines: (projectId: ProjectId, labels: StorylineLabels) => Promise<StorylineListItem[]>;
|
||||
@@ -749,6 +756,24 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
}
|
||||
};
|
||||
|
||||
const pickFoundrySource = async (mode: 'folder' | 'archive') => {
|
||||
return api.invoke(ipcChannels.project.pickFoundrySource, { mode });
|
||||
};
|
||||
|
||||
const importFoundryProject = async (sourcePath: string) => {
|
||||
try {
|
||||
const res = await api.invoke(ipcChannels.project.importFoundry, { sourcePath });
|
||||
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 });
|
||||
};
|
||||
@@ -781,11 +806,13 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
filePath: string,
|
||||
storylineSelections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
npcResolutions?: NpcImportResolution[],
|
||||
) => {
|
||||
const res = await api.invoke(ipcChannels.project.mergeImportZip, {
|
||||
filePath,
|
||||
storylineSelections,
|
||||
sceneResolutions,
|
||||
...(npcResolutions ? { npcResolutions } : {}),
|
||||
});
|
||||
setState((s) => ({
|
||||
...s,
|
||||
@@ -799,11 +826,13 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
sourceProjectId: ProjectId,
|
||||
storylineSelections: StorylineSelection[],
|
||||
sceneResolutions: SceneImportResolution[],
|
||||
npcResolutions?: NpcImportResolution[],
|
||||
) => {
|
||||
const res = await api.invoke(ipcChannels.project.mergeImportFromProject, {
|
||||
sourceProjectId,
|
||||
storylineSelections,
|
||||
sceneResolutions,
|
||||
...(npcResolutions ? { npcResolutions } : {}),
|
||||
});
|
||||
setState((s) => ({
|
||||
...s,
|
||||
@@ -884,6 +913,8 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
renameProject,
|
||||
importProject,
|
||||
importProjectFromPath,
|
||||
pickFoundrySource,
|
||||
importFoundryProject,
|
||||
peekImportZip,
|
||||
pickImportZipFile,
|
||||
peekImportZipPath,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { isNpcBindingNone, listStorylineOptionsForBinding, noneBinding } from '../../shared/npcs/npcBinding';
|
||||
import type { GraphNodeId, NpcBinding, Project, SceneId } from '../../shared/types';
|
||||
import editorStyles from '../editor/EditorApp.module.css';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import controlStyles from '../shared/ui/Controls.module.css';
|
||||
|
||||
type NpcBindingFieldsProps = {
|
||||
project: Project;
|
||||
binding: NpcBinding;
|
||||
onChange: (binding: NpcBinding) => void;
|
||||
};
|
||||
|
||||
function defaultBinding(project: Project): NpcBinding {
|
||||
const opts = listStorylineOptionsForBinding(project);
|
||||
if (opts.main) return { kind: 'storyline', storyline: { kind: 'main' } };
|
||||
if (opts.sides[0]) {
|
||||
return {
|
||||
kind: 'storyline',
|
||||
storyline: { kind: 'side', startGraphNodeId: opts.sides[0].startGraphNodeId },
|
||||
};
|
||||
}
|
||||
const firstScene = Object.keys(project.scenes)[0] as SceneId | undefined;
|
||||
if (firstScene) return { kind: 'scene', sceneId: firstScene };
|
||||
return noneBinding();
|
||||
}
|
||||
|
||||
export function NpcBindingFields({ project, binding, onChange }: NpcBindingFieldsProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const enabled = !isNpcBindingNone(binding);
|
||||
const storylineOpts = useMemo(() => listStorylineOptionsForBinding(project), [project]);
|
||||
const sceneOptions = useMemo(
|
||||
() =>
|
||||
Object.entries(project.scenes)
|
||||
.map(([id, scene]) => ({ id: id as SceneId, title: scene.title.trim() || id }))
|
||||
.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })),
|
||||
[project.scenes],
|
||||
);
|
||||
|
||||
const kind = binding.kind === 'none' ? 'storyline' : binding.kind;
|
||||
|
||||
const bindingTargetValue = useMemo(() => {
|
||||
if (binding.kind === 'scene') return binding.sceneId;
|
||||
if (binding.kind === 'storyline') {
|
||||
if (binding.storyline.kind === 'main') return 'main';
|
||||
return `side:${binding.storyline.startGraphNodeId}`;
|
||||
}
|
||||
return '';
|
||||
}, [binding]);
|
||||
|
||||
return (
|
||||
<div className={editorStyles.fieldGrid}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.checked ? defaultBinding(project) : noneBinding());
|
||||
}}
|
||||
/>
|
||||
<span>{t('npcs.bindingEnable')}</span>
|
||||
</label>
|
||||
|
||||
{enabled ? (
|
||||
<>
|
||||
<div className={editorStyles.fieldLabel}>{t('npcs.bindingKind')}</div>
|
||||
<select
|
||||
className={controlStyles.input}
|
||||
value={kind}
|
||||
onChange={(e) => {
|
||||
const nextKind = e.target.value;
|
||||
if (nextKind === 'scene') {
|
||||
const first = sceneOptions[0];
|
||||
onChange(first ? { kind: 'scene', sceneId: first.id } : noneBinding());
|
||||
return;
|
||||
}
|
||||
if (storylineOpts.main) {
|
||||
onChange({ kind: 'storyline', storyline: { kind: 'main' } });
|
||||
} else if (storylineOpts.sides[0]) {
|
||||
onChange({
|
||||
kind: 'storyline',
|
||||
storyline: { kind: 'side', startGraphNodeId: storylineOpts.sides[0].startGraphNodeId },
|
||||
});
|
||||
} else {
|
||||
onChange(noneBinding());
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="storyline">{t('npcs.bindingStoryline')}</option>
|
||||
<option value="scene">{t('npcs.bindingScene')}</option>
|
||||
</select>
|
||||
|
||||
<div className={editorStyles.fieldLabel}>{t('npcs.bindingSelect')}</div>
|
||||
<select
|
||||
className={controlStyles.input}
|
||||
value={bindingTargetValue}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (kind === 'scene') {
|
||||
onChange({ kind: 'scene', sceneId: v as SceneId });
|
||||
return;
|
||||
}
|
||||
if (v === 'main') {
|
||||
onChange({ kind: 'storyline', storyline: { kind: 'main' } });
|
||||
return;
|
||||
}
|
||||
if (v.startsWith('side:')) {
|
||||
onChange({
|
||||
kind: 'storyline',
|
||||
storyline: {
|
||||
kind: 'side',
|
||||
startGraphNodeId: v.slice(5) as GraphNodeId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{kind === 'storyline' ? (
|
||||
<>
|
||||
{storylineOpts.main ? <option value="main">{t('npcs.bindingMain')}</option> : null}
|
||||
{storylineOpts.sides.map((s) => (
|
||||
<option key={s.startGraphNodeId} value={`side:${s.startGraphNodeId}`}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
sceneOptions.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.title}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { ProjectNpc } from '../../shared/types';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
import { noneBinding } from '../../shared/npcs/npcBinding';
|
||||
import { buildNpcGroupForest } from '../../shared/npcs/npcGroups';
|
||||
import type { NpcBinding, NpcGroupId, Project, ProjectNpc, ProjectNpcGroup } from '../../shared/types';
|
||||
import editorStyles from '../editor/EditorApp.module.css';
|
||||
import {
|
||||
filterMaterialImagePaths,
|
||||
@@ -13,24 +13,51 @@ import {
|
||||
} from '../editor/fileDrop';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import matStyles from '../editor/MaterialsModals.module.css';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
import controlStyles from '../shared/ui/Controls.module.css';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import { NpcBindingFields } from './NpcBindingFields';
|
||||
|
||||
function normalizeName(input: string): string {
|
||||
return input.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function flattenGroupOptions(
|
||||
nodes: ReturnType<typeof buildNpcGroupForest>['roots'],
|
||||
depth = 0,
|
||||
): { id: NpcGroupId; label: string }[] {
|
||||
const out: { id: NpcGroupId; label: string }[] = [];
|
||||
for (const node of nodes) {
|
||||
const prefix = depth > 0 ? ' '.repeat(depth) : '';
|
||||
out.push({ id: node.group.id, label: `${prefix}${node.group.name}` });
|
||||
out.push(...flattenGroupOptions(node.children, depth + 1));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type NpcEditModalProps = {
|
||||
open: boolean;
|
||||
initial: ProjectNpc | null;
|
||||
existingNames: string[];
|
||||
project: Project | null;
|
||||
npcGroups: ProjectNpcGroup[];
|
||||
onClose: () => void;
|
||||
onPickImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>;
|
||||
onSave: (input: { name: string; filePath?: string }) => Promise<void>;
|
||||
onSave: (input: {
|
||||
name: string;
|
||||
filePath?: string;
|
||||
groupId?: NpcGroupId | null;
|
||||
binding?: NpcBinding;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
|
||||
export function NpcEditModal({
|
||||
open,
|
||||
initial,
|
||||
existingNames,
|
||||
project,
|
||||
npcGroups,
|
||||
onClose,
|
||||
onPickImage,
|
||||
onSave,
|
||||
@@ -39,15 +66,24 @@ export function NpcEditModal({
|
||||
const [name, setName] = useState('');
|
||||
const [filePath, setFilePath] = useState<string | null>(null);
|
||||
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
|
||||
const [groupId, setGroupId] = useState<NpcGroupId | ''>('');
|
||||
const [binding, setBinding] = useState<NpcBinding>(noneBinding());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const existingUrl = useAssetUrl(initial?.avatarAssetId ?? null);
|
||||
|
||||
const groupOptions = useMemo(() => {
|
||||
const { roots } = buildNpcGroupForest(npcGroups, []);
|
||||
return flattenGroupOptions(roots);
|
||||
}, [npcGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(initial?.name ?? '');
|
||||
setFilePath(null);
|
||||
setLocalPreviewUrl(null);
|
||||
setGroupId(initial?.groupId ?? '');
|
||||
setBinding(initial?.binding ?? noneBinding());
|
||||
setSaving(false);
|
||||
setError(null);
|
||||
}, [initial, open]);
|
||||
@@ -95,7 +131,7 @@ export function NpcEditModal({
|
||||
);
|
||||
const hasImage = Boolean(filePath) || Boolean(initial?.avatarAssetId);
|
||||
const canSave = nameOk && !nameDup && hasImage && !saving;
|
||||
const previewSrc = localPreviewUrl || existingUrl;
|
||||
const previewSrc = localPreviewUrl ?? existingUrl;
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -109,9 +145,7 @@ export function NpcEditModal({
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
|
||||
<div className={editorStyles.modalHeader}>
|
||||
<div className={editorStyles.modalTitle}>
|
||||
{initial ? t('npcs.editTitle') : t('npcs.addTitle')}
|
||||
</div>
|
||||
<div className={editorStyles.modalTitle}>{initial ? t('npcs.editTitle') : t('npcs.addTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
@@ -129,6 +163,24 @@ export function NpcEditModal({
|
||||
{nameDup ? <div className={editorStyles.fieldError}>{t('npcs.nameDup')}</div> : null}
|
||||
</div>
|
||||
|
||||
{!initial && groupOptions.length > 0 ? (
|
||||
<div className={editorStyles.fieldGrid}>
|
||||
<div className={editorStyles.fieldLabel}>{t('npcs.group')}</div>
|
||||
<select
|
||||
className={controlStyles.input}
|
||||
value={groupId}
|
||||
onChange={(e) => setGroupId(e.target.value as NpcGroupId | '')}
|
||||
>
|
||||
<option value="">{t('npcs.ungrouped')}</option>
|
||||
{groupOptions.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={editorStyles.fieldGrid}>
|
||||
<div className={editorStyles.fieldLabel}>{t('npcs.avatar')}</div>
|
||||
<div
|
||||
@@ -139,11 +191,11 @@ export function NpcEditModal({
|
||||
onDrop={(e) => {
|
||||
drop.onDrop(e);
|
||||
const entries = getDroppedFileEntries(e);
|
||||
const files = e.dataTransfer?.files;
|
||||
const files = e.dataTransfer.files;
|
||||
for (let i = 0; i < entries.length; i += 1) {
|
||||
const entry = entries[i]!;
|
||||
if (!pickFirstMaterialImagePath([entry.path])) continue;
|
||||
const file = files?.[i];
|
||||
const entry = entries[i];
|
||||
if (!entry || !pickFirstMaterialImagePath([entry.path])) continue;
|
||||
const file = files[i];
|
||||
if (file) {
|
||||
setPreviewFromPathAndUrl(entry.path, URL.createObjectURL(file));
|
||||
return;
|
||||
@@ -153,9 +205,7 @@ export function NpcEditModal({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{drop.dragOver ? (
|
||||
<div className={editorStyles.dropHintOverlay}>{t('npcs.dropHint')}</div>
|
||||
) : null}
|
||||
{drop.dragOver ? <div className={editorStyles.dropHintOverlay}>{t('npcs.dropHint')}</div> : null}
|
||||
{previewSrc ? (
|
||||
<img className={matStyles.previewThumb} src={previewSrc} alt="" />
|
||||
) : (
|
||||
@@ -176,6 +226,10 @@ export function NpcEditModal({
|
||||
{!hasImage ? <div className={editorStyles.fieldError}>{t('npcs.avatarRequired')}</div> : null}
|
||||
</div>
|
||||
|
||||
{project && !initial ? (
|
||||
<NpcBindingFields project={project} binding={binding} onChange={setBinding} />
|
||||
) : null}
|
||||
|
||||
{error ? <div className={editorStyles.fieldError}>{error}</div> : null}
|
||||
|
||||
<div className={editorStyles.modalFooter}>
|
||||
@@ -191,7 +245,11 @@ export function NpcEditModal({
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave(filePath ? { name: trimmed, filePath } : { name: trimmed });
|
||||
await onSave({
|
||||
name: trimmed,
|
||||
...(filePath ? { filePath } : {}),
|
||||
...(!initial ? { groupId: groupId || null, binding } : {}),
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
|
||||
@@ -40,11 +40,20 @@
|
||||
border: 1px solid var(--stroke);
|
||||
background: #18181b;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
border-left-width: 3px;
|
||||
border-left-color: var(--npc-group-color, var(--stroke));
|
||||
}
|
||||
|
||||
.nodeActive {
|
||||
border-color: #60a5fa;
|
||||
box-shadow: 0 0 0 1px #60a5fa, 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
border-left-color: var(--npc-group-color, #60a5fa);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--npc-group-color, #60a5fa),
|
||||
0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.nodeDimmed {
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
@@ -152,3 +161,19 @@
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
min-width: 160px;
|
||||
padding: 6px 10px;
|
||||
padding-right: 28px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--stroke);
|
||||
background-color: rgba(24, 24, 27, 0.92);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.25L6 7.75L9.5 4.25' stroke='rgba(255,255,255,0.72)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
background-size: 12px 12px;
|
||||
color: var(--text1);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
+206
-128
@@ -22,7 +22,15 @@ import ReactFlow, {
|
||||
} from 'reactflow';
|
||||
import 'reactflow/dist/style.css';
|
||||
|
||||
import type { NpcId, NpcRelationId, ProjectNpc, ProjectNpcRelation } from '../../shared/types';
|
||||
import { collectDescendantGroupIds } from '../../shared/npcs/npcGroups';
|
||||
import type {
|
||||
NpcGroupId,
|
||||
NpcId,
|
||||
NpcRelationId,
|
||||
ProjectNpc,
|
||||
ProjectNpcGroup,
|
||||
ProjectNpcRelation,
|
||||
} from '../../shared/types';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import styles from './NpcGraph.module.css';
|
||||
@@ -32,6 +40,8 @@ type SelectSourceNpcFn = (sourceNpcId: NpcId) => void;
|
||||
const OpenEdgeMenuContext = createContext<OpenEdgeMenuFn | null>(null);
|
||||
const SelectSourceNpcContext = createContext<SelectSourceNpcFn | null>(null);
|
||||
|
||||
export type GraphGroupFilter = 'all' | 'ungrouped' | NpcGroupId;
|
||||
|
||||
export type NpcGraphUiStrings = {
|
||||
zoomBar: string;
|
||||
zoomIn: string;
|
||||
@@ -40,12 +50,17 @@ export type NpcGraphUiStrings = {
|
||||
editRelation: string;
|
||||
deleteRelation: string;
|
||||
untitled: string;
|
||||
graphFilter: string;
|
||||
graphFilterAll: string;
|
||||
graphFilterUngrouped: string;
|
||||
};
|
||||
|
||||
type NpcNodeData = {
|
||||
name: string;
|
||||
avatarAssetId: ProjectNpc['avatarAssetId'];
|
||||
active: boolean;
|
||||
groupColor: string | null;
|
||||
dimmed: boolean;
|
||||
};
|
||||
|
||||
const NPC_ACCENT = '#60a5fa';
|
||||
@@ -102,30 +117,35 @@ function pickEndpointSides(
|
||||
? { sourceSide: 'right', targetSide: 'left' }
|
||||
: { sourceSide: 'left', targetSide: 'right' };
|
||||
}
|
||||
return dy >= 0
|
||||
? { sourceSide: 'bottom', targetSide: 'top' }
|
||||
: { sourceSide: 'top', targetSide: 'bottom' };
|
||||
return dy >= 0 ? { sourceSide: 'bottom', targetSide: 'top' } : { sourceSide: 'top', targetSide: 'bottom' };
|
||||
}
|
||||
|
||||
function NpcNode({ data, selected }: NodeProps<NpcNodeData>) {
|
||||
const url = useAssetUrl(data.avatarAssetId);
|
||||
const sides: Side[] = ['left', 'right', 'top', 'bottom'];
|
||||
const accent = data.groupColor ?? NPC_ACCENT;
|
||||
return (
|
||||
<div className={[styles.node, data.active || selected ? styles.nodeActive : ''].filter(Boolean).join(' ')}>
|
||||
<div
|
||||
className={[
|
||||
styles.node,
|
||||
data.active || selected ? styles.nodeActive : '',
|
||||
data.dimmed ? styles.nodeDimmed : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={
|
||||
data.groupColor
|
||||
? ({
|
||||
'--npc-group-color': accent,
|
||||
borderColor: data.active || selected ? accent : undefined,
|
||||
} as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{sides.map((side) => (
|
||||
<React.Fragment key={side}>
|
||||
<Handle
|
||||
type="source"
|
||||
position={sideToPosition(side)}
|
||||
id={`s-${side}`}
|
||||
className={styles.handle}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={sideToPosition(side)}
|
||||
id={`t-${side}`}
|
||||
className={styles.handle}
|
||||
/>
|
||||
<Handle type="source" position={sideToPosition(side)} id={`s-${side}`} className={styles.handle} />
|
||||
<Handle type="target" position={sideToPosition(side)} id={`t-${side}`} className={styles.handle} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
<div className={styles.avatar}>
|
||||
@@ -151,9 +171,7 @@ function parallelCubicPath(
|
||||
): { path: string; labelX: number; labelY: number } {
|
||||
// Канонический вектор между концами (не зависит от направления стрелки).
|
||||
const [ax, ay, bx, by] =
|
||||
sourceNpcId < targetNpcId
|
||||
? [sourceX, sourceY, targetX, targetY]
|
||||
: [targetX, targetY, sourceX, sourceY];
|
||||
sourceNpcId < targetNpcId ? [sourceX, sourceY, targetX, targetY] : [targetX, targetY, sourceX, sourceY];
|
||||
const cdx = bx - ax;
|
||||
const cdy = by - ay;
|
||||
const clen = Math.sqrt(cdx * cdx + cdy * cdy) || 1;
|
||||
@@ -195,15 +213,7 @@ function LabeledNpcEdge({
|
||||
const targetNpcId = data?.targetNpcId;
|
||||
const { path, labelX, labelY } =
|
||||
sourceNpcId && targetNpcId
|
||||
? parallelCubicPath(
|
||||
sourceNpcId,
|
||||
targetNpcId,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
worldOffset,
|
||||
)
|
||||
? parallelCubicPath(sourceNpcId, targetNpcId, sourceX, sourceY, targetX, targetY, worldOffset)
|
||||
: {
|
||||
path: `M ${sourceX},${sourceY} L ${targetX},${targetY}`,
|
||||
labelX: (sourceX + targetX) / 2,
|
||||
@@ -225,12 +235,7 @@ function LabeledNpcEdge({
|
||||
{label && relationId ? (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
className={[
|
||||
styles.edgeLabel,
|
||||
highlighted ? styles.edgeLabelActive : '',
|
||||
'nodrag',
|
||||
'nopan',
|
||||
]
|
||||
className={[styles.edgeLabel, highlighted ? styles.edgeLabelActive : '', 'nodrag', 'nopan']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{
|
||||
@@ -269,7 +274,12 @@ function ZoomToolbar({ ui }: { ui: NpcGraphUiStrings }) {
|
||||
<button type="button" className={styles.zoomBtn} onClick={() => zoomOut()} aria-label={ui.zoomOut}>
|
||||
−
|
||||
</button>
|
||||
<button type="button" className={styles.zoomBtn} onClick={() => fitView({ padding: 0.2 })} aria-label={ui.fitAll}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.zoomBtn}
|
||||
onClick={() => fitView({ padding: 0.2 })}
|
||||
aria-label={ui.fitAll}
|
||||
>
|
||||
⤢
|
||||
</button>
|
||||
</div>
|
||||
@@ -277,10 +287,44 @@ function ZoomToolbar({ ui }: { ui: NpcGraphUiStrings }) {
|
||||
);
|
||||
}
|
||||
|
||||
function FilterToolbar({
|
||||
ui,
|
||||
groups,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
ui: NpcGraphUiStrings;
|
||||
groups: ProjectNpcGroup[];
|
||||
value: GraphGroupFilter;
|
||||
onChange: (v: GraphGroupFilter) => void;
|
||||
}) {
|
||||
return (
|
||||
<Panel position="top-left">
|
||||
<select
|
||||
className={styles.filterSelect}
|
||||
aria-label={ui.graphFilter}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value as GraphGroupFilter)}
|
||||
>
|
||||
<option value="all">{ui.graphFilterAll}</option>
|
||||
<option value="ungrouped">{ui.graphFilterUngrouped}</option>
|
||||
{groups.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
export type NpcGraphProps = {
|
||||
npcs: ProjectNpc[];
|
||||
relations: ProjectNpcRelation[];
|
||||
npcGroups: ProjectNpcGroup[];
|
||||
selectedNpcId: NpcId | null;
|
||||
graphFilter: GraphGroupFilter;
|
||||
onGraphFilterChange: (filter: GraphGroupFilter) => void;
|
||||
graphUi: NpcGraphUiStrings;
|
||||
onSelect: (npcId: NpcId) => void;
|
||||
onConnectRequest: (sourceNpcId: NpcId, targetNpcId: NpcId) => void;
|
||||
@@ -292,7 +336,10 @@ export type NpcGraphProps = {
|
||||
function NpcGraphInner({
|
||||
npcs,
|
||||
relations,
|
||||
npcGroups,
|
||||
selectedNpcId,
|
||||
graphFilter,
|
||||
onGraphFilterChange,
|
||||
graphUi,
|
||||
onSelect,
|
||||
onConnectRequest,
|
||||
@@ -300,9 +347,7 @@ function NpcGraphInner({
|
||||
onEditRelation,
|
||||
onDeleteRelation,
|
||||
}: NpcGraphProps) {
|
||||
const [menu, setMenu] = useState<{ relationId: NpcRelationId; left: number; top: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [menu, setMenu] = useState<{ relationId: NpcRelationId; left: number; top: number } | null>(null);
|
||||
/** Откуда реально начали тянуть связь (Loose mode может перевернуть source/target). */
|
||||
const connectFromRef = useRef<NpcId | null>(null);
|
||||
|
||||
@@ -320,6 +365,23 @@ function NpcGraphInner({
|
||||
};
|
||||
}, [menu]);
|
||||
|
||||
const groupColorById = useMemo(() => new Map(npcGroups.map((g) => [g.id, g.color])), [npcGroups]);
|
||||
|
||||
const filterGroupIds = useMemo(() => {
|
||||
if (graphFilter === 'all' || graphFilter === 'ungrouped') return null;
|
||||
return collectDescendantGroupIds(npcGroups, graphFilter);
|
||||
}, [graphFilter, npcGroups]);
|
||||
|
||||
const isNpcDimmed = useCallback(
|
||||
(npc: ProjectNpc) => {
|
||||
if (graphFilter === 'all') return false;
|
||||
if (graphFilter === 'ungrouped') return npc.groupId !== null;
|
||||
if (!filterGroupIds) return true;
|
||||
return npc.groupId === null || !filterGroupIds.has(npc.groupId);
|
||||
},
|
||||
[filterGroupIds, graphFilter],
|
||||
);
|
||||
|
||||
const initialNodes: Node<NpcNodeData>[] = useMemo(
|
||||
() =>
|
||||
npcs.map((n) => ({
|
||||
@@ -330,9 +392,11 @@ function NpcGraphInner({
|
||||
name: n.name,
|
||||
avatarAssetId: n.avatarAssetId,
|
||||
active: n.id === selectedNpcId,
|
||||
groupColor: n.groupId ? (groupColorById.get(n.groupId) ?? null) : null,
|
||||
dimmed: isNpcDimmed(n),
|
||||
},
|
||||
})),
|
||||
[npcs, selectedNpcId],
|
||||
[groupColorById, isNpcDimmed, npcs, selectedNpcId],
|
||||
);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||
@@ -364,6 +428,11 @@ function NpcGraphInner({
|
||||
const { sourceSide, targetSide } = pickEndpointSides(sourcePos, targetPos);
|
||||
const offset = (index - (total - 1) / 2) * PARALLEL_EDGE_GAP;
|
||||
const highlighted = selectedNpcId !== null && r.sourceNpcId === selectedNpcId;
|
||||
const sourceNpc = npcs.find((n) => n.id === r.sourceNpcId);
|
||||
const targetNpc = npcs.find((n) => n.id === r.targetNpcId);
|
||||
const edgeDimmed =
|
||||
graphFilter !== 'all' &&
|
||||
((sourceNpc && isNpcDimmed(sourceNpc)) || (targetNpc && isNpcDimmed(targetNpc)));
|
||||
const color = highlighted ? NPC_ACCENT : NPC_EDGE_IDLE;
|
||||
out.push({
|
||||
id: r.id,
|
||||
@@ -381,7 +450,11 @@ function NpcGraphInner({
|
||||
targetNpcId: r.targetNpcId,
|
||||
highlighted,
|
||||
},
|
||||
style: { stroke: color, strokeWidth: highlighted ? 2.5 : 2 },
|
||||
style: {
|
||||
stroke: color,
|
||||
strokeWidth: highlighted ? 2.5 : 2,
|
||||
opacity: edgeDimmed ? 0.2 : 1,
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
width: 16,
|
||||
@@ -392,7 +465,7 @@ function NpcGraphInner({
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, [nodes, npcs, relations, selectedNpcId]);
|
||||
}, [graphFilter, isNpcDimmed, nodes, npcs, relations, selectedNpcId]);
|
||||
|
||||
useEffect(() => {
|
||||
setNodes(initialNodes);
|
||||
@@ -435,97 +508,102 @@ function NpcGraphInner({
|
||||
return (
|
||||
<OpenEdgeMenuContext.Provider value={openEdgeMenu}>
|
||||
<SelectSourceNpcContext.Provider value={selectSourceNpc}>
|
||||
<div className={styles.wrap}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnectStart={onConnectStart}
|
||||
onConnect={onConnect}
|
||||
onConnectEnd={() => {
|
||||
connectFromRef.current = null;
|
||||
}}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
fitView
|
||||
proOptions={{ hideAttribution: true }}
|
||||
onNodeClick={(_e, node) => {
|
||||
setMenu(null);
|
||||
onSelect(node.id as NpcId);
|
||||
}}
|
||||
onNodeDragStop={(_e, node) => {
|
||||
onNodePositionCommit(node.id as NpcId, node.position.x, node.position.y);
|
||||
}}
|
||||
onEdgeClick={(e, edge) => {
|
||||
e.stopPropagation();
|
||||
setMenu(null);
|
||||
const sourceId =
|
||||
(edge.data as NpcEdgeData | undefined)?.sourceNpcId ?? (edge.source as NpcId);
|
||||
onSelect(sourceId);
|
||||
}}
|
||||
onEdgeContextMenu={(e, edge) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const relationId =
|
||||
(edge.data as NpcEdgeData | undefined)?.relationId ?? (edge.id as NpcRelationId);
|
||||
openEdgeMenu(relationId, e.clientX, e.clientY);
|
||||
}}
|
||||
onPaneClick={() => setMenu(null)}
|
||||
onPaneContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
<Background gap={18} size={1} color="#27272a" />
|
||||
<ZoomToolbar ui={graphUi} />
|
||||
</ReactFlow>
|
||||
{menu && menuPosition
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="close"
|
||||
className={styles.menuBackdrop}
|
||||
onClick={() => setMenu(null)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setMenu(null);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={styles.menu}
|
||||
style={{ left: menuPosition.left, top: menuPosition.top }}
|
||||
data-npc-edge-menu="1"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={styles.wrap}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnectStart={onConnectStart}
|
||||
onConnect={onConnect}
|
||||
onConnectEnd={() => {
|
||||
connectFromRef.current = null;
|
||||
}}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
fitView
|
||||
proOptions={{ hideAttribution: true }}
|
||||
onNodeClick={(_e, node) => {
|
||||
setMenu(null);
|
||||
onSelect(node.id as NpcId);
|
||||
}}
|
||||
onNodeDragStop={(_e, node) => {
|
||||
onNodePositionCommit(node.id as NpcId, node.position.x, node.position.y);
|
||||
}}
|
||||
onEdgeClick={(e, edge) => {
|
||||
e.stopPropagation();
|
||||
setMenu(null);
|
||||
const sourceId = (edge.data as NpcEdgeData | undefined)?.sourceNpcId ?? (edge.source as NpcId);
|
||||
onSelect(sourceId);
|
||||
}}
|
||||
onEdgeContextMenu={(e, edge) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const relationId =
|
||||
(edge.data as NpcEdgeData | undefined)?.relationId ?? (edge.id as NpcRelationId);
|
||||
openEdgeMenu(relationId, e.clientX, e.clientY);
|
||||
}}
|
||||
onPaneClick={() => setMenu(null)}
|
||||
onPaneContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
<Background gap={18} size={1} color="#27272a" />
|
||||
<FilterToolbar
|
||||
ui={graphUi}
|
||||
groups={npcGroups}
|
||||
value={graphFilter}
|
||||
onChange={onGraphFilterChange}
|
||||
/>
|
||||
<ZoomToolbar ui={graphUi} />
|
||||
</ReactFlow>
|
||||
{menu && menuPosition
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuItem}
|
||||
onClick={() => {
|
||||
onEditRelation(menu.relationId);
|
||||
aria-label="close"
|
||||
className={styles.menuBackdrop}
|
||||
onClick={() => setMenu(null)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setMenu(null);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={styles.menu}
|
||||
style={{ left: menuPosition.left, top: menuPosition.top }}
|
||||
data-npc-edge-menu="1"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{graphUi.editRelation}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={[styles.menuItem, styles.menuItemDanger].join(' ')}
|
||||
onClick={() => {
|
||||
onDeleteRelation(menu.relationId);
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
{graphUi.deleteRelation}
|
||||
</button>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuItem}
|
||||
onClick={() => {
|
||||
onEditRelation(menu.relationId);
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
{graphUi.editRelation}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={[styles.menuItem, styles.menuItemDanger].join(' ')}
|
||||
onClick={() => {
|
||||
onDeleteRelation(menu.relationId);
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
{graphUi.deleteRelation}
|
||||
</button>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</SelectSourceNpcContext.Provider>
|
||||
</OpenEdgeMenuContext.Provider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
.nameColorRow {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.colorInput {
|
||||
flex: 0 0 40px;
|
||||
width: 40px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.colorInput::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.colorInput::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.colorInput::-moz-color-swatch {
|
||||
border: none;
|
||||
border-radius: inherit;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { DEFAULT_NPC_GROUP_COLOR } from '../../shared/npcs/npcGroups';
|
||||
import type { ProjectNpcGroup } from '../../shared/types';
|
||||
import editorStyles from '../editor/EditorApp.module.css';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
|
||||
import styles from './NpcGroupModal.module.css';
|
||||
|
||||
type NpcGroupModalProps = {
|
||||
open: boolean;
|
||||
initial: ProjectNpcGroup | null;
|
||||
siblingNames: string[];
|
||||
onClose: () => void;
|
||||
onSave: (input: { name: string; color: string }) => Promise<void>;
|
||||
};
|
||||
|
||||
function normalizeName(input: string): string {
|
||||
return input.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function NpcGroupModal({ open, initial, siblingNames, onClose, onSave }: NpcGroupModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const [name, setName] = useState('');
|
||||
const [color, setColor] = useState(DEFAULT_NPC_GROUP_COLOR);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(initial?.name ?? '');
|
||||
setColor(initial?.color ?? DEFAULT_NPC_GROUP_COLOR);
|
||||
setSaving(false);
|
||||
setError(null);
|
||||
}, [initial, 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]);
|
||||
|
||||
const trimmed = name.trim();
|
||||
const nameOk = trimmed.length >= 1;
|
||||
const nameDup = useMemo(() => {
|
||||
if (!nameOk) return false;
|
||||
const key = normalizeName(trimmed);
|
||||
const except = normalizeName(initial?.name ?? '');
|
||||
return siblingNames.some((n) => {
|
||||
const nk = normalizeName(n);
|
||||
return nk === key && nk !== except;
|
||||
});
|
||||
}, [initial?.name, nameOk, siblingNames, trimmed]);
|
||||
|
||||
const canSave = nameOk && !nameDup && !saving;
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={editorStyles.modalBackdrop}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
|
||||
<div className={editorStyles.modalHeader}>
|
||||
<div className={editorStyles.modalTitle}>{initial ? t('npcs.editGroup') : t('npcs.addGroup')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={editorStyles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={editorStyles.fieldGrid}>
|
||||
<div className={editorStyles.fieldLabel}>{t('npcs.groupName')}</div>
|
||||
<div className={styles.nameColorRow}>
|
||||
<input
|
||||
type="color"
|
||||
className={styles.colorInput}
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
aria-label={t('npcs.groupColor')}
|
||||
/>
|
||||
<Input value={name} onChange={setName} placeholder={t('npcs.groupName')} />
|
||||
</div>
|
||||
{!nameOk ? <div className={editorStyles.fieldError}>{t('npcs.groupNameRequired')}</div> : null}
|
||||
{nameDup ? <div className={editorStyles.fieldError}>{t('npcs.groupNameDup')}</div> : null}
|
||||
</div>
|
||||
|
||||
{error ? <div className={editorStyles.fieldError}>{error}</div> : null}
|
||||
|
||||
<div className={editorStyles.modalFooter}>
|
||||
<Button onClick={onClose} disabled={saving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!canSave}
|
||||
onClick={() => {
|
||||
if (!canSave) return;
|
||||
void (async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave({ name: trimmed, color });
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -64,11 +64,7 @@ export function NpcRelationModal({ open, initialLabel = '', onClose, onSave }: N
|
||||
|
||||
<div className={editorStyles.fieldGrid}>
|
||||
<div className={editorStyles.fieldLabel}>{t('npcs.relationLabel')}</div>
|
||||
<Input
|
||||
value={label}
|
||||
onChange={setLabel}
|
||||
placeholder={t('npcs.relationLabelPlaceholder')}
|
||||
/>
|
||||
<Input value={label} onChange={setLabel} placeholder={t('npcs.relationLabelPlaceholder')} />
|
||||
{trimmed.length < 1 ? (
|
||||
<div className={editorStyles.fieldError}>{t('npcs.relationLabelRequired')}</div>
|
||||
) : null}
|
||||
|
||||
@@ -158,3 +158,66 @@
|
||||
color: var(--text2);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.groupBlock {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.groupHeader {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 2px;
|
||||
border-bottom: 1px solid var(--stroke);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.groupToggle {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
width: 18px;
|
||||
padding: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.groupColorDot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.groupTitle {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.3px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.groupBody {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 4px 0 8px 4px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ungroupedSection {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ungroupedHeader {
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--text2);
|
||||
padding: 6px 2px 4px;
|
||||
border-bottom: 1px solid var(--stroke);
|
||||
}
|
||||
|
||||
+167
-25
@@ -1,9 +1,10 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
||||
import type { NpcId, ProjectNpc } from '../../shared/types';
|
||||
import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml';
|
||||
import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups';
|
||||
import type { NpcGroupId, NpcId, ProjectNpc } from '../../shared/types';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
@@ -37,28 +38,38 @@ function ZoomOutIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Убрать пустые ветки групп (удобно при поиске). */
|
||||
function pruneEmptyGroupNodes(nodes: NpcGroupTreeNode[]): NpcGroupTreeNode[] {
|
||||
const out: NpcGroupTreeNode[] = [];
|
||||
for (const node of nodes) {
|
||||
const children = pruneEmptyGroupNodes(node.children);
|
||||
if (node.npcs.length === 0 && children.length === 0) continue;
|
||||
out.push({ ...node, children });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function RuntimeNpcTile({
|
||||
npc,
|
||||
selected,
|
||||
active,
|
||||
accentColor,
|
||||
onActivate,
|
||||
}: {
|
||||
npc: ProjectNpc;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
accentColor?: string | null;
|
||||
onActivate: () => void;
|
||||
}) {
|
||||
const url = useAssetUrl(npc.avatarAssetId);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
styles.tile,
|
||||
selected ? styles.tileSelected : '',
|
||||
active ? styles.tileActive : '',
|
||||
]
|
||||
className={[styles.tile, selected ? styles.tileSelected : '', active ? styles.tileActive : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={accentColor ? { borderLeftColor: accentColor, borderLeftWidth: 3 } : undefined}
|
||||
onClick={onActivate}
|
||||
>
|
||||
<div className={styles.tileAvatar}>
|
||||
@@ -69,6 +80,70 @@ function RuntimeNpcTile({
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeGroupSection({
|
||||
node,
|
||||
depth,
|
||||
isExpanded,
|
||||
onToggleExpanded,
|
||||
selectedId,
|
||||
activeId,
|
||||
onActivate,
|
||||
}: {
|
||||
node: NpcGroupTreeNode;
|
||||
depth: number;
|
||||
isExpanded: (id: NpcGroupId) => boolean;
|
||||
onToggleExpanded: (id: NpcGroupId) => void;
|
||||
selectedId: NpcId | null;
|
||||
activeId: NpcId | null;
|
||||
onActivate: (id: NpcId) => void;
|
||||
}) {
|
||||
const g = node.group;
|
||||
const expanded = isExpanded(g.id);
|
||||
|
||||
return (
|
||||
<div className={styles.groupBlock} style={{ paddingLeft: depth > 0 ? 12 : 0 }}>
|
||||
<div className={styles.groupHeader}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.groupToggle}
|
||||
onClick={() => onToggleExpanded(g.id)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? '▾' : '▸'}
|
||||
</button>
|
||||
<span className={styles.groupColorDot} style={{ background: g.color }} aria-hidden />
|
||||
<span className={styles.groupTitle}>{g.name}</span>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<div className={styles.groupBody}>
|
||||
{node.npcs.map((n) => (
|
||||
<RuntimeNpcTile
|
||||
key={n.id}
|
||||
npc={n}
|
||||
selected={n.id === selectedId}
|
||||
active={n.id === activeId}
|
||||
accentColor={g.color}
|
||||
onActivate={() => onActivate(n.id)}
|
||||
/>
|
||||
))}
|
||||
{node.children.map((child) => (
|
||||
<RuntimeGroupSection
|
||||
key={child.group.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
selectedId={selectedId}
|
||||
activeId={activeId}
|
||||
onActivate={onActivate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NpcsApp() {
|
||||
const { t } = useEditorI18n();
|
||||
const api = getDndApi();
|
||||
@@ -76,6 +151,7 @@ export function NpcsApp() {
|
||||
const [overlay, overlayApi] = useNpcsOverlayState();
|
||||
const [selectedId, setSelectedId] = useState<NpcId | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<NpcGroupId>>(() => new Set());
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.project.get, {}).then(({ project }) => {
|
||||
@@ -106,18 +182,53 @@ export function NpcsApp() {
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [overlay?.activeNpcId, overlay?.zoomTool, overlayApi]);
|
||||
|
||||
const npcs = session?.project?.npcs ?? [];
|
||||
const relations = session?.project?.npcRelations ?? [];
|
||||
const npcs = useMemo(() => session?.project?.npcs ?? [], [session?.project?.npcs]);
|
||||
const npcGroups = useMemo(() => session?.project?.npcGroups ?? [], [session?.project?.npcGroups]);
|
||||
const relations = useMemo(() => session?.project?.npcRelations ?? [], [session?.project?.npcRelations]);
|
||||
const activeId = overlay?.activeNpcId ?? null;
|
||||
const zoomTool = overlay?.zoomTool ?? null;
|
||||
const selected = npcs.find((n) => n.id === selectedId) ?? null;
|
||||
const effectiveSelectedId =
|
||||
selectedId && npcs.some((n) => n.id === selectedId) ? selectedId : (npcs[0]?.id ?? null);
|
||||
const selected = npcs.find((n) => n.id === effectiveSelectedId) ?? null;
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const filteredNpcs = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return npcs;
|
||||
return npcs.filter((n) => n.name.toLowerCase().includes(q));
|
||||
}, [npcs, query]);
|
||||
|
||||
const searching = query.trim().length > 0;
|
||||
|
||||
const { roots, ungrouped } = useMemo(() => {
|
||||
const forest = buildNpcGroupForest(npcGroups, filteredNpcs);
|
||||
if (!searching) return forest;
|
||||
return {
|
||||
roots: pruneEmptyGroupNodes(forest.roots),
|
||||
ungrouped: forest.ungrouped,
|
||||
};
|
||||
}, [filteredNpcs, npcGroups, searching]);
|
||||
|
||||
const isExpanded = useCallback(
|
||||
(id: NpcGroupId) => {
|
||||
if (searching) return true;
|
||||
return !collapsedGroups.has(id);
|
||||
},
|
||||
[collapsedGroups, searching],
|
||||
);
|
||||
|
||||
const toggleExpanded = useCallback(
|
||||
(id: NpcGroupId) => {
|
||||
if (searching) return;
|
||||
setCollapsedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[searching],
|
||||
);
|
||||
|
||||
const relationsForSelected = useMemo(() => {
|
||||
if (!selected) return [];
|
||||
return relations
|
||||
@@ -202,10 +313,7 @@ export function NpcsApp() {
|
||||
{safeHtml ? (
|
||||
<div>
|
||||
<div className={styles.detailSectionTitle}>{t('npcs.description')}</div>
|
||||
<div
|
||||
className={styles.detailDesc}
|
||||
dangerouslySetInnerHTML={{ __html: safeHtml }}
|
||||
/>
|
||||
<div className={styles.detailDesc} dangerouslySetInnerHTML={{ __html: safeHtml }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.muted}>{t('npcs.descriptionEmpty')}</div>
|
||||
@@ -231,17 +339,51 @@ export function NpcsApp() {
|
||||
<div className={styles.listCol}>
|
||||
<Input value={query} onChange={setQuery} placeholder={t('npcs.search')} />
|
||||
<div className={styles.list}>
|
||||
{filtered.map((n) => (
|
||||
<RuntimeNpcTile
|
||||
key={n.id}
|
||||
npc={n}
|
||||
selected={n.id === selectedId}
|
||||
active={n.id === activeId}
|
||||
onActivate={() => onSelectTile(n.id)}
|
||||
/>
|
||||
))}
|
||||
{npcGroups.length === 0 ? (
|
||||
filteredNpcs.map((n) => (
|
||||
<RuntimeNpcTile
|
||||
key={n.id}
|
||||
npc={n}
|
||||
selected={n.id === effectiveSelectedId}
|
||||
active={n.id === activeId}
|
||||
onActivate={() => onSelectTile(n.id)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{roots.map((node) => (
|
||||
<RuntimeGroupSection
|
||||
key={node.group.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
selectedId={effectiveSelectedId}
|
||||
activeId={activeId}
|
||||
onActivate={onSelectTile}
|
||||
/>
|
||||
))}
|
||||
{ungrouped.length > 0 || !searching ? (
|
||||
<div className={styles.ungroupedSection}>
|
||||
<div className={styles.ungroupedHeader}>{t('npcs.ungrouped')}</div>
|
||||
<div className={styles.groupBody}>
|
||||
{ungrouped.map((n) => (
|
||||
<RuntimeNpcTile
|
||||
key={n.id}
|
||||
npc={n}
|
||||
selected={n.id === effectiveSelectedId}
|
||||
active={n.id === activeId}
|
||||
onActivate={() => onSelectTile(n.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{npcs.length === 0 ? <div className={styles.muted}>{t('npcs.windowEmpty')}</div> : null}
|
||||
{npcs.length > 0 && filtered.length === 0 ? (
|
||||
{npcs.length > 0 && filteredNpcs.length === 0 ? (
|
||||
<div className={styles.muted}>{t('npcs.searchEmpty')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -45,6 +45,27 @@
|
||||
background: #0f0f12;
|
||||
}
|
||||
|
||||
.sideActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.sideActions > * {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sideActions button {
|
||||
width: 100%;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -272,3 +293,100 @@
|
||||
.menuItemDanger:hover {
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
}
|
||||
|
||||
.groupBlock {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.groupHeader {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 2px;
|
||||
border-bottom: 1px solid var(--stroke);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.groupHeaderDragging {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.groupDropBefore {
|
||||
box-shadow: inset 0 2px 0 #60a5fa;
|
||||
}
|
||||
|
||||
.groupDropAfter {
|
||||
box-shadow: inset 0 -2px 0 #60a5fa;
|
||||
}
|
||||
|
||||
.groupToggle {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
width: 18px;
|
||||
padding: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.groupColorDot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.groupTitle {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.3px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.groupMenuBtn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.groupMenuBtn:hover {
|
||||
background: #27272a;
|
||||
color: var(--text1);
|
||||
}
|
||||
|
||||
.groupBody {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 4px 0 8px 4px;
|
||||
min-height: 8px;
|
||||
}
|
||||
|
||||
.groupBodyDrop {
|
||||
background: rgba(96, 165, 250, 0.08);
|
||||
border-radius: 8px;
|
||||
outline: 1px dashed rgba(96, 165, 250, 0.45);
|
||||
}
|
||||
|
||||
.ungroupedSection {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ungroupedHeader {
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--text2);
|
||||
padding: 6px 2px 4px;
|
||||
border-bottom: 1px solid var(--stroke);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,16 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
||||
import type { NpcId, NpcRelationId, ProjectNpc, ProjectNpcRelation } from '../../shared/types';
|
||||
import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups';
|
||||
import type {
|
||||
NpcBinding,
|
||||
NpcGroupId,
|
||||
NpcId,
|
||||
NpcRelationId,
|
||||
ProjectNpc,
|
||||
ProjectNpcGroup,
|
||||
ProjectNpcRelation,
|
||||
} from '../../shared/types';
|
||||
import editorStyles from '../editor/EditorApp.module.css';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
@@ -10,19 +19,33 @@ import { Button, Input } from '../shared/ui/controls';
|
||||
import controlStyles from '../shared/ui/Controls.module.css';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import { NpcBindingFields } from './NpcBindingFields';
|
||||
import { NpcDescriptionField } from './NpcDescriptionField';
|
||||
import { NpcEditModal } from './NpcEditModal';
|
||||
import { NpcGraph } from './NpcGraph';
|
||||
import { NpcGraph, type GraphGroupFilter } from './NpcGraph';
|
||||
import { NpcGroupModal } from './NpcGroupModal';
|
||||
import {
|
||||
flattenGroupOptions,
|
||||
moveNpcToGroupEnd,
|
||||
reorderNpcIds,
|
||||
reorderSiblingGroups,
|
||||
} from './npcListHelpers';
|
||||
import { NpcRelationModal } from './NpcRelationModal';
|
||||
import styles from './NpcsEditorApp.module.css';
|
||||
|
||||
const DND_NPC_ID_MIME = 'application/x-dnd-npc-id';
|
||||
const DND_NPC_GROUP_ID_MIME = 'application/x-dnd-npc-group-id';
|
||||
|
||||
type GroupModalState =
|
||||
| { mode: 'create'; parentId: NpcGroupId | null }
|
||||
| { mode: 'edit'; group: ProjectNpcGroup };
|
||||
|
||||
function NpcTile({
|
||||
npc,
|
||||
selected,
|
||||
dragging,
|
||||
dropPlace,
|
||||
accentColor,
|
||||
onSelect,
|
||||
onMenu,
|
||||
onDragStart,
|
||||
@@ -34,6 +57,7 @@ function NpcTile({
|
||||
selected: boolean;
|
||||
dragging: boolean;
|
||||
dropPlace: 'before' | 'after' | null;
|
||||
accentColor?: string | null;
|
||||
onSelect: () => void;
|
||||
onMenu: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onDragStart: () => void;
|
||||
@@ -54,6 +78,7 @@ function NpcTile({
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={accentColor ? { borderLeftColor: accentColor, borderLeftWidth: 3 } : undefined}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData(DND_NPC_ID_MIME, npc.id);
|
||||
@@ -91,6 +116,193 @@ function NpcTile({
|
||||
);
|
||||
}
|
||||
|
||||
function GroupSection({
|
||||
node,
|
||||
depth,
|
||||
isExpanded,
|
||||
onToggleExpanded,
|
||||
selectedId,
|
||||
dragId,
|
||||
dropPlace,
|
||||
dragGroupId,
|
||||
groupDropPlace,
|
||||
dropTargetGroup,
|
||||
groupColorById,
|
||||
onSelectNpc,
|
||||
onNpcMenu,
|
||||
onGroupMenu,
|
||||
onNpcDragStart,
|
||||
onNpcDragEnd,
|
||||
onNpcDragOver,
|
||||
onNpcDropReorder,
|
||||
onGroupDragStart,
|
||||
onGroupDragEnd,
|
||||
onGroupDragOver,
|
||||
onGroupDropReorder,
|
||||
onGroupBodyDragOver,
|
||||
onGroupBodyDrop,
|
||||
}: {
|
||||
node: NpcGroupTreeNode;
|
||||
depth: number;
|
||||
isExpanded: (id: NpcGroupId) => boolean;
|
||||
onToggleExpanded: (id: NpcGroupId) => void;
|
||||
selectedId: NpcId | null;
|
||||
dragId: NpcId | null;
|
||||
dropPlace: { id: NpcId; place: 'before' | 'after' } | null;
|
||||
dragGroupId: NpcGroupId | null;
|
||||
groupDropPlace: { id: NpcGroupId; place: 'before' | 'after' } | null;
|
||||
dropTargetGroup: NpcGroupId | null;
|
||||
groupColorById: Map<NpcGroupId, string>;
|
||||
onSelectNpc: (id: NpcId) => void;
|
||||
onNpcMenu: (id: NpcId, e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onGroupMenu: (id: NpcGroupId, e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onNpcDragStart: (id: NpcId) => void;
|
||||
onNpcDragEnd: () => void;
|
||||
onNpcDragOver: (id: NpcId, place: 'before' | 'after') => void;
|
||||
onNpcDropReorder: (targetId: NpcId) => void;
|
||||
onGroupDragStart: (id: NpcGroupId) => void;
|
||||
onGroupDragEnd: () => void;
|
||||
onGroupDragOver: (id: NpcGroupId, place: 'before' | 'after') => void;
|
||||
onGroupDropReorder: (targetId: NpcGroupId) => void;
|
||||
onGroupBodyDragOver: (groupId: NpcGroupId) => void;
|
||||
onGroupBodyDrop: (groupId: NpcGroupId) => void;
|
||||
}) {
|
||||
const { t } = useEditorI18n();
|
||||
const g = node.group;
|
||||
const expanded = isExpanded(g.id);
|
||||
const isGroupDragging = dragGroupId === g.id;
|
||||
const groupDropBefore = groupDropPlace?.id === g.id && groupDropPlace.place === 'before';
|
||||
const groupDropAfter = groupDropPlace?.id === g.id && groupDropPlace.place === 'after';
|
||||
const bodyHighlight = dropTargetGroup === g.id && dragId !== null;
|
||||
|
||||
return (
|
||||
<div className={styles.groupBlock} style={{ paddingLeft: depth > 0 ? 12 : 0 }}>
|
||||
<div
|
||||
className={[
|
||||
styles.groupHeader,
|
||||
isGroupDragging ? styles.groupHeaderDragging : '',
|
||||
groupDropBefore ? styles.groupDropBefore : '',
|
||||
groupDropAfter ? styles.groupDropAfter : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData(DND_NPC_GROUP_ID_MIME, g.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
onGroupDragStart(g.id);
|
||||
}}
|
||||
onDragEnd={onGroupDragEnd}
|
||||
onDragOver={(e) => {
|
||||
if (dragGroupId && dragGroupId !== g.id) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const mid = rect.top + rect.height / 2;
|
||||
onGroupDragOver(g.id, e.clientY < mid ? 'before' : 'after');
|
||||
return;
|
||||
}
|
||||
if (dragId) {
|
||||
e.preventDefault();
|
||||
onGroupBodyDragOver(g.id);
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (dragGroupId && dragGroupId !== g.id && groupDropPlace?.id === g.id) {
|
||||
onGroupDropReorder(g.id);
|
||||
return;
|
||||
}
|
||||
if (dragId) onGroupBodyDrop(g.id);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.groupToggle}
|
||||
onClick={() => onToggleExpanded(g.id)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? '▾' : '▸'}
|
||||
</button>
|
||||
<span className={styles.groupColorDot} style={{ background: g.color }} aria-hidden />
|
||||
<span className={styles.groupTitle}>{g.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.groupMenuBtn}
|
||||
data-npc-menu-root="1"
|
||||
aria-label={t('npcs.tileMenu')}
|
||||
onClick={(e) => onGroupMenu(g.id, e)}
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded ? (
|
||||
<div
|
||||
className={[styles.groupBody, bodyHighlight ? styles.groupBodyDrop : ''].filter(Boolean).join(' ')}
|
||||
onDragOver={(e) => {
|
||||
if (!dragId) return;
|
||||
e.preventDefault();
|
||||
onGroupBodyDragOver(g.id);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (!dragId) return;
|
||||
e.preventDefault();
|
||||
onGroupBodyDrop(g.id);
|
||||
}}
|
||||
>
|
||||
{node.npcs.map((n) => (
|
||||
<NpcTile
|
||||
key={n.id}
|
||||
npc={n}
|
||||
selected={n.id === selectedId}
|
||||
dragging={dragId === n.id}
|
||||
dropPlace={dropPlace?.id === n.id ? dropPlace.place : null}
|
||||
accentColor={groupColorById.get(g.id) ?? g.color}
|
||||
onSelect={() => onSelectNpc(n.id)}
|
||||
onMenu={(e) => onNpcMenu(n.id, e)}
|
||||
onDragStart={() => onNpcDragStart(n.id)}
|
||||
onDragEnd={onNpcDragEnd}
|
||||
onDragOver={(place) => onNpcDragOver(n.id, place)}
|
||||
onDropReorder={() => onNpcDropReorder(n.id)}
|
||||
/>
|
||||
))}
|
||||
{node.children.map((child) => (
|
||||
<GroupSection
|
||||
key={child.group.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
selectedId={selectedId}
|
||||
dragId={dragId}
|
||||
dropPlace={dropPlace}
|
||||
dragGroupId={dragGroupId}
|
||||
groupDropPlace={groupDropPlace}
|
||||
dropTargetGroup={dropTargetGroup}
|
||||
groupColorById={groupColorById}
|
||||
onSelectNpc={onSelectNpc}
|
||||
onNpcMenu={onNpcMenu}
|
||||
onGroupMenu={onGroupMenu}
|
||||
onNpcDragStart={onNpcDragStart}
|
||||
onNpcDragEnd={onNpcDragEnd}
|
||||
onNpcDragOver={onNpcDragOver}
|
||||
onNpcDropReorder={onNpcDropReorder}
|
||||
onGroupDragStart={onGroupDragStart}
|
||||
onGroupDragEnd={onGroupDragEnd}
|
||||
onGroupDragOver={onGroupDragOver}
|
||||
onGroupDropReorder={onGroupDropReorder}
|
||||
onGroupBodyDragOver={onGroupBodyDragOver}
|
||||
onGroupBodyDrop={onGroupBodyDrop}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NpcsEditorApp() {
|
||||
const { t } = useEditorI18n();
|
||||
const api = getDndApi();
|
||||
@@ -101,9 +313,20 @@ export function NpcsEditorApp() {
|
||||
const [editInitial, setEditInitial] = useState<ProjectNpc | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<ProjectNpc | null>(null);
|
||||
const [menuFor, setMenuFor] = useState<NpcId | null>(null);
|
||||
const [groupMenuFor, setGroupMenuFor] = useState<NpcGroupId | null>(null);
|
||||
const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||||
const [dragId, setDragId] = useState<NpcId | null>(null);
|
||||
const [dropPlace, setDropPlace] = useState<{ id: NpcId; place: 'before' | 'after' } | null>(null);
|
||||
const [dragGroupId, setDragGroupId] = useState<NpcGroupId | null>(null);
|
||||
const [groupDropPlace, setGroupDropPlace] = useState<{
|
||||
id: NpcGroupId;
|
||||
place: 'before' | 'after';
|
||||
} | null>(null);
|
||||
const [dropTargetGroup, setDropTargetGroup] = useState<NpcGroupId | 'ungrouped' | null>(null);
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<NpcGroupId>>(() => new Set());
|
||||
const [groupModal, setGroupModal] = useState<GroupModalState | null>(null);
|
||||
const [pendingDeleteGroup, setPendingDeleteGroup] = useState<ProjectNpcGroup | null>(null);
|
||||
const [graphFilter, setGraphFilter] = useState<GraphGroupFilter>('all');
|
||||
const [nameDraft, setNameDraft] = useState('');
|
||||
const [relationModal, setRelationModal] = useState<
|
||||
| { mode: 'create'; sourceNpcId: NpcId; targetNpcId: NpcId }
|
||||
@@ -118,14 +341,18 @@ export function NpcsEditorApp() {
|
||||
setSession({ project, currentSceneId: project?.currentSceneId ?? null });
|
||||
const list = project?.npcs ?? [];
|
||||
setSelectedId(list[0]?.id ?? null);
|
||||
const groups = project?.npcGroups ?? [];
|
||||
setExpandedGroups(new Set(groups.map((g) => g.id)));
|
||||
});
|
||||
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
|
||||
setSession(state);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const npcs = session?.project?.npcs ?? [];
|
||||
const relations = session?.project?.npcRelations ?? [];
|
||||
const project = session?.project ?? null;
|
||||
const npcs = useMemo(() => project?.npcs ?? [], [project?.npcs]);
|
||||
const npcGroups = useMemo(() => project?.npcGroups ?? [], [project?.npcGroups]);
|
||||
const relations = useMemo(() => project?.npcRelations ?? [], [project?.npcRelations]);
|
||||
const selected = npcs.find((n) => n.id === selectedId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -138,23 +365,33 @@ export function NpcsEditorApp() {
|
||||
}, [npcs, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuFor) return;
|
||||
if (!menuFor && !groupMenuFor) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const tgt = e.target as HTMLElement | null;
|
||||
if (tgt?.closest('[data-npc-menu-root="1"]')) return;
|
||||
setMenuFor(null);
|
||||
setGroupMenuFor(null);
|
||||
setMenuPos(null);
|
||||
};
|
||||
window.addEventListener('mousedown', onDown);
|
||||
return () => window.removeEventListener('mousedown', onDown);
|
||||
}, [menuFor]);
|
||||
}, [groupMenuFor, menuFor]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const filteredNpcs = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return npcs;
|
||||
return npcs.filter((n) => n.name.toLowerCase().includes(q));
|
||||
}, [npcs, query]);
|
||||
|
||||
const { roots, ungrouped } = useMemo(
|
||||
() => buildNpcGroupForest(npcGroups, filteredNpcs),
|
||||
[filteredNpcs, npcGroups],
|
||||
);
|
||||
|
||||
const groupColorById = useMemo(() => new Map(npcGroups.map((g) => [g.id, g.color])), [npcGroups]);
|
||||
|
||||
const groupOptions = useMemo(() => flattenGroupOptions(roots), [roots]);
|
||||
|
||||
const selectedUrl = useAssetUrl(selected?.avatarAssetId ?? null);
|
||||
|
||||
const relationsForSelected = useMemo(() => {
|
||||
@@ -173,6 +410,83 @@ export function NpcsEditorApp() {
|
||||
return { filePath: res.filePath, previewDataUrl: res.previewDataUrl };
|
||||
}, [api]);
|
||||
|
||||
const commitNpcGroupChange = useCallback(
|
||||
async (npcId: NpcId, groupId: NpcGroupId | null, orderIds?: NpcId[]) => {
|
||||
const npc = npcs.find((n) => n.id === npcId);
|
||||
if (npc && npc.groupId !== groupId) {
|
||||
await api.invoke(ipcChannels.project.updateNpcFields, { npcId, groupId });
|
||||
}
|
||||
if (orderIds) {
|
||||
await api.invoke(ipcChannels.project.setNpcsOrder, { npcIds: orderIds });
|
||||
}
|
||||
},
|
||||
[api, npcs],
|
||||
);
|
||||
|
||||
const handleNpcDropOnTile = useCallback(
|
||||
(targetId: NpcId) => {
|
||||
if (!dragId || dropPlace?.id !== targetId || dragId === targetId) return;
|
||||
const targetNpc = npcs.find((n) => n.id === targetId);
|
||||
if (!targetNpc) return;
|
||||
const dragNpc = npcs.find((n) => n.id === dragId);
|
||||
if (!dragNpc) return;
|
||||
|
||||
const newGroupId = targetNpc.groupId;
|
||||
const orderIds = reorderNpcIds(
|
||||
dragNpc.groupId === newGroupId
|
||||
? npcs
|
||||
: npcs.map((n) => (n.id === dragId ? { ...n, groupId: newGroupId } : n)),
|
||||
dragId,
|
||||
targetId,
|
||||
dropPlace.place,
|
||||
);
|
||||
|
||||
setDragId(null);
|
||||
setDropPlace(null);
|
||||
setDropTargetGroup(null);
|
||||
void commitNpcGroupChange(dragId, newGroupId, orderIds);
|
||||
},
|
||||
[commitNpcGroupChange, dragId, dropPlace, npcs],
|
||||
);
|
||||
|
||||
const handleNpcDropOnGroup = useCallback(
|
||||
(groupId: NpcGroupId | null) => {
|
||||
if (!dragId) return;
|
||||
const dragNpc = npcs.find((n) => n.id === dragId);
|
||||
if (!dragNpc) return;
|
||||
const orderIds = moveNpcToGroupEnd(
|
||||
dragNpc.groupId === groupId ? npcs : npcs.map((n) => (n.id === dragId ? { ...n, groupId } : n)),
|
||||
dragId,
|
||||
groupId,
|
||||
);
|
||||
setDragId(null);
|
||||
setDropPlace(null);
|
||||
setDropTargetGroup(null);
|
||||
void commitNpcGroupChange(dragId, groupId, orderIds);
|
||||
},
|
||||
[commitNpcGroupChange, dragId, npcs],
|
||||
);
|
||||
|
||||
const handleGroupDropReorder = useCallback(
|
||||
(targetId: NpcGroupId) => {
|
||||
if (!dragGroupId || !groupDropPlace || dragGroupId === groupDropPlace.id) return;
|
||||
const order = reorderSiblingGroups(npcGroups, dragGroupId, targetId, groupDropPlace.place);
|
||||
setDragGroupId(null);
|
||||
setGroupDropPlace(null);
|
||||
void api.invoke(ipcChannels.project.setNpcGroupsOrder, { groupIds: order });
|
||||
},
|
||||
[api, dragGroupId, groupDropPlace, npcGroups],
|
||||
);
|
||||
|
||||
const openMenuAt = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
const menuW = 180;
|
||||
const menuH = 120;
|
||||
const left = Math.max(8, Math.min(r.right - menuW, window.innerWidth - menuW - 8));
|
||||
const top = r.bottom + 8 + menuH > window.innerHeight - 8 ? Math.max(8, r.top - menuH - 8) : r.bottom + 8;
|
||||
setMenuPos({ left, top });
|
||||
};
|
||||
|
||||
const graphUi = useMemo(
|
||||
() => ({
|
||||
zoomBar: t('npcs.graphZoomBar'),
|
||||
@@ -182,10 +496,28 @@ export function NpcsEditorApp() {
|
||||
editRelation: t('npcs.relationEdit'),
|
||||
deleteRelation: t('npcs.relationDelete'),
|
||||
untitled: t('npcs.untitled'),
|
||||
graphFilter: t('npcs.graphFilter'),
|
||||
graphFilterAll: t('npcs.graphFilterAll'),
|
||||
graphFilterUngrouped: t('npcs.graphFilterUngrouped'),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const isExpanded = (id: NpcGroupId) => expandedGroups.has(id);
|
||||
const toggleExpanded = (id: NpcGroupId) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const siblingNamesForGroup = (parentId: NpcGroupId | null, exceptId?: NpcGroupId) =>
|
||||
npcGroups.filter((g) => g.parentId === parentId && g.id !== exceptId).map((g) => g.name);
|
||||
|
||||
const hasListContent = npcGroups.length > 0 || ungrouped.length > 0;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.topBar}>
|
||||
@@ -202,66 +534,138 @@ export function NpcsEditorApp() {
|
||||
<div className={styles.body}>
|
||||
<div className={[styles.col, styles.side].join(' ')}>
|
||||
<Input value={query} onChange={setQuery} placeholder={t('npcs.search')} />
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setEditInitial(null);
|
||||
setEditOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('npcs.add')}
|
||||
</Button>
|
||||
<div className={styles.sideActions}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setEditInitial(null);
|
||||
setEditOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('npcs.add')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setGroupModal({ mode: 'create', parentId: null });
|
||||
}}
|
||||
>
|
||||
{t('npcs.addGroup')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.list}>
|
||||
{filtered.map((n) => (
|
||||
<NpcTile
|
||||
key={n.id}
|
||||
npc={n}
|
||||
selected={n.id === selectedId}
|
||||
dragging={dragId === n.id}
|
||||
dropPlace={dropPlace?.id === n.id ? dropPlace.place : null}
|
||||
onSelect={() => setSelectedId(n.id)}
|
||||
onMenu={(e) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
const menuW = 180;
|
||||
const menuH = 88;
|
||||
const left = Math.max(8, Math.min(r.right - menuW, window.innerWidth - menuW - 8));
|
||||
const top =
|
||||
r.bottom + 8 + menuH > window.innerHeight - 8
|
||||
? Math.max(8, r.top - menuH - 8)
|
||||
: r.bottom + 8;
|
||||
setMenuPos({ left, top });
|
||||
setMenuFor((cur) => (cur === n.id ? null : n.id));
|
||||
{roots.map((node) => (
|
||||
<GroupSection
|
||||
key={node.group.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
selectedId={selectedId}
|
||||
dragId={dragId}
|
||||
dropPlace={dropPlace}
|
||||
dragGroupId={dragGroupId}
|
||||
groupDropPlace={groupDropPlace}
|
||||
dropTargetGroup={dropTargetGroup === 'ungrouped' ? null : dropTargetGroup}
|
||||
groupColorById={groupColorById}
|
||||
onSelectNpc={setSelectedId}
|
||||
onNpcMenu={(id, e) => {
|
||||
openMenuAt(e);
|
||||
setGroupMenuFor(null);
|
||||
setMenuFor((cur) => (cur === id ? null : id));
|
||||
}}
|
||||
onDragStart={() => setDragId(n.id)}
|
||||
onDragEnd={() => {
|
||||
onGroupMenu={(id, e) => {
|
||||
openMenuAt(e);
|
||||
setMenuFor(null);
|
||||
setGroupMenuFor((cur) => (cur === id ? null : id));
|
||||
}}
|
||||
onNpcDragStart={setDragId}
|
||||
onNpcDragEnd={() => {
|
||||
setDragId(null);
|
||||
setDropPlace(null);
|
||||
setDropTargetGroup(null);
|
||||
}}
|
||||
onDragOver={(place) => {
|
||||
if (!dragId || dragId === n.id) {
|
||||
onNpcDragOver={(id, place) => {
|
||||
if (!dragId || dragId === id) {
|
||||
setDropPlace(null);
|
||||
return;
|
||||
}
|
||||
setDropPlace({ id: n.id, place });
|
||||
setDropPlace({ id, place });
|
||||
}}
|
||||
onDropReorder={() => {
|
||||
if (!dragId || !dropPlace || dragId === dropPlace.id) return;
|
||||
const ids = npcs.map((x) => x.id);
|
||||
const from = ids.indexOf(dragId);
|
||||
if (from < 0) return;
|
||||
ids.splice(from, 1);
|
||||
let to = ids.indexOf(dropPlace.id);
|
||||
if (to < 0) return;
|
||||
if (dropPlace.place === 'after') to += 1;
|
||||
ids.splice(to, 0, dragId);
|
||||
setDragId(null);
|
||||
setDropPlace(null);
|
||||
void api.invoke(ipcChannels.project.setNpcsOrder, { npcIds: ids });
|
||||
onNpcDropReorder={handleNpcDropOnTile}
|
||||
onGroupDragStart={setDragGroupId}
|
||||
onGroupDragEnd={() => {
|
||||
setDragGroupId(null);
|
||||
setGroupDropPlace(null);
|
||||
}}
|
||||
onGroupDragOver={(id, place) => {
|
||||
if (!dragGroupId || dragGroupId === id) {
|
||||
setGroupDropPlace(null);
|
||||
return;
|
||||
}
|
||||
setGroupDropPlace({ id, place });
|
||||
}}
|
||||
onGroupDropReorder={handleGroupDropReorder}
|
||||
onGroupBodyDragOver={setDropTargetGroup}
|
||||
onGroupBodyDrop={(groupId) => handleNpcDropOnGroup(groupId)}
|
||||
/>
|
||||
))}
|
||||
{npcs.length === 0 ? <div className={styles.muted}>{t('npcs.empty')}</div> : null}
|
||||
{npcs.length > 0 && filtered.length === 0 ? (
|
||||
|
||||
<div className={styles.ungroupedSection}>
|
||||
<div className={styles.ungroupedHeader}>{t('npcs.ungrouped')}</div>
|
||||
<div
|
||||
className={[
|
||||
styles.groupBody,
|
||||
dropTargetGroup === 'ungrouped' && dragId ? styles.groupBodyDrop : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onDragOver={(e) => {
|
||||
if (!dragId) return;
|
||||
e.preventDefault();
|
||||
setDropTargetGroup('ungrouped');
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (!dragId) return;
|
||||
e.preventDefault();
|
||||
handleNpcDropOnGroup(null);
|
||||
}}
|
||||
>
|
||||
{ungrouped.map((n) => (
|
||||
<NpcTile
|
||||
key={n.id}
|
||||
npc={n}
|
||||
selected={n.id === selectedId}
|
||||
dragging={dragId === n.id}
|
||||
dropPlace={dropPlace?.id === n.id ? dropPlace.place : null}
|
||||
onSelect={() => setSelectedId(n.id)}
|
||||
onMenu={(e) => {
|
||||
openMenuAt(e);
|
||||
setGroupMenuFor(null);
|
||||
setMenuFor((cur) => (cur === n.id ? null : n.id));
|
||||
}}
|
||||
onDragStart={() => setDragId(n.id)}
|
||||
onDragEnd={() => {
|
||||
setDragId(null);
|
||||
setDropPlace(null);
|
||||
setDropTargetGroup(null);
|
||||
}}
|
||||
onDragOver={(place) => {
|
||||
if (!dragId || dragId === n.id) {
|
||||
setDropPlace(null);
|
||||
return;
|
||||
}
|
||||
setDropPlace({ id: n.id, place });
|
||||
}}
|
||||
onDropReorder={() => handleNpcDropOnTile(n.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hasListContent && npcs.length === 0 ? (
|
||||
<div className={styles.muted}>{t('npcs.empty')}</div>
|
||||
) : null}
|
||||
{npcs.length > 0 && filteredNpcs.length === 0 ? (
|
||||
<div className={styles.muted}>{t('npcs.searchEmpty')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -271,7 +675,10 @@ export function NpcsEditorApp() {
|
||||
<NpcGraph
|
||||
npcs={npcs}
|
||||
relations={relations}
|
||||
npcGroups={npcGroups}
|
||||
selectedNpcId={selectedId}
|
||||
graphFilter={graphFilter}
|
||||
onGraphFilterChange={setGraphFilter}
|
||||
graphUi={graphUi}
|
||||
onSelect={setSelectedId}
|
||||
onConnectRequest={(sourceNpcId, targetNpcId) => {
|
||||
@@ -295,7 +702,7 @@ export function NpcsEditorApp() {
|
||||
|
||||
<div className={[styles.col, styles.inspector].join(' ')}>
|
||||
<div className={styles.inspectorScroll}>
|
||||
{selected ? (
|
||||
{selected && project ? (
|
||||
<>
|
||||
<div>
|
||||
<div className={styles.fieldLabel}>{t('npcs.avatar')}</div>
|
||||
@@ -352,6 +759,28 @@ export function NpcsEditorApp() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={styles.fieldLabel}>{t('npcs.group')}</div>
|
||||
<select
|
||||
className={controlStyles.input}
|
||||
value={selected.groupId ?? ''}
|
||||
onChange={(e) => {
|
||||
const groupId = (e.target.value || null) as NpcGroupId | null;
|
||||
void api.invoke(ipcChannels.project.updateNpcFields, {
|
||||
npcId: selected.id,
|
||||
groupId,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">{t('npcs.ungrouped')}</option>
|
||||
{groupOptions.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={styles.fieldLabel}>{t('npcs.description')}</div>
|
||||
<NpcDescriptionField
|
||||
@@ -366,6 +795,20 @@ export function NpcsEditorApp() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={styles.fieldLabel}>{t('npcs.bindingEnable')}</div>
|
||||
<NpcBindingFields
|
||||
project={project}
|
||||
binding={selected.binding}
|
||||
onChange={(binding: NpcBinding) => {
|
||||
void api.invoke(ipcChannels.project.updateNpcFields, {
|
||||
npcId: selected.id,
|
||||
binding,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{relationsForSelected.length > 0 ? (
|
||||
<div>
|
||||
<div className={styles.relationsTitle}>{t('npcs.relations')}</div>
|
||||
@@ -390,6 +833,8 @@ export function NpcsEditorApp() {
|
||||
open={editOpen}
|
||||
initial={editInitial}
|
||||
existingNames={npcs.map((n) => n.name)}
|
||||
project={project}
|
||||
npcGroups={npcGroups}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onPickImage={pickAvatar}
|
||||
onSave={async (input) => {
|
||||
@@ -397,12 +842,43 @@ export function NpcsEditorApp() {
|
||||
...(editInitial ? { npcId: editInitial.id } : {}),
|
||||
name: input.name,
|
||||
...(input.filePath ? { filePath: input.filePath } : {}),
|
||||
...(input.groupId !== undefined ? { groupId: input.groupId } : {}),
|
||||
...(input.binding !== undefined ? { binding: input.binding } : {}),
|
||||
});
|
||||
const created = res.project.npcs.find((n) => n.name === input.name.trim());
|
||||
if (created) setSelectedId(created.id);
|
||||
}}
|
||||
/>
|
||||
|
||||
<NpcGroupModal
|
||||
open={Boolean(groupModal)}
|
||||
initial={groupModal?.mode === 'edit' ? groupModal.group : null}
|
||||
siblingNames={
|
||||
groupModal?.mode === 'edit'
|
||||
? siblingNamesForGroup(groupModal.group.parentId, groupModal.group.id)
|
||||
: groupModal?.mode === 'create'
|
||||
? siblingNamesForGroup(groupModal.parentId)
|
||||
: []
|
||||
}
|
||||
onClose={() => setGroupModal(null)}
|
||||
onSave={async ({ name, color }) => {
|
||||
if (!groupModal) return;
|
||||
if (groupModal.mode === 'edit') {
|
||||
await api.invoke(ipcChannels.project.upsertNpcGroup, {
|
||||
groupId: groupModal.group.id,
|
||||
name,
|
||||
color,
|
||||
});
|
||||
} else {
|
||||
await api.invoke(ipcChannels.project.upsertNpcGroup, {
|
||||
name,
|
||||
color,
|
||||
parentId: groupModal.parentId,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<NpcRelationModal
|
||||
open={Boolean(relationModal)}
|
||||
initialLabel={relationModal?.mode === 'edit' ? relationModal.label : ''}
|
||||
@@ -431,13 +907,14 @@ export function NpcsEditorApp() {
|
||||
{menuFor && menuPos
|
||||
? createPortal(
|
||||
<div
|
||||
role="menu"
|
||||
className={styles.menu}
|
||||
style={{ left: menuPos.left, top: menuPos.top }}
|
||||
data-npc-menu-root="1"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.menuItemDanger}
|
||||
onClick={() => {
|
||||
const npc = npcs.find((n) => n.id === menuFor);
|
||||
@@ -452,6 +929,54 @@ export function NpcsEditorApp() {
|
||||
)
|
||||
: null}
|
||||
|
||||
{groupMenuFor && menuPos
|
||||
? createPortal(
|
||||
<div
|
||||
role="menu"
|
||||
className={styles.menu}
|
||||
style={{ left: menuPos.left, top: menuPos.top }}
|
||||
data-npc-menu-root="1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.menuItem}
|
||||
onClick={() => {
|
||||
const g = npcGroups.find((x) => x.id === groupMenuFor);
|
||||
if (g) setGroupModal({ mode: 'edit', group: g });
|
||||
setGroupMenuFor(null);
|
||||
}}
|
||||
>
|
||||
{t('npcs.editGroup')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.menuItem}
|
||||
onClick={() => {
|
||||
setGroupModal({ mode: 'create', parentId: groupMenuFor });
|
||||
setGroupMenuFor(null);
|
||||
}}
|
||||
>
|
||||
{t('npcs.addSubgroup')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.menuItemDanger}
|
||||
onClick={() => {
|
||||
const g = npcGroups.find((x) => x.id === groupMenuFor);
|
||||
if (g) setPendingDeleteGroup(g);
|
||||
setGroupMenuFor(null);
|
||||
}}
|
||||
>
|
||||
{t('npcs.deleteGroup')}
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
|
||||
{pendingDelete
|
||||
? createPortal(
|
||||
<>
|
||||
@@ -492,6 +1017,46 @@ export function NpcsEditorApp() {
|
||||
)
|
||||
: null}
|
||||
|
||||
{pendingDeleteGroup
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={editorStyles.modalBackdrop}
|
||||
onClick={() => setPendingDeleteGroup(null)}
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
|
||||
<div className={editorStyles.modalHeader}>
|
||||
<div className={editorStyles.modalTitle}>{t('npcs.deleteGroupTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
className={editorStyles.modalClose}
|
||||
onClick={() => setPendingDeleteGroup(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div>{t('npcs.deleteGroupConfirm', { name: pendingDeleteGroup.name })}</div>
|
||||
<div className={editorStyles.modalFooter}>
|
||||
<Button onClick={() => setPendingDeleteGroup(null)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const id = pendingDeleteGroup.id;
|
||||
setPendingDeleteGroup(null);
|
||||
void api.invoke(ipcChannels.project.deleteNpcGroup, { groupId: id });
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
|
||||
{pendingDeleteRelation
|
||||
? createPortal(
|
||||
<>
|
||||
@@ -512,9 +1077,7 @@ export function NpcsEditorApp() {
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
{t('npcs.relationDeleteConfirm', { name: pendingDeleteRelation.label })}
|
||||
</div>
|
||||
<div>{t('npcs.relationDeleteConfirm', { name: pendingDeleteRelation.label })}</div>
|
||||
<div className={editorStyles.modalFooter}>
|
||||
<Button onClick={() => setPendingDeleteRelation(null)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { NpcGroupId, NpcId, ProjectNpc, ProjectNpcGroup } from '../../shared/types';
|
||||
|
||||
export function reorderNpcIds(
|
||||
npcs: ProjectNpc[],
|
||||
dragId: NpcId,
|
||||
targetId: NpcId,
|
||||
place: 'before' | 'after',
|
||||
): NpcId[] {
|
||||
const ids = npcs.map((n) => n.id);
|
||||
const from = ids.indexOf(dragId);
|
||||
if (from < 0) return ids;
|
||||
ids.splice(from, 1);
|
||||
let to = ids.indexOf(targetId);
|
||||
if (to < 0) return npcs.map((n) => n.id);
|
||||
if (place === 'after') to += 1;
|
||||
ids.splice(to, 0, dragId);
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Move NPC to end of its group block in global order. */
|
||||
export function moveNpcToGroupEnd(npcs: ProjectNpc[], dragId: NpcId, groupId: NpcGroupId | null): NpcId[] {
|
||||
const updated = npcs.map((n) => (n.id === dragId ? { ...n, groupId } : n));
|
||||
const ids = updated.map((n) => n.id);
|
||||
const from = ids.indexOf(dragId);
|
||||
if (from < 0) return ids;
|
||||
ids.splice(from, 1);
|
||||
|
||||
let insertAt = ids.length;
|
||||
for (let i = ids.length - 1; i >= 0; i -= 1) {
|
||||
const npc = updated.find((n) => n.id === ids[i]);
|
||||
if (npc?.groupId === groupId) {
|
||||
insertAt = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (groupId === null) {
|
||||
for (let i = ids.length - 1; i >= 0; i -= 1) {
|
||||
const npc = updated.find((n) => n.id === ids[i]);
|
||||
if (npc?.groupId === null) {
|
||||
insertAt = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
ids.splice(insertAt, 0, dragId);
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function reorderSiblingGroups(
|
||||
groups: ProjectNpcGroup[],
|
||||
dragId: NpcGroupId,
|
||||
targetId: NpcGroupId,
|
||||
place: 'before' | 'after',
|
||||
): NpcGroupId[] {
|
||||
const drag = groups.find((g) => g.id === dragId);
|
||||
const target = groups.find((g) => g.id === targetId);
|
||||
if (!drag || drag.parentId !== target?.parentId) return groups.map((g) => g.id);
|
||||
|
||||
const parentId = drag.parentId;
|
||||
const siblingIds = groups.filter((g) => g.parentId === parentId).map((g) => g.id);
|
||||
const from = siblingIds.indexOf(dragId);
|
||||
if (from < 0) return groups.map((g) => g.id);
|
||||
siblingIds.splice(from, 1);
|
||||
let to = siblingIds.indexOf(targetId);
|
||||
if (to < 0) return groups.map((g) => g.id);
|
||||
if (place === 'after') to += 1;
|
||||
siblingIds.splice(to, 0, dragId);
|
||||
|
||||
const result: NpcGroupId[] = [];
|
||||
let inserted = false;
|
||||
for (const g of groups) {
|
||||
if (g.parentId === parentId) {
|
||||
if (!inserted) {
|
||||
result.push(...siblingIds);
|
||||
inserted = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
result.push(g.id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function flattenGroupOptions(
|
||||
nodes: { group: ProjectNpcGroup; children: unknown[] }[],
|
||||
depth = 0,
|
||||
): { id: NpcGroupId; label: string }[] {
|
||||
const out: { id: NpcGroupId; label: string }[] = [];
|
||||
for (const node of nodes) {
|
||||
const prefix = depth > 0 ? ' '.repeat(depth) : '';
|
||||
out.push({ id: node.group.id, label: `${prefix}${node.group.name}` });
|
||||
out.push(...flattenGroupOptions(node.children as typeof nodes, depth + 1));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -85,6 +85,15 @@
|
||||
padding: 0 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-3);
|
||||
background-color: var(--color-overlay-dark-3);
|
||||
outline: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
select.input {
|
||||
padding-right: 28px;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.25L6 7.75L9.5 4.25' stroke='rgba(255,255,255,0.72)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
background-size: 12px 12px;
|
||||
}
|
||||
|
||||
@@ -44,11 +44,23 @@ a {
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Кастомная стрелка select: 8px от правого края (нативные часто вплотную). */
|
||||
select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.25L6 7.75L9.5 4.25' stroke='rgba(255,255,255,0.72)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
background-size: 12px 12px;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--selection-bg);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user