feat(control): grid snap, NPC context actions, and overlay dim fix
Re-enable users-branch UI, snap session tokens to square/hex grid from the control preview, refine inactive/open-info NPC menus, block marker actions while an effect brush is active, and dim materials/NPC overlays only when they are open. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -38,9 +38,11 @@
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
/** Затемнение всего родителя (превью пульта / экран презентации); только при открытом материале/NPC. */
|
||||
.dim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 39;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ function NpcSprite({
|
||||
inactive,
|
||||
onMove,
|
||||
onContextMenu,
|
||||
snapNorm,
|
||||
}: {
|
||||
placement: SceneNpcToken;
|
||||
npc: ProjectNpc;
|
||||
@@ -56,6 +57,7 @@ function NpcSprite({
|
||||
inactive: boolean;
|
||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
}) {
|
||||
const imageUrl = useAssetUrl(npc.avatarAssetId);
|
||||
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
|
||||
@@ -135,8 +137,11 @@ function NpcSprite({
|
||||
const drag = dragRef.current;
|
||||
if (drag?.pointerId !== e.pointerId) return;
|
||||
const p = point(e);
|
||||
drag.lastNx = Math.max(0, Math.min(1, drag.startNx + p.x - drag.pointerNx));
|
||||
drag.lastNy = Math.max(0, Math.min(1, drag.startNy + p.y - drag.pointerNy));
|
||||
const rawNx = Math.max(0, Math.min(1, drag.startNx + p.x - drag.pointerNx));
|
||||
const rawNy = Math.max(0, Math.min(1, drag.startNy + p.y - drag.pointerNy));
|
||||
const snapped = snapNorm ? snapNorm(rawNx, rawNy) : { nx: rawNx, ny: rawNy };
|
||||
drag.lastNx = snapped.nx;
|
||||
drag.lastNy = snapped.ny;
|
||||
setLocalPos({ nx: drag.lastNx, ny: drag.lastNy });
|
||||
schedule(drag.lastNx, drag.lastNy);
|
||||
}
|
||||
@@ -167,6 +172,7 @@ export function SceneNpcTokensOverlay({
|
||||
editable = false,
|
||||
onMove,
|
||||
onContextMenu,
|
||||
snapNorm,
|
||||
}: {
|
||||
placements: readonly SceneNpcToken[];
|
||||
library: readonly ProjectNpc[];
|
||||
@@ -177,6 +183,7 @@ export function SceneNpcTokensOverlay({
|
||||
editable?: boolean;
|
||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, placement: SceneNpcToken) => void;
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
}) {
|
||||
if (!viewport) return null;
|
||||
const byId = new Map(library.map((npc) => [npc.id, npc]));
|
||||
@@ -205,6 +212,7 @@ export function SceneNpcTokensOverlay({
|
||||
inactive={inactive}
|
||||
{...(onMove ? { onMove } : {})}
|
||||
{...(onContextMenu ? { onContextMenu } : {})}
|
||||
{...(snapNorm ? { snapNorm } : {})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -25,6 +25,7 @@ function PlayerSprite({
|
||||
displayScale,
|
||||
gridFit,
|
||||
onMove,
|
||||
snapNorm,
|
||||
}: {
|
||||
player: AppPlayer;
|
||||
nx: number;
|
||||
@@ -35,6 +36,7 @@ function PlayerSprite({
|
||||
displayScale: number;
|
||||
gridFit: number;
|
||||
onMove?: (playerId: string, nx: number, ny: number) => void;
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
}) {
|
||||
const imageUrl = usePlayerImageUrl(player.id);
|
||||
const [localPos, setLocalPos] = useState<{ nx: number; ny: number } | null>(null);
|
||||
@@ -105,8 +107,11 @@ function PlayerSprite({
|
||||
const drag = dragRef.current;
|
||||
if (drag?.pointerId !== e.pointerId) return;
|
||||
const p = point(e);
|
||||
drag.lastNx = Math.max(0, Math.min(1, drag.startNx + p.x - drag.pointerNx));
|
||||
drag.lastNy = Math.max(0, Math.min(1, drag.startNy + p.y - drag.pointerNy));
|
||||
const rawNx = Math.max(0, Math.min(1, drag.startNx + p.x - drag.pointerNx));
|
||||
const rawNy = Math.max(0, Math.min(1, drag.startNy + p.y - drag.pointerNy));
|
||||
const snapped = snapNorm ? snapNorm(rawNx, rawNy) : { nx: rawNx, ny: rawNy };
|
||||
drag.lastNx = snapped.nx;
|
||||
drag.lastNy = snapped.ny;
|
||||
setLocalPos({ nx: drag.lastNx, ny: drag.lastNy });
|
||||
schedule(drag.lastNx, drag.lastNy);
|
||||
}
|
||||
@@ -135,6 +140,7 @@ export function ScenePlayerTokensOverlay({
|
||||
grid = null,
|
||||
editable = false,
|
||||
onMove,
|
||||
snapNorm,
|
||||
}: {
|
||||
library: readonly AppPlayer[];
|
||||
session: ScenePlayerTokensSessionState | null;
|
||||
@@ -144,6 +150,7 @@ export function ScenePlayerTokensOverlay({
|
||||
grid?: SceneGrid | null;
|
||||
editable?: boolean;
|
||||
onMove?: (playerId: string, nx: number, ny: number) => void;
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
}) {
|
||||
if (!viewport || !session?.visible) return null;
|
||||
const byId = new Map(library.map((p) => [String(p.id), p]));
|
||||
@@ -166,6 +173,7 @@ export function ScenePlayerTokensOverlay({
|
||||
displayScale={displayScale}
|
||||
gridFit={gridFit}
|
||||
{...(onMove ? { onMove } : {})}
|
||||
{...(snapNorm ? { snapNorm } : {})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -15,9 +15,12 @@ export type SceneOverlayCloseAction = {
|
||||
type SceneOverlayHostProps = {
|
||||
/** Есть ли что показывать (материал и/или NPC). */
|
||||
active: boolean;
|
||||
/** Область картинки сцены (contain); координаты относительно родителя. */
|
||||
/**
|
||||
* Область раскладки кадров материалов/NPC (и жёлтой рамки).
|
||||
* На пульте — прямоугольник соотношения сторон презентации; на презентации обычно не задаётся (весь экран).
|
||||
*/
|
||||
viewport?: SceneOverlayViewport | null;
|
||||
/** Рамка видимой области (предпросмотр пульта). */
|
||||
/** Жёлтая рамка видимой области презентации (предпросмотр пульта). Без dim. */
|
||||
showViewportGuide?: boolean;
|
||||
zoomTool?: MaterialsZoomTool | NpcsZoomTool;
|
||||
onZoomAt?: (nx: number, ny: number, target: EventTarget | null) => void;
|
||||
@@ -26,8 +29,9 @@ type SceneOverlayHostProps = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Общий слой подложки для Materials + NPCs: один root и один `.dim`.
|
||||
* Кадры остаются в дочерних оверлеях (`embedded`).
|
||||
* Общий слой подложки для Materials + NPCs.
|
||||
* Dim — только при `active`, на весь родитель (экран презентации / рамка превью пульта).
|
||||
* Кадры остаются в дочерних оверлеях (`embedded`) внутри `viewport`.
|
||||
*/
|
||||
export function SceneOverlayHost({
|
||||
active,
|
||||
@@ -61,7 +65,7 @@ export function SceneOverlayHost({
|
||||
ro.disconnect();
|
||||
if (raf !== 0) window.cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [active]);
|
||||
}, [active, showViewportGuide, viewport]);
|
||||
|
||||
const ctx = useMemo(() => ({ rootRef, view }), [view]);
|
||||
|
||||
@@ -83,6 +87,7 @@ export function SceneOverlayHost({
|
||||
|
||||
return (
|
||||
<SceneOverlayViewContext.Provider value={ctx}>
|
||||
{active ? <div className={styles.dim} aria-hidden /> : null}
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={[styles.root, styles.hostHitThrough, captureZoom ? styles.captureZoom : '', zoomCursor]
|
||||
@@ -98,7 +103,6 @@ export function SceneOverlayHost({
|
||||
}}
|
||||
>
|
||||
{showViewportGuide ? <div className={styles.viewportGuide} aria-hidden /> : null}
|
||||
<div className={styles.dim} />
|
||||
{children}
|
||||
{closes.length > 0 ? (
|
||||
<div className={styles.closeStack}>
|
||||
|
||||
@@ -18,6 +18,9 @@ void test('SceneOverlayHost: один dim для materials + npcs в Control и
|
||||
|
||||
assert.ok(host.includes('styles.dim'));
|
||||
assert.ok(host.includes('hostHitThrough'));
|
||||
// Dim только при active; жёлтая рамка без затемнения.
|
||||
assert.match(host, /\{active \? <div className=\{styles\.dim\}/);
|
||||
assert.ok(host.includes('showViewportGuide'));
|
||||
assert.ok(control.includes('SceneOverlayHost'));
|
||||
assert.ok(control.includes('embedded'));
|
||||
assert.ok(presentation.includes('SceneOverlayHost'));
|
||||
@@ -29,6 +32,7 @@ void test('SceneOverlayHost: один dim для materials + npcs в Control и
|
||||
assert.ok(control.includes('embedded'));
|
||||
assert.ok(presentation.includes('embedded'));
|
||||
assert.match(css, /\.hostHitThrough\s*\{[^}]*pointer-events:\s*none/s);
|
||||
assert.match(css, /\.dim\s*\{[^}]*inset:\s*0/s);
|
||||
});
|
||||
|
||||
void test('MaterialOverlay / NpcsSceneOverlay поддерживают embedded без собственного dim', () => {
|
||||
|
||||
@@ -14,6 +14,8 @@ type Props = {
|
||||
viewport: Viewport | null;
|
||||
editable?: boolean;
|
||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||
/** Snap во время drag (пульт, привязка к сетке). */
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
};
|
||||
|
||||
function TokenSprite({
|
||||
@@ -23,6 +25,7 @@ function TokenSprite({
|
||||
viewport,
|
||||
editable,
|
||||
onMove,
|
||||
snapNorm,
|
||||
}: {
|
||||
placement: SceneToken;
|
||||
nx: number;
|
||||
@@ -30,6 +33,7 @@ function TokenSprite({
|
||||
viewport: Viewport;
|
||||
editable: boolean;
|
||||
onMove?: (placementId: string, nx: number, ny: number) => void;
|
||||
snapNorm?: (nx: number, ny: number) => { nx: number; ny: number };
|
||||
}) {
|
||||
const url = useTokenImageUrl(placement.tokenId);
|
||||
const dragRef = useRef<{
|
||||
@@ -132,8 +136,9 @@ function TokenSprite({
|
||||
const p = hostToNorm(e.clientX, e.clientY, host);
|
||||
const nextNx = Math.max(0, Math.min(1, d.startNx + (p.x - d.pointerNx)));
|
||||
const nextNy = Math.max(0, Math.min(1, d.startNy + (p.y - d.pointerNy)));
|
||||
d.lastNx = nextNx;
|
||||
d.lastNy = nextNy;
|
||||
const snapped = snapNorm ? snapNorm(nextNx, nextNy) : { nx: nextNx, ny: nextNy };
|
||||
d.lastNx = snapped.nx;
|
||||
d.lastNy = snapped.ny;
|
||||
if (frameRef.current) return;
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
frameRef.current = 0;
|
||||
@@ -163,6 +168,7 @@ export function SceneTokensOverlay({
|
||||
viewport,
|
||||
editable = false,
|
||||
onMove,
|
||||
snapNorm,
|
||||
}: Props) {
|
||||
if (!viewport || placements.length === 0) return null;
|
||||
const known = new Set(library.map((t) => t.id));
|
||||
@@ -185,6 +191,7 @@ export function SceneTokensOverlay({
|
||||
viewport={viewport}
|
||||
editable={editable}
|
||||
{...(onMove ? { onMove } : {})}
|
||||
{...(snapNorm ? { snapNorm } : {})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
export function useTokenGridSnapSession(): [
|
||||
boolean,
|
||||
{ setEnabled: (enabled: boolean) => Promise<boolean> },
|
||||
] {
|
||||
const api = getDndApi();
|
||||
const [enabled, setEnabledState] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.tokenGridSnap.getState, {}).then((res) => {
|
||||
setEnabledState(Boolean(res.enabled));
|
||||
});
|
||||
return api.on(ipcChannels.tokenGridSnap.stateChanged, ({ enabled: next }) => {
|
||||
setEnabledState(Boolean(next));
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const setEnabled = useCallback(
|
||||
async (next: boolean) => {
|
||||
setEnabledState(next);
|
||||
const res = await api.invoke(ipcChannels.tokenGridSnap.setEnabled, { enabled: next });
|
||||
setEnabledState(Boolean(res.enabled));
|
||||
return Boolean(res.enabled);
|
||||
},
|
||||
[api],
|
||||
);
|
||||
|
||||
return [enabled, { setEnabled }];
|
||||
}
|
||||
@@ -38,6 +38,10 @@
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.trapNonInteractive {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
|
||||
@@ -21,6 +21,8 @@ type Props = {
|
||||
viewport: { x: number; y: number; w: number; h: number } | null;
|
||||
/** Пульт: показывать все ловушки + RMB меню. Презентация: только revealed. */
|
||||
mode: 'control' | 'presentation';
|
||||
/** На пульте: false — без контекстного меню (например, активна кисть эффектов). */
|
||||
interactive?: boolean;
|
||||
onReveal?: (trapId: string) => void;
|
||||
onActivate?: (trapId: string) => void;
|
||||
onDisarm?: (trapId: string) => void;
|
||||
@@ -47,6 +49,7 @@ export function SceneTrapsOverlay({
|
||||
session,
|
||||
viewport,
|
||||
mode,
|
||||
interactive = true,
|
||||
onReveal,
|
||||
onActivate,
|
||||
onDisarm,
|
||||
@@ -54,6 +57,10 @@ export function SceneTrapsOverlay({
|
||||
const [menu, setMenu] = useState<{ trapId: string; x: number; y: number } | null>(null);
|
||||
const [activationFx, setActivationFx] = useState<ActivationFx | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!interactive) setMenu(null);
|
||||
}, [interactive]);
|
||||
|
||||
useEffect(() => {
|
||||
const act = session?.lastActivation;
|
||||
if (!act) return;
|
||||
@@ -112,6 +119,7 @@ export function SceneTrapsOverlay({
|
||||
rt.status === 'active' ? styles.trapActive : '',
|
||||
rt.status === 'disarmed' ? styles.trapDisarmed : '',
|
||||
mode === 'control' && !rt.revealed ? styles.trapGmHidden : '',
|
||||
mode === 'control' && !interactive ? styles.trapNonInteractive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
@@ -121,7 +129,7 @@ export function SceneTrapsOverlay({
|
||||
className={cls}
|
||||
style={{ left, top, width: sizePx, height: sizePx }}
|
||||
onContextMenu={
|
||||
mode === 'control'
|
||||
mode === 'control' && interactive
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -163,7 +171,7 @@ export function SceneTrapsOverlay({
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{menu && mode === 'control'
|
||||
{menu && mode === 'control' && interactive
|
||||
? createPortal(
|
||||
<div
|
||||
role="menu"
|
||||
|
||||
Reference in New Issue
Block a user