feat(control): история-аккордеон и форма кисти затемнения

Переименовать блок сюжетной линии в «История» и сделать его сворачиваемым. Добавить выбор круглой/квадратной формы для кистей открытия и закрытия с сохранением в штрихах и сбросом при смене инструмента или сцены без затемнения.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-02 10:00:10 +08:00
parent 0a9168b320
commit ea718a26df
8 changed files with 288 additions and 30 deletions
+62 -3
View File
@@ -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;
+107 -15
View File
@@ -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')");