Files
DndGamePlayer/app/renderer/shared/materials/MaterialOverlay.tsx
T
Ivan Fontosh 61875be857 feat(materials): add campaign materials overlay for sessions
Let GMs manage and show images over the scene from the editor and control panel, with zoom tools, help docs, and full ru/en i18n.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 11:05:38 +08:00

270 lines
8.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, MaterialsOverlayLayout, MaterialsZoomTool } from '../../../shared/types';
import { useAssetUrl } from '../useAssetImageUrl';
import styles from './MaterialOverlay.module.css';
type Corner = 'nw' | 'ne' | 'sw' | 'se';
type MaterialOverlayProps = {
assetId: AssetId | null;
rotationDeg?: 0 | 90 | 180 | 270;
layout: MaterialsOverlayLayout;
editable?: boolean;
zoomTool?: MaterialsZoomTool;
showClose?: boolean;
onClose?: () => void;
closeLabel?: string;
onLayoutChange?: (layout: MaterialsOverlayLayout) => void;
onZoomAt?: (nx: number, ny: number) => void;
};
function nextBaseSize(
viewW: number,
viewH: number,
naturalW: number,
naturalH: number,
rotationDeg: number,
): { w: number; h: number } {
const swapped = rotationDeg === 90 || rotationDeg === 270;
const iw = swapped ? naturalH : naturalW;
const ih = swapped ? naturalW : naturalH;
if (iw <= 0 || ih <= 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 / iw, maxH / ih);
return { w: iw * fit, h: ih * fit };
}
export function MaterialOverlay({
assetId,
rotationDeg = 0,
layout,
editable = false,
zoomTool = null,
showClose = false,
onClose,
closeLabel = 'Close',
onLayoutChange,
onZoomAt,
}: MaterialOverlayProps) {
const url = useAssetUrl(assetId);
const rootRef = useRef<HTMLDivElement | null>(null);
const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 1600, h: 900 });
const [view, setView] = useState({ w: 1, h: 1 });
const dragRef = useRef<
| { mode: 'move'; startX: number; startY: number; origin: MaterialsOverlayLayout }
| {
mode: 'resize';
corner: Corner;
startX: number;
startY: number;
origin: MaterialsOverlayLayout;
baseW: number;
baseH: number;
viewW: number;
viewH: number;
}
| null
>(null);
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();
}, [url]);
if (!assetId || !url) return null;
const interactive = editable || showClose || Boolean(zoomTool);
const zoomCursor =
zoomTool === 'zoomIn' ? styles.cursorZoomIn : zoomTool === 'zoomOut' ? styles.cursorZoomOut : '';
const base = nextBaseSize(view.w, view.h, natural.w, natural.h, rotationDeg);
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;
// Рамка — AABB; до CSS-rotate картинка имеет «натуральную» ориентацию.
const swapped = rotationDeg === 90 || rotationDeg === 270;
const contentW = swapped ? h : w;
const contentH = swapped ? w : h;
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 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({
...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({
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
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(nx, ny);
}}
>
<div className={styles.dim} />
<div
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : '']
.filter(Boolean)
.join(' ')}
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: contentW,
height: contentH,
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
}}
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>
{showClose ? (
<button
type="button"
className={styles.close}
onClick={onClose}
aria-label={closeLabel}
title={closeLabel}
>
×
</button>
) : null}
</div>
);
}