1829191410
Toggle NPCs independently from the characters window, keep one shared dim/close-all, and scroll descriptions for all selected characters. Co-authored-by: Cursor <cursoragent@cursor.com>
394 lines
13 KiB
TypeScript
394 lines
13 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
|
|
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
|
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';
|
|
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
|
|
|
import styles from './NpcsApp.module.css';
|
|
|
|
function ZoomInIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
|
|
<circle cx="10.5" cy="10.5" r="6.25" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
|
<path d="M15.2 15.2 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
|
<path
|
|
d="M10.5 7.8v5.4M7.8 10.5h5.4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.8"
|
|
strokeLinecap="round"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ZoomOutIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
|
|
<circle cx="10.5" cy="10.5" r="6.25" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
|
<path d="M15.2 15.2 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
|
<path d="M7.8 10.5h5.4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
/** Убрать пустые ветки групп (удобно при поиске). */
|
|
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,
|
|
accentColor,
|
|
onActivate,
|
|
}: {
|
|
npc: ProjectNpc;
|
|
selected: boolean;
|
|
accentColor?: string | null;
|
|
onActivate: () => void;
|
|
}) {
|
|
const url = useAssetUrl(npc.avatarAssetId);
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={[styles.tile, selected ? styles.tileSelected : ''].filter(Boolean).join(' ')}
|
|
style={accentColor ? { borderLeftColor: accentColor, borderLeftWidth: 3 } : undefined}
|
|
onClick={onActivate}
|
|
>
|
|
<div className={styles.tileAvatar}>
|
|
{url ? <img className={styles.tileAvatarImg} src={url} alt="" draggable={false} /> : null}
|
|
</div>
|
|
<div className={styles.tileName}>{npc.name}</div>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function RuntimeGroupSection({
|
|
node,
|
|
depth,
|
|
isExpanded,
|
|
onToggleExpanded,
|
|
selectedIds,
|
|
onActivate,
|
|
}: {
|
|
node: NpcGroupTreeNode;
|
|
depth: number;
|
|
isExpanded: (id: NpcGroupId) => boolean;
|
|
onToggleExpanded: (id: NpcGroupId) => void;
|
|
selectedIds: ReadonlySet<NpcId>;
|
|
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={selectedIds.has(n.id)}
|
|
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}
|
|
selectedIds={selectedIds}
|
|
onActivate={onActivate}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function NpcsApp() {
|
|
const { t } = useEditorI18n();
|
|
const api = getDndApi();
|
|
const [session, setSession] = useState<SessionState | null>(null);
|
|
const [overlay, overlayApi] = useNpcsOverlayState();
|
|
const [query, setQuery] = useState('');
|
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<NpcGroupId>>(() => new Set());
|
|
|
|
useEffect(() => {
|
|
void api.invoke(ipcChannels.project.get, {}).then(({ project }) => {
|
|
setSession({ project, currentSceneId: project?.currentSceneId ?? null });
|
|
});
|
|
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
|
|
setSession(state);
|
|
});
|
|
}, [api]);
|
|
|
|
const activeIds = overlay?.activeNpcIds ?? [];
|
|
const hasActive = activeIds.length > 0;
|
|
const zoomTool = overlay?.zoomTool ?? null;
|
|
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
if (hasActive) {
|
|
void overlayApi.dispatch({ kind: 'hide' });
|
|
return;
|
|
}
|
|
if (zoomTool) {
|
|
void overlayApi.dispatch({ kind: 'zoomTool.set', tool: null });
|
|
return;
|
|
}
|
|
window.close();
|
|
}
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
}, [hasActive, zoomTool, overlayApi]);
|
|
|
|
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 selectedIds = useMemo(() => new Set(activeIds), [activeIds]);
|
|
const selectedNpcs = useMemo(
|
|
() => activeIds.map((id) => npcs.find((n) => n.id === id)).filter((n): n is ProjectNpc => Boolean(n)),
|
|
[activeIds, npcs],
|
|
);
|
|
|
|
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 relationsByNpcId = useMemo(() => {
|
|
const map = new Map<NpcId, { id: string; text: string }[]>();
|
|
for (const npc of selectedNpcs) {
|
|
const list = relations
|
|
.filter((r) => r.sourceNpcId === npc.id)
|
|
.map((r) => {
|
|
const other = npcs.find((n) => n.id === r.targetNpcId);
|
|
return { id: r.id, text: `${r.label} ${other?.name ?? '—'}` };
|
|
});
|
|
map.set(npc.id, list);
|
|
}
|
|
return map;
|
|
}, [npcs, relations, selectedNpcs]);
|
|
|
|
const onSelectTile = useCallback(
|
|
(id: NpcId) => {
|
|
void overlayApi.dispatch({ kind: 'toggle', npcId: id });
|
|
},
|
|
[overlayApi],
|
|
);
|
|
|
|
return (
|
|
<div className={styles.page}>
|
|
<div className={styles.toolbar}>
|
|
<div className={styles.toolbarRow}>
|
|
<Button
|
|
variant={zoomTool === 'zoomIn' ? 'primary' : 'ghost'}
|
|
iconOnly
|
|
title={t('npcs.zoomIn')}
|
|
ariaLabel={t('npcs.zoomIn')}
|
|
tooltipPlacement="bottom"
|
|
onClick={() => {
|
|
void overlayApi.dispatch({
|
|
kind: 'zoomTool.set',
|
|
tool: zoomTool === 'zoomIn' ? null : 'zoomIn',
|
|
});
|
|
}}
|
|
>
|
|
<ZoomInIcon />
|
|
</Button>
|
|
<Button
|
|
variant={zoomTool === 'zoomOut' ? 'primary' : 'ghost'}
|
|
iconOnly
|
|
title={t('npcs.zoomOut')}
|
|
ariaLabel={t('npcs.zoomOut')}
|
|
tooltipPlacement="bottom"
|
|
onClick={() => {
|
|
void overlayApi.dispatch({
|
|
kind: 'zoomTool.set',
|
|
tool: zoomTool === 'zoomOut' ? null : 'zoomOut',
|
|
});
|
|
}}
|
|
>
|
|
<ZoomOutIcon />
|
|
</Button>
|
|
{hasActive ? (
|
|
<Button
|
|
title={t('npcs.closeOverlay')}
|
|
ariaLabel={t('npcs.closeOverlay')}
|
|
tooltipPlacement="bottom"
|
|
onClick={() => {
|
|
void overlayApi.dispatch({ kind: 'hide' });
|
|
}}
|
|
>
|
|
{t('npcs.closeOverlay')}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
<div className={styles.toolbarHint}>
|
|
{zoomTool === 'zoomIn'
|
|
? t('npcs.zoomInHint')
|
|
: zoomTool === 'zoomOut'
|
|
? t('npcs.zoomOutHint')
|
|
: t('npcs.zoomIdleHint')}
|
|
</div>
|
|
</div>
|
|
|
|
<div className={styles.body}>
|
|
<div className={styles.detail}>
|
|
{selectedNpcs.length > 0 ? (
|
|
selectedNpcs.map((npc) => {
|
|
const safeHtml = sanitizeSceneDescriptionHtml(npc.description);
|
|
const npcRelations = relationsByNpcId.get(npc.id) ?? [];
|
|
return (
|
|
<div key={npc.id} className={styles.detailCard}>
|
|
<div className={styles.detailName}>{npc.name}</div>
|
|
{safeHtml ? (
|
|
<div>
|
|
<div className={styles.detailSectionTitle}>{t('npcs.description')}</div>
|
|
<div className={styles.detailDesc} dangerouslySetInnerHTML={{ __html: safeHtml }} />
|
|
</div>
|
|
) : (
|
|
<div className={styles.muted}>{t('npcs.descriptionEmpty')}</div>
|
|
)}
|
|
{npcRelations.length > 0 ? (
|
|
<div>
|
|
<div className={styles.detailSectionTitle}>{t('npcs.relations')}</div>
|
|
<div className={styles.relationsList}>
|
|
{npcRelations.map((r) => (
|
|
<div key={r.id} className={styles.relationItem}>
|
|
{r.text}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})
|
|
) : (
|
|
<div className={styles.detailEmpty}>
|
|
{npcs.length === 0 ? t('npcs.windowEmpty') : t('npcs.selectToShow')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.listCol}>
|
|
<Input value={query} onChange={setQuery} placeholder={t('npcs.search')} />
|
|
<div className={styles.list}>
|
|
{npcGroups.length === 0 ? (
|
|
filteredNpcs.map((n) => (
|
|
<RuntimeNpcTile
|
|
key={n.id}
|
|
npc={n}
|
|
selected={selectedIds.has(n.id)}
|
|
onActivate={() => onSelectTile(n.id)}
|
|
/>
|
|
))
|
|
) : (
|
|
<>
|
|
{roots.map((node) => (
|
|
<RuntimeGroupSection
|
|
key={node.group.id}
|
|
node={node}
|
|
depth={0}
|
|
isExpanded={isExpanded}
|
|
onToggleExpanded={toggleExpanded}
|
|
selectedIds={selectedIds}
|
|
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={selectedIds.has(n.id)}
|
|
onActivate={() => onSelectTile(n.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
)}
|
|
|
|
{npcs.length === 0 ? <div className={styles.muted}>{t('npcs.windowEmpty')}</div> : null}
|
|
{npcs.length > 0 && filteredNpcs.length === 0 ? (
|
|
<div className={styles.muted}>{t('npcs.searchEmpty')}</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|