feat(npcs): free rotate overlays and drop session zoom tools

Drag the top-edge handle to rotate the whole frame with the avatar; remove unused zoom controls from the session NPC window.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-21 14:22:46 +08:00
parent 1829191410
commit 0cab7ce7ca
7 changed files with 217 additions and 111 deletions
+4 -1
View File
@@ -107,7 +107,10 @@ export class NpcsOverlayStore {
focusNpcId: event.npcId, focusNpcId: event.npcId,
layouts: { layouts: {
...this.state.layouts, ...this.state.layouts,
[event.npcId]: clampNpcsLayout(event.layout), [event.npcId]: clampNpcsLayout({
...layoutFor(this.state, event.npcId),
...event.layout,
}),
}, },
}; };
return this.state; return this.state;
+1 -6
View File
@@ -1631,20 +1631,15 @@ export function ControlApp() {
<NpcsSceneOverlay <NpcsSceneOverlay
items={items} items={items}
editable editable
zoomTool={npcsOverlay?.zoomTool ?? null}
showClose showClose
closeLabel={t('npcs.closeOverlay')} closeLabel={t('npcs.closeOverlay')}
rotateLabel={t('npcs.rotateOverlay')}
onClose={() => { onClose={() => {
void npcsApi.dispatch({ kind: 'hide' }); void npcsApi.dispatch({ kind: 'hide' });
}} }}
onLayoutChange={(npcId, layout) => { onLayoutChange={(npcId, layout) => {
void npcsApi.dispatch({ kind: 'layout.set', npcId, layout }); void npcsApi.dispatch({ kind: 'layout.set', npcId, layout });
}} }}
onZoomAt={(npcId, nx, ny) => {
void npcsApi.dispatch(
npcId ? { kind: 'zoomAt', nx, ny, npcId } : { kind: 'zoomAt', nx, ny },
);
}}
/> />
); );
})()} })()}
@@ -427,6 +427,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.windowEmpty': 'Добавьте НПС в редакторе.', 'npcs.windowEmpty': 'Добавьте НПС в редакторе.',
'npcs.selectToShow': 'Выберите персонажей в списке — они появятся на экране.', 'npcs.selectToShow': 'Выберите персонажей в списке — они появятся на экране.',
'npcs.closeOverlay': 'Закрыть всех', 'npcs.closeOverlay': 'Закрыть всех',
'npcs.rotateOverlay': 'Повернуть',
'npcs.zoomIn': 'Увеличить', 'npcs.zoomIn': 'Увеличить',
'npcs.zoomOut': 'Уменьшить', 'npcs.zoomOut': 'Уменьшить',
'npcs.zoomInHint': 'Кликните по аватару в предпросмотре пульта, чтобы увеличить.', 'npcs.zoomInHint': 'Кликните по аватару в предпросмотре пульта, чтобы увеличить.',
@@ -963,6 +964,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.windowEmpty': 'Add NPCs in the editor.', 'npcs.windowEmpty': 'Add NPCs in the editor.',
'npcs.selectToShow': 'Select characters in the list — they will appear on screen.', 'npcs.selectToShow': 'Select characters in the list — they will appear on screen.',
'npcs.closeOverlay': 'Close all', 'npcs.closeOverlay': 'Close all',
'npcs.rotateOverlay': 'Rotate',
'npcs.zoomIn': 'Zoom in', 'npcs.zoomIn': 'Zoom in',
'npcs.zoomOut': 'Zoom out', 'npcs.zoomOut': 'Zoom out',
'npcs.zoomInHint': 'Click the avatar on the control preview to zoom in.', 'npcs.zoomInHint': 'Click the avatar on the control preview to zoom in.',
+4 -72
View File
@@ -12,32 +12,6 @@ import { useAssetUrl } from '../shared/useAssetImageUrl';
import styles from './NpcsApp.module.css'; import styles from './NpcsApp.module.css';
function ZoomInIcon() {
return (
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
<circle cx="10.5" cy="10.5" r="6.25" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M15.2 15.2 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<path
d="M10.5 7.8v5.4M7.8 10.5h5.4"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
);
}
function ZoomOutIcon() {
return (
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
<circle cx="10.5" cy="10.5" r="6.25" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M15.2 15.2 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<path d="M7.8 10.5h5.4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
/** Убрать пустые ветки групп (удобно при поиске). */ /** Убрать пустые ветки групп (удобно при поиске). */
function pruneEmptyGroupNodes(nodes: NpcGroupTreeNode[]): NpcGroupTreeNode[] { function pruneEmptyGroupNodes(nodes: NpcGroupTreeNode[]): NpcGroupTreeNode[] {
const out: NpcGroupTreeNode[] = []; const out: NpcGroupTreeNode[] = [];
@@ -155,7 +129,6 @@ export function NpcsApp() {
const activeIds = overlay?.activeNpcIds ?? []; const activeIds = overlay?.activeNpcIds ?? [];
const hasActive = activeIds.length > 0; const hasActive = activeIds.length > 0;
const zoomTool = overlay?.zoomTool ?? null;
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
@@ -164,16 +137,12 @@ export function NpcsApp() {
void overlayApi.dispatch({ kind: 'hide' }); void overlayApi.dispatch({ kind: 'hide' });
return; return;
} }
if (zoomTool) {
void overlayApi.dispatch({ kind: 'zoomTool.set', tool: null });
return;
}
window.close(); window.close();
} }
}; };
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [hasActive, zoomTool, overlayApi]); }, [hasActive, overlayApi]);
const npcs = useMemo(() => session?.project?.npcs ?? [], [session?.project?.npcs]); const npcs = useMemo(() => session?.project?.npcs ?? [], [session?.project?.npcs]);
const npcGroups = useMemo(() => session?.project?.npcGroups ?? [], [session?.project?.npcGroups]); const npcGroups = useMemo(() => session?.project?.npcGroups ?? [], [session?.project?.npcGroups]);
@@ -245,39 +214,9 @@ export function NpcsApp() {
return ( return (
<div className={styles.page}> <div className={styles.page}>
{hasActive ? (
<div className={styles.toolbar}> <div className={styles.toolbar}>
<div className={styles.toolbarRow}> <div className={styles.toolbarRow}>
<Button
variant={zoomTool === 'zoomIn' ? 'primary' : 'ghost'}
iconOnly
title={t('npcs.zoomIn')}
ariaLabel={t('npcs.zoomIn')}
tooltipPlacement="bottom"
onClick={() => {
void overlayApi.dispatch({
kind: 'zoomTool.set',
tool: zoomTool === 'zoomIn' ? null : 'zoomIn',
});
}}
>
<ZoomInIcon />
</Button>
<Button
variant={zoomTool === 'zoomOut' ? 'primary' : 'ghost'}
iconOnly
title={t('npcs.zoomOut')}
ariaLabel={t('npcs.zoomOut')}
tooltipPlacement="bottom"
onClick={() => {
void overlayApi.dispatch({
kind: 'zoomTool.set',
tool: zoomTool === 'zoomOut' ? null : 'zoomOut',
});
}}
>
<ZoomOutIcon />
</Button>
{hasActive ? (
<Button <Button
title={t('npcs.closeOverlay')} title={t('npcs.closeOverlay')}
ariaLabel={t('npcs.closeOverlay')} ariaLabel={t('npcs.closeOverlay')}
@@ -288,16 +227,9 @@ export function NpcsApp() {
> >
{t('npcs.closeOverlay')} {t('npcs.closeOverlay')}
</Button> </Button>
</div>
</div>
) : null} ) : null}
</div>
<div className={styles.toolbarHint}>
{zoomTool === 'zoomIn'
? t('npcs.zoomInHint')
: zoomTool === 'zoomOut'
? t('npcs.zoomOutHint')
: t('npcs.zoomIdleHint')}
</div>
</div>
<div className={styles.body}> <div className={styles.body}>
<div className={styles.detail}> <div className={styles.detail}>
@@ -110,3 +110,35 @@
.close:hover { .close:hover {
background: rgba(24, 24, 32, 0.9); background: rgba(24, 24, 32, 0.9);
} }
.frameRotate {
position: absolute;
left: 50%;
top: 0;
z-index: 3;
width: 28px;
height: 28px;
border: none;
border-radius: 8px;
background: rgba(12, 12, 16, 0.78);
color: #fff;
padding: 0;
cursor: grab;
display: grid;
place-items: center;
pointer-events: auto;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.45);
transform: translate(-50%, -50%);
}
.frameRotate:hover {
background: rgba(24, 24, 32, 0.95);
}
.frameRotate:active {
cursor: grabbing;
}
.frameRotateSvg {
display: block;
}
+153 -22
View File
@@ -20,10 +20,26 @@ type NpcsSceneOverlayProps = {
showClose?: boolean; showClose?: boolean;
onClose?: () => void; onClose?: () => void;
closeLabel?: string; closeLabel?: string;
rotateLabel?: string;
onLayoutChange?: (npcId: NpcId, layout: NpcsOverlayLayout) => void; onLayoutChange?: (npcId: NpcId, layout: NpcsOverlayLayout) => void;
onZoomAt?: (npcId: NpcId | undefined, nx: number, ny: number) => void; onZoomAt?: (npcId: NpcId | undefined, nx: number, ny: number) => void;
}; };
function RotateIcon() {
return (
<svg className={styles.frameRotateSvg} viewBox="0 0 24 24" width="16" height="16" aria-hidden>
<path
d="M20 12a8 8 0 1 1-2.2-5.4"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
<path d="M20 4v5h-5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
function nextBaseSize( function nextBaseSize(
viewW: number, viewW: number,
viewH: number, viewH: number,
@@ -37,12 +53,48 @@ function nextBaseSize(
return { w: naturalW * fit, h: naturalH * fit }; return { w: naturalW * fit, h: naturalH * fit };
} }
function pointerAngleDeg(centerX: number, centerY: number, clientX: number, clientY: number): number {
return (Math.atan2(clientY - centerY, clientX - centerX) * 180) / Math.PI;
}
function shortestAngleDelta(fromDeg: number, toDeg: number): number {
let d = toDeg - fromDeg;
while (d > 180) d -= 360;
while (d < -180) d += 360;
return d;
}
function screenToLocal(
clientX: number,
clientY: number,
centerX: number,
centerY: number,
rotationDeg: number,
): { x: number; y: number } {
const rad = (-rotationDeg * Math.PI) / 180;
const dx = clientX - centerX;
const dy = clientY - centerY;
return {
x: dx * Math.cos(rad) - dy * Math.sin(rad),
y: dx * Math.sin(rad) + dy * Math.cos(rad),
};
}
function localToScreenOffset(localX: number, localY: number, rotationDeg: number): { x: number; y: number } {
const rad = (rotationDeg * Math.PI) / 180;
return {
x: localX * Math.cos(rad) - localY * Math.sin(rad),
y: localX * Math.sin(rad) + localY * Math.cos(rad),
};
}
function NpcAvatarFrame({ function NpcAvatarFrame({
item, item,
view, view,
rootRef, rootRef,
editable, editable,
zoomTool, zoomTool,
rotateLabel,
onLayoutChange, onLayoutChange,
}: { }: {
item: NpcsSceneOverlayItem; item: NpcsSceneOverlayItem;
@@ -50,6 +102,7 @@ function NpcAvatarFrame({
rootRef: React.RefObject<HTMLDivElement | null>; rootRef: React.RefObject<HTMLDivElement | null>;
editable: boolean; editable: boolean;
zoomTool: NpcsZoomTool; zoomTool: NpcsZoomTool;
rotateLabel: string;
onLayoutChange?: (npcId: NpcId, layout: NpcsOverlayLayout) => void; onLayoutChange?: (npcId: NpcId, layout: NpcsOverlayLayout) => void;
}) { }) {
const url = useAssetUrl(item.assetId); const url = useAssetUrl(item.assetId);
@@ -59,13 +112,20 @@ function NpcAvatarFrame({
| { | {
mode: 'resize'; mode: 'resize';
corner: Corner; corner: Corner;
startX: number;
startY: number;
origin: NpcsOverlayLayout; origin: NpcsOverlayLayout;
baseW: number; baseW: number;
baseH: number; baseH: number;
viewW: number; viewW: number;
viewH: number; viewH: number;
centerClientX: number;
centerClientY: number;
}
| {
mode: 'rotate';
origin: NpcsOverlayLayout;
startPointerAngle: number;
centerX: number;
centerY: number;
} }
| null | null
>(null); >(null);
@@ -73,6 +133,7 @@ function NpcAvatarFrame({
if (!item.assetId || !url) return null; if (!item.assetId || !url) return null;
const layout = item.layout ?? DEFAULT_NPCS_OVERLAY_LAYOUT; const layout = item.layout ?? DEFAULT_NPCS_OVERLAY_LAYOUT;
const rotationDeg = layout.rotationDeg ?? 0;
const base = nextBaseSize(view.w, view.h, natural.w, natural.h); const base = nextBaseSize(view.w, view.h, natural.w, natural.h);
const w = base.w * layout.scale; const w = base.w * layout.scale;
const h = base.h * layout.scale; const h = base.h * layout.scale;
@@ -82,13 +143,24 @@ function NpcAvatarFrame({
const onPointerMove = (e: PointerEvent) => { const onPointerMove = (e: PointerEvent) => {
const drag = dragRef.current; const drag = dragRef.current;
if (!drag || !onLayoutChange) return; if (!drag || !onLayoutChange) return;
if (drag.mode === 'rotate') {
const angle = pointerAngleDeg(drag.centerX, drag.centerY, e.clientX, e.clientY);
const delta = shortestAngleDelta(drag.startPointerAngle, angle);
onLayoutChange(item.npcId, {
...drag.origin,
rotationDeg: drag.origin.rotationDeg + delta,
});
return;
}
const root = rootRef.current; const root = rootRef.current;
if (!root) return; if (!root) return;
const r = root.getBoundingClientRect(); const r = root.getBoundingClientRect();
const dx = (e.clientX - drag.startX) / Math.max(1, r.width);
const dy = (e.clientY - drag.startY) / Math.max(1, r.height);
if (drag.mode === 'move') { if (drag.mode === 'move') {
const dx = (e.clientX - drag.startX) / Math.max(1, r.width);
const dy = (e.clientY - drag.startY) / Math.max(1, r.height);
onLayoutChange(item.npcId, { onLayoutChange(item.npcId, {
...drag.origin, ...drag.origin,
cx: drag.origin.cx + dx, cx: drag.origin.cx + dx,
@@ -97,13 +169,13 @@ function NpcAvatarFrame({
return; return;
} }
const { baseW, baseH, viewW, viewH, origin, corner } = drag; const { baseW, baseH, viewW, viewH, origin, corner, centerClientX, centerClientY } = drag;
const originW = baseW * origin.scale; const originW = baseW * origin.scale;
const originH = baseH * origin.scale; const originH = baseH * origin.scale;
const originLeft = origin.cx * viewW - originW / 2; const originLeft = -originW / 2;
const originTop = origin.cy * viewH - originH / 2; const originTop = -originH / 2;
const originRight = originLeft + originW; const originRight = originW / 2;
const originBottom = originTop + originH; const originBottom = originH / 2;
let anchorX = originLeft; let anchorX = originLeft;
let anchorY = originTop; let anchorY = originTop;
@@ -118,10 +190,15 @@ function NpcAvatarFrame({
anchorY = originTop; anchorY = originTop;
} }
const pointerX = e.clientX - r.left; const local = screenToLocal(
const pointerY = e.clientY - r.top; e.clientX,
const newW = Math.max(8, Math.abs(pointerX - anchorX)); e.clientY,
const newH = Math.max(8, Math.abs(pointerY - anchorY)); centerClientX,
centerClientY,
origin.rotationDeg ?? 0,
);
const newW = Math.max(8, Math.abs(local.x - anchorX));
const newH = Math.max(8, Math.abs(local.y - anchorY));
const nextScale = Math.max(newW / Math.max(1, baseW), newH / Math.max(1, baseH)); const nextScale = Math.max(newW / Math.max(1, baseW), newH / Math.max(1, baseH));
const ww = baseW * nextScale; const ww = baseW * nextScale;
const hh = baseH * nextScale; const hh = baseH * nextScale;
@@ -139,9 +216,14 @@ function NpcAvatarFrame({
nextTop = anchorY; nextTop = anchorY;
} }
const localCenterX = nextLeft + ww / 2;
const localCenterY = nextTop + hh / 2;
const screenOffset = localToScreenOffset(localCenterX, localCenterY, origin.rotationDeg ?? 0);
onLayoutChange(item.npcId, { onLayoutChange(item.npcId, {
cx: (nextLeft + ww / 2) / Math.max(1, viewW), ...origin,
cy: (nextTop + hh / 2) / Math.max(1, viewH), cx: origin.cx + screenOffset.x / Math.max(1, viewW),
cy: origin.cy + screenOffset.y / Math.max(1, viewH),
scale: nextScale, scale: nextScale,
}); });
}; };
@@ -152,23 +234,51 @@ function NpcAvatarFrame({
window.removeEventListener('pointerup', endDrag); window.removeEventListener('pointerup', endDrag);
}; };
const layoutCenterClient = () => {
const root = rootRef.current;
if (!root) return { x: 0, y: 0 };
const r = root.getBoundingClientRect();
return {
x: r.left + layout.cx * r.width,
y: r.top + layout.cy * r.height,
};
};
const startDrag = (e: React.PointerEvent, mode: 'move' | 'resize', corner?: Corner) => { const startDrag = (e: React.PointerEvent, mode: 'move' | 'resize', corner?: Corner) => {
if (!editable || !onLayoutChange || zoomTool) return; if (!editable || !onLayoutChange || zoomTool) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
dragRef.current = if (mode === 'move') {
mode === 'move' dragRef.current = { mode: 'move', startX: e.clientX, startY: e.clientY, origin: { ...layout } };
? { mode: 'move', startX: e.clientX, startY: e.clientY, origin: { ...layout } } } else {
: { const center = layoutCenterClient();
dragRef.current = {
mode: 'resize', mode: 'resize',
corner: corner ?? 'se', corner: corner ?? 'se',
startX: e.clientX,
startY: e.clientY,
origin: { ...layout }, origin: { ...layout },
baseW: base.w, baseW: base.w,
baseH: base.h, baseH: base.h,
viewW: view.w, viewW: view.w,
viewH: view.h, viewH: view.h,
centerClientX: center.x,
centerClientY: center.y,
};
}
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', endDrag);
};
const startRotate = (e: React.PointerEvent) => {
if (!editable || !onLayoutChange || zoomTool) return;
e.preventDefault();
e.stopPropagation();
const center = layoutCenterClient();
dragRef.current = {
mode: 'rotate',
origin: { ...layout },
startPointerAngle: pointerAngleDeg(center.x, center.y, e.clientX, e.clientY),
centerX: center.x,
centerY: center.y,
}; };
window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', endDrag); window.addEventListener('pointerup', endDrag);
@@ -178,7 +288,14 @@ function NpcAvatarFrame({
<div <div
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : ''].filter(Boolean).join(' ')} className={[styles.frame, editable && !zoomTool ? styles.frameEditable : ''].filter(Boolean).join(' ')}
data-npc-id={item.npcId} data-npc-id={item.npcId}
style={{ left, top, width: w, height: h }} style={{
left,
top,
width: w,
height: h,
transform: `rotate(${String(rotationDeg)}deg)`,
transformOrigin: 'center center',
}}
onPointerDown={(e) => { onPointerDown={(e) => {
if (zoomTool) return; if (zoomTool) return;
startDrag(e, 'move'); startDrag(e, 'move');
@@ -210,6 +327,17 @@ function NpcAvatarFrame({
/> />
)) ))
: null} : null}
{editable && !zoomTool && onLayoutChange ? (
<button
type="button"
className={styles.frameRotate}
aria-label={rotateLabel}
title={rotateLabel}
onPointerDown={startRotate}
>
<RotateIcon />
</button>
) : null}
</div> </div>
); );
} }
@@ -221,6 +349,7 @@ export function NpcsSceneOverlay({
showClose = false, showClose = false,
onClose, onClose,
closeLabel = 'Close', closeLabel = 'Close',
rotateLabel = 'Rotate',
onLayoutChange, onLayoutChange,
onZoomAt, onZoomAt,
}: NpcsSceneOverlayProps) { }: NpcsSceneOverlayProps) {
@@ -285,6 +414,7 @@ export function NpcsSceneOverlay({
rootRef={rootRef} rootRef={rootRef}
editable={editable} editable={editable}
zoomTool={zoomTool} zoomTool={zoomTool}
rotateLabel={rotateLabel}
onLayoutChange={onLayoutChange} onLayoutChange={onLayoutChange}
/> />
) : ( ) : (
@@ -295,6 +425,7 @@ export function NpcsSceneOverlay({
rootRef={rootRef} rootRef={rootRef}
editable={editable} editable={editable}
zoomTool={zoomTool} zoomTool={zoomTool}
rotateLabel={rotateLabel}
/> />
), ),
)} )}
+11
View File
@@ -5,6 +5,8 @@ export type NpcsOverlayLayout = {
cx: number; cx: number;
cy: number; cy: number;
scale: number; scale: number;
/** Свободный угол поворота, градусы. */
rotationDeg: number;
}; };
export type NpcsZoomTool = 'zoomIn' | 'zoomOut' | null; export type NpcsZoomTool = 'zoomIn' | 'zoomOut' | null;
@@ -24,6 +26,7 @@ export const DEFAULT_NPCS_OVERLAY_LAYOUT: NpcsOverlayLayout = {
cx: 0.5, cx: 0.5,
cy: 0.5, cy: 0.5,
scale: 1, scale: 1,
rotationDeg: 0,
}; };
export type NpcsOverlayEvent = export type NpcsOverlayEvent =
@@ -34,11 +37,19 @@ export type NpcsOverlayEvent =
| { kind: 'zoomTool.set'; tool: NpcsZoomTool } | { kind: 'zoomTool.set'; tool: NpcsZoomTool }
| { kind: 'zoomAt'; nx: number; ny: number; npcId?: NpcId }; | { kind: 'zoomAt'; nx: number; ny: number; npcId?: NpcId };
/** Нормализация угла в диапазон [0, 360). */
export function normalizeNpcsRotation(deg: number): number {
if (!Number.isFinite(deg)) return 0;
const n = deg % 360;
return n < 0 ? n + 360 : n;
}
export function clampNpcsLayout(layout: NpcsOverlayLayout): NpcsOverlayLayout { export function clampNpcsLayout(layout: NpcsOverlayLayout): NpcsOverlayLayout {
return { return {
cx: Math.min(1.2, Math.max(-0.2, layout.cx)), cx: Math.min(1.2, Math.max(-0.2, layout.cx)),
cy: Math.min(1.2, Math.max(-0.2, layout.cy)), cy: Math.min(1.2, Math.max(-0.2, layout.cy)),
scale: Math.min(8, Math.max(0.15, layout.scale)), scale: Math.min(8, Math.max(0.15, layout.scale)),
rotationDeg: normalizeNpcsRotation(layout.rotationDeg ?? 0),
}; };
} }