refactor(studio): split oversized files to pass 600-line check (#1313)

This commit is contained in:
Miguel Ángel
2026-06-09 19:53:14 -04:00
committed by GitHub
parent d13ae13670
commit 81416ab3c9
17 changed files with 1380 additions and 1055 deletions
+21 -190
View File
@@ -10,10 +10,9 @@ import { usePanelLayout } from "./hooks/usePanelLayout";
import { useFileManager } from "./hooks/useFileManager";
import { usePreviewPersistence } from "./hooks/usePreviewPersistence";
import { useTimelineEditing } from "./hooks/useTimelineEditing";
import { addBlockToProject } from "./utils/blockInstaller";
import type { BlockParam } from "@hyperframes/core/registry";
import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
import { useDomEditSession } from "./hooks/useDomEditSession";
import { useBlockHandlers } from "./hooks/useBlockHandlers";
import { useAppHotkeys } from "./hooks/useAppHotkeys";
import { useClipboard } from "./hooks/useClipboard";
import { readStudioUiPreferences, writeStudioUiPreferences } from "./utils/studioUiPreferences";
@@ -35,8 +34,7 @@ import type { DomEditSelection } from "./components/editor/domEditing";
import { AskAgentModal } from "./components/AskAgentModal";
import { StudioGlobalDragOverlay } from "./components/StudioGlobalDragOverlay";
import { StudioHeader } from "./components/StudioHeader";
import { useGestureRecording } from "./hooks/useGestureRecording";
import { simplifyGestureSamples } from "./utils/rdpSimplify";
import { useGestureCommit } from "./hooks/useGestureCommit";
import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay";
import { StudioLeftSidebar } from "./components/StudioLeftSidebar";
@@ -82,12 +80,6 @@ export function StudioApp() {
const [compositionLoading, setCompositionLoading] = useState(true);
const [refreshKey, setRefreshKey] = useState(0);
const [, setPreviewDocumentVersion] = useState(0);
const [activeBlockParams, setActiveBlockParams] = useState<{
blockName: string;
blockTitle: string;
params: BlockParam[];
compositionPath: string;
} | null>(null);
const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
@@ -192,8 +184,15 @@ export function StudioApp() {
isRecordingRef: isGestureRecordingRef,
});
const blockCtx = useMemo(
() => ({
const {
activeBlockParams,
setActiveBlockParams,
handleAddBlock,
handleTimelineBlockDrop,
handlePreviewBlockDrop,
} = useBlockHandlers({
projectId,
blockCtxDeps: {
activeCompPath,
timelineElements,
readProjectFile: fileManager.readProjectFile,
@@ -202,70 +201,11 @@ export function StudioApp() {
refreshFileTree: fileManager.refreshFileTree,
reloadPreview,
showToast,
}),
[
activeCompPath,
timelineElements,
fileManager,
editHistory.recordEdit,
reloadPreview,
showToast,
],
);
const handleAddBlock = useCallback(
(blockName: string) => {
if (!projectId) return;
void (async () => {
const result = await addBlockToProject({
projectId,
blockName,
...blockCtx,
previewIframe: previewIframeRef.current,
currentTime: usePlayerStore.getState().currentTime,
});
const params = result?.block.type === "hyperframes:block" ? result.block.params : undefined;
if (params?.length) {
setActiveBlockParams({
blockName: result!.block.name,
blockTitle: result!.block.title,
params,
compositionPath: result!.compositionPath,
});
panelLayout.setRightCollapsed(false);
panelLayout.setRightPanelTab("block-params");
}
})();
},
[projectId, blockCtx, panelLayout],
);
const handleTimelineBlockDrop = useCallback(
(blockName: string, placement: { start: number; track: number }) => {
if (!projectId) return;
void addBlockToProject({
projectId,
blockName,
placement,
...blockCtx,
previewIframe: previewIframeRef.current,
currentTime: usePlayerStore.getState().currentTime,
});
},
[projectId, blockCtx],
);
const handlePreviewBlockDrop = useCallback(
(blockName: string, position: { left: number; top: number }) => {
if (!projectId) return;
void addBlockToProject({
projectId,
blockName,
visualPosition: position,
...blockCtx,
previewIframe: previewIframeRef.current,
currentTime: usePlayerStore.getState().currentTime,
});
},
[projectId, blockCtx],
);
previewIframeRef,
setRightCollapsed: panelLayout.setRightCollapsed,
setRightPanelTab: panelLayout.setRightPanelTab,
});
const clearDomSelectionRef = useRef<() => void>(() => {});
const domEditSelectionBridgeRef = useRef<DomEditSelection | null>(null);
@@ -406,125 +346,16 @@ export function StudioApp() {
const dragOverlay = useDragOverlay(fileManager.handleImportFiles);
// Gesture recording
const gestureRecording = useGestureRecording();
const [gestureState, setGestureState] = useState<"idle" | "recording">("idle");
// Synchronous mirror of gestureState — immune to React batching.
// Prevents double-R-press within a single render cycle from swallowing the stop.
const gestureStateRef = useRef<"idle" | "recording">("idle");
const recordingAutoStopRef = useRef<ReturnType<typeof setInterval>>(undefined);
const recordingStartTimeRef = useRef(0);
const commitInFlightRef = useRef(false);
const handleToggleRecordingRef = useRef<() => void>(() => {});
const domEditSessionRef = useRef(domEditSession);
domEditSessionRef.current = domEditSession;
// Unmount: clear auto-stop interval
useEffect(() => () => clearInterval(recordingAutoStopRef.current), []);
// fallow-ignore-next-line complexity
const stopAndCommitRecording = useCallback(async () => {
clearInterval(recordingAutoStopRef.current);
if (commitInFlightRef.current) return;
commitInFlightRef.current = true;
gestureStateRef.current = "idle";
isGestureRecordingRef.current = false;
const frozenSamples = gestureRecording.stopRecording();
const store = usePlayerStore.getState();
store.setIsPlaying(false);
try {
const liveSession = domEditSessionRef.current;
const sel = liveSession.domEditSelection;
if (!sel) {
if (frozenSamples.length > 2) {
showToast("Selection lost during recording", "error");
}
return;
}
const duration = frozenSamples.length > 0 ? frozenSamples[frozenSamples.length - 1]!.time : 0;
if (frozenSamples.length <= 2) {
showToast("No gesture detected — move the pointer while recording", "error");
return;
}
if (duration <= 0) {
showToast("Recording too short — try again", "error");
return;
}
const simplified = simplifyGestureSamples(frozenSamples, duration, 5);
const sortedPcts = Array.from(simplified.keys()).sort((a, b) => a - b);
// Always create a new tween scoped to the recording range.
// Injecting into an existing tween creates keyframes before the recording
// start (from the convert-to-keyframes step), causing wrong positions.
const selector = sel.id ? `#${sel.id}` : sel.selector;
if (!selector) {
showToast("Cannot save — element has no selector", "error");
return;
}
if (liveSession.commitMutation) {
const recStart = recordingStartTimeRef.current;
const keyframes = sortedPcts.map((pct) => ({
percentage: pct,
properties: simplified.get(pct) as Record<string, number | string>,
}));
await liveSession.commitMutation(
{
type: "add-with-keyframes",
targetSelector: selector,
position: Math.round(recStart * 1000) / 1000,
duration: Math.round(duration * 1000) / 1000,
keyframes,
},
{ label: "Gesture recording", softReload: true },
);
}
showToast(`Recorded ${sortedPcts.length} keyframes`, "info");
} finally {
store.requestSeek(recordingStartTimeRef.current);
gestureRecording.clearSamples();
setGestureState("idle");
commitInFlightRef.current = false;
}
}, [gestureRecording, showToast]);
const handleToggleRecording = useCallback(() => {
if (gestureStateRef.current === "recording") {
void stopAndCommitRecording();
return;
}
const sel = domEditSessionRef.current.domEditSelection;
if (!sel) {
showToast("Select an element first", "error");
return;
}
const iframe = previewIframeRef.current;
if (!iframe) {
showToast("Preview not ready — try again", "error");
return;
}
const store = usePlayerStore.getState();
recordingStartTimeRef.current = store.currentTime;
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
const elDur = Number.parseFloat(sel.dataAttributes?.duration ?? "0") || 0;
const elementEnd = elDur > 0 ? elStart + elDur : undefined;
gestureRecording.startRecording(sel.element, iframe, elementEnd);
gestureStateRef.current = "recording";
isGestureRecordingRef.current = true;
setGestureState("recording");
clearInterval(recordingAutoStopRef.current);
const autoStopAt = elementEnd ?? Infinity;
recordingAutoStopRef.current = setInterval(() => {
const { currentTime: t, duration: d } = usePlayerStore.getState();
const limit = Math.min(autoStopAt, d);
if (limit > 0 && t >= limit - 0.05) {
void stopAndCommitRecording();
}
}, 100);
}, [gestureRecording, showToast, stopAndCommitRecording]);
const { gestureState, gestureRecording, handleToggleRecording } = useGestureCommit({
domEditSessionRef,
previewIframeRef,
showToast,
isGestureRecordingRef,
});
handleToggleRecordingRef.current = handleToggleRecording;
const handlePreviewIframeRef = useCallback(
@@ -7,11 +7,14 @@ import {
formatPxMetricValue,
parsePxMetricValue,
RESPONSIVE_GRID,
readGsapRuntimeValuesForPanel,
readGsapBorderRadiusForPanel,
} from "./propertyPanelHelpers";
import { MetricField, Section } from "./propertyPanelPrimitives";
import { isMediaElement, MediaSection } from "./propertyPanelMediaSection";
import { TextSection, StyleSections } from "./propertyPanelSections";
import { GsapAnimationSection } from "./GsapAnimationSection";
import { PropertyPanel3dTransform } from "./propertyPanel3dTransform";
import { KeyframeNavigation } from "./KeyframeNavigation";
import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
import { usePlayerStore } from "../../player";
@@ -188,69 +191,19 @@ export const PropertyPanel = memo(function PropertyPanel({
gsapAnimations?.find((a) => a.keyframes)?.id ?? gsapAnimations?.[0]?.id ?? null;
// Read ALL GSAP-interpolated values at the current seek time.
// Discovers animated properties from the animation's keyframes/tween vars.
const gsapRuntimeValues: Record<string, number> | null = (() => {
if (!gsapAnimId || gsapAnimations.length === 0) return null;
const iframe = previewIframeRef?.current;
if (!iframe?.contentWindow) return null;
const selector = element.id ? `#${element.id}` : element.selector;
if (!selector) return null;
try {
const gsap = (
iframe.contentWindow as unknown as {
gsap?: { getProperty: (el: Element, prop: string) => number | string };
}
).gsap;
if (!gsap?.getProperty) return null;
const el = iframe.contentDocument?.querySelector(selector);
if (!el) return null;
const propKeys = new Set<string>();
for (const anim of gsapAnimations) {
if (anim.keyframes) {
for (const kf of anim.keyframes.keyframes) {
for (const p of Object.keys(kf.properties)) propKeys.add(p);
}
}
for (const p of Object.keys(anim.properties)) propKeys.add(p);
}
const result: Record<string, number> = {};
for (const prop of propKeys) {
const v = Number(gsap.getProperty(el, prop));
if (Number.isFinite(v)) result[prop] = Math.round(v * 100) / 100;
}
return Object.keys(result).length > 0 ? result : null;
} catch {
return null;
}
})();
const gsapRuntimeValues = readGsapRuntimeValuesForPanel(
gsapAnimId,
gsapAnimations,
element,
previewIframeRef ?? { current: null },
);
const gsapBorderRadius: { tl: number; tr: number; br: number; bl: number } | null = (() => {
if (!gsapRuntimeValues || !("borderRadius" in gsapRuntimeValues)) {
const hasBRProp = gsapAnimations.some(
(a) =>
"borderRadius" in a.properties ||
a.keyframes?.keyframes.some((kf) => "borderRadius" in kf.properties),
);
if (!hasBRProp) return null;
}
const iframe = previewIframeRef?.current;
const selector = element.id ? `#${element.id}` : element.selector;
if (!iframe?.contentDocument || !selector) return null;
try {
const el = iframe.contentDocument.querySelector(selector);
if (!el) return null;
const cs = iframe.contentWindow!.getComputedStyle(el);
const parse = (v: string) => Number.parseFloat(v) || 0;
return {
tl: parse(cs.borderTopLeftRadius),
tr: parse(cs.borderTopRightRadius),
br: parse(cs.borderBottomRightRadius),
bl: parse(cs.borderBottomLeftRadius),
};
} catch {
return null;
}
})();
const gsapBorderRadius = readGsapBorderRadiusForPanel(
gsapRuntimeValues,
gsapAnimations,
element,
previewIframeRef ?? { current: null },
);
const displayX = gsapRuntimeValues?.x ?? manualOffset.x;
const displayY = gsapRuntimeValues?.y ?? manualOffset.y;
@@ -510,97 +463,19 @@ export const PropertyPanel = memo(function PropertyPanel({
</div>
</div>
{gsapRuntimeValues && (
<div className="mt-3 border-t border-neutral-800/40 pt-3">
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
3D Transform
</div>
<div className={RESPONSIVE_GRID}>
<div className="flex items-center gap-1">
<div className="flex-1">
<MetricField
label="Z"
value={formatPxMetricValue(gsapRuntimeValues.z ?? 0)}
scrub
onCommit={(next) => {
const v = parsePxMetricValue(next);
if (v != null && onCommitAnimatedProperty) {
void onCommitAnimatedProperty(element, "z", v);
}
}}
/>
</div>
{STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
<KeyframeNavigation
property="z"
keyframes={gsapKeyframes}
currentPercentage={currentPct}
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
onAddKeyframe={() => {
if (onCommitAnimatedProperty) {
void onCommitAnimatedProperty(element, "z", gsapRuntimeValues?.z ?? 0);
}
}}
onRemoveKeyframe={(pct) => gsapAnimId && onRemoveKeyframe?.(gsapAnimId, pct)}
onConvertToKeyframes={() => gsapAnimId && onConvertToKeyframes?.(gsapAnimId)}
/>
)}
</div>
<div className="flex items-center gap-1">
<div className="flex-1">
<MetricField
label="Scale"
value={String(gsapRuntimeValues.scale ?? 1)}
scrub
onCommit={(next) => {
const v = Number.parseFloat(next);
if (Number.isFinite(v) && onCommitAnimatedProperty) {
void onCommitAnimatedProperty(element, "scale", v);
}
}}
/>
</div>
{STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
<KeyframeNavigation
property="scale"
keyframes={gsapKeyframes}
currentPercentage={currentPct}
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
onAddKeyframe={() => {
if (onCommitAnimatedProperty) {
void onCommitAnimatedProperty(
element,
"scale",
gsapRuntimeValues?.scale ?? 1,
);
}
}}
onRemoveKeyframe={(pct) => gsapAnimId && onRemoveKeyframe?.(gsapAnimId, pct)}
onConvertToKeyframes={() => gsapAnimId && onConvertToKeyframes?.(gsapAnimId)}
/>
)}
</div>
<MetricField
label="RotX"
value={`${gsapRuntimeValues.rotationX ?? 0}°`}
onCommit={(next) => {
const v = Number.parseFloat(next.replace("°", ""));
if (Number.isFinite(v) && onCommitAnimatedProperty) {
void onCommitAnimatedProperty(element, "rotationX", v);
}
}}
/>
<MetricField
label="RotY"
value={`${gsapRuntimeValues.rotationY ?? 0}°`}
onCommit={(next) => {
const v = Number.parseFloat(next.replace("°", ""));
if (Number.isFinite(v) && onCommitAnimatedProperty) {
void onCommitAnimatedProperty(element, "rotationY", v);
}
}}
/>
</div>
</div>
<PropertyPanel3dTransform
gsapRuntimeValues={gsapRuntimeValues}
gsapAnimId={gsapAnimId}
gsapKeyframes={gsapKeyframes}
currentPct={currentPct}
elStart={elStart}
elDuration={elDuration}
element={element}
onCommitAnimatedProperty={onCommitAnimatedProperty}
onSeekToTime={onSeekToTime}
onRemoveKeyframe={onRemoveKeyframe}
onConvertToKeyframes={onConvertToKeyframes}
/>
)}
<div className="mt-3">
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
@@ -0,0 +1,52 @@
/**
* Checks whether GSAP actively animates one or more CSS/GSAP properties on
* the given element by inspecting all registered `__timelines`.
*/
export function gsapAnimatesProperty(el: HTMLElement, ...props: string[]): boolean {
const win = el.ownerDocument.defaultView as
| (Window & {
__timelines?: Record<
string,
{
getChildren?: (
deep: boolean,
) => Array<{ targets?: () => Element[]; vars?: Record<string, unknown> }>;
}
>;
})
| null;
if (!win?.__timelines) return false;
const propSet = new Set(props);
for (const tl of Object.values(win.__timelines)) {
if (!tl?.getChildren) continue;
try {
for (const child of tl.getChildren(true)) {
if (!child.targets || !child.vars) continue;
let targetsEl = false;
for (const t of child.targets()) {
if (t === el || (el.id && t.id === el.id)) {
targetsEl = true;
break;
}
}
if (!targetsEl) continue;
const vars = child.vars;
for (const p of propSet) {
if (p in vars) return true;
}
if (vars.keyframes && typeof vars.keyframes === "object") {
for (const kfVal of Object.values(vars.keyframes as Record<string, unknown>)) {
if (kfVal && typeof kfVal === "object") {
for (const p of propSet) {
if (p in (kfVal as Record<string, unknown>)) return true;
}
}
}
}
}
} catch {
/* */
}
}
return false;
}
@@ -32,6 +32,7 @@ import {
} from "./manualEditsTypes";
import { roundRotationAngle } from "./manualEditsParsing";
import { applyStudioMotionFromDom } from "./studioMotion";
import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
/* ── Gesture tracking ─────────────────────────────────────────────── */
let studioManualEditGestureId = 0;
@@ -534,55 +535,6 @@ function reapplyPathOffsets(doc: Document): void {
}
}
function gsapAnimatesProperty(el: HTMLElement, ...props: string[]): boolean {
const win = el.ownerDocument.defaultView as
| (Window & {
__timelines?: Record<
string,
{
getChildren?: (
deep: boolean,
) => Array<{ targets?: () => Element[]; vars?: Record<string, unknown> }>;
}
>;
})
| null;
if (!win?.__timelines) return false;
const propSet = new Set(props);
for (const tl of Object.values(win.__timelines)) {
if (!tl?.getChildren) continue;
try {
for (const child of tl.getChildren(true)) {
if (!child.targets || !child.vars) continue;
let targetsEl = false;
for (const t of child.targets()) {
if (t === el || (el.id && t.id === el.id)) {
targetsEl = true;
break;
}
}
if (!targetsEl) continue;
const vars = child.vars;
for (const p of propSet) {
if (p in vars) return true;
}
if (vars.keyframes && typeof vars.keyframes === "object") {
for (const kfVal of Object.values(vars.keyframes as Record<string, unknown>)) {
if (kfVal && typeof kfVal === "object") {
for (const p of propSet) {
if (p in (kfVal as Record<string, unknown>)) return true;
}
}
}
}
}
} catch {
/* */
}
}
return false;
}
function reapplyBoxSizes(doc: Document): void {
for (const el of queryStudioElements(doc, STUDIO_BOX_SIZE_ATTR)) {
if (gsapAnimatesProperty(el, "width", "height")) continue;
@@ -0,0 +1,133 @@
import type { DomEditSelection } from "./domEditingTypes";
import { STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
import { MetricField } from "./propertyPanelPrimitives";
import { KeyframeNavigation } from "./KeyframeNavigation";
import { formatPxMetricValue, parsePxMetricValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
type KeyframeEntry = Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}> | null;
interface PropertyPanel3dTransformProps {
gsapRuntimeValues: Record<string, number>;
gsapAnimId: string | null;
gsapKeyframes: KeyframeEntry;
currentPct: number;
elStart: number;
elDuration: number;
element: DomEditSelection;
onCommitAnimatedProperty?: (
element: DomEditSelection,
property: string,
value: number,
) => Promise<void>;
onSeekToTime?: (time: number) => void;
onRemoveKeyframe?: (animId: string, pct: number) => void;
onConvertToKeyframes?: (animId: string) => void;
}
export function PropertyPanel3dTransform({
gsapRuntimeValues,
gsapAnimId,
gsapKeyframes,
currentPct,
elStart,
elDuration,
element,
onCommitAnimatedProperty,
onSeekToTime,
onRemoveKeyframe,
onConvertToKeyframes,
}: PropertyPanel3dTransformProps) {
return (
<div className="mt-3 border-t border-neutral-800/40 pt-3">
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
3D Transform
</div>
<div className={RESPONSIVE_GRID}>
<div className="flex items-center gap-1">
<div className="flex-1">
<MetricField
label="Z"
value={formatPxMetricValue(gsapRuntimeValues.z ?? 0)}
scrub
onCommit={(next) => {
const v = parsePxMetricValue(next);
if (v != null && onCommitAnimatedProperty) {
void onCommitAnimatedProperty(element, "z", v);
}
}}
/>
</div>
{STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
<KeyframeNavigation
property="z"
keyframes={gsapKeyframes}
currentPercentage={currentPct}
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
onAddKeyframe={() => {
if (onCommitAnimatedProperty) {
void onCommitAnimatedProperty(element, "z", gsapRuntimeValues?.z ?? 0);
}
}}
onRemoveKeyframe={(pct) => gsapAnimId && onRemoveKeyframe?.(gsapAnimId, pct)}
onConvertToKeyframes={() => gsapAnimId && onConvertToKeyframes?.(gsapAnimId)}
/>
)}
</div>
<div className="flex items-center gap-1">
<div className="flex-1">
<MetricField
label="Scale"
value={String(gsapRuntimeValues.scale ?? 1)}
scrub
onCommit={(next) => {
const v = Number.parseFloat(next);
if (Number.isFinite(v) && onCommitAnimatedProperty) {
void onCommitAnimatedProperty(element, "scale", v);
}
}}
/>
</div>
{STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
<KeyframeNavigation
property="scale"
keyframes={gsapKeyframes}
currentPercentage={currentPct}
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
onAddKeyframe={() => {
if (onCommitAnimatedProperty) {
void onCommitAnimatedProperty(element, "scale", gsapRuntimeValues?.scale ?? 1);
}
}}
onRemoveKeyframe={(pct) => gsapAnimId && onRemoveKeyframe?.(gsapAnimId, pct)}
onConvertToKeyframes={() => gsapAnimId && onConvertToKeyframes?.(gsapAnimId)}
/>
)}
</div>
<MetricField
label="RotX"
value={`${gsapRuntimeValues.rotationX ?? 0}°`}
onCommit={(next) => {
const v = Number.parseFloat(next.replace("°", ""));
if (Number.isFinite(v) && onCommitAnimatedProperty) {
void onCommitAnimatedProperty(element, "rotationX", v);
}
}}
/>
<MetricField
label="RotY"
value={`${gsapRuntimeValues.rotationY ?? 0}°`}
onCommit={(next) => {
const v = Number.parseFloat(next.replace("°", ""));
if (Number.isFinite(v) && onCommitAnimatedProperty) {
void onCommitAnimatedProperty(element, "rotationY", v);
}
}}
/>
</div>
</div>
);
}
@@ -2,6 +2,7 @@ import { parseCssColor, type ParsedColor } from "./colorValue";
import { COMMON_LOCAL_FONT_FAMILIES } from "./fontCatalog";
import type { DomEditSelection } from "./domEditing";
import type { ImportedFontAsset } from "./fontAssets";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
export interface PropertyPanelProps {
projectId: string;
@@ -505,3 +506,78 @@ export function computeFitToChildrenSize(
const height = Math.round((maxY - minY) * scaleY);
return width > 0 && height > 0 ? { width, height } : null;
}
// ── GSAP runtime value readers (used by PropertyPanel) ────────────────────
export function readGsapRuntimeValuesForPanel(
gsapAnimId: string | null,
gsapAnimations: GsapAnimation[],
element: DomEditSelection,
previewIframeRef: React.RefObject<HTMLIFrameElement | null>,
): Record<string, number> | null {
if (!gsapAnimId || gsapAnimations.length === 0) return null;
const iframe = previewIframeRef?.current;
if (!iframe?.contentWindow) return null;
const selector = element.id ? `#${element.id}` : element.selector;
if (!selector) return null;
try {
const gsap = (
iframe.contentWindow as unknown as {
gsap?: { getProperty: (el: Element, prop: string) => number | string };
}
).gsap;
if (!gsap?.getProperty) return null;
const el = iframe.contentDocument?.querySelector(selector);
if (!el) return null;
const propKeys = new Set<string>();
for (const anim of gsapAnimations) {
if (anim.keyframes) {
for (const kf of anim.keyframes.keyframes) {
for (const p of Object.keys(kf.properties)) propKeys.add(p);
}
}
for (const p of Object.keys(anim.properties)) propKeys.add(p);
}
const result: Record<string, number> = {};
for (const prop of propKeys) {
const v = Number(gsap.getProperty(el, prop));
if (Number.isFinite(v)) result[prop] = Math.round(v * 100) / 100;
}
return Object.keys(result).length > 0 ? result : null;
} catch {
return null;
}
}
export function readGsapBorderRadiusForPanel(
gsapRuntimeValues: Record<string, number> | null,
gsapAnimations: GsapAnimation[],
element: DomEditSelection,
previewIframeRef: React.RefObject<HTMLIFrameElement | null>,
): { tl: number; tr: number; br: number; bl: number } | null {
if (!gsapRuntimeValues || !("borderRadius" in gsapRuntimeValues)) {
const hasBRProp = gsapAnimations.some(
(a) =>
"borderRadius" in a.properties ||
a.keyframes?.keyframes.some((kf) => "borderRadius" in kf.properties),
);
if (!hasBRProp) return null;
}
const iframe = previewIframeRef?.current;
const selector = element.id ? `#${element.id}` : element.selector;
if (!iframe?.contentDocument || !selector) return null;
try {
const el = iframe.contentDocument.querySelector(selector);
if (!el) return null;
const cs = iframe.contentWindow!.getComputedStyle(el);
const parse = (v: string) => Number.parseFloat(v) || 0;
return {
tl: parse(cs.borderTopLeftRadius),
tr: parse(cs.borderTopRightRadius),
br: parse(cs.borderBottomRightRadius),
bl: parse(cs.borderBottomLeftRadius),
};
} catch {
return null;
}
}
+295
View File
@@ -0,0 +1,295 @@
/**
* Low-level drag commit helpers for GSAP position mutations.
* Extracted from gsapRuntimeBridge.ts to keep file sizes under the 600-line limit.
*/
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
import {
absoluteToPercentage,
resolveTweenStart,
resolveTweenDuration,
} from "../utils/globalTimeCompiler";
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
export interface GsapDragCommitCallbacks {
commitMutation: (
selection: DomEditSelection,
mutation: Record<string, unknown>,
options: {
label: string;
coalesceKey?: string;
softReload?: boolean;
skipReload?: boolean;
beforeReload?: () => void;
},
) => Promise<void>;
}
// ── Percentage computation ─────────────────────────────────────────────────
export function computeCurrentPercentage(
selection: DomEditSelection,
animation?: GsapAnimation,
): number {
const currentTime = usePlayerStore.getState().currentTime;
if (animation) {
const start = resolveTweenStart(animation);
const duration = resolveTweenDuration(animation);
if (start !== null) {
return absoluteToPercentage(currentTime, start, duration);
}
}
const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1;
return elDuration > 0
? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
: 0;
}
// ── Dynamic keyframe materialization ──────────────────────────────────────
export async function materializeIfDynamic(
anim: GsapAnimation,
iframe: HTMLIFrameElement | null,
commitMutation: GsapDragCommitCallbacks["commitMutation"],
selection: DomEditSelection,
): Promise<string | void> {
if (!anim.hasUnresolvedKeyframes && !anim.hasUnresolvedSelector) return;
if (anim.hasUnresolvedSelector) {
const allScanned = scanAllRuntimeKeyframes(iframe);
if (allScanned.size === 0) return;
const allElements = Array.from(allScanned.entries()).map(([id, data]) => ({
selector: `#${id}`,
keyframes: data.keyframes,
easeEach: data.easeEach,
}));
await commitMutation(
selection,
{
type: "materialize-keyframes",
animationId: anim.id,
keyframes: allScanned.get(selection.id ?? "")?.keyframes ?? [],
allElements,
},
{ label: "Unroll dynamic animations", skipReload: true },
);
return `${anim.targetSelector}-to-0`;
}
const runtime = readRuntimeKeyframes(iframe, anim.targetSelector);
if (!runtime || runtime.keyframes.length === 0) return;
await commitMutation(
selection,
{
type: "materialize-keyframes",
animationId: anim.id,
keyframes: runtime.keyframes,
easeEach: runtime.easeEach,
},
{ label: "Materialize dynamic keyframes", skipReload: true },
);
}
// ── Extend tween ──────────────────────────────────────────────────────────
/**
* Extend a tween's time range to cover `targetTime`, remap all existing
* keyframe percentages to preserve their absolute positions, then add
* a new keyframe at the target time.
*/
export async function extendTweenAndAddKeyframe(
selection: DomEditSelection,
anim: GsapAnimation,
properties: Record<string, number>,
targetTime: number,
tweenStart: number,
tweenDuration: number,
callbacks: GsapDragCommitCallbacks,
beforeReload?: () => void,
): Promise<void> {
const tweenEnd = tweenStart + tweenDuration;
const newStart = Math.min(targetTime, tweenStart);
const newEnd = Math.max(targetTime, tweenEnd);
const newDuration = Math.max(0.01, newEnd - newStart);
const existingKfs = anim.keyframes?.keyframes ?? [];
const remappedKfs: Array<{ percentage: number; properties: Record<string, number | string> }> =
[];
for (const kf of existingKfs) {
const absTime = tweenStart + (kf.percentage / 100) * tweenDuration;
const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
remappedKfs.push({ percentage: newPct, properties: { ...kf.properties } });
}
const targetPct = Math.round(((targetTime - newStart) / newDuration) * 1000) / 10;
remappedKfs.push({ percentage: targetPct, properties });
remappedKfs.sort((a, b) => a.percentage - b.percentage);
await callbacks.commitMutation(
selection,
{ type: "delete", animationId: anim.id },
{ label: "Extend tween range", skipReload: true },
);
const selector = anim.targetSelector;
await callbacks.commitMutation(
selection,
{
type: "add-with-keyframes",
targetSelector: selector,
position: Math.round(newStart * 1000) / 1000,
duration: Math.round(newDuration * 1000) / 1000,
keyframes: remappedKfs,
},
{ label: `Move layer (extended keyframe)`, softReload: true, beforeReload },
);
}
// fallow-ignore-next-line complexity
export async function commitKeyframedPosition(
selection: DomEditSelection,
anim: GsapAnimation,
properties: Record<string, number>,
callbacks: GsapDragCommitCallbacks,
beforeReload?: () => void,
): Promise<void> {
const pct = computeCurrentPercentage(selection, anim);
await callbacks.commitMutation(
selection,
{
type: "add-keyframe",
animationId: anim.id,
percentage: pct,
properties,
},
{ label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload },
);
}
/**
* For flat to()/set() tweens, convert to keyframes first so we can place the
* drag position at the current percentage.
*/
// fallow-ignore-next-line complexity
export async function commitFlatViaKeyframes(
selection: DomEditSelection,
anim: GsapAnimation,
properties: Record<string, number>,
callbacks: GsapDragCommitCallbacks,
beforeReload?: () => void,
): Promise<void> {
await callbacks.commitMutation(
selection,
{ type: "convert-to-keyframes", animationId: anim.id },
{ label: "Convert to keyframes for drag", skipReload: true },
);
const pct = computeCurrentPercentage(selection, anim);
await callbacks.commitMutation(
selection,
{
type: "add-keyframe",
animationId: anim.id,
percentage: pct,
properties,
},
{ label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload },
);
}
// ── Main drag commit ──────────────────────────────────────────────────────
/**
* Compute the new GSAP position values from runtime-read positions + drag
* offset, then commit the mutation to the GSAP script.
*/
// fallow-ignore-next-line complexity
export async function commitGsapPositionFromDrag(
selection: DomEditSelection,
anim: GsapAnimation,
studioOffset: { x: number; y: number },
gsapPos: { x: number; y: number },
iframe: HTMLIFrameElement | null,
selector: string,
callbacks: GsapDragCommitCallbacks,
): Promise<void> {
const rotStyle = selection.element.style.getPropertyValue("--hf-studio-rotation");
const rotDeg = Number.parseFloat(rotStyle) || 0;
const rad = (-rotDeg * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const el = selection.element;
const origX = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0;
const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0;
const deltaX = studioOffset.x - origX;
const deltaY = studioOffset.y - origY;
const adjX = deltaX * cos - deltaY * sin;
const adjY = deltaX * sin + deltaY * cos;
const baseGsapX =
Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-x") ?? "") || gsapPos.x;
const baseGsapY =
Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-y") ?? "") || gsapPos.y;
const newX = Math.round(baseGsapX + adjX);
const newY = Math.round(baseGsapY + adjY);
const restoreOffset = () => {
el.style.setProperty("--hf-studio-offset-x", `${origX}px`);
el.style.setProperty("--hf-studio-offset-y", `${origY}px`);
el.removeAttribute("data-hf-drag-initial-offset-x");
el.removeAttribute("data-hf-drag-initial-offset-y");
};
if (anim.keyframes) {
const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
const effectiveAnim = newId ? { ...anim, id: newId } : anim;
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
const ct = usePlayerStore.getState().currentTime;
const ts = resolveTweenStart(effectiveAnim);
const td = resolveTweenDuration(effectiveAnim);
if (ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01)) {
await extendTweenAndAddKeyframe(
selection,
effectiveAnim,
{ ...runtimeProps, x: newX, y: newY },
ct,
ts,
td,
callbacks,
restoreOffset,
);
} else {
await commitKeyframedPosition(
selection,
effectiveAnim,
{ ...runtimeProps, x: newX, y: newY },
callbacks,
restoreOffset,
);
}
} else if (anim.method === "from" || anim.method === "fromTo") {
await callbacks.commitMutation(
selection,
{
type: "convert-to-keyframes",
animationId: anim.id,
resolvedFromValues: { x: newX, y: newY },
},
{ label: "Move layer (keyframe rest)", softReload: true, beforeReload: restoreOffset },
);
} else {
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
await commitFlatViaKeyframes(
selection,
anim,
{ ...runtimeProps, x: newX, y: newY },
callbacks,
restoreOffset,
);
}
}
@@ -0,0 +1,88 @@
/**
* Helpers for reading/writing the GSAP keyframe cache in the player store.
* Extracted from useGsapScriptCommits to keep file sizes under the 600-line limit.
*/
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
export function updateKeyframeCacheFromParsed(
animations: GsapAnimation[],
targetPath: string,
selectionId: string | undefined,
mutation: Record<string, unknown>,
): void {
const { setKeyframeCache, elements } = usePlayerStore.getState();
const idsWithKeyframes = new Set<string>();
const merged = new Map<string, KeyframeCacheEntry>();
for (const anim of animations) {
const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1];
if (!id || !anim.keyframes) continue;
idsWithKeyframes.add(id);
// Convert tween-relative percentages to clip-relative so diamonds
// render at the correct position within the timeline clip.
const tweenPos = typeof anim.position === "number" ? anim.position : 0;
const tweenDur = anim.duration ?? 1;
const timelineEl = elements.find(
(el) => el.domId === id || (el.key ?? el.id) === `${targetPath}#${id}`,
);
const elStart = timelineEl?.start ?? 0;
const elDuration = timelineEl?.duration ?? 4;
const clipKeyframes = anim.keyframes.keyframes.map((kf) => {
const absTime = tweenPos + (kf.percentage / 100) * tweenDur;
const clipPct =
elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage;
return { ...kf, percentage: clipPct };
});
const existing = merged.get(id);
if (existing) {
const byPct = new Map<number, (typeof existing.keyframes)[0]>();
for (const kf of [...existing.keyframes, ...clipKeyframes]) {
const prev = byPct.get(kf.percentage);
if (prev) {
prev.properties = { ...prev.properties, ...kf.properties };
if (kf.ease) prev.ease = kf.ease;
} else {
byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
}
}
existing.keyframes = Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage);
} else {
merged.set(id, { ...anim.keyframes, keyframes: clipKeyframes });
}
}
for (const [id, entry] of merged) {
setKeyframeCache(`${targetPath}#${id}`, entry);
setKeyframeCache(id, entry);
if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, entry);
}
const targetId =
(mutation as { targetSelector?: string }).targetSelector?.match(/^#([\w-]+)/)?.[1] ??
selectionId;
if (targetId && !idsWithKeyframes.has(targetId)) {
setKeyframeCache(`${targetPath}#${targetId}`, undefined);
if (targetPath !== "index.html") setKeyframeCache(`index.html#${targetId}`, undefined);
}
}
export function buildCacheKey(sourceFile: string, elementId: string): string {
return `${sourceFile}#${elementId}`;
}
export function readKeyframeSnapshot(
sourceFile: string,
elementId: string | null | undefined,
): KeyframeCacheEntry | undefined {
if (!elementId) return undefined;
return usePlayerStore.getState().keyframeCache.get(buildCacheKey(sourceFile, elementId));
}
export function writeKeyframeCache(
sourceFile: string,
elementId: string | null | undefined,
data: KeyframeCacheEntry | undefined,
): void {
if (!elementId) return;
usePlayerStore.getState().setKeyframeCache(buildCacheKey(sourceFile, elementId), data);
}
+11 -377
View File
@@ -11,13 +11,14 @@
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
import {
absoluteToPercentage,
resolveTweenStart,
resolveTweenDuration,
} from "../utils/globalTimeCompiler";
commitGsapPositionFromDrag,
computeCurrentPercentage,
materializeIfDynamic,
} from "./gsapDragCommit";
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
// ── Runtime reads ──────────────────────────────────────────────────────────
@@ -94,94 +95,16 @@ function selectorForSelection(selection: DomEditSelection): string | null {
return null;
}
// ── Percentage computation ─────────────────────────────────────────────────
function computeCurrentPercentage(selection: DomEditSelection, animation?: GsapAnimation): number {
const currentTime = usePlayerStore.getState().currentTime;
if (animation) {
const start = resolveTweenStart(animation);
const duration = resolveTweenDuration(animation);
if (start !== null) {
return absoluteToPercentage(currentTime, start, duration);
}
}
const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1;
return elDuration > 0
? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
: 0;
}
// ── Dynamic keyframe materialization ──────────────────────────────────────
async function materializeIfDynamic(
anim: GsapAnimation,
iframe: HTMLIFrameElement | null,
commitMutation: GsapDragCommitCallbacks["commitMutation"],
selection: DomEditSelection,
): Promise<string | void> {
if (!anim.hasUnresolvedKeyframes && !anim.hasUnresolvedSelector) return;
if (anim.hasUnresolvedSelector) {
// Unroll: read ALL elements' keyframes from runtime and replace the loop
const allScanned = scanAllRuntimeKeyframes(iframe);
if (allScanned.size === 0) return;
const allElements = Array.from(allScanned.entries()).map(([id, data]) => ({
selector: `#${id}`,
keyframes: data.keyframes,
easeEach: data.easeEach,
}));
await commitMutation(
selection,
{
type: "materialize-keyframes",
animationId: anim.id,
keyframes: allScanned.get(selection.id ?? "")?.keyframes ?? [],
allElements,
},
{ label: "Unroll dynamic animations", skipReload: true },
);
return `${anim.targetSelector}-to-0`;
}
const runtime = readRuntimeKeyframes(iframe, anim.targetSelector);
if (!runtime || runtime.keyframes.length === 0) return;
await commitMutation(
selection,
{
type: "materialize-keyframes",
animationId: anim.id,
keyframes: runtime.keyframes,
easeEach: runtime.easeEach,
},
{ label: "Materialize dynamic keyframes", skipReload: true },
);
}
// ── High-level intercept ───────────────────────────────────────────────────
export interface GsapDragCommitCallbacks {
commitMutation: (
selection: DomEditSelection,
mutation: Record<string, unknown>,
options: {
label: string;
coalesceKey?: string;
softReload?: boolean;
skipReload?: boolean;
beforeReload?: () => void;
},
) => Promise<void>;
}
export type { GsapDragCommitCallbacks };
/**
* Attempt to handle a drag commit via the GSAP script mutation path.
*
* Returns a Promise that resolves to true if the drag was handled via GSAP
* (caller should skip the CSS path), or false if no GSAP position animation
* exists. The promise resolves only AFTER the mutation has been persisted and
* the preview soft-reloaded — the CSS offset stays visible until then so the
* element doesn't snap back during the async gap.
* exists.
*/
// fallow-ignore-next-line complexity
export async function tryGsapDragIntercept(
@@ -202,10 +125,6 @@ export async function tryGsapDragIntercept(
const selector = selectorForSelection(selection);
if (!selector) return false;
// Keyframe writes at 0%/100% when outside the tween range. Acceptable
// trade-off — CSS path must NEVER touch GSAP-targeted elements because
// changing the CSS offset corrupts all existing keyframes (baked mismatch).
const gsapPos = readGsapPositionFromIframe(iframe, selector);
if (!gsapPos) return false;
@@ -215,294 +134,9 @@ export async function tryGsapDragIntercept(
return true;
}
// ── Commit helpers ─────────────────────────────────────────────────────────
// ── Runtime property readers (re-exported for external callers) ───────────
/**
* Compute the new GSAP position values from runtime-read positions + drag
* offset, then commit the mutation to the GSAP script.
*
* `gsap.getProperty` reads from GSAP's internal cache (element._gsap), not
* from the DOM transform matrix. The strip in `applyStudioPathOffset` does
* not affect the cached values, so the formula is simply:
* newValue = cachedGsapValue + dragOffset
*
* For flat tweens (to/set), the mutation would change the tween endpoint,
* which is invisible at t=0. Instead, we convert to keyframes first so the
* position is set at the exact seek percentage via a keyframe.
*/
// fallow-ignore-next-line complexity
async function commitGsapPositionFromDrag(
selection: DomEditSelection,
anim: GsapAnimation,
studioOffset: { x: number; y: number },
gsapPos: { x: number; y: number },
iframe: HTMLIFrameElement | null,
selector: string,
callbacks: GsapDragCommitCallbacks,
): Promise<void> {
// CSS composition: translate → rotate → transform. The studioOffset is in
// pre-rotation space (CSS translate), but GSAP x/y are in post-CSS-rotate
// space (CSS transform). Counter-rotate the offset to match GSAP's frame.
const rotStyle = selection.element.style.getPropertyValue("--hf-studio-rotation");
const rotDeg = Number.parseFloat(rotStyle) || 0;
const rad = (-rotDeg * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const el = selection.element;
const origX = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0;
const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0;
const deltaX = studioOffset.x - origX;
const deltaY = studioOffset.y - origY;
const adjX = deltaX * cos - deltaY * sin;
const adjY = deltaX * sin + deltaY * cos;
// Use the GSAP base captured at drag start — the live gsapPos is corrupted
// by the draft's gsap.set() calls during drag.
const baseGsapX =
Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-x") ?? "") || gsapPos.x;
const baseGsapY =
Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-y") ?? "") || gsapPos.y;
const newX = Math.round(baseGsapX + adjX);
const newY = Math.round(baseGsapY + adjY);
// Restore the CSS offset to pre-drag value so the baked translate stays
// consistent with existing keyframes. The drag is captured in the new keyframe.
const restoreOffset = () => {
el.style.setProperty("--hf-studio-offset-x", `${origX}px`);
el.style.setProperty("--hf-studio-offset-y", `${origY}px`);
el.removeAttribute("data-hf-drag-initial-offset-x");
el.removeAttribute("data-hf-drag-initial-offset-y");
};
if (anim.keyframes) {
const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
const effectiveAnim = newId ? { ...anim, id: newId } : anim;
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
// Check if current time is outside the tween's range — extend the tween
// to cover the playhead, remap existing keyframes, then add the new one.
const ct = usePlayerStore.getState().currentTime;
const ts = resolveTweenStart(effectiveAnim);
const td = resolveTweenDuration(effectiveAnim);
if (ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01)) {
await extendTweenAndAddKeyframe(
selection,
effectiveAnim,
{ ...runtimeProps, x: newX, y: newY },
ct,
ts,
td,
callbacks,
restoreOffset,
);
} else {
await commitKeyframedPosition(
selection,
effectiveAnim,
{ ...runtimeProps, x: newX, y: newY },
callbacks,
restoreOffset,
);
}
} else if (anim.method === "from" || anim.method === "fromTo") {
// from()/fromTo() — convert to keyframes in a single mutation, placing
// the dragged position at the 100% (rest) keyframe. A single mutation
// avoids the stable-id flip (from→to) that breaks chained mutations.
await callbacks.commitMutation(
selection,
{
type: "convert-to-keyframes",
animationId: anim.id,
resolvedFromValues: { x: newX, y: newY },
},
{ label: "Move layer (keyframe rest)", softReload: true, beforeReload: restoreOffset },
);
} else {
// Flat to()/set() — convert to keyframes then add at current percentage.
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
await commitFlatViaKeyframes(
selection,
anim,
{ ...runtimeProps, x: newX, y: newY },
callbacks,
restoreOffset,
);
}
}
/**
* Extend a tween's time range to cover `targetTime`, remap all existing
* keyframe percentages to preserve their absolute positions, then add
* a new keyframe at the target time.
*/
async function extendTweenAndAddKeyframe(
selection: DomEditSelection,
anim: GsapAnimation,
properties: Record<string, number>,
targetTime: number,
tweenStart: number,
tweenDuration: number,
callbacks: GsapDragCommitCallbacks,
beforeReload?: () => void,
): Promise<void> {
const tweenEnd = tweenStart + tweenDuration;
const newStart = Math.min(targetTime, tweenStart);
const newEnd = Math.max(targetTime, tweenEnd);
const newDuration = Math.max(0.01, newEnd - newStart);
// Step 1: Remap all existing keyframes to preserve their absolute times
// in the new range, then add the new keyframe.
const existingKfs = anim.keyframes?.keyframes ?? [];
const remappedKfs: Array<{ percentage: number; properties: Record<string, number | string> }> =
[];
for (const kf of existingKfs) {
const absTime = tweenStart + (kf.percentage / 100) * tweenDuration;
const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
remappedKfs.push({ percentage: newPct, properties: { ...kf.properties } });
}
// Add the new keyframe at the target time
const targetPct = Math.round(((targetTime - newStart) / newDuration) * 1000) / 10;
remappedKfs.push({ percentage: targetPct, properties });
// Sort and dedupe
remappedKfs.sort((a, b) => a.percentage - b.percentage);
// Step 2: Delete the old tween and create a new one with the extended range
// and all remapped keyframes. Using delete + add-with-keyframes as an atomic pair.
await callbacks.commitMutation(
selection,
{ type: "delete", animationId: anim.id },
{ label: "Extend tween range", skipReload: true },
);
const selector = anim.targetSelector;
await callbacks.commitMutation(
selection,
{
type: "add-with-keyframes",
targetSelector: selector,
position: Math.round(newStart * 1000) / 1000,
duration: Math.round(newDuration * 1000) / 1000,
keyframes: remappedKfs,
},
{ label: `Move layer (extended keyframe)`, softReload: true, beforeReload },
);
}
// fallow-ignore-next-line complexity
async function commitKeyframedPosition(
selection: DomEditSelection,
anim: GsapAnimation,
properties: Record<string, number>,
callbacks: GsapDragCommitCallbacks,
beforeReload?: () => void,
): Promise<void> {
const pct = computeCurrentPercentage(selection, anim);
await callbacks.commitMutation(
selection,
{
type: "add-keyframe",
animationId: anim.id,
percentage: pct,
properties,
},
{ label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload },
);
}
/**
* For flat to()/set() tweens, convert to keyframes first so we can place the
* drag position at the current percentage. Without conversion, the mutation
* only changes the tween endpoint, which is invisible at t=0.
*/
// fallow-ignore-next-line complexity
async function commitFlatViaKeyframes(
selection: DomEditSelection,
anim: GsapAnimation,
properties: Record<string, number>,
callbacks: GsapDragCommitCallbacks,
beforeReload?: () => void,
): Promise<void> {
await callbacks.commitMutation(
selection,
{ type: "convert-to-keyframes", animationId: anim.id },
{ label: "Convert to keyframes for drag", skipReload: true },
);
const pct = computeCurrentPercentage(selection, anim);
await callbacks.commitMutation(
selection,
{
type: "add-keyframe",
animationId: anim.id,
percentage: pct,
properties,
},
{ label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload },
);
}
// ── Runtime property reader ───────────────────────────────────────────────
export function readGsapProperty(
iframe: HTMLIFrameElement | null,
selector: string | null,
prop: string,
): number | null {
if (!iframe?.contentWindow || !selector) return null;
try {
const gsap = (iframe.contentWindow as unknown as { gsap?: IframeGsap }).gsap;
if (!gsap?.getProperty) return null;
const el = iframe.contentDocument?.querySelector(selector);
if (!el) return null;
const val = Number(gsap.getProperty(el, prop));
return Number.isFinite(val) ? Math.round(val) : null;
} catch {
return null;
}
}
export function readAllAnimatedProperties(
iframe: HTMLIFrameElement | null,
selector: string,
anim: GsapAnimation,
): Record<string, number> {
const result: Record<string, number> = {};
if (!iframe?.contentWindow) return result;
let gsap: IframeGsap | undefined;
try {
gsap = (iframe.contentWindow as unknown as { gsap?: IframeGsap }).gsap;
} catch {
return result;
}
if (!gsap?.getProperty) return result;
let doc: Document | null = null;
try {
doc = iframe.contentDocument;
} catch {
return result;
}
const el = doc?.querySelector(selector);
if (!el) return result;
const propKeys = new Set<string>();
if (anim.keyframes) {
for (const kf of anim.keyframes.keyframes) {
for (const p of Object.keys(kf.properties)) {
if (typeof kf.properties[p] === "number") propKeys.add(p);
}
}
} else {
for (const p of Object.keys(anim.properties)) propKeys.add(p);
}
for (const prop of propKeys) {
const val = Number(gsap.getProperty(el, prop));
if (Number.isFinite(val)) result[prop] = Math.round(val);
}
return result;
}
export { readGsapProperty, readAllAnimatedProperties };
// ── Resize intercept ──────────────────────────────────────────────────────
@@ -0,0 +1,67 @@
/**
* Low-level GSAP runtime property readers shared by gsapRuntimeBridge and gsapDragCommit.
*/
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
interface IframeGsap {
getProperty: (el: Element, prop: string) => number;
}
export function readGsapProperty(
iframe: HTMLIFrameElement | null,
selector: string | null,
prop: string,
): number | null {
if (!iframe?.contentWindow || !selector) return null;
try {
const gsap = (iframe.contentWindow as unknown as { gsap?: IframeGsap }).gsap;
if (!gsap?.getProperty) return null;
const el = iframe.contentDocument?.querySelector(selector);
if (!el) return null;
const val = Number(gsap.getProperty(el, prop));
return Number.isFinite(val) ? Math.round(val) : null;
} catch {
return null;
}
}
export function readAllAnimatedProperties(
iframe: HTMLIFrameElement | null,
selector: string,
anim: GsapAnimation,
): Record<string, number> {
const result: Record<string, number> = {};
if (!iframe?.contentWindow) return result;
let gsap: IframeGsap | undefined;
try {
gsap = (iframe.contentWindow as unknown as { gsap?: IframeGsap }).gsap;
} catch {
return result;
}
if (!gsap?.getProperty) return result;
let doc: Document | null = null;
try {
doc = iframe.contentDocument;
} catch {
return result;
}
const el = doc?.querySelector(selector);
if (!el) return result;
const propKeys = new Set<string>();
if (anim.keyframes) {
for (const kf of anim.keyframes.keyframes) {
for (const p of Object.keys(kf.properties)) {
if (typeof kf.properties[p] === "number") propKeys.add(p);
}
}
} else {
for (const p of Object.keys(anim.properties)) propKeys.add(p);
}
for (const prop of propKeys) {
const val = Number(gsap.getProperty(el, prop));
if (Number.isFinite(val)) result[prop] = Math.round(val);
}
return result;
}
@@ -0,0 +1,145 @@
import type { TimelineElement } from "../player";
import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import type { EditHistoryKind } from "../utils/editHistory";
// ── Types ──
interface RecordEditInput {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
files: Record<string, { before: string; after: string }>;
}
export function buildPatchTarget(element: {
domId?: string;
hfId?: string;
selector?: string;
selectorIndex?: number;
}) {
if (element.domId) {
return {
id: element.domId,
hfId: element.hfId,
selector: element.selector,
selectorIndex: element.selectorIndex,
};
}
if (element.hfId) {
return { hfId: element.hfId, selector: element.selector, selectorIndex: element.selectorIndex };
}
if (element.selector) {
return { selector: element.selector, selectorIndex: element.selectorIndex };
}
return null;
}
export type PatchTarget = NonNullable<ReturnType<typeof buildPatchTarget>>;
// The runtime re-reads data-start/data-duration from the DOM on each sync tick
// (packages/core/src/runtime/init.ts:1324-1368), so attribute mutations here are
// picked up automatically on the next frame without a rebind call.
export function patchIframeDomTiming(
iframe: HTMLIFrameElement | null,
element: TimelineElement,
attrs: Array<[string, string]>,
): void {
try {
const doc = iframe?.contentDocument;
if (!doc) return;
const el = element.domId
? doc.getElementById(element.domId)
: element.selector
? (doc.querySelectorAll(element.selector)[element.selectorIndex ?? 0] ?? null)
: null;
if (!el) return;
for (const [name, value] of attrs) el.setAttribute(name, value);
} catch {
// Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
}
}
export function resolveResizePlaybackStart(
original: string,
target: PatchTarget,
element: TimelineElement,
updates: Pick<TimelineElement, "start" | "playbackStart">,
): { attrName: string; value: number } | null {
if (updates.playbackStart != null) {
const attrName =
element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
return { attrName, value: updates.playbackStart };
}
const trimDelta = updates.start - element.start;
if (trimDelta === 0) return null;
const raw =
readAttributeByTarget(original, target, "playback-start") ??
readAttributeByTarget(original, target, "media-start");
const current = raw != null ? parseFloat(raw) : undefined;
if (current == null || !Number.isFinite(current)) return null;
const attrName =
element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
return {
attrName,
value: Math.max(0, current + trimDelta * Math.max(element.playbackRate ?? 1, 0.1)),
};
}
export interface PersistTimelineEditInput {
projectId: string;
element: TimelineElement;
activeCompPath: string | null;
label: string;
buildPatches: (original: string, target: PatchTarget) => string;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
}
export async function persistTimelineEdit(input: PersistTimelineEditInput): Promise<void> {
const targetPath = input.element.sourceFile || input.activeCompPath || "index.html";
const originalContent = await readFileContent(input.projectId, targetPath);
const patchTarget = buildPatchTarget(input.element);
if (!patchTarget) {
throw new Error(`Timeline element ${input.element.id} is missing a patchable target`);
}
const patchedContent = input.buildPatches(originalContent, patchTarget);
if (patchedContent === originalContent) {
throw new Error(`Unable to patch timeline element ${input.element.id} in ${targetPath}`);
}
input.pendingTimelineEditPathRef.current.add(targetPath);
input.domEditSaveTimestampRef.current = Date.now();
await saveProjectFilesWithHistory({
projectId: input.projectId,
label: input.label,
kind: "timeline",
files: { [targetPath]: patchedContent },
readFile: async () => originalContent,
writeFile: input.writeProjectFile,
recordEdit: input.recordEdit,
});
input.domEditSaveTimestampRef.current = Date.now();
}
export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
const response = await fetch(
`/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
);
if (!response.ok) {
throw new Error(`Failed to read ${targetPath}`);
}
const data = (await response.json()) as { content?: string };
if (typeof data.content !== "string") {
throw new Error(`Missing file contents for ${targetPath}`);
}
return data.content;
}
// Re-export applyPatchByTarget for use in the hook (avoids double import in callers)
export { applyPatchByTarget, formatTimelineAttributeNumber };
@@ -0,0 +1,157 @@
/**
* Block drop/add handlers for the Studio.
* Extracted from App.tsx to keep file sizes under the 600-line limit.
*/
import { useCallback, useMemo, useState } from "react";
import type { TimelineElement } from "../player";
import { usePlayerStore } from "../player";
import { addBlockToProject } from "../utils/blockInstaller";
import type { BlockParam } from "@hyperframes/core/registry";
import type { EditHistoryKind } from "../utils/editHistory";
import type { RightPanelTab } from "../utils/studioHelpers";
interface BlockCtxDeps {
activeCompPath: string | null;
timelineElements: TimelineElement[];
readProjectFile: (path: string) => Promise<string>;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (entry: {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
files: Record<string, { before: string; after: string }>;
}) => Promise<void>;
refreshFileTree: () => Promise<void>;
reloadPreview: () => void;
showToast: (message: string, tone?: "error" | "info") => void;
}
interface UseBlockHandlersParams {
projectId: string | null;
blockCtxDeps: BlockCtxDeps;
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
setRightCollapsed: (collapsed: boolean) => void;
setRightPanelTab: (tab: RightPanelTab) => void;
}
export interface UseBlockHandlersResult {
activeBlockParams: {
blockName: string;
blockTitle: string;
params: BlockParam[];
compositionPath: string;
} | null;
setActiveBlockParams: React.Dispatch<
React.SetStateAction<UseBlockHandlersResult["activeBlockParams"]>
>;
handleAddBlock: (blockName: string) => void;
handleTimelineBlockDrop: (
blockName: string,
placement: { start: number; track: number },
) => void;
handlePreviewBlockDrop: (
blockName: string,
position: { left: number; top: number },
) => void;
}
export function useBlockHandlers({
projectId,
blockCtxDeps,
previewIframeRef,
setRightCollapsed,
setRightPanelTab,
}: UseBlockHandlersParams): UseBlockHandlersResult {
const [activeBlockParams, setActiveBlockParams] = useState<
UseBlockHandlersResult["activeBlockParams"]
>(null);
const blockCtx = useMemo(
() => ({
activeCompPath: blockCtxDeps.activeCompPath,
timelineElements: blockCtxDeps.timelineElements,
readProjectFile: blockCtxDeps.readProjectFile,
writeProjectFile: blockCtxDeps.writeProjectFile,
recordEdit: blockCtxDeps.recordEdit,
refreshFileTree: blockCtxDeps.refreshFileTree,
reloadPreview: blockCtxDeps.reloadPreview,
showToast: blockCtxDeps.showToast,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
blockCtxDeps.activeCompPath,
blockCtxDeps.timelineElements,
blockCtxDeps.readProjectFile,
blockCtxDeps.writeProjectFile,
blockCtxDeps.recordEdit,
blockCtxDeps.refreshFileTree,
blockCtxDeps.reloadPreview,
blockCtxDeps.showToast,
],
);
const handleAddBlock = useCallback(
(blockName: string) => {
if (!projectId) return;
void (async () => {
const result = await addBlockToProject({
projectId,
blockName,
...blockCtx,
previewIframe: previewIframeRef.current,
currentTime: usePlayerStore.getState().currentTime,
});
const params = result?.block.type === "hyperframes:block" ? result.block.params : undefined;
if (params?.length) {
setActiveBlockParams({
blockName: result!.block.name,
blockTitle: result!.block.title,
params,
compositionPath: result!.compositionPath,
});
setRightCollapsed(false);
setRightPanelTab("block-params");
}
})();
},
[projectId, blockCtx, previewIframeRef, setRightCollapsed, setRightPanelTab],
);
const handleTimelineBlockDrop = useCallback(
(blockName: string, placement: { start: number; track: number }) => {
if (!projectId) return;
void addBlockToProject({
projectId,
blockName,
placement,
...blockCtx,
previewIframe: previewIframeRef.current,
currentTime: usePlayerStore.getState().currentTime,
});
},
[projectId, blockCtx, previewIframeRef],
);
const handlePreviewBlockDrop = useCallback(
(blockName: string, position: { left: number; top: number }) => {
if (!projectId) return;
void addBlockToProject({
projectId,
blockName,
visualPosition: position,
...blockCtx,
previewIframe: previewIframeRef.current,
currentTime: usePlayerStore.getState().currentTime,
});
},
[projectId, blockCtx, previewIframeRef],
);
return {
activeBlockParams,
setActiveBlockParams,
handleAddBlock,
handleTimelineBlockDrop,
handlePreviewBlockDrop,
};
}
@@ -0,0 +1,128 @@
/**
* Side effects for syncing the DOM edit selection with the preview iframe on
* load/refresh, and for auto-revealing source in the Code tab.
* Extracted from useDomEditSession to keep file sizes under the 600-line limit.
*/
import { useEffect, useRef } from "react";
import {
STUDIO_INSPECTOR_PANELS_ENABLED,
} from "../components/editor/manualEditingAvailability";
import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing";
import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits";
import type { SidebarTab } from "../components/sidebar/LeftSidebar";
import type { PatchTarget } from "../utils/sourcePatcher";
interface UseDomEditPreviewSyncParams {
previewIframe: HTMLIFrameElement | null;
activeCompPath: string | null;
captionEditMode: boolean;
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
domEditSelection: DomEditSelection | null;
applyDomSelection: (
selection: DomEditSelection | null,
options?: { revealPanel?: boolean; preserveGroup?: boolean },
) => void;
buildDomSelectionFromTarget: (element: HTMLElement) => Promise<DomEditSelection | null>;
refreshPreviewDocumentVersion: () => void;
syncPreviewHistoryHotkey: (iframe: HTMLIFrameElement | null) => void;
applyStudioManualEditsToPreviewRef: React.MutableRefObject<
(iframe: HTMLIFrameElement) => Promise<void>
>;
openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void;
getSidebarTab?: () => SidebarTab;
}
export function useDomEditPreviewSync({
previewIframe,
activeCompPath,
captionEditMode,
domEditSelectionRef,
domEditSelection,
applyDomSelection,
buildDomSelectionFromTarget,
refreshPreviewDocumentVersion,
syncPreviewHistoryHotkey,
applyStudioManualEditsToPreviewRef,
openSourceForSelection,
getSidebarTab,
}: UseDomEditPreviewSyncParams): void {
// Sync selection from preview document on load / refresh
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!previewIframe) return;
// fallow-ignore-next-line complexity
const syncSelectionFromDocument = async () => {
if (!STUDIO_INSPECTOR_PANELS_ENABLED || captionEditMode) return;
const currentSelection = domEditSelectionRef.current;
if (!currentSelection) return;
let doc: Document | null = null;
try {
doc = previewIframe.contentDocument;
} catch {
return;
}
if (!doc) return;
reapplyPositionEditsAfterSeek(doc);
const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
if (!nextElement) {
applyDomSelection(null, { revealPanel: false });
return;
}
const nextSelection = await buildDomSelectionFromTarget(nextElement);
if (nextSelection) {
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
}
};
syncPreviewHistoryHotkey(previewIframe);
void applyStudioManualEditsToPreviewRef.current(previewIframe);
void syncSelectionFromDocument();
refreshPreviewDocumentVersion();
const handleLoad = () => {
syncPreviewHistoryHotkey(previewIframe);
void applyStudioManualEditsToPreviewRef.current(previewIframe);
void syncSelectionFromDocument();
refreshPreviewDocumentVersion();
};
previewIframe.addEventListener("load", handleLoad);
return () => {
previewIframe.removeEventListener("load", handleLoad);
};
}, [
activeCompPath,
applyDomSelection,
buildDomSelectionFromTarget,
captionEditMode,
domEditSelectionRef,
previewIframe,
refreshPreviewDocumentVersion,
syncPreviewHistoryHotkey,
applyStudioManualEditsToPreviewRef,
]);
// Auto-reveal source when an element is selected while the Code tab is active.
// Use a ref for the callback so the effect only fires on selection changes,
// not when openSourceForSelection is recreated due to editingFile content updates.
const openSourceRef = useRef(openSourceForSelection);
openSourceRef.current = openSourceForSelection;
useEffect(
// fallow-ignore-next-line complexity
() => {
if (!domEditSelection || !openSourceRef.current || !getSidebarTab) return;
if (!domEditSelection.sourceFile) return;
if (getSidebarTab() !== "code") return;
openSourceRef.current(domEditSelection.sourceFile, {
id: domEditSelection.id,
selector: domEditSelection.selector,
selectorIndex: domEditSelection.selectorIndex,
});
},
[domEditSelection, getSidebarTab],
);
}
+10 -76
View File
@@ -2,11 +2,10 @@ import { useCallback, useEffect, useRef } from "react";
import type { TimelineElement } from "../player";
import { usePlayerStore } from "../player";
import {
STUDIO_INSPECTOR_PANELS_ENABLED,
STUDIO_GSAP_PANEL_ENABLED,
} from "../components/editor/manualEditingAvailability";
import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing";
import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits";
import { type DomEditSelection } from "../components/editor/domEditing";
import { useDomEditPreviewSync } from "./useDomEditPreviewSync";
import type { ImportedFontAsset } from "../components/editor/fontAssets";
import type { EditHistoryKind } from "../utils/editHistory";
import type { RightPanelTab } from "../utils/studioHelpers";
@@ -482,85 +481,20 @@ export function useDomEditSession({
[domEditSelection, updateArcSegment],
);
// Sync selection from preview document on load / refresh
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!previewIframe) return;
// fallow-ignore-next-line complexity
const syncSelectionFromDocument = async () => {
if (!STUDIO_INSPECTOR_PANELS_ENABLED || captionEditMode) return;
const currentSelection = domEditSelectionRef.current;
if (!currentSelection) return;
let doc: Document | null = null;
try {
doc = previewIframe.contentDocument;
} catch {
return;
}
if (!doc) return;
reapplyPositionEditsAfterSeek(doc);
const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
if (!nextElement) {
applyDomSelection(null, { revealPanel: false });
return;
}
const nextSelection = await buildDomSelectionFromTarget(nextElement);
if (nextSelection) {
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
}
};
syncPreviewHistoryHotkey(previewIframe);
void applyStudioManualEditsToPreviewRef.current(previewIframe);
void syncSelectionFromDocument();
refreshPreviewDocumentVersion();
const handleLoad = () => {
syncPreviewHistoryHotkey(previewIframe);
void applyStudioManualEditsToPreviewRef.current(previewIframe);
void syncSelectionFromDocument();
refreshPreviewDocumentVersion();
};
previewIframe.addEventListener("load", handleLoad);
return () => {
previewIframe.removeEventListener("load", handleLoad);
};
}, [
useDomEditPreviewSync({
previewIframe,
activeCompPath,
applyDomSelection,
buildDomSelectionFromTarget,
captionEditMode,
domEditSelectionRef,
previewIframe,
domEditSelection,
applyDomSelection,
buildDomSelectionFromTarget,
refreshPreviewDocumentVersion,
syncPreviewHistoryHotkey,
applyStudioManualEditsToPreviewRef,
]);
// Auto-reveal source when an element is selected while the Code tab is active.
// Use a ref for the callback so the effect only fires on selection changes,
// not when openSourceForSelection is recreated due to editingFile content updates.
const openSourceRef = useRef(openSourceForSelection);
openSourceRef.current = openSourceForSelection;
useEffect(
// fallow-ignore-next-line complexity
() => {
if (!domEditSelection || !openSourceRef.current || !getSidebarTab) return;
if (!domEditSelection.sourceFile) return;
if (getSidebarTab() !== "code") return;
openSourceRef.current(domEditSelection.sourceFile, {
id: domEditSelection.id,
selector: domEditSelection.selector,
selectorIndex: domEditSelection.selectorIndex,
});
},
[domEditSelection, getSidebarTab],
);
openSourceForSelection,
getSidebarTab,
});
return {
// State
@@ -0,0 +1,153 @@
/**
* Manages gesture recording state and commit logic for the Studio.
* Extracted from App.tsx to keep file sizes under the 600-line limit.
*/
import { useState, useCallback, useRef, useEffect } from "react";
import { useGestureRecording } from "./useGestureRecording";
import { simplifyGestureSamples } from "../utils/rdpSimplify";
import { usePlayerStore } from "../player";
import type { DomEditSelection } from "../components/editor/domEditing";
// Minimal subset of the session used by gesture commit
interface GestureSessionRef {
domEditSelection: DomEditSelection | null;
commitMutation?: (
mutation: Record<string, unknown>,
options: { label: string; softReload?: boolean },
) => Promise<void>;
}
interface UseGestureCommitParams {
domEditSessionRef: React.MutableRefObject<GestureSessionRef>;
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
showToast: (message: string, tone?: "error" | "info") => void;
isGestureRecordingRef: React.MutableRefObject<boolean>;
}
export interface UseGestureCommitResult {
gestureState: "idle" | "recording";
gestureRecording: ReturnType<typeof useGestureRecording>;
handleToggleRecording: () => void;
}
// fallow-ignore-next-line complexity
export function useGestureCommit({
domEditSessionRef,
previewIframeRef,
showToast,
isGestureRecordingRef,
}: UseGestureCommitParams): UseGestureCommitResult {
const gestureRecording = useGestureRecording();
const [gestureState, setGestureState] = useState<"idle" | "recording">("idle");
const gestureStateRef = useRef<"idle" | "recording">("idle");
const recordingAutoStopRef = useRef<ReturnType<typeof setInterval>>(undefined);
const recordingStartTimeRef = useRef(0);
const commitInFlightRef = useRef(false);
// Unmount: clear auto-stop interval
useEffect(() => () => clearInterval(recordingAutoStopRef.current), []);
// fallow-ignore-next-line complexity
const stopAndCommitRecording = useCallback(async () => {
clearInterval(recordingAutoStopRef.current);
if (commitInFlightRef.current) return;
commitInFlightRef.current = true;
gestureStateRef.current = "idle";
isGestureRecordingRef.current = false;
const frozenSamples = gestureRecording.stopRecording();
const store = usePlayerStore.getState();
store.setIsPlaying(false);
try {
const liveSession = domEditSessionRef.current;
const sel = liveSession.domEditSelection;
if (!sel) {
if (frozenSamples.length > 2) {
showToast("Selection lost during recording", "error");
}
return;
}
const duration = frozenSamples.length > 0 ? frozenSamples[frozenSamples.length - 1]!.time : 0;
if (frozenSamples.length <= 2) {
showToast("No gesture detected — move the pointer while recording", "error");
return;
}
if (duration <= 0) {
showToast("Recording too short — try again", "error");
return;
}
const simplified = simplifyGestureSamples(frozenSamples, duration, 5);
const sortedPcts = Array.from(simplified.keys()).sort((a, b) => a - b);
const selector = sel.id ? `#${sel.id}` : sel.selector;
if (!selector) {
showToast("Cannot save — element has no selector", "error");
return;
}
if (liveSession.commitMutation) {
const recStart = recordingStartTimeRef.current;
const keyframes = sortedPcts.map((pct) => ({
percentage: pct,
properties: simplified.get(pct) as Record<string, number | string>,
}));
await liveSession.commitMutation(
{
type: "add-with-keyframes",
targetSelector: selector,
position: Math.round(recStart * 1000) / 1000,
duration: Math.round(duration * 1000) / 1000,
keyframes,
},
{ label: "Gesture recording", softReload: true },
);
}
showToast(`Recorded ${sortedPcts.length} keyframes`, "info");
} finally {
store.requestSeek(recordingStartTimeRef.current);
gestureRecording.clearSamples();
setGestureState("idle");
commitInFlightRef.current = false;
}
}, [gestureRecording, showToast, isGestureRecordingRef, domEditSessionRef]);
const handleToggleRecording = useCallback(() => {
if (gestureStateRef.current === "recording") {
void stopAndCommitRecording();
return;
}
const sel = domEditSessionRef.current.domEditSelection;
if (!sel) {
showToast("Select an element first", "error");
return;
}
const iframe = previewIframeRef.current;
if (!iframe) {
showToast("Preview not ready — try again", "error");
return;
}
const store = usePlayerStore.getState();
recordingStartTimeRef.current = store.currentTime;
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
const elDur = Number.parseFloat(sel.dataAttributes?.duration ?? "0") || 0;
const elementEnd = elDur > 0 ? elStart + elDur : undefined;
gestureRecording.startRecording(sel.element, iframe, elementEnd);
gestureStateRef.current = "recording";
isGestureRecordingRef.current = true;
setGestureState("recording");
clearInterval(recordingAutoStopRef.current);
const autoStopAt = elementEnd ?? Infinity;
recordingAutoStopRef.current = setInterval(() => {
const { currentTime: t, duration: d } = usePlayerStore.getState();
const limit = Math.min(autoStopAt, d);
if (limit > 0 && t >= limit - 0.05) {
void stopAndCommitRecording();
}
}, 100);
}, [gestureRecording, showToast, stopAndCommitRecording, previewIframeRef, domEditSessionRef, isGestureRecordingRef]);
return { gestureState, gestureRecording, handleToggleRecording };
}
@@ -6,6 +6,11 @@ import { applySoftReload } from "../utils/gsapSoftReload";
import { executeOptimistic } from "../utils/optimisticUpdate";
import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
import {
updateKeyframeCacheFromParsed,
readKeyframeSnapshot,
writeKeyframeCache,
} from "./gsapKeyframeCacheHelpers";
const PROPERTY_DEFAULTS: Record<string, number> = {
opacity: 1,
@@ -72,84 +77,6 @@ async function mutateGsapScript(
return null;
}
}
function updateKeyframeCacheFromParsed(
animations: GsapAnimation[],
targetPath: string,
selectionId: string | undefined,
mutation: Record<string, unknown>,
): void {
const { setKeyframeCache, elements } = usePlayerStore.getState();
const idsWithKeyframes = new Set<string>();
const merged = new Map<string, KeyframeCacheEntry>();
for (const anim of animations) {
const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1];
if (!id || !anim.keyframes) continue;
idsWithKeyframes.add(id);
// Convert tween-relative percentages to clip-relative so diamonds
// render at the correct position within the timeline clip.
const tweenPos = typeof anim.position === "number" ? anim.position : 0;
const tweenDur = anim.duration ?? 1;
const timelineEl = elements.find(
(el) => el.domId === id || (el.key ?? el.id) === `${targetPath}#${id}`,
);
const elStart = timelineEl?.start ?? 0;
const elDuration = timelineEl?.duration ?? 4;
const clipKeyframes = anim.keyframes.keyframes.map((kf) => {
const absTime = tweenPos + (kf.percentage / 100) * tweenDur;
const clipPct =
elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage;
return { ...kf, percentage: clipPct };
});
const existing = merged.get(id);
if (existing) {
const byPct = new Map<number, (typeof existing.keyframes)[0]>();
for (const kf of [...existing.keyframes, ...clipKeyframes]) {
const prev = byPct.get(kf.percentage);
if (prev) {
prev.properties = { ...prev.properties, ...kf.properties };
if (kf.ease) prev.ease = kf.ease;
} else {
byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
}
}
existing.keyframes = Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage);
} else {
merged.set(id, { ...anim.keyframes, keyframes: clipKeyframes });
}
}
for (const [id, entry] of merged) {
setKeyframeCache(`${targetPath}#${id}`, entry);
setKeyframeCache(id, entry);
if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, entry);
}
const targetId =
(mutation as { targetSelector?: string }).targetSelector?.match(/^#([\w-]+)/)?.[1] ??
selectionId;
if (targetId && !idsWithKeyframes.has(targetId)) {
setKeyframeCache(`${targetPath}#${targetId}`, undefined);
if (targetPath !== "index.html") setKeyframeCache(`index.html#${targetId}`, undefined);
}
}
function buildCacheKey(sourceFile: string, elementId: string): string {
return `${sourceFile}#${elementId}`;
}
function readKeyframeSnapshot(
sourceFile: string,
elementId: string | null | undefined,
): KeyframeCacheEntry | undefined {
if (!elementId) return undefined;
return usePlayerStore.getState().keyframeCache.get(buildCacheKey(sourceFile, elementId));
}
function writeKeyframeCache(
sourceFile: string,
elementId: string | null | undefined,
data: KeyframeCacheEntry | undefined,
): void {
if (!elementId) return;
usePlayerStore.getState().setKeyframeCache(buildCacheKey(sourceFile, elementId), data);
}
interface GsapScriptCommitsParams {
projectIdRef: React.MutableRefObject<string | null>;
activeCompPath: string | null;
+10 -132
View File
@@ -1,8 +1,6 @@
import { useCallback, useRef } from "react";
import type { TimelineElement } from "../player";
import { usePlayerStore } from "../player";
import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
import {
buildTimelineAssetId,
buildTimelineAssetInsertHtml,
@@ -19,6 +17,16 @@ import {
resolveDroppedAssetDuration,
} from "../utils/studioHelpers";
import type { EditHistoryKind } from "../utils/editHistory";
import {
buildPatchTarget,
patchIframeDomTiming,
resolveResizePlaybackStart,
persistTimelineEdit,
readFileContent,
applyPatchByTarget,
formatTimelineAttributeNumber,
} from "./timelineEditingHelpers";
import type { PatchTarget, PersistTimelineEditInput } from "./timelineEditingHelpers";
// ── Types ──
@@ -44,136 +52,6 @@ interface UseTimelineEditingOptions {
isRecordingRef?: React.RefObject<boolean>;
}
// ── Helpers ──
function buildPatchTarget(element: {
domId?: string;
hfId?: string;
selector?: string;
selectorIndex?: number;
}) {
if (element.domId) {
return {
id: element.domId,
hfId: element.hfId,
selector: element.selector,
selectorIndex: element.selectorIndex,
};
}
if (element.hfId) {
return { hfId: element.hfId, selector: element.selector, selectorIndex: element.selectorIndex };
}
if (element.selector) {
return { selector: element.selector, selectorIndex: element.selectorIndex };
}
return null;
}
// The runtime re-reads data-start/data-duration from the DOM on each sync tick
// (packages/core/src/runtime/init.ts:1324-1368), so attribute mutations here are
// picked up automatically on the next frame without a rebind call.
function patchIframeDomTiming(
iframe: HTMLIFrameElement | null,
element: TimelineElement,
attrs: Array<[string, string]>,
): void {
try {
const doc = iframe?.contentDocument;
if (!doc) return;
const el = element.domId
? doc.getElementById(element.domId)
: element.selector
? (doc.querySelectorAll(element.selector)[element.selectorIndex ?? 0] ?? null)
: null;
if (!el) return;
for (const [name, value] of attrs) el.setAttribute(name, value);
} catch {
// Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
}
}
function resolveResizePlaybackStart(
original: string,
target: PatchTarget,
element: TimelineElement,
updates: Pick<TimelineElement, "start" | "playbackStart">,
): { attrName: string; value: number } | null {
if (updates.playbackStart != null) {
const attrName =
element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
return { attrName, value: updates.playbackStart };
}
const trimDelta = updates.start - element.start;
if (trimDelta === 0) return null;
const raw =
readAttributeByTarget(original, target, "playback-start") ??
readAttributeByTarget(original, target, "media-start");
const current = raw != null ? parseFloat(raw) : undefined;
if (current == null || !Number.isFinite(current)) return null;
const attrName =
element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
return {
attrName,
value: Math.max(0, current + trimDelta * Math.max(element.playbackRate ?? 1, 0.1)),
};
}
type PatchTarget = NonNullable<ReturnType<typeof buildPatchTarget>>;
interface PersistTimelineEditInput {
projectId: string;
element: TimelineElement;
activeCompPath: string | null;
label: string;
buildPatches: (original: string, target: PatchTarget) => string;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
}
async function persistTimelineEdit(input: PersistTimelineEditInput): Promise<void> {
const targetPath = input.element.sourceFile || input.activeCompPath || "index.html";
const originalContent = await readFileContent(input.projectId, targetPath);
const patchTarget = buildPatchTarget(input.element);
if (!patchTarget) {
throw new Error(`Timeline element ${input.element.id} is missing a patchable target`);
}
const patchedContent = input.buildPatches(originalContent, patchTarget);
if (patchedContent === originalContent) {
throw new Error(`Unable to patch timeline element ${input.element.id} in ${targetPath}`);
}
input.pendingTimelineEditPathRef.current.add(targetPath);
input.domEditSaveTimestampRef.current = Date.now();
await saveProjectFilesWithHistory({
projectId: input.projectId,
label: input.label,
kind: "timeline",
files: { [targetPath]: patchedContent },
readFile: async () => originalContent,
writeFile: input.writeProjectFile,
recordEdit: input.recordEdit,
});
input.domEditSaveTimestampRef.current = Date.now();
}
async function readFileContent(projectId: string, targetPath: string): Promise<string> {
const response = await fetch(
`/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
);
if (!response.ok) {
throw new Error(`Failed to read ${targetPath}`);
}
const data = (await response.json()) as { content?: string };
if (typeof data.content !== "string") {
throw new Error(`Missing file contents for ${targetPath}`);
}
return data.content;
}
// ── Hook ──
export function useTimelineEditing({