Files
Ivan Fontosh 2979d06f1c feat: multi-material overlays, presentation guides, and release prebuilds
Align control/presentation with presentation screen rect and darkness z-order; sync window titles and session window cleanup. Pack Win/Mac/Linux with npmRebuild disabled and release-native-prep for classic-level and sharp.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 11:28:18 +08:00

467 lines
14 KiB
TypeScript
Raw Permalink 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, MaterialId, MaterialsOverlayLayout, MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types';
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types';
import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext';
import { useAssetUrl } from '../useAssetImageUrl';
import styles from './MaterialOverlay.module.css';
type Corner = 'nw' | 'ne' | 'sw' | 'se';
type MaterialOverlayProps = {
assetId: AssetId | null;
layout: MaterialsOverlayLayout;
materialId?: MaterialId;
editable?: boolean;
zoomTool?: MaterialsZoomTool | NpcsZoomTool;
showClose?: boolean;
onClose?: () => void;
closeLabel?: string;
rotateLabel?: string;
onLayoutChange?: (layout: MaterialsOverlayLayout) => void;
onZoomAt?: (nx: number, ny: number) => void;
/** Без собственного root/dim — внутри `SceneOverlayHost`. */
embedded?: boolean;
/** Маркеры легенды (норм. координаты картинки). */
legendMarkers?: readonly { id: string; number: number; nx: number; ny: number }[];
};
function RotateIcon() {
return (
<svg className={styles.frameRotateSvg} viewBox="0 0 24 24" width="16" height="16" aria-hidden>
<path
d="M20 12a8 8 0 1 1-2.2-5.4"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
<path d="M20 4v5h-5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
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 pointerAngleDeg(centerX: number, centerY: number, clientX: number, clientY: number): number {
return (Math.atan2(clientY - centerY, clientX - centerX) * 180) / Math.PI;
}
function shortestAngleDelta(fromDeg: number, toDeg: number): number {
let d = toDeg - fromDeg;
while (d > 180) d -= 360;
while (d < -180) d += 360;
return d;
}
function screenToLocal(
clientX: number,
clientY: number,
centerX: number,
centerY: number,
rotationDeg: number,
): { x: number; y: number } {
const rad = (-rotationDeg * Math.PI) / 180;
const dx = clientX - centerX;
const dy = clientY - centerY;
return {
x: dx * Math.cos(rad) - dy * Math.sin(rad),
y: dx * Math.sin(rad) + dy * Math.cos(rad),
};
}
function localToScreenOffset(localX: number, localY: number, rotationDeg: number): { x: number; y: number } {
const rad = (rotationDeg * Math.PI) / 180;
return {
x: localX * Math.cos(rad) - localY * Math.sin(rad),
y: localX * Math.sin(rad) + localY * Math.cos(rad),
};
}
export function MaterialOverlay({
assetId,
layout,
materialId,
editable = false,
zoomTool = null,
showClose = false,
onClose,
closeLabel = 'Close',
rotateLabel = 'Rotate',
onLayoutChange,
onZoomAt,
embedded = false,
legendMarkers,
}: MaterialOverlayProps) {
const url = useAssetUrl(assetId);
const host = useSceneOverlayView();
const localRootRef = useRef<HTMLDivElement | null>(null);
const rootRef = embedded && host ? host.rootRef : localRootRef;
const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 1600, h: 900 });
const [localView, setLocalView] = useState({ w: 1, h: 1 });
/** Локальный layout + IPC не чаще 1/frame (лайв, без спама pointermove). */
const [draftLayout, setDraftLayout] = useState<MaterialsOverlayLayout | null>(null);
const pendingLayoutRef = useRef<MaterialsOverlayLayout | null>(null);
const draftRafRef = useRef(0);
const onLayoutChangeRef = useRef(onLayoutChange);
onLayoutChangeRef.current = onLayoutChange;
const view = embedded && host ? host.view : localView;
const dragRef = useRef<
| { mode: 'move'; startX: number; startY: number; origin: MaterialsOverlayLayout }
| {
mode: 'resize';
corner: Corner;
origin: MaterialsOverlayLayout;
baseW: number;
baseH: number;
viewW: number;
viewH: number;
centerClientX: number;
centerClientY: number;
}
| {
mode: 'rotate';
origin: MaterialsOverlayLayout;
startPointerAngle: number;
centerX: number;
centerY: number;
}
| null
>(null);
useEffect(() => {
if (embedded) return;
const el = localRootRef.current;
if (!el) return;
let raf = 0;
const sync = () => {
if (raf !== 0) return;
raf = window.requestAnimationFrame(() => {
raf = 0;
const w = Math.max(1, el.clientWidth);
const h = Math.max(1, el.clientHeight);
setLocalView((prev) => (prev.w === w && prev.h === h ? prev : { w, h }));
});
};
sync();
const ro = new ResizeObserver(sync);
ro.observe(el);
return () => {
ro.disconnect();
if (raf !== 0) window.cancelAnimationFrame(raf);
};
}, [embedded, url]);
useEffect(() => {
if (dragRef.current) return;
setDraftLayout(null);
pendingLayoutRef.current = null;
}, [layout]);
if (!assetId || !url) return null;
const interactive = editable || showClose || Boolean(zoomTool);
const zoomCursor =
zoomTool === 'zoomIn' ? styles.cursorZoomIn : zoomTool === 'zoomOut' ? styles.cursorZoomOut : '';
const effectiveLayout = draftLayout ?? layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT;
const rotationDeg = effectiveLayout.rotationDeg ?? 0;
const base = nextBaseSize(view.w, view.h, natural.w, natural.h);
const w = base.w * effectiveLayout.scale;
const h = base.h * effectiveLayout.scale;
const left = effectiveLayout.cx * view.w - w / 2;
const top = effectiveLayout.cy * view.h - h / 2;
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 layoutCenterClient = () => {
const root = rootRef.current;
if (!root) return { x: 0, y: 0 };
const r = root.getBoundingClientRect();
return {
x: r.left + effectiveLayout.cx * r.width,
y: r.top + effectiveLayout.cy * r.height,
};
};
const publishDraft = (next: MaterialsOverlayLayout): void => {
pendingLayoutRef.current = next;
if (draftRafRef.current !== 0) return;
draftRafRef.current = window.requestAnimationFrame(() => {
draftRafRef.current = 0;
const pending = pendingLayoutRef.current;
if (!pending) return;
setDraftLayout(pending);
onLayoutChangeRef.current?.(pending);
});
};
const onPointerMove = (e: PointerEvent) => {
const drag = dragRef.current;
if (!drag || !onLayoutChange) return;
if (drag.mode === 'rotate') {
const angle = pointerAngleDeg(drag.centerX, drag.centerY, e.clientX, e.clientY);
const delta = shortestAngleDelta(drag.startPointerAngle, angle);
publishDraft({
...drag.origin,
rotationDeg: drag.origin.rotationDeg + delta,
});
return;
}
const root = rootRef.current;
if (!root) return;
const r = root.getBoundingClientRect();
if (drag.mode === 'move') {
const dx = (e.clientX - drag.startX) / Math.max(1, r.width);
const dy = (e.clientY - drag.startY) / Math.max(1, r.height);
publishDraft({
...drag.origin,
cx: drag.origin.cx + dx,
cy: drag.origin.cy + dy,
});
return;
}
const { baseW, baseH, viewW, viewH, origin, corner, centerClientX, centerClientY } = drag;
const originW = baseW * origin.scale;
const originH = baseH * origin.scale;
const originLeft = -originW / 2;
const originTop = -originH / 2;
const originRight = originW / 2;
const originBottom = originH / 2;
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 local = screenToLocal(
e.clientX,
e.clientY,
centerClientX,
centerClientY,
origin.rotationDeg ?? 0,
);
const newW = Math.max(8, Math.abs(local.x - anchorX));
const newH = Math.max(8, Math.abs(local.y - 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;
}
const localCenterX = nextLeft + ww / 2;
const localCenterY = nextTop + hh / 2;
const screenOffset = localToScreenOffset(localCenterX, localCenterY, origin.rotationDeg ?? 0);
publishDraft({
...origin,
cx: origin.cx + screenOffset.x / Math.max(1, viewW),
cy: origin.cy + screenOffset.y / Math.max(1, viewH),
scale: nextScale,
});
};
const endDrag = () => {
dragRef.current = null;
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', endDrag);
if (draftRafRef.current !== 0) {
window.cancelAnimationFrame(draftRafRef.current);
draftRafRef.current = 0;
}
const finalLayout = pendingLayoutRef.current;
if (finalLayout) {
setDraftLayout(finalLayout);
onLayoutChangeRef.current?.(finalLayout);
}
};
const startDrag = (e: React.PointerEvent, mode: 'move' | 'resize', corner?: Corner) => {
if (!editable || !onLayoutChange || zoomTool) return;
e.preventDefault();
e.stopPropagation();
if (mode === 'move') {
dragRef.current = {
mode: 'move',
startX: e.clientX,
startY: e.clientY,
origin: { ...effectiveLayout },
};
} else {
const center = layoutCenterClient();
dragRef.current = {
mode: 'resize',
corner: corner ?? 'se',
origin: { ...effectiveLayout },
baseW: base.w,
baseH: base.h,
viewW: view.w,
viewH: view.h,
centerClientX: center.x,
centerClientY: center.y,
};
}
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', endDrag);
};
const startRotate = (e: React.PointerEvent) => {
if (!editable || !onLayoutChange || zoomTool) return;
e.preventDefault();
e.stopPropagation();
const center = layoutCenterClient();
dragRef.current = {
mode: 'rotate',
origin: { ...effectiveLayout },
startPointerAngle: pointerAngleDeg(center.x, center.y, e.clientX, e.clientY),
centerX: center.x,
centerY: center.y,
};
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', endDrag);
};
const frame = (
<div
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : ''].filter(Boolean).join(' ')}
data-overlay-kind="material"
{...(materialId ? { 'data-material-id': materialId } : {})}
style={{
left,
top,
width: w,
height: h,
transform: `rotate(${String(rotationDeg)}deg)`,
transformOrigin: 'center center',
}}
onPointerDown={(e) => {
if (zoomTool) return;
startDrag(e, 'move');
}}
>
<img
className={styles.image}
src={url}
alt=""
draggable={false}
onLoad={(e) => {
const img = e.currentTarget;
setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 });
}}
/>
{(legendMarkers ?? []).map((m) => (
<div
key={m.id}
className={styles.legendMarker}
style={{
left: `${String(m.nx * 100)}%`,
top: `${String(m.ny * 100)}%`,
}}
>
{m.number}
</div>
))}
{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}
{editable && !zoomTool && onLayoutChange ? (
<button
type="button"
className={styles.frameRotate}
aria-label={rotateLabel}
title={rotateLabel}
onPointerDown={startRotate}
>
<RotateIcon />
</button>
) : null}
</div>
);
if (embedded) {
return frame;
}
return (
<div
ref={localRootRef}
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} />
{frame}
{showClose ? (
<button
type="button"
className={styles.close}
onClick={onClose}
aria-label={closeLabel}
title={closeLabel}
>
×
</button>
) : null}
</div>
);
}