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>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
.root {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.interactive {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.passive {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cursorZoomIn {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.cursorZoomOut {
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
.dim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.frame {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.frameEditable {
|
||||
pointer-events: auto;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.image {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
display: block;
|
||||
object-fit: fill;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55);
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.handle {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff;
|
||||
background: var(--color-accent, #c9a227);
|
||||
padding: 0;
|
||||
z-index: 2;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.45);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.handle_nw {
|
||||
left: -6px;
|
||||
top: -6px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.handle_ne {
|
||||
right: -6px;
|
||||
top: -6px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.handle_sw {
|
||||
left: -6px;
|
||||
bottom: -6px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.handle_se {
|
||||
right: -6px;
|
||||
bottom: -6px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
z-index: 3;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: rgba(12, 12, 16, 0.72);
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
background: rgba(24, 24, 32, 0.9);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type { MaterialsOverlayEvent, MaterialsOverlayState } from '../../../shared/types';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
export function useMaterialsOverlayState(): readonly [
|
||||
MaterialsOverlayState | null,
|
||||
{ dispatch: (event: MaterialsOverlayEvent) => Promise<void> },
|
||||
] {
|
||||
const api = getDndApi();
|
||||
const [state, setState] = useState<MaterialsOverlayState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.materialsOverlay.getState, {}).then((r) => {
|
||||
setState(r.state);
|
||||
});
|
||||
return api.on(ipcChannels.materialsOverlay.stateChanged, ({ state: next }) => {
|
||||
setState(next);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
return [
|
||||
state,
|
||||
{
|
||||
dispatch: async (event) => {
|
||||
await api.invoke(ipcChannels.materialsOverlay.dispatch, { event });
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
Reference in New Issue
Block a user