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:
Ivan Fontosh
2026-08-19 10:42:57 +08:00
parent 1a9f0f2557
commit 0f4bb76b41
35 changed files with 1189 additions and 259 deletions
+188 -24
View File
@@ -11,11 +11,14 @@ import type {
ProjectNpcGroup,
ProjectNpcRelation,
} from '../../shared/types';
import { normalizeNpcExternalUrl, npcExternalUrlIsValid } from '../../shared/types/npcExternalUrl';
import editorStyles from '../editor/EditorApp.module.css';
import { pickFirstMaterialImagePath, useFileDropZone } from '../editor/fileDrop';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
import tokenStyles from '../shared/playerToken/PlayerTokenView.module.css';
import { Button, Input, Select } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { npcDispositionRingColor } from '../../shared/types/npcDisposition';
@@ -333,6 +336,8 @@ export function NpcsEditorApp() {
const [pendingDeleteGroup, setPendingDeleteGroup] = useState<ProjectNpcGroup | null>(null);
const [graphFilter, setGraphFilter] = useState<GraphGroupFilter>('all');
const [nameDraft, setNameDraft] = useState('');
const [linkDraft, setLinkDraft] = useState('');
const [linkError, setLinkError] = useState(false);
const [relationModal, setRelationModal] = useState<
| { mode: 'create'; sourceNpcId: NpcId; targetNpcId: NpcId }
| { mode: 'edit'; relationId: NpcRelationId; label: string }
@@ -364,6 +369,11 @@ export function NpcsEditorApp() {
setNameDraft(selected?.name ?? '');
}, [selected?.id, selected?.name]);
useEffect(() => {
setLinkDraft(selected?.externalUrl ?? '');
setLinkError(false);
}, [selected?.id, selected?.externalUrl]);
useEffect(() => {
if (selectedId && npcs.some((n) => n.id === selectedId)) return;
setSelectedId(npcs[0]?.id ?? null);
@@ -415,6 +425,53 @@ export function NpcsEditorApp() {
return { filePath: res.filePath, previewDataUrl: res.previewDataUrl };
}, [api]);
const applyAvatarPath = useCallback(
async (filePath: string) => {
if (!selected || avatarBusy) return;
setAvatarBusy(true);
try {
await api.invoke(ipcChannels.project.upsertNpc, {
npcId: selected.id,
name: selected.name,
filePath,
});
} finally {
setAvatarBusy(false);
}
},
[api, avatarBusy, selected],
);
const chooseInspectorAvatar = useCallback(() => {
if (avatarBusy || !selected) return;
void (async () => {
setAvatarBusy(true);
try {
const picked = await pickAvatar();
if (!picked || !selected) return;
await api.invoke(ipcChannels.project.upsertNpc, {
npcId: selected.id,
name: selected.name,
filePath: picked.filePath,
});
} finally {
setAvatarBusy(false);
}
})();
}, [api, avatarBusy, pickAvatar, selected]);
const avatarDrop = useFileDropZone({
disabled: !selected || avatarBusy,
filterPaths: (paths) => {
const picked = pickFirstMaterialImagePath(paths);
return picked ? [picked] : [];
},
onDropPaths: (paths) => {
const picked = pickFirstMaterialImagePath(paths);
if (picked) void applyAvatarPath(picked);
},
});
const commitNpcGroupChange = useCallback(
async (npcId: NpcId, groupId: NpcGroupId | null, orderIds?: NpcId[]) => {
const npc = npcs.find((n) => n.id === npcId);
@@ -737,7 +794,19 @@ export function NpcsEditorApp() {
<>
<div>
<div className={styles.fieldLabel}>{t('npcs.avatar')}</div>
<div className={styles.avatarPick}>
<div
className={[
styles.avatarPick,
avatarDrop.dragOver ? styles.avatarPickDrop : '',
avatarBusy ? styles.avatarPickBusy : '',
]
.filter(Boolean)
.join(' ')}
onDragEnter={avatarDrop.onDragEnter}
onDragLeave={avatarDrop.onDragLeave}
onDragOver={avatarDrop.onDragOver}
onDrop={avatarDrop.onDrop}
>
{USERS_BRANCH_FEATURES_ENABLED ? (
<PlayerTokenView
name={selected.name}
@@ -746,7 +815,48 @@ export function NpcsEditorApp() {
imageOffset={selected.imageOffset}
imageScale={selected.imageScale}
sizePx={140}
panEnabled
syncKey={selected.id}
panEnabled={Boolean(selectedUrl)}
dropActive={avatarDrop.dragOver}
onCircleClick={selectedUrl ? undefined : chooseInspectorAvatar}
emptyContent={
selectedUrl || avatarDrop.dragOver ? null : (
<div className={tokenStyles.avatarEmptyHint}>{t('npcs.avatarPickHint')}</div>
)
}
circleOverlay={
<>
{avatarDrop.dragOver ? (
<div className={editorStyles.dropHintOverlay}>{t('npcs.dropHint')}</div>
) : null}
{selectedUrl && !avatarDrop.dragOver ? (
<div className={styles.avatarEdit}>
<button
type="button"
className={styles.avatarEditBtn}
title={t('npcs.changeAvatar')}
aria-label={t('npcs.changeAvatar')}
disabled={avatarBusy}
onPointerDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.stopPropagation();
chooseInspectorAvatar();
}}
>
<svg viewBox="0 0 24 24" width={16} height={16} aria-hidden>
<path
fill="currentColor"
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"
/>
</svg>
</button>
</div>
) : null}
</>
}
onImageOffsetChange={(imageOffset) => {
void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
@@ -761,33 +871,48 @@ export function NpcsEditorApp() {
}}
/>
) : (
<div className={styles.avatarPreview}>
<div
className={[
styles.avatarPreview,
avatarDrop.dragOver ? styles.avatarPreviewDrop : '',
!selectedUrl ? styles.avatarPreviewPick : '',
]
.filter(Boolean)
.join(' ')}
onClick={selectedUrl ? undefined : chooseInspectorAvatar}
>
{selectedUrl ? (
<img className={styles.avatarPreviewImg} src={selectedUrl} alt="" />
) : avatarDrop.dragOver ? null : (
<div className={styles.avatarEmptyHint}>{t('npcs.avatarPickHint')}</div>
)}
{avatarDrop.dragOver ? (
<div className={editorStyles.dropHintOverlay}>{t('npcs.dropHint')}</div>
) : null}
{selectedUrl && !avatarDrop.dragOver ? (
<div className={styles.avatarEdit}>
<button
type="button"
className={styles.avatarEditBtn}
title={t('npcs.changeAvatar')}
aria-label={t('npcs.changeAvatar')}
disabled={avatarBusy}
onClick={(e) => {
e.stopPropagation();
chooseInspectorAvatar();
}}
>
<svg viewBox="0 0 24 24" width={16} height={16} aria-hidden>
<path
fill="currentColor"
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"
/>
</svg>
</button>
</div>
) : null}
</div>
)}
<Button
disabled={avatarBusy}
onClick={() => {
void (async () => {
setAvatarBusy(true);
try {
const picked = await pickAvatar();
if (!picked) return;
await api.invoke(ipcChannels.project.upsertNpc, {
npcId: selected.id,
name: selected.name,
filePath: picked.filePath,
});
} finally {
setAvatarBusy(false);
}
})();
}}
>
{t('npcs.chooseAvatar')}
</Button>
</div>
</div>
@@ -868,6 +993,45 @@ export function NpcsEditorApp() {
/>
</div>
<div>
<div className={styles.fieldLabel}>{t('npcs.externalLink')}</div>
<Input
value={linkDraft}
onChange={(next) => {
setLinkDraft(next);
if (linkError) setLinkError(!npcExternalUrlIsValid(next));
}}
onBlur={() => {
const trimmed = linkDraft.trim();
if (!trimmed) {
setLinkError(false);
setLinkDraft('');
if (selected.externalUrl) {
void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
externalUrl: '',
});
}
return;
}
const normalized = normalizeNpcExternalUrl(trimmed);
if (!normalized) {
setLinkError(true);
return;
}
setLinkError(false);
setLinkDraft(normalized);
if (normalized === selected.externalUrl) return;
void api.invoke(ipcChannels.project.updateNpcFields, {
npcId: selected.id,
externalUrl: normalized,
});
}}
placeholder={t('npcs.externalLinkPlaceholder')}
/>
{linkError ? <div className={editorStyles.fieldError}>{t('npcs.externalLinkInvalid')}</div> : null}
</div>
{relationsForSelected.length > 0 ? (
<div>
<div className={styles.relationsTitle}>{t('npcs.relations')}</div>