mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
fix(studio): surface gesture recording controls (#1390)
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user