fix(studio): surface gesture recording controls (#1390)

This commit is contained in:
Miguel Ángel
2026-06-12 23:16:41 -04:00
committed by GitHub
parent 953bab319b
commit 5b6c62e151
8 changed files with 152 additions and 33 deletions
+2
View File
@@ -515,6 +515,8 @@ export function StudioApp() {
setCompositionLoading={setCompositionLoading}
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
isGestureRecording={gestureState === "recording"}
recordingState={gestureState}
onToggleRecording={STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined}
blockPreview={blockPreview}
gestureOverlay={
gestureState === "recording" && previewIframe ? (
@@ -17,6 +17,7 @@ import { useStudioContext } from "../contexts/StudioContext";
import { useDomEditContext } from "../contexts/DomEditContext";
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
import type { GestureRecordingState } from "./editor/GestureRecordControl";
export interface StudioPreviewAreaProps {
timelineToolbar: ReactNode;
@@ -59,6 +60,8 @@ export interface StudioPreviewAreaProps {
shouldShowSelectedDomBounds: boolean;
blockPreview?: BlockPreviewInfo | null;
isGestureRecording?: boolean;
recordingState?: GestureRecordingState;
onToggleRecording?: () => void;
gestureOverlay?: ReactNode;
}
@@ -81,6 +84,8 @@ export function StudioPreviewArea({
setCompositionLoading,
shouldShowSelectedDomBounds,
isGestureRecording,
recordingState,
onToggleRecording,
blockPreview,
gestureOverlay,
}: StudioPreviewAreaProps) {
@@ -290,6 +295,8 @@ export function StudioPreviewArea({
onRotationCommit={handleDomRotationCommit}
gridVisible={snapPrefs.gridVisible}
gridSpacing={snapPrefs.gridSpacing}
recordingState={recordingState}
onToggleRecording={onToggleRecording}
/>
<SnapToolbar onSnapChange={setSnapPrefs} />
{gestureOverlay}
@@ -282,6 +282,7 @@ describe("DomEditOverlay", () => {
};
let currentSelection: DomEditSelection | null = selection;
const onToggleRecording = vi.fn();
const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
const originalPointerCapture = HTMLDivElement.prototype.setPointerCapture;
HTMLDivElement.prototype.setPointerCapture = () => {};
@@ -290,15 +291,16 @@ describe("DomEditOverlay", () => {
const [selected, setSelected] = React.useState<DomEditSelection | null>(selection);
currentSelection = selected;
return React.createElement(
DomEditOverlay,
createOverlayProps({
return React.createElement(DomEditOverlay, {
...createOverlayProps({
iframeRef,
selection: selected,
hoverSelection: null,
onSelectionChange: (next: DomEditSelection) => setSelected(next),
}),
);
recordingState: "idle",
onToggleRecording,
});
}
act(() => {
@@ -338,6 +340,16 @@ describe("DomEditOverlay", () => {
"drag",
expect.objectContaining({ button: 0 }),
);
const recordButton = host.querySelector(
'[aria-label="Record gesture (R)"]',
) as HTMLButtonElement;
expect(recordButton).toBeTruthy();
act(() => {
recordButton.click();
});
expect(onToggleRecording).toHaveBeenCalledTimes(1);
act(() => {
root.unmount();
@@ -13,6 +13,7 @@ import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
import { GridOverlay } from "./GridOverlay";
import { GestureRecordBadge, type GestureRecordingState } from "./GestureRecordControl";
// Re-exports for external consumers — preserving existing import paths.
export {
@@ -66,6 +67,8 @@ interface DomEditOverlayProps {
onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
gridVisible?: boolean;
gridSpacing?: number;
recordingState?: GestureRecordingState;
onToggleRecording?: () => void;
}
export const DomEditOverlay = memo(function DomEditOverlay({
@@ -87,6 +90,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
onGroupPathOffsetCommit,
onBoxSizeCommit,
onRotationCommit,
recordingState,
onToggleRecording,
}: DomEditOverlayProps) {
const overlayRef = useRef<HTMLDivElement | null>(null);
const boxRef = useRef<HTMLDivElement | null>(null);
@@ -431,6 +436,13 @@ export const DomEditOverlay = memo(function DomEditOverlay({
/>
</div>
)}
{onToggleRecording && (
<GestureRecordBadge
rect={overlayRect}
recordingState={recordingState}
onToggleRecording={onToggleRecording}
/>
)}
<div
key={selectionKey}
ref={boxRef}
@@ -0,0 +1,98 @@
export type GestureRecordingState = "idle" | "recording" | "preview";
interface GestureRecordIconProps {
recording: boolean;
}
function GestureRecordIcon({ recording }: GestureRecordIconProps) {
return (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
{recording ? (
<rect x="1" y="1" width="8" height="8" rx="1" fill="currentColor" />
) : (
<circle cx="5" cy="5" r="4.5" fill="currentColor" />
)}
</svg>
);
}
interface GestureRecordPanelButtonProps {
recordingState?: GestureRecordingState;
recordingDuration?: number;
onToggleRecording: () => void;
}
export function GestureRecordPanelButton({
recordingState,
recordingDuration,
onToggleRecording,
}: GestureRecordPanelButtonProps) {
const recording = recordingState === "recording";
return (
<div className="px-4 pb-3">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={onToggleRecording}
className={`w-full flex items-center justify-center gap-2 rounded-lg py-2 text-[11px] font-medium transition-colors ${
recording
? "bg-red-500/15 text-red-400 border border-red-500/30 animate-pulse"
: "bg-panel-input text-panel-text-2 hover:bg-panel-hover border border-panel-border"
}`}
>
<GestureRecordIcon recording={recording} />
{recording
? `Stop recording ${(recordingDuration ?? 0).toFixed(1)}s — press R`
: "Record gesture (R) — move pointer to capture motion"}
</button>
</div>
);
}
interface GestureRecordBadgeProps {
rect: { left: number; top: number; width: number; height: number };
recordingState?: GestureRecordingState;
onToggleRecording: () => void;
}
export function GestureRecordBadge({
rect,
recordingState,
onToggleRecording,
}: GestureRecordBadgeProps) {
const recording = recordingState === "recording";
const label = recording ? "Stop gesture recording (R)" : "Record gesture (R)";
return (
<button
type="button"
aria-label={label}
title={label}
className={`pointer-events-auto absolute z-20 flex h-7 w-7 items-center justify-center rounded-full border shadow-lg transition-colors ${
recording
? "border-red-400/60 bg-red-500 text-white animate-pulse"
: "border-studio-accent/60 bg-neutral-950 text-studio-accent hover:bg-neutral-900"
}`}
style={{
left: Math.max(0, rect.left + rect.width + 8),
top: Math.max(0, rect.top - 4),
}}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onToggleRecording();
}}
>
<GestureRecordIcon recording={recording} />
</button>
);
}
@@ -21,6 +21,7 @@ import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEdi
import { usePlayerStore, liveTime } from "../../player";
import { TimingSection } from "./propertyPanelTimingSection";
import { type PropertyPanelProps } from "./propertyPanelHelpers";
import { GestureRecordPanelButton } from "./GestureRecordControl";
// Re-export helpers that external consumers import from this module
export {
@@ -354,6 +355,14 @@ export const PropertyPanel = memo(function PropertyPanel({
</div>
</div>
<div className="flex-1 overflow-y-auto">
{onToggleRecording && (
<GestureRecordPanelButton
recordingState={recordingState}
recordingDuration={recordingDuration}
onToggleRecording={onToggleRecording}
/>
)}
<TextSection
element={element}
styles={styles}
@@ -558,31 +567,6 @@ export const PropertyPanel = memo(function PropertyPanel({
/>
)}
{onToggleRecording && (
<div className="px-4 pb-3">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={onToggleRecording}
className={`w-full flex items-center justify-center gap-2 rounded-lg py-2 text-[11px] font-medium transition-colors ${
recordingState === "recording"
? "bg-red-500/15 text-red-400 border border-red-500/30 animate-pulse"
: "bg-panel-input text-panel-text-2 hover:bg-panel-hover border border-panel-border"
}`}
>
<svg width="10" height="10" viewBox="0 0 10 10">
{recordingState === "recording" ? (
<rect x="1" y="1" width="8" height="8" rx="1" fill="currentColor" />
) : (
<circle cx="5" cy="5" r="4.5" fill="currentColor" />
)}
</svg>
{recordingState === "recording"
? `Stop recording ${(recordingDuration ?? 0).toFixed(1)}s — press R`
: "Record gesture (R) — move pointer to capture motion"}
</button>
</div>
)}
{showEditableSections && (
<StyleSections
projectId={projectId}