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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user