feat(control): инструмент «Маркер» на области видимости

Добавлена кисть маркера на пульте и презентации с настройками в панели эффектов; штрихи привязаны к жёлтой рамке, ластик и очистка работают с отдельным сессионным слоем.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-02 12:53:06 +08:00
parent 153be98780
commit 8b28a9b11a
18 changed files with 906 additions and 32 deletions
+28 -1
View File
@@ -7,8 +7,10 @@ import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranch
import { ExplosionVideoOverlay } from './effects/ExplosionVideoOverlay';
import { PixiEffectsOverlay } from './effects/PxiEffectsOverlay';
import { SceneDarknessOverlay } from './effects/SceneDarknessOverlay';
import { SceneMarkerOverlay } from './effects/SceneMarkerOverlay';
import { useEffectsState } from './effects/useEffectsState';
import { useSceneDarknessState } from './effects/useSceneDarknessState';
import { useSceneMarkerState } from './effects/useSceneMarkerState';
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT, defaultLegendLayoutForMaterial } from '../../shared/types';
import { SceneGridOverlay } from './grid/SceneGridOverlay';
import { MaterialLegendPanel } from './materials/MaterialLegendPanel';
@@ -57,6 +59,7 @@ export function PresentationView({
}: PresentationViewProps) {
const [fxState] = useEffectsState();
const [sdState] = useSceneDarknessState();
const [markerState] = useSceneMarkerState();
const [sceneTraps] = useSceneTrapsState();
const [sceneView] = useSceneViewState();
const [materialsOverlay] = useMaterialsOverlayState();
@@ -70,9 +73,30 @@ export function PresentationView({
const [tokenPathSession, tokenPathApi] = useTokenPathSession();
const [vp] = useVideoPlaybackState();
const videoElRef = useRef<HTMLVideoElement | null>(null);
const rootRef = useRef<HTMLDivElement | null>(null);
const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>(
null,
);
const [markerViewport, setMarkerViewport] = React.useState<{
x: number;
y: number;
w: number;
h: number;
} | null>(null);
useEffect(() => {
const el = rootRef.current;
if (!el) return;
const sync = () => {
const w = Math.max(1, el.clientWidth);
const h = Math.max(1, el.clientHeight);
setMarkerViewport({ x: 0, y: 0, w, h });
};
sync();
const ro = new ResizeObserver(sync);
ro.observe(el);
return () => ro.disconnect();
}, []);
const scene =
session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined;
const project = session?.project;
@@ -181,7 +205,7 @@ export function PresentationView({
]);
return (
<div className={styles.root}>
<div ref={rootRef} className={styles.root}>
{shownImageUrl && scene?.previewAssetType === 'image' ? (
<div className={styles.fill}>
<RotatedImage
@@ -285,6 +309,9 @@ export function PresentationView({
contentRect ? (
<SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} style={{ zIndex: 30 }} />
) : null}
{markerViewport ? (
<SceneMarkerOverlay state={markerState} viewport={markerViewport} style={{ zIndex: 48 }} />
) : null}
<SceneOverlayHost
active={activeMaterialItems.length > 0 || activeNpcItems.length > 0}
legendLayer={
@@ -0,0 +1,131 @@
import React, { useEffect, useRef } from 'react';
import type {
SceneMarkerBrushShape,
SceneMarkerDraft,
SceneMarkerState,
SceneMarkerStroke,
} from '../../../shared/types';
import { normalizeSceneMarkerBrushShape } from '../../../shared/types/sceneMarker';
export type SceneMarkerOverlayProps = {
state: SceneMarkerState | null;
viewport?: { x: number; y: number; w: number; h: number };
style?: React.CSSProperties;
};
type MarkerStrokeLike = SceneMarkerStroke | SceneMarkerDraft;
function stampSquare(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
side: number,
): void {
ctx.fillRect(x - side / 2, y - side / 2, side, side);
}
function drawSquareMarkerStroke(
ctx: CanvasRenderingContext2D,
stroke: MarkerStrokeLike,
w: number,
h: number,
): void {
const pts = stroke.points;
if (pts.length === 0) return;
const side = stroke.radiusN * Math.min(w, h) * 2;
for (const p of pts) {
stampSquare(ctx, p.x * w, p.y * h, side);
}
}
function drawCircleMarkerStroke(
ctx: CanvasRenderingContext2D,
stroke: MarkerStrokeLike,
w: number,
h: number,
): void {
const pts = stroke.points;
if (pts.length === 0) return;
const r = stroke.radiusN * Math.min(w, h);
if (pts.length === 1) {
const p = pts[0];
if (!p) return;
ctx.beginPath();
ctx.arc(p.x * w, p.y * h, r, 0, Math.PI * 2);
ctx.fill();
return;
}
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.lineWidth = r * 2;
ctx.beginPath();
const first = pts[0];
if (!first) return;
ctx.moveTo(first.x * w, first.y * h);
for (let i = 1; i < pts.length; i += 1) {
const p = pts[i];
if (!p) continue;
ctx.lineTo(p.x * w, p.y * h);
}
ctx.stroke();
}
function drawMarkerStroke(
ctx: CanvasRenderingContext2D,
stroke: MarkerStrokeLike,
w: number,
h: number,
): void {
const shape: SceneMarkerBrushShape = normalizeSceneMarkerBrushShape(stroke.shape);
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = stroke.color;
ctx.strokeStyle = stroke.color;
if (shape === 'square') {
drawSquareMarkerStroke(ctx, stroke, w, h);
return;
}
drawCircleMarkerStroke(ctx, stroke, w, h);
}
export function SceneMarkerOverlay({ state, viewport, style }: SceneMarkerOverlayProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !state || !viewport) return;
const w = Math.max(1, Math.round(viewport.w));
const h = Math.max(1, Math.round(viewport.h));
if (canvas.width !== w) canvas.width = w;
if (canvas.height !== h) canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, w, h);
for (const stroke of state.strokes) {
drawMarkerStroke(ctx, stroke, w, h);
}
if (state.draft && state.draft.points.length > 0) {
drawMarkerStroke(ctx, state.draft, w, h);
}
}, [state, viewport]);
if (!state || !viewport) return null;
return (
<canvas
ref={canvasRef}
aria-hidden
style={{
position: 'absolute',
left: viewport.x,
top: viewport.y,
width: viewport.w,
height: viewport.h,
pointerEvents: 'none',
zIndex: 3,
...style,
}}
/>
);
}
@@ -0,0 +1,37 @@
import { useEffect, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { SceneMarkerEvent, SceneMarkerState } from '../../../shared/types';
import { getDndApi } from '../dndApi';
export function useSceneMarkerState(): readonly [
SceneMarkerState | null,
{ dispatch: (event: SceneMarkerEvent) => Promise<void> },
] {
const api = getDndApi();
const [state, setState] = useState<SceneMarkerState | null>(null);
useEffect(() => {
void api.invoke(ipcChannels.sceneMarker.getState, {}).then((r) => {
setState((prev) => {
if (prev && prev.revision > r.state.revision) return prev;
return r.state;
});
});
return api.on(ipcChannels.sceneMarker.stateChanged, ({ state: next }) => {
setState((prev) => {
if (prev && prev.revision > next.revision) return prev;
return next;
});
});
}, [api]);
return [
state,
{
dispatch: async (event) => {
await api.invoke(ipcChannels.sceneMarker.dispatch, { event });
},
},
] as const;
}
@@ -22,6 +22,9 @@ void test('video scenes share map overlays / effects with image scenes', () => {
assert.ok(presentation.includes('SceneTrapsOverlay'));
assert.ok(presentation.includes('PixiEffectsOverlay'));
assert.ok(presentation.includes('SceneDarknessOverlay'));
assert.ok(presentation.includes('SceneMarkerOverlay'));
assert.ok(presentation.includes('markerViewport'));
assert.ok(main.includes('syncSceneMarkerForProject'));
assert.ok(sceneEditor.includes('ContainedVideo'));
assert.ok(sceneEditor.includes('hasMapMedia'));