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:
@@ -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
@@ -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}
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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