Skip to content

Commit 2e43fa4

Browse files
committed
fix(studio): surface gesture recording controls
1 parent e2cc134 commit 2e43fa4

6 files changed

Lines changed: 144 additions & 29 deletions

File tree

packages/studio/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,8 @@ export function StudioApp() {
515515
setCompositionLoading={setCompositionLoading}
516516
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
517517
isGestureRecording={gestureState === "recording"}
518+
recordingState={gestureState}
519+
onToggleRecording={STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined}
518520
blockPreview={blockPreview}
519521
gestureOverlay={
520522
gestureState === "recording" && previewIframe ? (

packages/studio/src/components/StudioPreviewArea.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { useStudioContext } from "../contexts/StudioContext";
1717
import { useDomEditContext } from "../contexts/DomEditContext";
1818
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
1919
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
20+
import type { GestureRecordingState } from "./editor/GestureRecordControl";
2021

2122
export interface StudioPreviewAreaProps {
2223
timelineToolbar: ReactNode;
@@ -59,6 +60,8 @@ export interface StudioPreviewAreaProps {
5960
shouldShowSelectedDomBounds: boolean;
6061
blockPreview?: BlockPreviewInfo | null;
6162
isGestureRecording?: boolean;
63+
recordingState?: GestureRecordingState;
64+
onToggleRecording?: () => void;
6265
gestureOverlay?: ReactNode;
6366
}
6467

@@ -81,6 +84,8 @@ export function StudioPreviewArea({
8184
setCompositionLoading,
8285
shouldShowSelectedDomBounds,
8386
isGestureRecording,
87+
recordingState,
88+
onToggleRecording,
8489
blockPreview,
8590
gestureOverlay,
8691
}: StudioPreviewAreaProps) {
@@ -290,6 +295,8 @@ export function StudioPreviewArea({
290295
onRotationCommit={handleDomRotationCommit}
291296
gridVisible={snapPrefs.gridVisible}
292297
gridSpacing={snapPrefs.gridSpacing}
298+
recordingState={recordingState}
299+
onToggleRecording={onToggleRecording}
293300
/>
294301
<SnapToolbar onSnapChange={setSnapPrefs} />
295302
{gestureOverlay}

packages/studio/src/components/editor/DomEditOverlay.test.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ describe("DomEditOverlay", () => {
282282
};
283283

284284
let currentSelection: DomEditSelection | null = selection;
285+
const onToggleRecording = vi.fn();
285286
const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
286287
const originalPointerCapture = HTMLDivElement.prototype.setPointerCapture;
287288
HTMLDivElement.prototype.setPointerCapture = () => {};
@@ -290,15 +291,16 @@ describe("DomEditOverlay", () => {
290291
const [selected, setSelected] = React.useState<DomEditSelection | null>(selection);
291292
currentSelection = selected;
292293

293-
return React.createElement(
294-
DomEditOverlay,
295-
createOverlayProps({
294+
return React.createElement(DomEditOverlay, {
295+
...createOverlayProps({
296296
iframeRef,
297297
selection: selected,
298298
hoverSelection: null,
299299
onSelectionChange: (next: DomEditSelection) => setSelected(next),
300300
}),
301-
);
301+
recordingState: "idle",
302+
onToggleRecording,
303+
});
302304
}
303305

304306
act(() => {
@@ -338,6 +340,16 @@ describe("DomEditOverlay", () => {
338340
"drag",
339341
expect.objectContaining({ button: 0 }),
340342
);
343+
const recordButton = host.querySelector(
344+
'[aria-label="Record gesture (R)"]',
345+
) as HTMLButtonElement;
346+
expect(recordButton).toBeTruthy();
347+
348+
act(() => {
349+
recordButton.click();
350+
});
351+
352+
expect(onToggleRecording).toHaveBeenCalledTimes(1);
341353

342354
act(() => {
343355
root.unmount();

packages/studio/src/components/editor/DomEditOverlay.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
1313
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
1414
import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
1515
import { GridOverlay } from "./GridOverlay";
16+
import { GestureRecordBadge, type GestureRecordingState } from "./GestureRecordControl";
1617

1718
// Re-exports for external consumers — preserving existing import paths.
1819
export {
@@ -66,6 +67,8 @@ interface DomEditOverlayProps {
6667
onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
6768
gridVisible?: boolean;
6869
gridSpacing?: number;
70+
recordingState?: GestureRecordingState;
71+
onToggleRecording?: () => void;
6972
}
7073

7174
export const DomEditOverlay = memo(function DomEditOverlay({
@@ -87,6 +90,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
8790
onGroupPathOffsetCommit,
8891
onBoxSizeCommit,
8992
onRotationCommit,
93+
recordingState,
94+
onToggleRecording,
9095
}: DomEditOverlayProps) {
9196
const overlayRef = useRef<HTMLDivElement | null>(null);
9297
const boxRef = useRef<HTMLDivElement | null>(null);
@@ -431,6 +436,13 @@ export const DomEditOverlay = memo(function DomEditOverlay({
431436
/>
432437
</div>
433438
)}
439+
{onToggleRecording && (
440+
<GestureRecordBadge
441+
rect={overlayRect}
442+
recordingState={recordingState}
443+
onToggleRecording={onToggleRecording}
444+
/>
445+
)}
434446
<div
435447
key={selectionKey}
436448
ref={boxRef}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
export type GestureRecordingState = "idle" | "recording" | "preview";
2+
3+
interface GestureRecordIconProps {
4+
recording: boolean;
5+
}
6+
7+
function GestureRecordIcon({ recording }: GestureRecordIconProps) {
8+
return (
9+
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
10+
{recording ? (
11+
<rect x="1" y="1" width="8" height="8" rx="1" fill="currentColor" />
12+
) : (
13+
<circle cx="5" cy="5" r="4.5" fill="currentColor" />
14+
)}
15+
</svg>
16+
);
17+
}
18+
19+
interface GestureRecordPanelButtonProps {
20+
recordingState?: GestureRecordingState;
21+
recordingDuration?: number;
22+
onToggleRecording: () => void;
23+
}
24+
25+
export function GestureRecordPanelButton({
26+
recordingState,
27+
recordingDuration,
28+
onToggleRecording,
29+
}: GestureRecordPanelButtonProps) {
30+
const recording = recordingState === "recording";
31+
32+
return (
33+
<div className="px-4 pb-3">
34+
<button
35+
type="button"
36+
onMouseDown={(e) => e.preventDefault()}
37+
onClick={onToggleRecording}
38+
className={`w-full flex items-center justify-center gap-2 rounded-lg py-2 text-[11px] font-medium transition-colors ${
39+
recording
40+
? "bg-red-500/15 text-red-400 border border-red-500/30 animate-pulse"
41+
: "bg-panel-input text-panel-text-2 hover:bg-panel-hover border border-panel-border"
42+
}`}
43+
>
44+
<GestureRecordIcon recording={recording} />
45+
{recording
46+
? `Stop recording ${(recordingDuration ?? 0).toFixed(1)}s — press R`
47+
: "Record gesture (R) — move pointer to capture motion"}
48+
</button>
49+
</div>
50+
);
51+
}
52+
53+
interface GestureRecordBadgeProps {
54+
rect: { left: number; top: number; width: number; height: number };
55+
recordingState?: GestureRecordingState;
56+
onToggleRecording: () => void;
57+
}
58+
59+
export function GestureRecordBadge({
60+
rect,
61+
recordingState,
62+
onToggleRecording,
63+
}: GestureRecordBadgeProps) {
64+
const recording = recordingState === "recording";
65+
const label = recording ? "Stop gesture recording (R)" : "Record gesture (R)";
66+
67+
return (
68+
<button
69+
type="button"
70+
aria-label={label}
71+
title={label}
72+
className={`pointer-events-auto absolute z-20 flex h-7 w-7 items-center justify-center rounded-full border shadow-lg transition-colors ${
73+
recording
74+
? "border-red-400/60 bg-red-500 text-white animate-pulse"
75+
: "border-studio-accent/60 bg-neutral-950 text-studio-accent hover:bg-neutral-900"
76+
}`}
77+
style={{
78+
left: Math.max(0, rect.left + rect.width + 8),
79+
top: Math.max(0, rect.top - 4),
80+
}}
81+
onPointerDown={(event) => {
82+
event.preventDefault();
83+
event.stopPropagation();
84+
}}
85+
onMouseDown={(event) => {
86+
event.preventDefault();
87+
event.stopPropagation();
88+
}}
89+
onClick={(event) => {
90+
event.preventDefault();
91+
event.stopPropagation();
92+
onToggleRecording();
93+
}}
94+
>
95+
<GestureRecordIcon recording={recording} />
96+
</button>
97+
);
98+
}

packages/studio/src/components/editor/PropertyPanel.tsx

Lines changed: 9 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEdi
2121
import { usePlayerStore, liveTime } from "../../player";
2222
import { TimingSection } from "./propertyPanelTimingSection";
2323
import { type PropertyPanelProps } from "./propertyPanelHelpers";
24+
import { GestureRecordPanelButton } from "./GestureRecordControl";
2425

2526
// Re-export helpers that external consumers import from this module
2627
export {
@@ -354,6 +355,14 @@ export const PropertyPanel = memo(function PropertyPanel({
354355
</div>
355356
</div>
356357
<div className="flex-1 overflow-y-auto">
358+
{onToggleRecording && (
359+
<GestureRecordPanelButton
360+
recordingState={recordingState}
361+
recordingDuration={recordingDuration}
362+
onToggleRecording={onToggleRecording}
363+
/>
364+
)}
365+
357366
<TextSection
358367
element={element}
359368
styles={styles}
@@ -558,31 +567,6 @@ export const PropertyPanel = memo(function PropertyPanel({
558567
/>
559568
)}
560569

561-
{onToggleRecording && (
562-
<div className="px-4 pb-3">
563-
<button
564-
type="button"
565-
onMouseDown={(e) => e.preventDefault()}
566-
onClick={onToggleRecording}
567-
className={`w-full flex items-center justify-center gap-2 rounded-lg py-2 text-[11px] font-medium transition-colors ${
568-
recordingState === "recording"
569-
? "bg-red-500/15 text-red-400 border border-red-500/30 animate-pulse"
570-
: "bg-panel-input text-panel-text-2 hover:bg-panel-hover border border-panel-border"
571-
}`}
572-
>
573-
<svg width="10" height="10" viewBox="0 0 10 10">
574-
{recordingState === "recording" ? (
575-
<rect x="1" y="1" width="8" height="8" rx="1" fill="currentColor" />
576-
) : (
577-
<circle cx="5" cy="5" r="4.5" fill="currentColor" />
578-
)}
579-
</svg>
580-
{recordingState === "recording"
581-
? `Stop recording ${(recordingDuration ?? 0).toFixed(1)}s — press R`
582-
: "Record gesture (R) — move pointer to capture motion"}
583-
</button>
584-
</div>
585-
)}
586570
{showEditableSections && (
587571
<StyleSections
588572
projectId={projectId}

0 commit comments

Comments
 (0)