Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/core/src/studio-api/helpers/safePath.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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");
});
});
8 changes: 6 additions & 2 deletions packages/core/src/studio-api/helpers/safePath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? (
Expand Down
7 changes: 7 additions & 0 deletions packages/studio/src/components/StudioPreviewArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -59,6 +60,8 @@ export interface StudioPreviewAreaProps {
shouldShowSelectedDomBounds: boolean;
blockPreview?: BlockPreviewInfo | null;
isGestureRecording?: boolean;
recordingState?: GestureRecordingState;
onToggleRecording?: () => void;
gestureOverlay?: ReactNode;
}

Expand All @@ -81,6 +84,8 @@ export function StudioPreviewArea({
setCompositionLoading,
shouldShowSelectedDomBounds,
isGestureRecording,
recordingState,
onToggleRecording,
blockPreview,
gestureOverlay,
}: StudioPreviewAreaProps) {
Expand Down Expand Up @@ -290,6 +295,8 @@ export function StudioPreviewArea({
onRotationCommit={handleDomRotationCommit}
gridVisible={snapPrefs.gridVisible}
gridSpacing={snapPrefs.gridSpacing}
recordingState={recordingState}
onToggleRecording={onToggleRecording}
/>
<SnapToolbar onSnapChange={setSnapPrefs} />
{gestureOverlay}
Expand Down
20 changes: 16 additions & 4 deletions packages/studio/src/components/editor/DomEditOverlay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {};
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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();
Expand Down
12 changes: 12 additions & 0 deletions packages/studio/src/components/editor/DomEditOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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({
Expand All @@ -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);
Expand Down Expand Up @@ -431,6 +436,13 @@ export const DomEditOverlay = memo(function DomEditOverlay({
/>
</div>
)}
{onToggleRecording && (
<GestureRecordBadge
rect={overlayRect}
recordingState={recordingState}
onToggleRecording={onToggleRecording}
/>
)}
<div
key={selectionKey}
ref={boxRef}
Expand Down
98 changes: 98 additions & 0 deletions packages/studio/src/components/editor/GestureRecordControl.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
34 changes: 9 additions & 25 deletions packages/studio/src/components/editor/PropertyPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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}
Expand Down
Loading