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
+90 -5
View File
@@ -39,8 +39,9 @@
padding: 14px 16px;
border-right: 1px solid var(--stroke);
background: #0f0f12;
display: grid;
gap: 14px;
display: flex;
flex-direction: column;
gap: 0;
align-content: start;
}
@@ -53,8 +54,13 @@
.detailCard {
display: grid;
gap: 10px;
padding-bottom: 14px;
padding: 14px 0;
border-bottom: 1px solid var(--stroke);
min-width: 0;
}
.detailCard:first-child {
padding-top: 0;
}
.detailCard:last-child {
@@ -62,11 +68,67 @@
padding-bottom: 0;
}
.detailCardWebview {
min-height: 480px;
grid-template-rows: auto auto minmax(380px, 1fr);
}
.detailAccordion {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px;
align-items: center;
width: 100%;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
padding: 0;
}
.detailAccordionMark {
color: var(--text2);
font-size: 12px;
width: 14px;
}
.detailName {
font-size: 18px;
font-weight: 900;
}
.detailTabs {
display: flex;
gap: 6px;
}
.detailTab {
border: 1px solid var(--stroke);
background: #18181b;
color: var(--text2);
border-radius: 8px;
padding: 6px 10px;
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.detailTabActive {
border-color: #60a5fa;
color: var(--text1);
background: #1e293b;
}
.webviewHost {
min-height: 380px;
height: 52vh;
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--stroke);
background: #ffffff;
}
.detailSectionTitle {
font-size: 12px;
font-weight: 900;
@@ -123,8 +185,8 @@
display: grid;
grid-template-columns: auto 1fr;
gap: 4px;
align-items: center;
padding: 4px 8px 4px 4px;
align-items: start;
padding: 6px 8px 8px 4px;
border-radius: 12px;
border: 1px solid var(--stroke);
background: #18181b;
@@ -133,6 +195,12 @@
width: 100%;
}
.tileMain {
display: grid;
gap: 6px;
min-width: 0;
}
.tileBody {
display: grid;
grid-template-columns: 44px 1fr;
@@ -150,6 +218,7 @@
.tileMenuBtn {
width: 28px;
height: 28px;
margin-top: 10px;
border: 0;
border-radius: 8px;
background: transparent;
@@ -229,6 +298,22 @@
white-space: nowrap;
}
.tileShow {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: var(--text2);
cursor: pointer;
user-select: none;
}
.tileShow input {
margin: 0;
accent-color: #60a5fa;
}
.muted {
color: var(--text2);
font-size: 13px;
+237 -56
View File
@@ -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}
+81 -1
View File
@@ -201,10 +201,21 @@
.avatarPick {
display: grid;
gap: 10px;
justify-items: start;
justify-items: center;
width: 100%;
}
.avatarPickBusy {
opacity: 0.65;
pointer-events: none;
}
.avatarPickDrop {
position: relative;
}
.avatarPreview {
position: relative;
width: 120px;
height: 120px;
border-radius: 14px;
@@ -213,6 +224,75 @@
background: #09090b;
}
.avatarPreviewPick {
cursor: pointer;
}
.avatarPreviewDrop {
border-color: var(--accent, #a78bfa);
border-style: dashed;
background: var(--accent-fill-soft, rgba(167, 139, 250, 0.16));
}
.avatarEmptyHint {
position: absolute;
inset: 10px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-size: 11px;
font-weight: 700;
line-height: 1.35;
color: var(--text2);
pointer-events: none;
user-select: none;
}
.avatarEdit {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity 0.15s ease;
pointer-events: none;
z-index: 1;
}
.avatarPick [data-token-circle]:hover .avatarEdit,
.avatarPreview:hover .avatarEdit {
opacity: 1;
}
.avatarPick [data-token-circle]:active .avatarEdit {
opacity: 0;
}
.avatarEditBtn {
pointer-events: auto;
display: grid;
place-items: center;
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
background: rgba(24, 24, 27, 0.88);
color: #fafafa;
cursor: pointer;
}
.avatarEditBtn:hover {
background: rgba(39, 39, 42, 0.96);
}
.avatarEditBtn:disabled {
opacity: 0.5;
cursor: default;
}
.avatarPreviewImg {
width: 100%;
height: 100%;
+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>