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:
Ivan Fontosh
2026-07-17 11:05:38 +08:00
parent 195d4be086
commit 61875be857
39 changed files with 2521 additions and 22 deletions
+15
View File
@@ -7,6 +7,9 @@ import { PixiEffectsOverlay } from './effects/PxiEffectsOverlay';
import { SceneDarknessOverlay } from './effects/SceneDarknessOverlay';
import { useEffectsState } from './effects/useEffectsState';
import { useSceneDarknessState } from './effects/useSceneDarknessState';
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../shared/types';
import { MaterialOverlay } from './materials/MaterialOverlay';
import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState';
import styles from './PresentationView.module.css';
import { RotatedImage } from './RotatedImage';
import { useAssetUrl } from './useAssetImageUrl';
@@ -28,6 +31,7 @@ export function PresentationView({
}: PresentationViewProps) {
const [fxState] = useEffectsState();
const [sdState] = useSceneDarknessState();
const [materialsOverlay] = useMaterialsOverlayState();
const [vp] = useVideoPlaybackState();
const videoElRef = useRef<HTMLVideoElement | null>(null);
const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>(
@@ -35,6 +39,10 @@ export function PresentationView({
);
const scene =
session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined;
const activeMaterial =
session?.project && materialsOverlay?.activeMaterialId
? (session.project.materials ?? []).find((m) => m.id === materialsOverlay.activeMaterialId)
: undefined;
const originalUrl = useAssetUrl(scene?.previewAssetId ?? null);
const thumbUrl = useAssetUrl(scene?.previewThumbAssetId ?? null);
const [shownImageUrl, setShownImageUrl] = useState<string | null>(null);
@@ -138,6 +146,13 @@ export function PresentationView({
{showEffects && scene?.previewAssetType === 'image' && scene.darkenScene && contentRect ? (
<SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} />
) : null}
{activeMaterial ? (
<MaterialOverlay
assetId={activeMaterial.assetId}
rotationDeg={activeMaterial.rotationDeg ?? 0}
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
/>
) : null}
{showTitle ? (
<div className={styles.titleWrap}>
<div className={compact ? styles.titleCompact : styles.titleFull}>
@@ -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;
}
@@ -63,6 +63,22 @@
white-space: normal;
}
.tooltipBottom {
position: fixed;
transform: translate(-50%, 8px);
padding: 6px 10px;
border-radius: var(--radius-xs);
font-size: var(--text-xs);
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
background: var(--color-tooltip-bg);
border: 1px solid var(--stroke-2);
box-shadow: var(--shadow-tooltip);
pointer-events: none;
z-index: var(--z-tooltip);
white-space: nowrap;
}
.input {
height: 34px;
width: 100%;
+11 -2
View File
@@ -14,7 +14,7 @@ type ButtonProps = {
/** Компактная кнопка под одну иконку. */
iconOnly?: boolean;
/** Позиция тултипа относительно кнопки. */
tooltipPlacement?: 'top' | 'bottom-left';
tooltipPlacement?: 'top' | 'bottom' | 'bottom-left';
};
export function Button({
@@ -40,6 +40,10 @@ export function Button({
setTipPos({ x: r.right, y: r.bottom });
return;
}
if (tooltipPlacement === 'bottom') {
setTipPos({ x: r.left + r.width / 2, y: r.bottom });
return;
}
setTipPos({ x: r.left + r.width / 2, y: r.top });
}, [disabled, title, tooltipPlacement]);
@@ -55,7 +59,12 @@ export function Button({
.filter(Boolean)
.join(' ');
const tipClass = tooltipPlacement === 'bottom-left' ? styles.tooltipBottomLeft : styles.tooltipTop;
const tipClass =
tooltipPlacement === 'bottom-left'
? styles.tooltipBottomLeft
: tooltipPlacement === 'bottom'
? styles.tooltipBottom
: styles.tooltipTop;
const tip =
title && tipPos && typeof document !== 'undefined'