feat(npcs): show multiple character overlays in session playback
Toggle NPCs independently from the characters window, keep one shared dim/close-all, and scroll descriptions for all selected characters. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -20,6 +20,7 @@ import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState';
|
||||
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types';
|
||||
import { MaterialOverlay } from '../shared/materials/MaterialOverlay';
|
||||
import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverlayState';
|
||||
import { NpcsSceneOverlay } from '../shared/npcs/NpcsSceneOverlay';
|
||||
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
|
||||
import { Button } from '../shared/ui/controls';
|
||||
import { Surface } from '../shared/ui/Surface';
|
||||
@@ -1611,15 +1612,24 @@ export function ControlApp() {
|
||||
);
|
||||
})()}
|
||||
{(() => {
|
||||
const activeNpc =
|
||||
session?.project && npcsOverlay?.activeNpcId
|
||||
? (session.project.npcs ?? []).find((n) => n.id === npcsOverlay.activeNpcId)
|
||||
: undefined;
|
||||
if (!activeNpc) return null;
|
||||
const project = session?.project;
|
||||
const activeIds = npcsOverlay?.activeNpcIds ?? [];
|
||||
if (!project || activeIds.length === 0) return null;
|
||||
const items = activeIds
|
||||
.map((id) => {
|
||||
const npc = (project.npcs ?? []).find((n) => n.id === id);
|
||||
if (!npc) return null;
|
||||
return {
|
||||
npcId: npc.id,
|
||||
assetId: npc.avatarAssetId,
|
||||
layout: npcsOverlay?.layouts[id] ?? DEFAULT_NPCS_OVERLAY_LAYOUT,
|
||||
};
|
||||
})
|
||||
.filter((x): x is NonNullable<typeof x> => x !== null);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<MaterialOverlay
|
||||
assetId={activeNpc.avatarAssetId}
|
||||
layout={npcsOverlay?.layout ?? DEFAULT_NPCS_OVERLAY_LAYOUT}
|
||||
<NpcsSceneOverlay
|
||||
items={items}
|
||||
editable
|
||||
zoomTool={npcsOverlay?.zoomTool ?? null}
|
||||
showClose
|
||||
@@ -1627,11 +1637,13 @@ export function ControlApp() {
|
||||
onClose={() => {
|
||||
void npcsApi.dispatch({ kind: 'hide' });
|
||||
}}
|
||||
onLayoutChange={(layout) => {
|
||||
void npcsApi.dispatch({ kind: 'layout.set', layout });
|
||||
onLayoutChange={(npcId, layout) => {
|
||||
void npcsApi.dispatch({ kind: 'layout.set', npcId, layout });
|
||||
}}
|
||||
onZoomAt={(nx, ny) => {
|
||||
void npcsApi.dispatch({ kind: 'zoomAt', nx, ny });
|
||||
onZoomAt={(npcId, nx, ny) => {
|
||||
void npcsApi.dispatch(
|
||||
npcId ? { kind: 'zoomAt', nx, ny, npcId } : { kind: 'zoomAt', nx, ny },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -425,7 +425,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'npcs.graphZoomOut': 'Уменьшить',
|
||||
'npcs.graphFitAll': 'Показать всё',
|
||||
'npcs.windowEmpty': 'Добавьте НПС в редакторе.',
|
||||
'npcs.closeOverlay': 'Закрыть НПС',
|
||||
'npcs.selectToShow': 'Выберите персонажей в списке — они появятся на экране.',
|
||||
'npcs.closeOverlay': 'Закрыть всех',
|
||||
'npcs.zoomIn': 'Увеличить',
|
||||
'npcs.zoomOut': 'Уменьшить',
|
||||
'npcs.zoomInHint': 'Кликните по аватару в предпросмотре пульта, чтобы увеличить.',
|
||||
@@ -960,7 +961,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'npcs.graphZoomOut': 'Zoom out',
|
||||
'npcs.graphFitAll': 'Fit view',
|
||||
'npcs.windowEmpty': 'Add NPCs in the editor.',
|
||||
'npcs.closeOverlay': 'Close NPC',
|
||||
'npcs.selectToShow': 'Select characters in the list — they will appear on screen.',
|
||||
'npcs.closeOverlay': 'Close all',
|
||||
'npcs.zoomIn': 'Zoom in',
|
||||
'npcs.zoomOut': 'Zoom out',
|
||||
'npcs.zoomInHint': 'Click the avatar on the control preview to zoom in.',
|
||||
|
||||
@@ -50,6 +50,18 @@
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.detailCard {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid var(--stroke);
|
||||
}
|
||||
|
||||
.detailCard:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.detailName {
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
|
||||
@@ -52,13 +52,11 @@ function pruneEmptyGroupNodes(nodes: NpcGroupTreeNode[]): NpcGroupTreeNode[] {
|
||||
function RuntimeNpcTile({
|
||||
npc,
|
||||
selected,
|
||||
active,
|
||||
accentColor,
|
||||
onActivate,
|
||||
}: {
|
||||
npc: ProjectNpc;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
accentColor?: string | null;
|
||||
onActivate: () => void;
|
||||
}) {
|
||||
@@ -66,9 +64,7 @@ function RuntimeNpcTile({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={[styles.tile, selected ? styles.tileSelected : '', active ? styles.tileActive : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
className={[styles.tile, selected ? styles.tileSelected : ''].filter(Boolean).join(' ')}
|
||||
style={accentColor ? { borderLeftColor: accentColor, borderLeftWidth: 3 } : undefined}
|
||||
onClick={onActivate}
|
||||
>
|
||||
@@ -85,16 +81,14 @@ function RuntimeGroupSection({
|
||||
depth,
|
||||
isExpanded,
|
||||
onToggleExpanded,
|
||||
selectedId,
|
||||
activeId,
|
||||
selectedIds,
|
||||
onActivate,
|
||||
}: {
|
||||
node: NpcGroupTreeNode;
|
||||
depth: number;
|
||||
isExpanded: (id: NpcGroupId) => boolean;
|
||||
onToggleExpanded: (id: NpcGroupId) => void;
|
||||
selectedId: NpcId | null;
|
||||
activeId: NpcId | null;
|
||||
selectedIds: ReadonlySet<NpcId>;
|
||||
onActivate: (id: NpcId) => void;
|
||||
}) {
|
||||
const g = node.group;
|
||||
@@ -120,8 +114,7 @@ function RuntimeGroupSection({
|
||||
<RuntimeNpcTile
|
||||
key={n.id}
|
||||
npc={n}
|
||||
selected={n.id === selectedId}
|
||||
active={n.id === activeId}
|
||||
selected={selectedIds.has(n.id)}
|
||||
accentColor={g.color}
|
||||
onActivate={() => onActivate(n.id)}
|
||||
/>
|
||||
@@ -133,8 +126,7 @@ function RuntimeGroupSection({
|
||||
depth={depth + 1}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
selectedId={selectedId}
|
||||
activeId={activeId}
|
||||
selectedIds={selectedIds}
|
||||
onActivate={onActivate}
|
||||
/>
|
||||
))}
|
||||
@@ -149,29 +141,30 @@ export function NpcsApp() {
|
||||
const api = getDndApi();
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const [overlay, overlayApi] = useNpcsOverlayState();
|
||||
const [selectedId, setSelectedId] = useState<NpcId | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<NpcGroupId>>(() => new Set());
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.project.get, {}).then(({ project }) => {
|
||||
setSession({ project, currentSceneId: project?.currentSceneId ?? null });
|
||||
const list = project?.npcs ?? [];
|
||||
setSelectedId(list[0]?.id ?? null);
|
||||
});
|
||||
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
|
||||
setSession(state);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const activeIds = overlay?.activeNpcIds ?? [];
|
||||
const hasActive = activeIds.length > 0;
|
||||
const zoomTool = overlay?.zoomTool ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (overlay?.activeNpcId) {
|
||||
if (hasActive) {
|
||||
void overlayApi.dispatch({ kind: 'hide' });
|
||||
return;
|
||||
}
|
||||
if (overlay?.zoomTool) {
|
||||
if (zoomTool) {
|
||||
void overlayApi.dispatch({ kind: 'zoomTool.set', tool: null });
|
||||
return;
|
||||
}
|
||||
@@ -180,16 +173,16 @@ export function NpcsApp() {
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [overlay?.activeNpcId, overlay?.zoomTool, overlayApi]);
|
||||
}, [hasActive, zoomTool, overlayApi]);
|
||||
|
||||
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 activeId = overlay?.activeNpcId ?? null;
|
||||
const zoomTool = overlay?.zoomTool ?? null;
|
||||
const effectiveSelectedId =
|
||||
selectedId && npcs.some((n) => n.id === selectedId) ? selectedId : (npcs[0]?.id ?? null);
|
||||
const selected = npcs.find((n) => n.id === effectiveSelectedId) ?? null;
|
||||
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 filteredNpcs = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
@@ -229,26 +222,27 @@ export function NpcsApp() {
|
||||
[searching],
|
||||
);
|
||||
|
||||
const relationsForSelected = useMemo(() => {
|
||||
if (!selected) return [];
|
||||
return relations
|
||||
.filter((r) => r.sourceNpcId === selected.id)
|
||||
.map((r) => {
|
||||
const other = npcs.find((n) => n.id === r.targetNpcId);
|
||||
return { id: r.id, text: `${r.label} ${other?.name ?? '—'}` };
|
||||
});
|
||||
}, [npcs, relations, selected]);
|
||||
const relationsByNpcId = useMemo(() => {
|
||||
const map = new Map<NpcId, { id: string; text: string }[]>();
|
||||
for (const npc of selectedNpcs) {
|
||||
const list = relations
|
||||
.filter((r) => r.sourceNpcId === npc.id)
|
||||
.map((r) => {
|
||||
const other = npcs.find((n) => n.id === r.targetNpcId);
|
||||
return { id: r.id, text: `${r.label} ${other?.name ?? '—'}` };
|
||||
});
|
||||
map.set(npc.id, list);
|
||||
}
|
||||
return map;
|
||||
}, [npcs, relations, selectedNpcs]);
|
||||
|
||||
const onSelectTile = useCallback(
|
||||
(id: NpcId) => {
|
||||
setSelectedId(id);
|
||||
void overlayApi.dispatch({ kind: 'toggle', npcId: id });
|
||||
},
|
||||
[overlayApi],
|
||||
);
|
||||
|
||||
const safeHtml = selected ? sanitizeSceneDescriptionHtml(selected.description) : '';
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.toolbar}>
|
||||
@@ -283,7 +277,7 @@ export function NpcsApp() {
|
||||
>
|
||||
<ZoomOutIcon />
|
||||
</Button>
|
||||
{activeId ? (
|
||||
{hasActive ? (
|
||||
<Button
|
||||
title={t('npcs.closeOverlay')}
|
||||
ariaLabel={t('npcs.closeOverlay')}
|
||||
@@ -307,32 +301,40 @@ export function NpcsApp() {
|
||||
|
||||
<div className={styles.body}>
|
||||
<div className={styles.detail}>
|
||||
{selected ? (
|
||||
<>
|
||||
<div className={styles.detailName}>{selected.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>
|
||||
)}
|
||||
{relationsForSelected.length > 0 ? (
|
||||
<div>
|
||||
<div className={styles.detailSectionTitle}>{t('npcs.relations')}</div>
|
||||
<div className={styles.relationsList}>
|
||||
{relationsForSelected.map((r) => (
|
||||
<div key={r.id} className={styles.relationItem}>
|
||||
{r.text}
|
||||
{selectedNpcs.length > 0 ? (
|
||||
selectedNpcs.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>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className={styles.detailEmpty}>{t('npcs.windowEmpty')}</div>
|
||||
<div className={styles.detailEmpty}>
|
||||
{npcs.length === 0 ? t('npcs.windowEmpty') : t('npcs.selectToShow')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -344,8 +346,7 @@ export function NpcsApp() {
|
||||
<RuntimeNpcTile
|
||||
key={n.id}
|
||||
npc={n}
|
||||
selected={n.id === effectiveSelectedId}
|
||||
active={n.id === activeId}
|
||||
selected={selectedIds.has(n.id)}
|
||||
onActivate={() => onSelectTile(n.id)}
|
||||
/>
|
||||
))
|
||||
@@ -358,8 +359,7 @@ export function NpcsApp() {
|
||||
depth={0}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
selectedId={effectiveSelectedId}
|
||||
activeId={activeId}
|
||||
selectedIds={selectedIds}
|
||||
onActivate={onSelectTile}
|
||||
/>
|
||||
))}
|
||||
@@ -371,8 +371,7 @@ export function NpcsApp() {
|
||||
<RuntimeNpcTile
|
||||
key={n.id}
|
||||
npc={n}
|
||||
selected={n.id === effectiveSelectedId}
|
||||
active={n.id === activeId}
|
||||
selected={selectedIds.has(n.id)}
|
||||
onActivate={() => onSelectTile(n.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useSceneDarknessState } from './effects/useSceneDarknessState';
|
||||
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types';
|
||||
import { MaterialOverlay } from './materials/MaterialOverlay';
|
||||
import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState';
|
||||
import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay';
|
||||
import { useNpcsOverlayState } from './npcs/useNpcsOverlayState';
|
||||
import styles from './PresentationView.module.css';
|
||||
import { RotatedImage } from './RotatedImage';
|
||||
@@ -45,10 +46,21 @@ export function PresentationView({
|
||||
session?.project && materialsOverlay?.activeMaterialId
|
||||
? (session.project.materials ?? []).find((m) => m.id === materialsOverlay.activeMaterialId)
|
||||
: undefined;
|
||||
const activeNpc =
|
||||
session?.project && npcsOverlay?.activeNpcId
|
||||
? (session.project.npcs ?? []).find((n) => n.id === npcsOverlay.activeNpcId)
|
||||
: undefined;
|
||||
const project = session?.project;
|
||||
const activeNpcItems =
|
||||
project && (npcsOverlay?.activeNpcIds?.length ?? 0) > 0
|
||||
? (npcsOverlay?.activeNpcIds ?? [])
|
||||
.map((id) => {
|
||||
const npc = (project.npcs ?? []).find((n) => n.id === id);
|
||||
if (!npc) return null;
|
||||
return {
|
||||
npcId: npc.id,
|
||||
assetId: npc.avatarAssetId,
|
||||
layout: npcsOverlay?.layouts[id] ?? DEFAULT_NPCS_OVERLAY_LAYOUT,
|
||||
};
|
||||
})
|
||||
.filter((x): x is NonNullable<typeof x> => x !== null)
|
||||
: [];
|
||||
const originalUrl = useAssetUrl(scene?.previewAssetId ?? null);
|
||||
const thumbUrl = useAssetUrl(scene?.previewThumbAssetId ?? null);
|
||||
const [shownImageUrl, setShownImageUrl] = useState<string | null>(null);
|
||||
@@ -159,12 +171,7 @@ export function PresentationView({
|
||||
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
|
||||
/>
|
||||
) : null}
|
||||
{activeNpc ? (
|
||||
<MaterialOverlay
|
||||
assetId={activeNpc.avatarAssetId}
|
||||
layout={npcsOverlay?.layout ?? DEFAULT_NPCS_OVERLAY_LAYOUT}
|
||||
/>
|
||||
) : null}
|
||||
{activeNpcItems.length > 0 ? <NpcsSceneOverlay items={activeNpcItems} /> : null}
|
||||
{showTitle ? (
|
||||
<div className={styles.titleWrap}>
|
||||
<div className={compact ? styles.titleCompact : styles.titleFull}>
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { AssetId, NpcId, NpcsOverlayLayout, NpcsZoomTool } from '../../../shared/types';
|
||||
import { DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../../shared/types';
|
||||
import styles from '../materials/MaterialOverlay.module.css';
|
||||
import { useAssetUrl } from '../useAssetImageUrl';
|
||||
|
||||
type Corner = 'nw' | 'ne' | 'sw' | 'se';
|
||||
|
||||
export type NpcsSceneOverlayItem = {
|
||||
npcId: NpcId;
|
||||
assetId: AssetId | null;
|
||||
layout: NpcsOverlayLayout;
|
||||
};
|
||||
|
||||
type NpcsSceneOverlayProps = {
|
||||
items: readonly NpcsSceneOverlayItem[];
|
||||
editable?: boolean;
|
||||
zoomTool?: NpcsZoomTool;
|
||||
showClose?: boolean;
|
||||
onClose?: () => void;
|
||||
closeLabel?: string;
|
||||
onLayoutChange?: (npcId: NpcId, layout: NpcsOverlayLayout) => void;
|
||||
onZoomAt?: (npcId: NpcId | undefined, nx: number, ny: number) => void;
|
||||
};
|
||||
|
||||
function nextBaseSize(
|
||||
viewW: number,
|
||||
viewH: number,
|
||||
naturalW: number,
|
||||
naturalH: number,
|
||||
): { w: number; h: number } {
|
||||
if (naturalW <= 0 || naturalH <= 0 || viewW <= 0 || viewH <= 0) return { w: 200, h: 120 };
|
||||
const maxW = viewW * 0.92;
|
||||
const maxH = viewH * 0.88;
|
||||
const fit = Math.min(maxW / naturalW, maxH / naturalH);
|
||||
return { w: naturalW * fit, h: naturalH * fit };
|
||||
}
|
||||
|
||||
function NpcAvatarFrame({
|
||||
item,
|
||||
view,
|
||||
rootRef,
|
||||
editable,
|
||||
zoomTool,
|
||||
onLayoutChange,
|
||||
}: {
|
||||
item: NpcsSceneOverlayItem;
|
||||
view: { w: number; h: number };
|
||||
rootRef: React.RefObject<HTMLDivElement | null>;
|
||||
editable: boolean;
|
||||
zoomTool: NpcsZoomTool;
|
||||
onLayoutChange?: (npcId: NpcId, layout: NpcsOverlayLayout) => void;
|
||||
}) {
|
||||
const url = useAssetUrl(item.assetId);
|
||||
const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 1600, h: 900 });
|
||||
const dragRef = useRef<
|
||||
| { mode: 'move'; startX: number; startY: number; origin: NpcsOverlayLayout }
|
||||
| {
|
||||
mode: 'resize';
|
||||
corner: Corner;
|
||||
startX: number;
|
||||
startY: number;
|
||||
origin: NpcsOverlayLayout;
|
||||
baseW: number;
|
||||
baseH: number;
|
||||
viewW: number;
|
||||
viewH: number;
|
||||
}
|
||||
| null
|
||||
>(null);
|
||||
|
||||
if (!item.assetId || !url) return null;
|
||||
|
||||
const layout = item.layout ?? DEFAULT_NPCS_OVERLAY_LAYOUT;
|
||||
const base = nextBaseSize(view.w, view.h, natural.w, natural.h);
|
||||
const w = base.w * layout.scale;
|
||||
const h = base.h * layout.scale;
|
||||
const left = layout.cx * view.w - w / 2;
|
||||
const top = layout.cy * view.h - h / 2;
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || !onLayoutChange) return;
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
const r = root.getBoundingClientRect();
|
||||
const dx = (e.clientX - drag.startX) / Math.max(1, r.width);
|
||||
const dy = (e.clientY - drag.startY) / Math.max(1, r.height);
|
||||
|
||||
if (drag.mode === 'move') {
|
||||
onLayoutChange(item.npcId, {
|
||||
...drag.origin,
|
||||
cx: drag.origin.cx + dx,
|
||||
cy: drag.origin.cy + dy,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { baseW, baseH, viewW, viewH, origin, corner } = drag;
|
||||
const originW = baseW * origin.scale;
|
||||
const originH = baseH * origin.scale;
|
||||
const originLeft = origin.cx * viewW - originW / 2;
|
||||
const originTop = origin.cy * viewH - originH / 2;
|
||||
const originRight = originLeft + originW;
|
||||
const originBottom = originTop + originH;
|
||||
|
||||
let anchorX = originLeft;
|
||||
let anchorY = originTop;
|
||||
if (corner === 'nw') {
|
||||
anchorX = originRight;
|
||||
anchorY = originBottom;
|
||||
} else if (corner === 'ne') {
|
||||
anchorX = originLeft;
|
||||
anchorY = originBottom;
|
||||
} else if (corner === 'sw') {
|
||||
anchorX = originRight;
|
||||
anchorY = originTop;
|
||||
}
|
||||
|
||||
const pointerX = e.clientX - r.left;
|
||||
const pointerY = e.clientY - r.top;
|
||||
const newW = Math.max(8, Math.abs(pointerX - anchorX));
|
||||
const newH = Math.max(8, Math.abs(pointerY - anchorY));
|
||||
const nextScale = Math.max(newW / Math.max(1, baseW), newH / Math.max(1, baseH));
|
||||
const ww = baseW * nextScale;
|
||||
const hh = baseH * nextScale;
|
||||
|
||||
let nextLeft = anchorX;
|
||||
let nextTop = anchorY;
|
||||
if (corner === 'nw') {
|
||||
nextLeft = anchorX - ww;
|
||||
nextTop = anchorY - hh;
|
||||
} else if (corner === 'ne') {
|
||||
nextLeft = anchorX;
|
||||
nextTop = anchorY - hh;
|
||||
} else if (corner === 'sw') {
|
||||
nextLeft = anchorX - ww;
|
||||
nextTop = anchorY;
|
||||
}
|
||||
|
||||
onLayoutChange(item.npcId, {
|
||||
cx: (nextLeft + ww / 2) / Math.max(1, viewW),
|
||||
cy: (nextTop + hh / 2) / Math.max(1, viewH),
|
||||
scale: nextScale,
|
||||
});
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
dragRef.current = null;
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
window.removeEventListener('pointerup', endDrag);
|
||||
};
|
||||
|
||||
const startDrag = (e: React.PointerEvent, mode: 'move' | 'resize', corner?: Corner) => {
|
||||
if (!editable || !onLayoutChange || zoomTool) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragRef.current =
|
||||
mode === 'move'
|
||||
? { mode: 'move', startX: e.clientX, startY: e.clientY, origin: { ...layout } }
|
||||
: {
|
||||
mode: 'resize',
|
||||
corner: corner ?? 'se',
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
origin: { ...layout },
|
||||
baseW: base.w,
|
||||
baseH: base.h,
|
||||
viewW: view.w,
|
||||
viewH: view.h,
|
||||
};
|
||||
window.addEventListener('pointermove', onPointerMove);
|
||||
window.addEventListener('pointerup', endDrag);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : ''].filter(Boolean).join(' ')}
|
||||
data-npc-id={item.npcId}
|
||||
style={{ left, top, width: w, height: h }}
|
||||
onPointerDown={(e) => {
|
||||
if (zoomTool) return;
|
||||
startDrag(e, 'move');
|
||||
}}
|
||||
>
|
||||
<img
|
||||
className={styles.image}
|
||||
src={url}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{
|
||||
width: w,
|
||||
height: h,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
onLoad={(e) => {
|
||||
const img = e.currentTarget;
|
||||
setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 });
|
||||
}}
|
||||
/>
|
||||
{editable && !zoomTool
|
||||
? (['nw', 'ne', 'sw', 'se'] as const).map((corner) => (
|
||||
<button
|
||||
key={corner}
|
||||
type="button"
|
||||
className={[styles.handle, styles[`handle_${corner}`]].join(' ')}
|
||||
aria-label={corner}
|
||||
onPointerDown={(e) => startDrag(e, 'resize', corner)}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NpcsSceneOverlay({
|
||||
items,
|
||||
editable = false,
|
||||
zoomTool = null,
|
||||
showClose = false,
|
||||
onClose,
|
||||
closeLabel = 'Close',
|
||||
onLayoutChange,
|
||||
onZoomAt,
|
||||
}: NpcsSceneOverlayProps) {
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const [view, setView] = useState({ w: 1, h: 1 });
|
||||
|
||||
useEffect(() => {
|
||||
const el = rootRef.current;
|
||||
if (!el) return;
|
||||
const sync = () => setView({ w: el.clientWidth, h: el.clientHeight });
|
||||
sync();
|
||||
const ro = new ResizeObserver(sync);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [items.length]);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const interactive = editable || showClose || Boolean(zoomTool);
|
||||
const zoomCursor =
|
||||
zoomTool === 'zoomIn' ? styles.cursorZoomIn : zoomTool === 'zoomOut' ? styles.cursorZoomOut : '';
|
||||
|
||||
const toNorm = (clientX: number, clientY: number) => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return { nx: 0.5, ny: 0.5 };
|
||||
const r = root.getBoundingClientRect();
|
||||
return {
|
||||
nx: (clientX - r.left) / Math.max(1, r.width),
|
||||
ny: (clientY - r.top) / Math.max(1, r.height),
|
||||
};
|
||||
};
|
||||
|
||||
const npcIdFromTarget = (target: EventTarget | null): NpcId | undefined => {
|
||||
if (!(target instanceof Element)) return undefined;
|
||||
const frame = target.closest('[data-npc-id]');
|
||||
const raw = frame?.getAttribute('data-npc-id');
|
||||
return raw ? (raw as NpcId) : undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={[styles.root, interactive ? styles.interactive : styles.passive, zoomCursor]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={(e) => {
|
||||
if (!zoomTool || !onZoomAt) return;
|
||||
e.stopPropagation();
|
||||
const { nx, ny } = toNorm(e.clientX, e.clientY);
|
||||
onZoomAt(npcIdFromTarget(e.target), nx, ny);
|
||||
}}
|
||||
>
|
||||
<div className={styles.dim} />
|
||||
{items.map((item) =>
|
||||
onLayoutChange ? (
|
||||
<NpcAvatarFrame
|
||||
key={item.npcId}
|
||||
item={item}
|
||||
view={view}
|
||||
rootRef={rootRef}
|
||||
editable={editable}
|
||||
zoomTool={zoomTool}
|
||||
onLayoutChange={onLayoutChange}
|
||||
/>
|
||||
) : (
|
||||
<NpcAvatarFrame
|
||||
key={item.npcId}
|
||||
item={item}
|
||||
view={view}
|
||||
rootRef={rootRef}
|
||||
editable={editable}
|
||||
zoomTool={zoomTool}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{showClose ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.close}
|
||||
onClick={onClose}
|
||||
aria-label={closeLabel}
|
||||
title={closeLabel}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user