feat(npcs): inspect cards, Show overlay, and optional external link
Clicking an NPC tile opens description cards (multi-select accordion); Show puts avatars on screen. Optional URL opens a webview tab. Avatar pan in the editor commits on pointer up. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+237
-56
@@ -4,6 +4,12 @@ import { createPortal } from 'react-dom';
|
||||
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 {
|
||||
listVisibleSceneNpcTokens,
|
||||
nextSessionNpcSpawnPoint,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import { normalizeNpcDisposition } from '../../shared/types/npcDisposition';
|
||||
import { normalizeNpcExternalUrl } from '../../shared/types/npcExternalUrl';
|
||||
import matStyles from '../editor/MaterialsModals.module.css';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml';
|
||||
@@ -13,11 +19,6 @@ import { useNpcMapSpawnDrag } from '../shared/playerToken/useNpcMapSpawnDrag';
|
||||
import { useSceneNpcTokensSession } from '../shared/playerToken/useSceneNpcTokensSession';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
import {
|
||||
listVisibleSceneNpcTokens,
|
||||
nextSessionNpcSpawnPoint,
|
||||
} from '../../shared/types/appPlayers';
|
||||
import { normalizeNpcDisposition } from '../../shared/types/npcDisposition';
|
||||
|
||||
import styles from './NpcsApp.module.css';
|
||||
|
||||
@@ -61,18 +62,22 @@ function pruneEmptyGroupNodes(nodes: NpcGroupTreeNode[]): NpcGroupTreeNode[] {
|
||||
function RuntimeNpcTile({
|
||||
npc,
|
||||
selected,
|
||||
shown,
|
||||
accentColor,
|
||||
mapDragEnabled,
|
||||
onActivate,
|
||||
onToggleShow,
|
||||
onAddToMap,
|
||||
onMapDragBegin,
|
||||
onMapDragEnd,
|
||||
}: {
|
||||
npc: ProjectNpc;
|
||||
selected: boolean;
|
||||
shown: boolean;
|
||||
accentColor?: string | null;
|
||||
mapDragEnabled: boolean;
|
||||
onActivate: () => void;
|
||||
onToggleShow: () => void;
|
||||
onAddToMap: () => void;
|
||||
onMapDragBegin: (npcId: NpcId) => void;
|
||||
onMapDragEnd: () => void;
|
||||
@@ -138,30 +143,38 @@ function RuntimeNpcTile({
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
<div className={styles.tileMain}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tileBody}
|
||||
onClick={(e) => {
|
||||
if (!suppressClickRef.current) return;
|
||||
suppressClickRef.current = false;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (suppressClickRef.current) {
|
||||
suppressClickRef.current = false;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
onActivate();
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0 || !mapDragEnabled) return;
|
||||
dragRef.current = { pointerId: e.pointerId, x: e.clientX, y: e.clientY, started: false };
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || d.pointerId !== e.pointerId || d.started) return;
|
||||
if (Math.hypot(e.clientX - d.x, e.clientY - d.y) < 7) return;
|
||||
d.started = true;
|
||||
try {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
onMapDragBegin(npc.id);
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || d.pointerId !== e.pointerId) return;
|
||||
if (d && d.pointerId !== e.pointerId) return;
|
||||
dragRef.current = null;
|
||||
try {
|
||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||
@@ -170,13 +183,11 @@ function RuntimeNpcTile({
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (d.started) {
|
||||
if (d?.started) {
|
||||
suppressClickRef.current = true;
|
||||
e.preventDefault();
|
||||
onMapDragEnd();
|
||||
return;
|
||||
}
|
||||
onActivate();
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
dragRef.current = null;
|
||||
@@ -187,6 +198,21 @@ function RuntimeNpcTile({
|
||||
</div>
|
||||
<div className={styles.tileName}>{npc.name}</div>
|
||||
</button>
|
||||
<label
|
||||
className={styles.tileShow}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shown}
|
||||
onChange={() => {
|
||||
onToggleShow();
|
||||
}}
|
||||
/>
|
||||
<span>{t('npcs.showOnScreen')}</span>
|
||||
</label>
|
||||
</div>
|
||||
{menu
|
||||
? createPortal(
|
||||
<div
|
||||
@@ -224,8 +250,10 @@ function RuntimeGroupSection({
|
||||
isExpanded,
|
||||
onToggleExpanded,
|
||||
selectedIds,
|
||||
shownIds,
|
||||
mapDragEnabled,
|
||||
onActivate,
|
||||
onToggleShow,
|
||||
onAddToMap,
|
||||
onMapDragBegin,
|
||||
onMapDragEnd,
|
||||
@@ -235,8 +263,10 @@ function RuntimeGroupSection({
|
||||
isExpanded: (id: NpcGroupId) => boolean;
|
||||
onToggleExpanded: (id: NpcGroupId) => void;
|
||||
selectedIds: ReadonlySet<NpcId>;
|
||||
shownIds: ReadonlySet<NpcId>;
|
||||
mapDragEnabled: boolean;
|
||||
onActivate: (id: NpcId) => void;
|
||||
onToggleShow: (id: NpcId) => void;
|
||||
onAddToMap: (id: NpcId) => void;
|
||||
onMapDragBegin: (npcId: NpcId) => void;
|
||||
onMapDragEnd: () => void;
|
||||
@@ -265,9 +295,11 @@ function RuntimeGroupSection({
|
||||
key={n.id}
|
||||
npc={n}
|
||||
selected={selectedIds.has(n.id)}
|
||||
shown={shownIds.has(n.id)}
|
||||
accentColor={g.color}
|
||||
mapDragEnabled={mapDragEnabled}
|
||||
onActivate={() => onActivate(n.id)}
|
||||
onToggleShow={() => onToggleShow(n.id)}
|
||||
onAddToMap={() => onAddToMap(n.id)}
|
||||
onMapDragBegin={onMapDragBegin}
|
||||
onMapDragEnd={onMapDragEnd}
|
||||
@@ -281,8 +313,10 @@ function RuntimeGroupSection({
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
selectedIds={selectedIds}
|
||||
shownIds={shownIds}
|
||||
mapDragEnabled={mapDragEnabled}
|
||||
onActivate={onActivate}
|
||||
onToggleShow={onToggleShow}
|
||||
onAddToMap={onAddToMap}
|
||||
onMapDragBegin={onMapDragBegin}
|
||||
onMapDragEnd={onMapDragEnd}
|
||||
@@ -294,6 +328,128 @@ function RuntimeGroupSection({
|
||||
);
|
||||
}
|
||||
|
||||
function NpcExternalWebview({ src }: { src: string }) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
const el = document.createElement('webview');
|
||||
el.setAttribute('src', src);
|
||||
el.setAttribute('partition', 'persist:npc-external-link');
|
||||
el.style.width = '100%';
|
||||
el.style.height = '100%';
|
||||
el.style.border = '0';
|
||||
el.style.display = 'flex';
|
||||
host.replaceChildren(el);
|
||||
return () => {
|
||||
host.replaceChildren();
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
return <div ref={hostRef} className={styles.webviewHost} />;
|
||||
}
|
||||
|
||||
function NpcInspectCard({
|
||||
npc,
|
||||
relations,
|
||||
expanded,
|
||||
onToggleExpanded,
|
||||
}: {
|
||||
npc: ProjectNpc;
|
||||
relations: { id: string; text: string }[];
|
||||
expanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
}) {
|
||||
const { t } = useEditorI18n();
|
||||
const externalUrl = normalizeNpcExternalUrl(npc.externalUrl);
|
||||
const [tab, setTab] = useState<'description' | 'link'>('description');
|
||||
const safeHtml = sanitizeSceneDescriptionHtml(npc.description);
|
||||
const hasLink = Boolean(externalUrl);
|
||||
const showLink = hasLink && tab === 'link';
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasLink && tab === 'link') setTab('description');
|
||||
}, [hasLink, tab]);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={[
|
||||
styles.detailCard,
|
||||
expanded && showLink ? styles.detailCardWebview : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.detailAccordion}
|
||||
aria-expanded={expanded}
|
||||
onClick={onToggleExpanded}
|
||||
>
|
||||
<span className={styles.detailAccordionMark}>{expanded ? '▾' : '▸'}</span>
|
||||
<span className={styles.detailName}>{npc.name}</span>
|
||||
</button>
|
||||
{expanded ? (
|
||||
<>
|
||||
{hasLink ? (
|
||||
<div className={styles.detailTabs} role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'description'}
|
||||
className={[styles.detailTab, tab === 'description' ? styles.detailTabActive : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => setTab('description')}
|
||||
>
|
||||
{t('npcs.tabDescription')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'link'}
|
||||
className={[styles.detailTab, tab === 'link' ? styles.detailTabActive : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => setTab('link')}
|
||||
>
|
||||
{t('npcs.tabExternalLink')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{showLink && externalUrl ? (
|
||||
<NpcExternalWebview src={externalUrl} />
|
||||
) : (
|
||||
<>
|
||||
{safeHtml ? (
|
||||
<div>
|
||||
{!hasLink ? <div className={styles.detailSectionTitle}>{t('npcs.description')}</div> : null}
|
||||
<div className={styles.detailDesc} dangerouslySetInnerHTML={{ __html: safeHtml }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.muted}>{t('npcs.descriptionEmpty')}</div>
|
||||
)}
|
||||
{relations.length > 0 ? (
|
||||
<div>
|
||||
<div className={styles.detailSectionTitle}>{t('npcs.relations')}</div>
|
||||
<div className={styles.relationsList}>
|
||||
{relations.map((r) => (
|
||||
<div key={r.id} className={styles.relationItem}>
|
||||
{r.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function NpcsApp() {
|
||||
const { t } = useEditorI18n();
|
||||
const api = getDndApi();
|
||||
@@ -303,6 +459,7 @@ export function NpcsApp() {
|
||||
const [mapDrag, mapDragApi] = useNpcMapSpawnDrag();
|
||||
const [query, setQuery] = useState('');
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<NpcGroupId>>(() => new Set());
|
||||
const [collapsedInspect, setCollapsedInspect] = useState<Set<NpcId>>(() => new Set());
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.project.get, {}).then(({ project }) => {
|
||||
@@ -314,7 +471,14 @@ export function NpcsApp() {
|
||||
}, [api]);
|
||||
|
||||
const activeIds = overlay?.activeNpcIds ?? [];
|
||||
const [inspectIds, setInspectIds] = useState<NpcId[]>([]);
|
||||
const hasActive = activeIds.length > 0;
|
||||
const shownIds = useMemo(() => new Set(activeIds), [activeIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!overlay) return;
|
||||
setInspectIds(overlay.inspectNpcIds ?? []);
|
||||
}, [overlay?.inspectNpcIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -343,20 +507,36 @@ export function NpcsApp() {
|
||||
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 selectedIds = useMemo(() => new Set(inspectIds), [inspectIds]);
|
||||
const detailNpcs = useMemo(
|
||||
() => inspectIds.map((id) => npcs.find((n) => n.id === id)).filter((n): n is ProjectNpc => Boolean(n)),
|
||||
[inspectIds, npcs],
|
||||
);
|
||||
/** Описание: сфокусированный НПС (открытие информации / последний выбор), иначе все активные. */
|
||||
const detailNpcs = useMemo(() => {
|
||||
const focusId = overlay?.focusNpcId ?? null;
|
||||
if (focusId) {
|
||||
const focused = npcs.find((n) => n.id === focusId);
|
||||
if (focused) return [focused];
|
||||
}
|
||||
return selectedNpcs;
|
||||
}, [npcs, overlay?.focusNpcId, selectedNpcs]);
|
||||
|
||||
useEffect(() => {
|
||||
const focusId = overlay?.inspectFocusNpcId ?? null;
|
||||
if (!focusId) return;
|
||||
setCollapsedInspect((prev) => {
|
||||
if (!prev.has(focusId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(focusId);
|
||||
return next;
|
||||
});
|
||||
}, [overlay?.inspectFocusNpcId, overlay?.revision]);
|
||||
|
||||
useEffect(() => {
|
||||
setCollapsedInspect((prev) => {
|
||||
let changed = false;
|
||||
const next = new Set(prev);
|
||||
for (const id of prev) {
|
||||
if (!selectedIds.has(id)) {
|
||||
next.delete(id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [selectedIds]);
|
||||
|
||||
const filteredNpcs = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
@@ -411,6 +591,14 @@ export function NpcsApp() {
|
||||
}, [detailNpcs, npcs, relations]);
|
||||
|
||||
const onSelectTile = useCallback(
|
||||
(id: NpcId) => {
|
||||
setInspectIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||
void overlayApi.dispatch({ kind: 'inspect.toggle', npcId: id });
|
||||
},
|
||||
[overlayApi],
|
||||
);
|
||||
|
||||
const onToggleShow = useCallback(
|
||||
(id: NpcId) => {
|
||||
void overlayApi.dispatch({ kind: 'toggle', npcId: id });
|
||||
},
|
||||
@@ -521,35 +709,22 @@ export function NpcsApp() {
|
||||
<div className={styles.body}>
|
||||
<div className={styles.detail}>
|
||||
{detailNpcs.length > 0 ? (
|
||||
detailNpcs.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>
|
||||
);
|
||||
})
|
||||
detailNpcs.map((npc) => (
|
||||
<NpcInspectCard
|
||||
key={npc.id}
|
||||
npc={npc}
|
||||
relations={relationsByNpcId.get(npc.id) ?? []}
|
||||
expanded={!collapsedInspect.has(npc.id)}
|
||||
onToggleExpanded={() => {
|
||||
setCollapsedInspect((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(npc.id)) next.delete(npc.id);
|
||||
else next.add(npc.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className={styles.detailCard}>
|
||||
<div className={styles.detailEmpty}>
|
||||
@@ -568,8 +743,10 @@ export function NpcsApp() {
|
||||
key={n.id}
|
||||
npc={n}
|
||||
selected={selectedIds.has(n.id)}
|
||||
shown={shownIds.has(n.id)}
|
||||
mapDragEnabled={mapDragEnabled}
|
||||
onActivate={() => onSelectTile(n.id)}
|
||||
onToggleShow={() => onToggleShow(n.id)}
|
||||
onAddToMap={() => spawnNpcOnMap(n.id)}
|
||||
onMapDragBegin={onMapDragBegin}
|
||||
onMapDragEnd={onMapDragEnd}
|
||||
@@ -585,8 +762,10 @@ export function NpcsApp() {
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
selectedIds={selectedIds}
|
||||
shownIds={shownIds}
|
||||
mapDragEnabled={mapDragEnabled}
|
||||
onActivate={onSelectTile}
|
||||
onToggleShow={onToggleShow}
|
||||
onAddToMap={spawnNpcOnMap}
|
||||
onMapDragBegin={onMapDragBegin}
|
||||
onMapDragEnd={onMapDragEnd}
|
||||
@@ -601,8 +780,10 @@ export function NpcsApp() {
|
||||
key={n.id}
|
||||
npc={n}
|
||||
selected={selectedIds.has(n.id)}
|
||||
shown={shownIds.has(n.id)}
|
||||
mapDragEnabled={mapDragEnabled}
|
||||
onActivate={() => onSelectTile(n.id)}
|
||||
onToggleShow={() => onToggleShow(n.id)}
|
||||
onAddToMap={() => spawnNpcOnMap(n.id)}
|
||||
onMapDragBegin={onMapDragBegin}
|
||||
onMapDragEnd={onMapDragEnd}
|
||||
|
||||
Reference in New Issue
Block a user