feat(control): инструмент «Маркер» на области видимости
Добавлена кисть маркера на пульте и презентации с настройками в панели эффектов; штрихи привязаны к жёлтой рамке, ластик и очистка работают с отдельным сессионным слоем. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -453,6 +453,33 @@
|
||||
color: var(--text1);
|
||||
}
|
||||
|
||||
.markerColorInput {
|
||||
flex: 0 0 40px;
|
||||
width: 40px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.markerColorInput::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.markerColorInput::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.markerColorInput::-moz-color-swatch {
|
||||
border: none;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.brushLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -17,9 +17,11 @@ import type {
|
||||
NpcDisposition,
|
||||
Scene,
|
||||
SceneDarknessBrushShape,
|
||||
SceneMarkerBrushShape,
|
||||
SceneId,
|
||||
SceneViewCamera,
|
||||
} from '../../shared/types';
|
||||
import { normalizeHexColor } from '../../shared/npcs/npcGroups';
|
||||
import {
|
||||
DEFAULT_NPC_TOKEN_SESSION_SCALE,
|
||||
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
|
||||
@@ -47,8 +49,10 @@ import { ExplosionVideoOverlay } from '../shared/effects/ExplosionVideoOverlay';
|
||||
import { PixiEffectsOverlay, type PixiEffectsOverlayHandle } from '../shared/effects/PxiEffectsOverlay';
|
||||
import type { EffectInstance, EffectToolType, ExplosionInstance } from '../../shared/types/effects';
|
||||
import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay';
|
||||
import { SceneMarkerOverlay } from '../shared/effects/SceneMarkerOverlay';
|
||||
import { useEffectsState } from '../shared/effects/useEffectsState';
|
||||
import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState';
|
||||
import { useSceneMarkerState } from '../shared/effects/useSceneMarkerState';
|
||||
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT, defaultLegendLayoutForMaterial } from '../../shared/types';
|
||||
import { MaterialLegendPanel } from '../shared/materials/MaterialLegendPanel';
|
||||
import { MaterialOverlay } from '../shared/materials/MaterialOverlay';
|
||||
@@ -160,6 +164,7 @@ export function ControlApp() {
|
||||
const [fxState, fx] = useEffectsState();
|
||||
const [effectsSfxGainUi, setEffectsSfxGainUi] = useState(() => getEffectsSfxGain());
|
||||
const [sdState, sd] = useSceneDarknessState();
|
||||
const [markerState, marker] = useSceneMarkerState();
|
||||
const [sceneTraps, sceneTrapsApi] = useSceneTrapsState();
|
||||
const appTokens = useAppTokens();
|
||||
const [sceneTokensSession, sceneTokensApi] = useSceneTokensSession();
|
||||
@@ -190,6 +195,12 @@ export function ControlApp() {
|
||||
const [darknessBrushShape, setDarknessBrushShape] = useState<SceneDarknessBrushShape>('circle');
|
||||
const darknessBrushShapeRef = useRef(darknessBrushShape);
|
||||
darknessBrushShapeRef.current = darknessBrushShape;
|
||||
const [markerBrushShape, setMarkerBrushShape] = useState<SceneMarkerBrushShape>('circle');
|
||||
const markerBrushShapeRef = useRef(markerBrushShape);
|
||||
markerBrushShapeRef.current = markerBrushShape;
|
||||
const [markerColor, setMarkerColor] = useState('#ff0000');
|
||||
const markerColorRef = useRef(markerColor);
|
||||
markerColorRef.current = markerColor;
|
||||
/** Сцена основного сюжета, с которой ушли в побочную линию (только текущая сессия). */
|
||||
const mainStoryReturnRef = useRef<GraphNodeId | null>(null);
|
||||
const [mainStoryReturnGraphNodeId, setMainStoryReturnGraphNodeId] = useState<GraphNodeId | null>(null);
|
||||
@@ -232,6 +243,7 @@ export function ControlApp() {
|
||||
| 'freeze'
|
||||
| 'exploreBrush'
|
||||
| 'closeBrush'
|
||||
| 'marker'
|
||||
| 'eraser';
|
||||
startN?: { x: number; y: number };
|
||||
points?: { x: number; y: number; tMs: number }[];
|
||||
@@ -262,6 +274,16 @@ export function ControlApp() {
|
||||
presentationContentSize.height,
|
||||
);
|
||||
}, [presentationContentSize, previewSize.h, previewSize.w]);
|
||||
/** Область видимости (жёлтая рамка на пульте); маркер рисуется в этих координатах. */
|
||||
const markerViewport = useMemo(() => {
|
||||
if (presentationScreenRect) return presentationScreenRect;
|
||||
if (previewSize.w > 1 && previewSize.h > 1) {
|
||||
return { x: 0, y: 0, w: previewSize.w, h: previewSize.h };
|
||||
}
|
||||
return previewContentRect;
|
||||
}, [presentationScreenRect, previewContentRect, previewSize.h, previewSize.w]);
|
||||
const markerViewportRef = useRef(markerViewport);
|
||||
markerViewportRef.current = markerViewport;
|
||||
const brushCursorElRef = useRef<HTMLDivElement | null>(null);
|
||||
const cursorPosRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const draftPaintRafRef = useRef(0);
|
||||
@@ -1123,9 +1145,21 @@ export function ControlApp() {
|
||||
if (leavingDarknessBrush) {
|
||||
setDarknessBrushShape('circle');
|
||||
}
|
||||
if (tool.tool === 'marker' && toolName !== 'marker') {
|
||||
setMarkerBrushShape('circle');
|
||||
}
|
||||
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: toolName } });
|
||||
}
|
||||
|
||||
function markerBrushDraft(points: { x: number; y: number; tMs: number }[]) {
|
||||
return {
|
||||
points,
|
||||
radiusN: toolRef.current.radiusN,
|
||||
color: markerColorRef.current,
|
||||
shape: markerBrushShapeRef.current,
|
||||
};
|
||||
}
|
||||
|
||||
function darknessBrushDraft(points: { x: number; y: number; tMs: number }[], mode: 'reveal' | 'cover') {
|
||||
return {
|
||||
points,
|
||||
@@ -1138,7 +1172,8 @@ export function ControlApp() {
|
||||
function layoutBrushCursor(): void {
|
||||
const el = brushCursorElRef.current;
|
||||
const p = cursorPosRef.current;
|
||||
const cr = previewContentRectRef.current;
|
||||
const cr =
|
||||
toolRef.current.tool === 'marker' ? markerViewportRef.current : previewContentRectRef.current;
|
||||
const ps = previewSizeRef.current;
|
||||
const t = toolRef.current;
|
||||
if (!el) return;
|
||||
@@ -1148,9 +1183,11 @@ export function ControlApp() {
|
||||
}
|
||||
el.style.visibility = 'visible';
|
||||
const isDarknessBrush = t.tool === 'exploreBrush' || t.tool === 'closeBrush';
|
||||
const isMarkerBrush = t.tool === 'marker';
|
||||
el.className = [
|
||||
styles.brushCursor,
|
||||
isDarknessBrush && darknessBrushShapeRef.current === 'square' ? styles.brushCursorSquare : '',
|
||||
isMarkerBrush && markerBrushShapeRef.current === 'square' ? styles.brushCursorSquare : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
@@ -1337,12 +1374,26 @@ export function ControlApp() {
|
||||
),
|
||||
});
|
||||
}
|
||||
if (b?.tool === 'marker' && b.points) {
|
||||
void marker.dispatch({
|
||||
kind: 'draft.set',
|
||||
draft: markerBrushDraft(b.points),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
layoutBrushCursor();
|
||||
}, [tool.radiusN, previewContentRect, previewSize.w, previewSize.h, darknessBrushShape]);
|
||||
}, [
|
||||
tool.radiusN,
|
||||
previewContentRect,
|
||||
markerViewport,
|
||||
previewSize.w,
|
||||
previewSize.h,
|
||||
darknessBrushShape,
|
||||
markerBrushShape,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -1352,20 +1403,36 @@ export function ControlApp() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
function toNPoint(e: React.PointerEvent): { x: number; y: number } | null {
|
||||
function normPointInViewport(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
viewport: { x: number; y: number; w: number; h: number } | null,
|
||||
opts?: { rejectOutside?: boolean },
|
||||
): { x: number; y: number } | null {
|
||||
const host = previewHostRef.current;
|
||||
if (!host) return null;
|
||||
if (!host || !viewport) return null;
|
||||
const r = host.getBoundingClientRect();
|
||||
const cr = previewContentRectRef.current;
|
||||
const ox = cr ? cr.x : 0;
|
||||
const oy = cr ? cr.y : 0;
|
||||
const cw = cr ? cr.w : r.width;
|
||||
const ch = cr ? cr.h : r.height;
|
||||
const x = (e.clientX - (r.left + ox)) / Math.max(1, cw);
|
||||
const y = (e.clientY - (r.top + oy)) / Math.max(1, ch);
|
||||
const x = (clientX - (r.left + viewport.x)) / Math.max(1, viewport.w);
|
||||
const y = (clientY - (r.top + viewport.y)) / Math.max(1, viewport.h);
|
||||
if (opts?.rejectOutside && (x < 0 || x > 1 || y < 0 || y > 1)) return null;
|
||||
return { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) };
|
||||
}
|
||||
|
||||
function toNPoint(e: React.PointerEvent): { x: number; y: number } | null {
|
||||
return normPointInViewport(e.clientX, e.clientY, previewContentRectRef.current);
|
||||
}
|
||||
|
||||
function toMarkerNPoint(e: React.PointerEvent): { x: number; y: number } | null {
|
||||
return normPointInViewport(e.clientX, e.clientY, markerViewportRef.current, {
|
||||
rejectOutside: true,
|
||||
});
|
||||
}
|
||||
|
||||
function brushPointFromEvent(e: React.PointerEvent): { x: number; y: number } | null {
|
||||
if (toolRef.current.tool === 'marker') return toMarkerNPoint(e);
|
||||
return toNPoint(e);
|
||||
}
|
||||
|
||||
function dispatchFieldEraserPoints(
|
||||
points: { x: number; y: number; tMs: number }[],
|
||||
opts?: {
|
||||
@@ -1383,6 +1450,15 @@ export function ControlApp() {
|
||||
});
|
||||
}
|
||||
|
||||
function dispatchMarkerEraserPoints(points: { x: number; y: number; tMs: number }[]): void {
|
||||
if (points.length === 0) return;
|
||||
void marker.dispatch({
|
||||
kind: 'strokes.erase',
|
||||
points,
|
||||
radiusN: toolRef.current.radiusN,
|
||||
});
|
||||
}
|
||||
|
||||
/** Вода/дождь гасят огонь только под самой кистью (не «аура» радиуса огня). */
|
||||
function extinguishFireAlong(points: { x: number; y: number; tMs: number }[]): void {
|
||||
dispatchFieldEraserPoints(points, { types: ['fire'], hitMode: 'brush' });
|
||||
@@ -1496,6 +1572,20 @@ export function ControlApp() {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (b.tool === 'marker' && b.points && b.points.length > 0) {
|
||||
await marker.dispatch({
|
||||
kind: 'stroke.add',
|
||||
stroke: {
|
||||
id: `mk_${String(createdAtMs)}_${String(seed)}`,
|
||||
seed,
|
||||
createdAtMs,
|
||||
points: b.points,
|
||||
radiusN: tool.radiusN,
|
||||
color: markerColorRef.current,
|
||||
shape: markerBrushShapeRef.current,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (b.tool === 'darkness' && b.points && b.points.length > 0) {
|
||||
const last = b.points[b.points.length - 1];
|
||||
if (last === undefined) return;
|
||||
@@ -1668,6 +1758,17 @@ export function ControlApp() {
|
||||
<div className={styles.sectionLabel}>{t('control.instruments')}</div>
|
||||
<div className={styles.spacer8} />
|
||||
<div className={styles.iconRow}>
|
||||
<Button
|
||||
variant={tool.tool === 'marker' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title={t('control.markerTool')}
|
||||
ariaLabel={t('control.markerTool')}
|
||||
onClick={() => selectEffectTool('marker')}
|
||||
>
|
||||
<span className={styles.iconGlyph} aria-hidden>
|
||||
🖍️
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
@@ -1763,7 +1864,10 @@ export function ControlApp() {
|
||||
iconOnly
|
||||
title={t('control.clearEffects')}
|
||||
ariaLabel={t('control.clearEffects')}
|
||||
onClick={() => void fx.dispatch({ kind: 'instances.clear' })}
|
||||
onClick={() => {
|
||||
void fx.dispatch({ kind: 'instances.clear' });
|
||||
void marker.dispatch({ kind: 'strokes.clear' });
|
||||
}}
|
||||
>
|
||||
<span className={styles.clearIcon}>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
|
||||
@@ -1974,6 +2078,70 @@ export function ControlApp() {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{tool.tool === 'marker' ? (
|
||||
<>
|
||||
<div className={styles.brushShapeRow}>
|
||||
<div className={styles.radiusLabel}>{t('control.brushShape')}</div>
|
||||
<div className={styles.brushShapeButtons}>
|
||||
<Button
|
||||
variant={markerBrushShape === 'circle' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title={t('control.brushShapeCircle')}
|
||||
ariaLabel={t('control.brushShapeCircle')}
|
||||
onClick={() => setMarkerBrushShape('circle')}
|
||||
>
|
||||
<span className={styles.brushShapeIcon} aria-hidden>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="7"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={markerBrushShape === 'square' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title={t('control.brushShapeSquare')}
|
||||
ariaLabel={t('control.brushShapeSquare')}
|
||||
onClick={() => setMarkerBrushShape('square')}
|
||||
>
|
||||
<span className={styles.brushShapeIcon} aria-hidden>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
|
||||
<rect
|
||||
x="5"
|
||||
y="5"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
rx="1"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.radiusRow}>
|
||||
<div className={styles.radiusLabel}>{t('control.markerColor')}</div>
|
||||
<input
|
||||
type="color"
|
||||
value={markerColor}
|
||||
onChange={(e) => {
|
||||
const next = normalizeHexColor((e.currentTarget as HTMLInputElement).value, '#ff0000');
|
||||
setMarkerColor(next);
|
||||
}}
|
||||
className={styles.markerColorInput}
|
||||
aria-label={t('control.markerColor')}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className={[styles.radiusRow, styles.effectsSoundRow].join(' ')}>
|
||||
<div className={styles.radiusLabel}>{t('control.effectsSound')}</div>
|
||||
<input
|
||||
@@ -2219,7 +2387,7 @@ export function ControlApp() {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onPointerEnter={(e) => {
|
||||
const p = toNPoint(e);
|
||||
const p = brushPointFromEvent(e);
|
||||
if (!p) return;
|
||||
cursorPosRef.current = p;
|
||||
layoutBrushCursor();
|
||||
@@ -2243,16 +2411,23 @@ export function ControlApp() {
|
||||
}
|
||||
if (e.button !== 0) return;
|
||||
if (tool.tool === 'none') return;
|
||||
const p = toNPoint(e);
|
||||
const p = brushPointFromEvent(e);
|
||||
if (!p) return;
|
||||
cursorPosRef.current = p;
|
||||
layoutBrushCursor();
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
if (tool.tool === 'eraser') {
|
||||
const pt = { x: p.x, y: p.y, tMs: Date.now() };
|
||||
brushRef.current = { tool: 'eraser', startN: p, points: [pt] };
|
||||
dispatchFieldEraserPoints([pt]);
|
||||
tryEraseActionEffect(p);
|
||||
const pFx = toNPoint(e);
|
||||
const pMk = toMarkerNPoint(e);
|
||||
if (!pFx) return;
|
||||
const tMs = Date.now();
|
||||
const ptFx = { x: pFx.x, y: pFx.y, tMs };
|
||||
brushRef.current = { tool: 'eraser', startN: pFx, points: [ptFx] };
|
||||
dispatchFieldEraserPoints([ptFx]);
|
||||
if (pMk) {
|
||||
dispatchMarkerEraserPoints([{ x: pMk.x, y: pMk.y, tMs }]);
|
||||
}
|
||||
tryEraseActionEffect(pFx);
|
||||
return;
|
||||
}
|
||||
draftMetaRef.current = { createdAtMs: Date.now(), seed: 12345 };
|
||||
@@ -2274,6 +2449,12 @@ export function ControlApp() {
|
||||
),
|
||||
});
|
||||
}
|
||||
if (tool.tool === 'marker') {
|
||||
void marker.dispatch({
|
||||
kind: 'draft.set',
|
||||
draft: markerBrushDraft([startPt]),
|
||||
});
|
||||
}
|
||||
pushDraftToPixi();
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
@@ -2291,31 +2472,46 @@ export function ControlApp() {
|
||||
publishSceneViewCamera(sceneViewPanBy(cam, { containW, containH, dx, dy }));
|
||||
return;
|
||||
}
|
||||
const p = toNPoint(e);
|
||||
if (!p) return;
|
||||
cursorPosRef.current = p;
|
||||
layoutBrushCursor();
|
||||
const p = brushPointFromEvent(e);
|
||||
if (tool.tool !== 'eraser' && !p) return;
|
||||
if (p) {
|
||||
cursorPosRef.current = p;
|
||||
layoutBrushCursor();
|
||||
}
|
||||
if (tool.tool === 'eraser' && (e.buttons & 1) !== 0) {
|
||||
const pFx = toNPoint(e);
|
||||
const pMk = toMarkerNPoint(e);
|
||||
if (!pFx) return;
|
||||
const b = brushRef.current;
|
||||
const pt = { x: p.x, y: p.y, tMs: Date.now() };
|
||||
const tMs = Date.now();
|
||||
const ptFx = { x: pFx.x, y: pFx.y, tMs };
|
||||
if (b?.tool !== 'eraser' || !b.points) {
|
||||
brushRef.current = { tool: 'eraser', startN: p, points: [pt] };
|
||||
dispatchFieldEraserPoints([pt]);
|
||||
brushRef.current = { tool: 'eraser', startN: pFx, points: [ptFx] };
|
||||
dispatchFieldEraserPoints([ptFx]);
|
||||
if (pMk) {
|
||||
dispatchMarkerEraserPoints([{ x: pMk.x, y: pMk.y, tMs }]);
|
||||
}
|
||||
} else {
|
||||
const last = b.points[b.points.length - 1];
|
||||
if (last) {
|
||||
const dx = p.x - last.x;
|
||||
const dy = p.y - last.y;
|
||||
const dx = pFx.x - last.x;
|
||||
const dy = pFx.y - last.y;
|
||||
const minStep = Math.max(0.004, tool.radiusN * 0.25);
|
||||
if (dx * dx + dy * dy >= minStep * minStep) {
|
||||
b.points.push(pt);
|
||||
dispatchFieldEraserPoints([last, pt]);
|
||||
b.points.push(ptFx);
|
||||
dispatchFieldEraserPoints([last, ptFx]);
|
||||
if (pMk) {
|
||||
dispatchMarkerEraserPoints([
|
||||
{ x: pMk.x, y: pMk.y, tMs },
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tryEraseActionEffect(p);
|
||||
tryEraseActionEffect(pFx);
|
||||
return;
|
||||
}
|
||||
if (!p) return;
|
||||
const b = brushRef.current;
|
||||
if (!b?.points) return;
|
||||
const last = b.points[b.points.length - 1];
|
||||
@@ -2353,6 +2549,9 @@ export function ControlApp() {
|
||||
) {
|
||||
void sd.dispatch({ kind: 'draft.set', draft: null });
|
||||
}
|
||||
if (brushRef.current?.tool === 'marker') {
|
||||
void marker.dispatch({ kind: 'draft.set', draft: null });
|
||||
}
|
||||
brushRef.current = null;
|
||||
clearDraftFromPixi();
|
||||
}}
|
||||
@@ -2686,6 +2885,13 @@ export function ControlApp() {
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{markerViewport ? (
|
||||
<SceneMarkerOverlay
|
||||
state={markerState}
|
||||
viewport={markerViewport}
|
||||
style={{ zIndex: 48 }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
|
||||
@@ -78,6 +78,15 @@ void test('ControlApp: эффекты в пульте, иконки с тулт
|
||||
const src = readControlApp();
|
||||
assert.ok(src.includes("t('control.instruments')"));
|
||||
assert.ok(src.includes("t('control.descriptionTool')"));
|
||||
assert.ok(src.includes("t('control.markerTool')"));
|
||||
assert.ok(src.includes("selectEffectTool('marker')"));
|
||||
assert.ok(src.includes('SceneMarkerOverlay'));
|
||||
assert.ok(src.includes('useSceneMarkerState'));
|
||||
assert.ok(src.includes("t('control.markerColor')"));
|
||||
assert.ok(src.includes('dispatchMarkerEraserPoints'));
|
||||
assert.ok(src.includes('markerViewport'));
|
||||
assert.ok(src.includes('toMarkerNPoint'));
|
||||
assert.ok(src.includes("kind: 'strokes.clear'"));
|
||||
assert.ok(src.includes("t('control.descriptionMissing')"));
|
||||
assert.ok(src.includes('openSceneDescription'));
|
||||
assert.ok(!src.includes('SceneDescriptionViewModal'));
|
||||
|
||||
@@ -607,6 +607,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.remoteTitle': 'ПУЛЬТ УПРАВЛЕНИЯ',
|
||||
'control.instruments': 'ИНСТРУМЕНТЫ',
|
||||
'control.descriptionTool': 'Описание',
|
||||
'control.markerTool': 'Маркер',
|
||||
'control.markerColor': 'Цвет маркера',
|
||||
'control.materialsTool': 'Материалы',
|
||||
'control.npcsTool': 'НПС',
|
||||
'control.descriptionMissing': 'Описание отсутствует',
|
||||
@@ -1247,6 +1249,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.remoteTitle': 'CONTROL PANEL',
|
||||
'control.instruments': 'TOOLS',
|
||||
'control.descriptionTool': 'Description',
|
||||
'control.markerTool': 'Marker',
|
||||
'control.markerColor': 'Marker color',
|
||||
'control.materialsTool': 'Materials',
|
||||
'control.npcsTool': 'NPCs',
|
||||
'control.descriptionMissing': 'No description',
|
||||
|
||||
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user