mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
refactor(studio): split oversized files to pass 600-line check (#1313)
This commit is contained in:
+21
-190
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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],
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user