Files
DndGamePlayer/app/renderer/shared/npcs/NpcsSceneOverlay.tsx
T
Ivan Fontosh 1829191410 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>
2026-07-21 13:23:19 +08:00

315 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}