feat(control): история-аккордеон и форма кисти затемнения
Переименовать блок сюжетной линии в «История» и сделать его сворачиваемым. Добавить выбор круглой/квадратной формы для кистей открытия и закрытия с сохранением в штрихах и сбросом при смене инструмента или сцены без затемнения. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user