Files
Ivan Fontosh 41b112159f feat(effects): add Closing brush as inverse of Opening brush
Allow covering revealed darkness again during darkened scene sessions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:21:50 +08:00

104 lines
2.8 KiB
TypeScript

import React, { useEffect, useRef } from 'react';
import type {
SceneDarknessRevealStroke,
SceneDarknessState,
SceneDarknessStrokeMode,
} from '../../../shared/types';
import { normalizeSceneDarknessStrokeMode } from '../../../shared/types/sceneDarkness';
export type SceneDarknessOverlayProps = {
state: SceneDarknessState | null;
viewport?: { x: number; y: number; w: number; h: number };
/** 1.0 — полностью чёрный (Presentation); 0.5 — полупрозрачный (Control). */
overlayAlpha: number;
style?: React.CSSProperties;
};
function drawDarknessStroke(
ctx: CanvasRenderingContext2D,
stroke: SceneDarknessRevealStroke | { points: { x: number; y: number }[]; radiusN: number; mode?: SceneDarknessStrokeMode },
w: number,
h: number,
): void {
const pts = stroke.points;
if (pts.length === 0) return;
const mode = normalizeSceneDarknessStrokeMode(stroke.mode);
ctx.globalCompositeOperation = mode === 'cover' ? 'source-over' : 'destination-out';
ctx.fillStyle = '#000000';
ctx.strokeStyle = '#000000';
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++) {
const p = pts[i];
if (!p) continue;
ctx.lineTo(p.x * w, p.y * h);
}
ctx.stroke();
}
export function SceneDarknessOverlay({
state,
viewport,
overlayAlpha,
style,
}: SceneDarknessOverlayProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !state?.enabled || !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.globalCompositeOperation = 'source-over';
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, w, h);
for (const stroke of state.strokes) {
drawDarknessStroke(ctx, stroke, w, h);
}
if (state.draft && state.draft.points.length > 0) {
drawDarknessStroke(ctx, state.draft, w, h);
}
}, [state, viewport]);
if (!state?.enabled || !viewport) return null;
return (
<canvas
ref={canvasRef}
aria-hidden
style={{
position: 'absolute',
left: viewport.x,
top: viewport.y,
width: viewport.w,
height: viewport.h,
opacity: overlayAlpha,
pointerEvents: 'none',
zIndex: 3,
...style,
}}
/>
);
}