From 6f0c25c88285948a39b0b792615d424fe3fc700f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 12 Jun 2026 15:52:33 -0400 Subject: [PATCH] fix(studio): surface gesture recording controls --- .../src/studio-api/helpers/safePath.test.ts | 4 +- .../core/src/studio-api/helpers/safePath.ts | 8 +- packages/studio/src/App.tsx | 2 + .../src/components/StudioPreviewArea.tsx | 7 ++ .../components/editor/DomEditOverlay.test.ts | 20 +++- .../src/components/editor/DomEditOverlay.tsx | 12 +++ .../editor/GestureRecordControl.tsx | 98 +++++++++++++++++++ .../src/components/editor/PropertyPanel.tsx | 34 ++----- 8 files changed, 152 insertions(+), 33 deletions(-) create mode 100644 packages/studio/src/components/editor/GestureRecordControl.tsx diff --git a/packages/core/src/studio-api/helpers/safePath.test.ts b/packages/core/src/studio-api/helpers/safePath.test.ts index fbfce353f..9cefec8f3 100644 --- a/packages/core/src/studio-api/helpers/safePath.test.ts +++ b/packages/core/src/studio-api/helpers/safePath.test.ts @@ -19,7 +19,7 @@ function createProjectDir(): string { } describe("walkDir", () => { - it("hides internal HyperFrames files from project listings", () => { + it("hides internal HyperFrames backup files from project listings", () => { const projectDir = createProjectDir(); mkdirSync(join(projectDir, ".hyperframes", "backup"), { recursive: true }); mkdirSync(join(projectDir, ".hyperframes", "examples"), { recursive: true }); @@ -32,8 +32,8 @@ describe("walkDir", () => { const files = walkDir(projectDir); expect(files).toContain(".cache/examples/preset.html"); + expect(files).toContain(".hyperframes/examples/preset.html"); expect(files).toContain("compositions/scene.html"); expect(files).not.toContain(".hyperframes/backup/snapshot.html"); - expect(files).not.toContain(".hyperframes/examples/preset.html"); }); }); diff --git a/packages/core/src/studio-api/helpers/safePath.ts b/packages/core/src/studio-api/helpers/safePath.ts index 7626c3b66..25edab21f 100644 --- a/packages/core/src/studio-api/helpers/safePath.ts +++ b/packages/core/src/studio-api/helpers/safePath.ts @@ -7,7 +7,11 @@ export function isSafePath(base: string, resolved: string): boolean { return resolved.startsWith(norm) || resolved === resolve(base); } -const IGNORE_DIRS = new Set([".thumbnails", ".hyperframes", "node_modules", ".git"]); +const IGNORE_DIRS = new Set([".thumbnails", "node_modules", ".git"]); + +function shouldIgnoreDir(rel: string): boolean { + return rel === ".hyperframes/backup"; +} /** * True when any directory segment of a relative path is a dot-directory or @@ -26,7 +30,7 @@ export function walkDir(dir: string, prefix = ""): string[] { const files: string[] = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { const rel = prefix ? `${prefix}/${entry.name}` : entry.name; - if (IGNORE_DIRS.has(entry.name)) continue; + if (IGNORE_DIRS.has(entry.name) || shouldIgnoreDir(rel)) continue; if (entry.isDirectory()) { files.push(...walkDir(join(dir, entry.name), rel)); } else { diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index c5fff0402..075b4e7c7 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -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 ? ( diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index 68978c10b..757137338 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -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} /> {gestureOverlay} diff --git a/packages/studio/src/components/editor/DomEditOverlay.test.ts b/packages/studio/src/components/editor/DomEditOverlay.test.ts index 44f30ba6c..7fb292913 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.test.ts +++ b/packages/studio/src/components/editor/DomEditOverlay.test.ts @@ -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(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(); diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index 9794b3109..6a844e32b 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -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; 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(null); const boxRef = useRef(null); @@ -431,6 +436,13 @@ export const DomEditOverlay = memo(function DomEditOverlay({ /> )} + {onToggleRecording && ( + + )}
+ {onToggleRecording && ( + + )} + )} - {onToggleRecording && ( -
- -
- )} {showEditableSections && (