feat(control): история-аккордеон и форма кисти затемнения
Переименовать блок сюжетной линии в «История» и сделать его сворачиваемым. Добавить выбор круглой/квадратной формы для кистей открытия и закрытия с сохранением в штрихах и сбросом при смене инструмента или сцены без затемнения. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -217,11 +217,42 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.storyScroll {
|
||||
flex: 0 0 70%;
|
||||
.storyWrapCollapsed {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.storyWrapCollapsed .historyAccordion {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.historyAccordion {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.historyAccordionHead {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 800;
|
||||
color: var(--text2);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.storyScroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border-top: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-2);
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
@@ -394,6 +425,34 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.brushCursorSquare {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.brushShapeRow {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.brushShapeButtons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brushShapeIcon {
|
||||
display: flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text1);
|
||||
}
|
||||
|
||||
.brushLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
MaterialId,
|
||||
NpcDisposition,
|
||||
Scene,
|
||||
SceneDarknessBrushShape,
|
||||
SceneId,
|
||||
SceneViewCamera,
|
||||
} from '../../shared/types';
|
||||
@@ -185,6 +186,10 @@ export function ControlApp() {
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const historyRef = useRef<GraphNodeId[]>([]);
|
||||
const [history, setHistory] = useState<GraphNodeId[]>([]);
|
||||
const [historyOpen, setHistoryOpen] = useState(true);
|
||||
const [darknessBrushShape, setDarknessBrushShape] = useState<SceneDarknessBrushShape>('circle');
|
||||
const darknessBrushShapeRef = useRef(darknessBrushShape);
|
||||
darknessBrushShapeRef.current = darknessBrushShape;
|
||||
/** Сцена основного сюжета, с которой ушли в побочную линию (только текущая сессия). */
|
||||
const mainStoryReturnRef = useRef<GraphNodeId | null>(null);
|
||||
const [mainStoryReturnGraphNodeId, setMainStoryReturnGraphNodeId] = useState<GraphNodeId | null>(null);
|
||||
@@ -399,6 +404,12 @@ export function ControlApp() {
|
||||
);
|
||||
const isDarkenScene = Boolean(currentScene?.darkenScene);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDarkenScene) {
|
||||
setDarknessBrushShape('circle');
|
||||
}
|
||||
}, [isDarkenScene]);
|
||||
|
||||
const snapNormActive = useCallback(
|
||||
(nx: number, ny: number) => {
|
||||
const grid = currentScene?.grid;
|
||||
@@ -1105,9 +1116,25 @@ export function ControlApp() {
|
||||
/** Повторный клик по активному инструменту снимает выбор. */
|
||||
function selectEffectTool(next: Exclude<EffectToolType, 'none'>): void {
|
||||
const toolName: EffectToolType = tool.tool === next ? 'none' : next;
|
||||
const leavingDarknessBrush =
|
||||
(tool.tool === 'exploreBrush' || tool.tool === 'closeBrush') &&
|
||||
toolName !== 'exploreBrush' &&
|
||||
toolName !== 'closeBrush';
|
||||
if (leavingDarknessBrush) {
|
||||
setDarknessBrushShape('circle');
|
||||
}
|
||||
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: toolName } });
|
||||
}
|
||||
|
||||
function darknessBrushDraft(points: { x: number; y: number; tMs: number }[], mode: 'reveal' | 'cover') {
|
||||
return {
|
||||
points,
|
||||
radiusN: toolRef.current.radiusN,
|
||||
mode,
|
||||
shape: darknessBrushShapeRef.current,
|
||||
};
|
||||
}
|
||||
|
||||
function layoutBrushCursor(): void {
|
||||
const el = brushCursorElRef.current;
|
||||
const p = cursorPosRef.current;
|
||||
@@ -1120,6 +1147,13 @@ export function ControlApp() {
|
||||
return;
|
||||
}
|
||||
el.style.visibility = 'visible';
|
||||
const isDarknessBrush = t.tool === 'exploreBrush' || t.tool === 'closeBrush';
|
||||
el.className = [
|
||||
styles.brushCursor,
|
||||
isDarknessBrush && darknessBrushShapeRef.current === 'square' ? styles.brushCursorSquare : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const ox = cr ? cr.x : 0;
|
||||
const oy = cr ? cr.y : 0;
|
||||
const cw = cr ? cr.w : ps.w;
|
||||
@@ -1297,11 +1331,10 @@ export function ControlApp() {
|
||||
) {
|
||||
void sd.dispatch({
|
||||
kind: 'draft.set',
|
||||
draft: {
|
||||
points: b.points,
|
||||
radiusN: toolRef.current.radiusN,
|
||||
mode: b.tool === 'closeBrush' ? 'cover' : 'reveal',
|
||||
},
|
||||
draft: darknessBrushDraft(
|
||||
b.points,
|
||||
b.tool === 'closeBrush' ? 'cover' : 'reveal',
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1309,7 +1342,7 @@ export function ControlApp() {
|
||||
|
||||
useLayoutEffect(() => {
|
||||
layoutBrushCursor();
|
||||
}, [tool.radiusN, previewContentRect, previewSize.w, previewSize.h]);
|
||||
}, [tool.radiusN, previewContentRect, previewSize.w, previewSize.h, darknessBrushShape]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -1459,6 +1492,7 @@ export function ControlApp() {
|
||||
points: b.points,
|
||||
radiusN: tool.radiusN,
|
||||
mode: b.tool === 'closeBrush' ? 'cover' : 'reveal',
|
||||
shape: darknessBrushShapeRef.current,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1891,6 +1925,55 @@ export function ControlApp() {
|
||||
/>
|
||||
<div className={styles.radiusValue}>{Math.round(tool.radiusN * 100)}</div>
|
||||
</div>
|
||||
{tool.tool === 'exploreBrush' || tool.tool === 'closeBrush' ? (
|
||||
<div className={styles.brushShapeRow}>
|
||||
<div className={styles.radiusLabel}>{t('control.brushShape')}</div>
|
||||
<div className={styles.brushShapeButtons}>
|
||||
<Button
|
||||
variant={darknessBrushShape === 'circle' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title={t('control.brushShapeCircle')}
|
||||
ariaLabel={t('control.brushShapeCircle')}
|
||||
onClick={() => setDarknessBrushShape('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={darknessBrushShape === 'square' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title={t('control.brushShapeSquare')}
|
||||
ariaLabel={t('control.brushShapeSquare')}
|
||||
onClick={() => setDarknessBrushShape('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>
|
||||
) : null}
|
||||
<div className={[styles.radiusRow, styles.effectsSoundRow].join(' ')}>
|
||||
<div className={styles.radiusLabel}>{t('control.effectsSound')}</div>
|
||||
<input
|
||||
@@ -1914,10 +1997,18 @@ export function ControlApp() {
|
||||
</div>
|
||||
<div className={styles.spacer12} />
|
||||
</>
|
||||
<div className={styles.storyWrap}>
|
||||
<div className={styles.sectionLabel}>{t('control.storyLine')}</div>
|
||||
<div className={styles.spacer10} />
|
||||
<div className={styles.storyScroll}>
|
||||
<div className={[styles.storyWrap, historyOpen ? '' : styles.storyWrapCollapsed].filter(Boolean).join(' ')}>
|
||||
<div className={styles.historyAccordion} data-testid="control-history-accordion">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.historyAccordionHead}
|
||||
aria-expanded={historyOpen}
|
||||
onClick={() => setHistoryOpen((v) => !v)}
|
||||
>
|
||||
{t('control.storyLine')} {historyOpen ? '▾' : '▸'}
|
||||
</button>
|
||||
{historyOpen ? (
|
||||
<div className={styles.storyScroll}>
|
||||
{history.map((gnId, idx) => {
|
||||
const gn = project?.sceneGraphNodes.find((n) => n.id === gnId);
|
||||
const s = gn ? project?.scenes[gn.sceneId] : undefined;
|
||||
@@ -1952,6 +2043,8 @@ export function ControlApp() {
|
||||
{history.length === 0 ? (
|
||||
<div className={styles.emptyStory}>{t('control.noActiveScene')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
@@ -2175,11 +2268,10 @@ export function ControlApp() {
|
||||
if (tool.tool === 'exploreBrush' || tool.tool === 'closeBrush') {
|
||||
void sd.dispatch({
|
||||
kind: 'draft.set',
|
||||
draft: {
|
||||
points: [startPt],
|
||||
radiusN: tool.radiusN,
|
||||
mode: tool.tool === 'closeBrush' ? 'cover' : 'reveal',
|
||||
},
|
||||
draft: darknessBrushDraft(
|
||||
[startPt],
|
||||
tool.tool === 'closeBrush' ? 'cover' : 'reveal',
|
||||
),
|
||||
});
|
||||
}
|
||||
pushDraftToPixi();
|
||||
|
||||
@@ -113,7 +113,7 @@ void test('ControlApp: эффекты в пульте, иконки с тулт
|
||||
instruments !== -1 && fx !== -1 && instruments < fx,
|
||||
'Блок инструментов должен быть выше эффектов',
|
||||
);
|
||||
assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше сюжетной линии');
|
||||
assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше истории');
|
||||
});
|
||||
|
||||
void test('ControlApp: чекбокс привязки токенов к сетке', () => {
|
||||
@@ -128,11 +128,14 @@ void test('ControlApp: чекбокс привязки токенов к сет
|
||||
assert.ok(css.includes('.snapToGrid'));
|
||||
});
|
||||
|
||||
void test('ControlApp: сюжетная линия — колонка сверху вниз и фон как у карточек ветвления', () => {
|
||||
void test('ControlApp: история — колонка сверху вниз и фон как у карточек ветвления', () => {
|
||||
const src = readControlApp();
|
||||
const css = readControlAppCss();
|
||||
const story = src.indexOf("t('control.storyLine')");
|
||||
assert.ok(story !== -1);
|
||||
assert.ok(src.includes('data-testid="control-history-accordion"'));
|
||||
assert.ok(src.includes('aria-expanded={historyOpen}'));
|
||||
assert.ok(src.includes('const [historyOpen, setHistoryOpen] = useState(true)'));
|
||||
assert.ok(src.includes('className={styles.storyScroll}'));
|
||||
assert.match(css, /\.storyScroll[\s\S]*?justify-content:\s*flex-start/);
|
||||
assert.match(css, /\.storyScroll[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
|
||||
@@ -173,6 +176,27 @@ void test('ControlApp: слой кисти не использует курсо
|
||||
assert.match(css, /\.brushLayer[\s\S]*?cursor:\s*crosshair/);
|
||||
});
|
||||
|
||||
void test('ControlApp: форма кисти затемнения — UI, shape в штрихах и сброс', () => {
|
||||
const src = readControlApp();
|
||||
const css = readControlAppCss();
|
||||
assert.ok(src.includes("t('control.brushShape')"));
|
||||
assert.ok(src.includes("t('control.brushShapeCircle')"));
|
||||
assert.ok(src.includes("t('control.brushShapeSquare')"));
|
||||
assert.ok(src.includes('darknessBrushShape'));
|
||||
assert.ok(src.includes('darknessBrushDraft'));
|
||||
assert.ok(src.includes("shape: darknessBrushShapeRef.current"));
|
||||
assert.ok(src.includes('setDarknessBrushShape'));
|
||||
assert.ok(src.includes('styles.brushShapeRow'));
|
||||
assert.ok(src.includes("tool.tool === 'exploreBrush' || tool.tool === 'closeBrush'"));
|
||||
assert.ok(css.includes('.brushCursorSquare'));
|
||||
assert.ok(css.includes('.brushShapeRow'));
|
||||
const radius = src.indexOf("t('control.brushRadius')");
|
||||
const shape = src.indexOf("t('control.brushShape')");
|
||||
const sound = src.indexOf("t('control.effectsSound')");
|
||||
assert.ok(radius !== -1 && shape !== -1 && sound !== -1);
|
||||
assert.ok(radius < shape && shape < sound, 'Форма кисти между радиусом и звуком эффектов');
|
||||
});
|
||||
|
||||
void test('ControlApp: радиус кисти не в блоке предпросмотра', () => {
|
||||
const src = readControlApp();
|
||||
const previewLabel = src.indexOf("t('control.screenPreview')");
|
||||
|
||||
@@ -630,8 +630,11 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.poisonCloud': 'Облако яда',
|
||||
'control.explosion': 'Взрыв',
|
||||
'control.brushRadius': 'Радиус кисти',
|
||||
'control.brushShape': 'Форма кисти',
|
||||
'control.brushShapeCircle': 'Круглая',
|
||||
'control.brushShapeSquare': 'Квадратная',
|
||||
'control.effectsSound': 'Звук эффектов',
|
||||
'control.storyLine': 'СЮЖЕТНАЯ ЛИНИЯ',
|
||||
'control.storyLine': 'ИСТОРИЯ',
|
||||
'control.gotoScene': 'Перейти к этой сцене',
|
||||
'control.currentSceneBadge': 'ТЕКУЩАЯ СЦЕНА',
|
||||
'control.passed': 'Пройдено',
|
||||
@@ -1267,8 +1270,11 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'control.poisonCloud': 'Poison cloud',
|
||||
'control.explosion': 'Explosion',
|
||||
'control.brushRadius': 'Brush radius',
|
||||
'control.brushShape': 'Brush shape',
|
||||
'control.brushShapeCircle': 'Circle',
|
||||
'control.brushShapeSquare': 'Square',
|
||||
'control.effectsSound': 'Effects sound',
|
||||
'control.storyLine': 'STORYLINE',
|
||||
'control.storyLine': 'HISTORY',
|
||||
'control.gotoScene': 'Go to this scene',
|
||||
'control.currentSceneBadge': 'CURRENT SCENE',
|
||||
'control.passed': 'Visited',
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
import type {
|
||||
SceneDarknessBrushShape,
|
||||
SceneDarknessRevealStroke,
|
||||
SceneDarknessState,
|
||||
SceneDarknessStrokeMode,
|
||||
} from '../../../shared/types';
|
||||
import { normalizeSceneDarknessStrokeMode } from '../../../shared/types/sceneDarkness';
|
||||
import {
|
||||
normalizeSceneDarknessBrushShape,
|
||||
normalizeSceneDarknessStrokeMode,
|
||||
} from '../../../shared/types/sceneDarkness';
|
||||
|
||||
export type SceneDarknessOverlayProps = {
|
||||
state: SceneDarknessState | null;
|
||||
@@ -15,18 +19,44 @@ export type SceneDarknessOverlayProps = {
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
function drawDarknessStroke(
|
||||
type DarknessStrokeLike = SceneDarknessRevealStroke | {
|
||||
points: { x: number; y: number }[];
|
||||
radiusN: number;
|
||||
mode?: SceneDarknessStrokeMode;
|
||||
shape?: SceneDarknessBrushShape;
|
||||
};
|
||||
|
||||
function stampSquare(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
stroke: SceneDarknessRevealStroke | { points: { x: number; y: number }[]; radiusN: number; mode?: SceneDarknessStrokeMode },
|
||||
x: number,
|
||||
y: number,
|
||||
side: number,
|
||||
): void {
|
||||
ctx.fillRect(x - side / 2, y - side / 2, side, side);
|
||||
}
|
||||
|
||||
function drawSquareDarknessStroke(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
stroke: DarknessStrokeLike,
|
||||
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 drawCircleDarknessStroke(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
stroke: DarknessStrokeLike,
|
||||
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];
|
||||
@@ -51,6 +81,24 @@ function drawDarknessStroke(
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawDarknessStroke(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
stroke: DarknessStrokeLike,
|
||||
w: number,
|
||||
h: number,
|
||||
): void {
|
||||
const mode = normalizeSceneDarknessStrokeMode(stroke.mode);
|
||||
const shape = normalizeSceneDarknessBrushShape(stroke.shape);
|
||||
ctx.globalCompositeOperation = mode === 'cover' ? 'source-over' : 'destination-out';
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.strokeStyle = '#000000';
|
||||
if (shape === 'square') {
|
||||
drawSquareDarknessStroke(ctx, stroke, w, h);
|
||||
return;
|
||||
}
|
||||
drawCircleDarknessStroke(ctx, stroke, w, h);
|
||||
}
|
||||
|
||||
export function SceneDarknessOverlay({
|
||||
state,
|
||||
viewport,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
normalizeSceneDarknessBrushShape,
|
||||
normalizeSceneDarknessStrokeMode,
|
||||
} from './sceneDarkness';
|
||||
|
||||
void test('normalizeSceneDarknessStrokeMode: reveal по умолчанию', () => {
|
||||
assert.equal(normalizeSceneDarknessStrokeMode(undefined), 'reveal');
|
||||
assert.equal(normalizeSceneDarknessStrokeMode('reveal'), 'reveal');
|
||||
assert.equal(normalizeSceneDarknessStrokeMode('cover'), 'cover');
|
||||
});
|
||||
|
||||
void test('normalizeSceneDarknessBrushShape: circle по умолчанию', () => {
|
||||
assert.equal(normalizeSceneDarknessBrushShape(undefined), 'circle');
|
||||
assert.equal(normalizeSceneDarknessBrushShape('circle'), 'circle');
|
||||
assert.equal(normalizeSceneDarknessBrushShape('square'), 'square');
|
||||
assert.equal(normalizeSceneDarknessBrushShape('triangle'), 'circle');
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import type { NPoint } from './effects';
|
||||
/** reveal — снимает тьму (Кисть Открытия); cover — возвращает тьму (Кисть Закрытия). */
|
||||
export type SceneDarknessStrokeMode = 'reveal' | 'cover';
|
||||
|
||||
export type SceneDarknessBrushShape = 'circle' | 'square';
|
||||
|
||||
export type SceneDarknessRevealStroke = {
|
||||
id: string;
|
||||
seed: number;
|
||||
@@ -11,12 +13,15 @@ export type SceneDarknessRevealStroke = {
|
||||
radiusN: number;
|
||||
/** По умолчанию reveal (старые штрихи без поля). */
|
||||
mode?: SceneDarknessStrokeMode;
|
||||
/** По умолчанию circle (старые штрихи без поля). */
|
||||
shape?: SceneDarknessBrushShape;
|
||||
};
|
||||
|
||||
export type SceneDarknessDraft = {
|
||||
points: NPoint[];
|
||||
radiusN: number;
|
||||
mode?: SceneDarknessStrokeMode;
|
||||
shape?: SceneDarknessBrushShape;
|
||||
};
|
||||
|
||||
export type SceneDarknessState = {
|
||||
@@ -35,3 +40,7 @@ export type SceneDarknessEvent =
|
||||
export function normalizeSceneDarknessStrokeMode(raw: unknown): SceneDarknessStrokeMode {
|
||||
return raw === 'cover' ? 'cover' : 'reveal';
|
||||
}
|
||||
|
||||
export function normalizeSceneDarknessBrushShape(raw: unknown): SceneDarknessBrushShape {
|
||||
return raw === 'square' ? 'square' : 'circle';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user