mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(studio,core): resolve SDK-cutover review findings (#1471)
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
co-authored by
Miguel Ángel
parent
7ca4490328
commit
377b0368bd
@@ -190,6 +190,7 @@ export function StudioApp() {
|
||||
uploadProjectFiles: fileManager.uploadProjectFiles,
|
||||
isRecordingRef: isGestureRecordingRef,
|
||||
sdkSession: sdkHandle.session,
|
||||
forceReloadSdkSession: sdkHandle.forceReload,
|
||||
});
|
||||
const {
|
||||
activeBlockParams,
|
||||
@@ -263,14 +264,10 @@ export function StudioApp() {
|
||||
? () => handleToggleRecordingRef.current()
|
||||
: undefined,
|
||||
});
|
||||
const selectSidebarTabStable = useCallback(
|
||||
(tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab),
|
||||
[],
|
||||
);
|
||||
const getSidebarTabStable = useCallback(
|
||||
() => leftSidebarRef.current?.getTab() ?? "compositions",
|
||||
[],
|
||||
);
|
||||
const sidebarTabRef = useRef({
|
||||
select: (t: SidebarTab) => leftSidebarRef.current?.selectTab(t),
|
||||
get: () => leftSidebarRef.current?.getTab() ?? "compositions",
|
||||
});
|
||||
const domEditSession = useDomEditSession({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
@@ -303,9 +300,10 @@ export function StudioApp() {
|
||||
reloadPreview,
|
||||
setRefreshKey,
|
||||
openSourceForSelection: fileManager.openSourceForSelection,
|
||||
selectSidebarTab: selectSidebarTabStable,
|
||||
getSidebarTab: getSidebarTabStable,
|
||||
selectSidebarTab: sidebarTabRef.current.select,
|
||||
getSidebarTab: sidebarTabRef.current.get,
|
||||
sdkSession: sdkHandle.session,
|
||||
forceReloadSdkSession: sdkHandle.forceReload,
|
||||
});
|
||||
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
|
||||
clearDomSelectionRef.current = domEditSession.clearDomSelection;
|
||||
|
||||
@@ -67,4 +67,6 @@ export interface GsapScriptCommitsParams {
|
||||
/** Stage 7 §3.5: SDK session for routing GSAP tween ops through addGsapTween/setGsapTween/removeGsapTween. */
|
||||
sdkSession?: Composition | null;
|
||||
writeProjectFile?: (path: string, content: string) => Promise<void>;
|
||||
/** Resync the in-memory SDK session after a server-authoritative write. */
|
||||
forceReloadSdkSession?: () => void;
|
||||
}
|
||||
|
||||
@@ -73,14 +73,17 @@ export interface UseDomEditCommitsParams {
|
||||
target: HTMLElement,
|
||||
options?: { preferClipAncestor?: boolean },
|
||||
) => Promise<DomEditSelection | null>;
|
||||
/** Stage 7 Step 3b: called after a successful server-side element patch. */
|
||||
onDomEditPersisted?: (selection: DomEditSelection, operations: PatchOperation[]) => void;
|
||||
/** Resync the in-memory SDK session after a SERVER-side write (NOT the SDK
|
||||
* path, whose session is already current) so a later SDK edit doesn't
|
||||
* serialize the pre-write doc and revert the server's change. */
|
||||
forceReloadSdkSession?: () => void;
|
||||
/** Stage 7 Step 3c: called before the server-side patch path; returns true if SDK handled it. */
|
||||
onTrySdkPersist?: (
|
||||
selection: DomEditSelection,
|
||||
operations: PatchOperation[],
|
||||
originalContent: string,
|
||||
targetPath: string,
|
||||
options?: { label?: string; coalesceKey?: string; skipRefresh?: boolean },
|
||||
) => Promise<boolean>;
|
||||
/** Stage 7 §3.1: called before the server-side delete path; returns true if SDK handled it. */
|
||||
onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise<boolean>;
|
||||
@@ -104,7 +107,7 @@ export function useDomEditCommits({
|
||||
clearDomSelection,
|
||||
refreshDomEditSelectionFromPreview,
|
||||
buildDomSelectionFromTarget,
|
||||
onDomEditPersisted,
|
||||
forceReloadSdkSession,
|
||||
onTrySdkPersist,
|
||||
onTrySdkDelete,
|
||||
}: UseDomEditCommitsParams) {
|
||||
@@ -156,19 +159,22 @@ export function useDomEditCommits({
|
||||
}
|
||||
|
||||
if (options?.shouldSave && !options.shouldSave()) return;
|
||||
|
||||
// Skip the SDK path when prepareContent is set (e.g. @font-face injection
|
||||
// for a custom font): sdkCutoverPersist serializes only the patched DOM
|
||||
// and would drop the injected content. Let the server path run prepareContent.
|
||||
if (
|
||||
onTrySdkPersist &&
|
||||
!options?.prepareContent &&
|
||||
(await onTrySdkPersist(selection, operations, originalContent, targetPath))
|
||||
(await onTrySdkPersist(selection, operations, originalContent, targetPath, {
|
||||
label: options?.label,
|
||||
coalesceKey: options?.coalesceKey,
|
||||
skipRefresh: options?.skipRefresh,
|
||||
}))
|
||||
) {
|
||||
// SDK handled it — its in-memory doc is already current.
|
||||
// SDK handled it — its in-memory doc is already current, so do NOT
|
||||
// forceReload (that would echo-reload the session we just wrote).
|
||||
return;
|
||||
}
|
||||
|
||||
const patchTarget = buildDomEditPatchTarget(selection);
|
||||
const patchBody = { target: patchTarget, operations };
|
||||
const unsafeFields = findUnsafeDomPatchValues(patchBody);
|
||||
@@ -240,7 +246,7 @@ export function useDomEditCommits({
|
||||
coalesceKey: options?.coalesceKey,
|
||||
files: { [targetPath]: { before: originalContent, after: finalContent } },
|
||||
});
|
||||
onDomEditPersisted?.(selection, operations);
|
||||
forceReloadSdkSession?.();
|
||||
|
||||
if (!options?.skipRefresh) {
|
||||
reloadPreview();
|
||||
@@ -254,7 +260,7 @@ export function useDomEditCommits({
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
showToast,
|
||||
onDomEditPersisted,
|
||||
forceReloadSdkSession,
|
||||
onTrySdkPersist,
|
||||
],
|
||||
);
|
||||
@@ -317,6 +323,7 @@ export function useDomEditCommits({
|
||||
reloadPreview,
|
||||
clearDomSelection,
|
||||
onTrySdkDelete,
|
||||
forceReloadSdkSession,
|
||||
commitPositionPatchToHtml,
|
||||
});
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ export interface UseDomEditSessionParams {
|
||||
selectSidebarTab?: (tab: SidebarTab) => void;
|
||||
getSidebarTab?: () => SidebarTab;
|
||||
sdkSession?: Composition | null;
|
||||
forceReloadSdkSession?: () => void;
|
||||
}
|
||||
|
||||
// ── Hook ──
|
||||
@@ -100,6 +101,7 @@ export function useDomEditSession({
|
||||
selectSidebarTab,
|
||||
getSidebarTab,
|
||||
sdkSession,
|
||||
forceReloadSdkSession,
|
||||
}: UseDomEditSessionParams) {
|
||||
void _setRefreshKey;
|
||||
void _readProjectFile;
|
||||
@@ -195,6 +197,7 @@ export function useDomEditSession({
|
||||
showToast,
|
||||
sdkSession,
|
||||
writeProjectFile,
|
||||
forceReloadSdkSession,
|
||||
});
|
||||
|
||||
// ── DOM commit handlers ──
|
||||
@@ -234,14 +237,24 @@ export function useDomEditSession({
|
||||
clearDomSelection,
|
||||
refreshDomEditSelectionFromPreview,
|
||||
buildDomSelectionFromTarget,
|
||||
forceReloadSdkSession,
|
||||
onTrySdkPersist: sdkSession
|
||||
? (selection, operations, originalContent, targetPath) =>
|
||||
sdkCutoverPersist(selection, operations, originalContent, targetPath, sdkSession, {
|
||||
editHistory,
|
||||
writeProjectFile,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
})
|
||||
? (selection, operations, originalContent, targetPath, options) =>
|
||||
sdkCutoverPersist(
|
||||
selection,
|
||||
operations,
|
||||
originalContent,
|
||||
targetPath,
|
||||
sdkSession,
|
||||
{
|
||||
editHistory,
|
||||
writeProjectFile,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
compositionPath: activeCompPath,
|
||||
},
|
||||
options,
|
||||
)
|
||||
: undefined,
|
||||
onTrySdkDelete: sdkSession
|
||||
? (hfId, originalContent, targetPath) =>
|
||||
@@ -250,6 +263,7 @@ export function useDomEditSession({
|
||||
writeProjectFile,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
compositionPath: activeCompPath,
|
||||
})
|
||||
: undefined,
|
||||
});
|
||||
|
||||
@@ -28,6 +28,8 @@ interface UseElementLifecycleOpsParams {
|
||||
clearDomSelection: () => void;
|
||||
/** Route delete through SDK when session resolves the hf-id; returns true if handled. */
|
||||
onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise<boolean>;
|
||||
/** Resync the SDK session after a server-fallback delete. */
|
||||
forceReloadSdkSession?: () => void;
|
||||
commitPositionPatchToHtml: (
|
||||
selection: DomEditSelection,
|
||||
patches: PatchOperation[],
|
||||
@@ -47,6 +49,7 @@ export function useElementLifecycleOps({
|
||||
reloadPreview,
|
||||
clearDomSelection,
|
||||
onTrySdkDelete,
|
||||
forceReloadSdkSession,
|
||||
commitPositionPatchToHtml,
|
||||
onElementDeleted,
|
||||
}: UseElementLifecycleOpsParams) {
|
||||
@@ -106,6 +109,12 @@ export function useElementLifecycleOps({
|
||||
const removeData = (await removeResponse.json()) as { changed?: boolean; content?: string };
|
||||
const patchedContent =
|
||||
typeof removeData.content === "string" ? removeData.content : originalContent;
|
||||
// ponytail: the server remove-element route (removeElementFromHtml) strips
|
||||
// only the element node — it does NOT cascade-remove GSAP tweens targeting
|
||||
// it, unlike the SDK path (removeElement → cascadeRemoveAnimations). This
|
||||
// fallback runs only when the element isn't in the SDK doc (e.g. runtime-
|
||||
// generated / unaddressable), where targeting tweens are unlikely. Upgrade
|
||||
// path: cascade in removeElementFromHtml by selector/hf-id to fully match.
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
label: "Delete element",
|
||||
@@ -118,6 +127,9 @@ export function useElementLifecycleOps({
|
||||
|
||||
clearDomSelection();
|
||||
usePlayerStore.getState().setSelectedElementId(null);
|
||||
// Server wrote the file; resync the stale in-memory SDK doc so a later
|
||||
// SDK edit doesn't resurrect the deleted element.
|
||||
forceReloadSdkSession?.();
|
||||
reloadPreview();
|
||||
onElementDeleted?.(selection);
|
||||
showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
|
||||
@@ -133,6 +145,7 @@ export function useElementLifecycleOps({
|
||||
editHistory.recordEdit,
|
||||
onTrySdkDelete,
|
||||
onElementDeleted,
|
||||
forceReloadSdkSession,
|
||||
projectIdRef,
|
||||
reloadPreview,
|
||||
showToast,
|
||||
|
||||
@@ -2,27 +2,16 @@ import { useCallback } from "react";
|
||||
import type { Composition } from "@hyperframes/sdk";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import { roundTo3 } from "../utils/rounding";
|
||||
import { sdkGsapTweenPersist } from "../utils/sdkCutover";
|
||||
import { sdkGsapTweenPersist, type CutoverDeps } from "../utils/sdkCutover";
|
||||
import {
|
||||
assignGsapTargetAutoIdIfNeeded,
|
||||
ensureElementAddressable,
|
||||
} from "./gsapScriptCommitHelpers";
|
||||
import type { CommitMutation, SafeGsapCommitMutation } from "./gsapScriptCommitTypes";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
|
||||
interface SdkAnimationDeps {
|
||||
sdkSession?: Composition | null;
|
||||
writeProjectFile?: (path: string, content: string) => Promise<void>;
|
||||
editHistory?: {
|
||||
recordEdit: (entry: {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
reloadPreview?: () => void;
|
||||
domEditSaveTimestampRef?: React.MutableRefObject<number>;
|
||||
sdkDeps?: CutoverDeps | null;
|
||||
}
|
||||
|
||||
interface GsapAnimationOpsParams extends SdkAnimationDeps {
|
||||
@@ -40,10 +29,7 @@ export function useGsapAnimationOps({
|
||||
commitMutationSafely,
|
||||
showToast,
|
||||
sdkSession,
|
||||
writeProjectFile,
|
||||
editHistory,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
sdkDeps,
|
||||
}: GsapAnimationOpsParams) {
|
||||
const updateGsapMeta = useCallback(
|
||||
async (
|
||||
@@ -51,19 +37,13 @@ export function useGsapAnimationOps({
|
||||
animationId: string,
|
||||
updates: { duration?: number; ease?: string; position?: number },
|
||||
) => {
|
||||
if (
|
||||
sdkSession &&
|
||||
writeProjectFile &&
|
||||
editHistory &&
|
||||
reloadPreview &&
|
||||
domEditSaveTimestampRef
|
||||
) {
|
||||
if (sdkSession && sdkDeps) {
|
||||
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
||||
const handled = await sdkGsapTweenPersist(
|
||||
targetPath,
|
||||
{ kind: "set", animationId, properties: updates },
|
||||
sdkSession,
|
||||
{ editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef },
|
||||
sdkDeps,
|
||||
{ label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta` },
|
||||
);
|
||||
if (handled) return;
|
||||
@@ -74,32 +54,18 @@ export function useGsapAnimationOps({
|
||||
{ label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta` },
|
||||
);
|
||||
},
|
||||
[
|
||||
commitMutationSafely,
|
||||
activeCompPath,
|
||||
sdkSession,
|
||||
writeProjectFile,
|
||||
editHistory,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
],
|
||||
[commitMutationSafely, activeCompPath, sdkSession, sdkDeps],
|
||||
);
|
||||
|
||||
const deleteGsapAnimation = useCallback(
|
||||
async (selection: DomEditSelection, animationId: string) => {
|
||||
if (
|
||||
sdkSession &&
|
||||
writeProjectFile &&
|
||||
editHistory &&
|
||||
reloadPreview &&
|
||||
domEditSaveTimestampRef
|
||||
) {
|
||||
if (sdkSession && sdkDeps) {
|
||||
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
||||
const handled = await sdkGsapTweenPersist(
|
||||
targetPath,
|
||||
{ kind: "remove", animationId },
|
||||
sdkSession,
|
||||
{ editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef },
|
||||
sdkDeps,
|
||||
{ label: "Delete GSAP animation" },
|
||||
);
|
||||
if (handled) return;
|
||||
@@ -110,15 +76,7 @@ export function useGsapAnimationOps({
|
||||
{ label: "Delete GSAP animation" },
|
||||
);
|
||||
},
|
||||
[
|
||||
commitMutationSafely,
|
||||
activeCompPath,
|
||||
sdkSession,
|
||||
writeProjectFile,
|
||||
editHistory,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
],
|
||||
[commitMutationSafely, activeCompPath, sdkSession, sdkDeps],
|
||||
);
|
||||
|
||||
const deleteAllForSelector = useCallback(
|
||||
@@ -168,16 +126,12 @@ export function useGsapAnimationOps({
|
||||
fromTo: { x: 0, y: 0, opacity: 1 },
|
||||
};
|
||||
|
||||
// SDK path: addGsapTween only supports from/to/fromTo; "set" stays server-side
|
||||
if (
|
||||
method !== "set" &&
|
||||
selection.hfId &&
|
||||
sdkSession &&
|
||||
writeProjectFile &&
|
||||
editHistory &&
|
||||
reloadPreview &&
|
||||
domEditSaveTimestampRef
|
||||
) {
|
||||
// SDK path: addGsapTween only supports from/to/fromTo; "set" stays
|
||||
// server-side. Skip the SDK path when an id was just assigned server-side
|
||||
// (autoId): the SDK session hasn't reloaded that write yet, so persisting
|
||||
// its serialization would clobber the new id — let the server add the
|
||||
// tween atomically with the id it wrote.
|
||||
if (!autoId && method !== "set" && selection.hfId && sdkSession && sdkDeps) {
|
||||
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
||||
const spec = {
|
||||
method: method as "to" | "from" | "fromTo",
|
||||
@@ -191,7 +145,7 @@ export function useGsapAnimationOps({
|
||||
targetPath,
|
||||
{ kind: "add", target: selection.hfId, spec },
|
||||
sdkSession,
|
||||
{ editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef },
|
||||
sdkDeps,
|
||||
{ label: `Add GSAP ${method} animation` },
|
||||
);
|
||||
if (handled) return;
|
||||
@@ -212,17 +166,7 @@ export function useGsapAnimationOps({
|
||||
{ label: `Add GSAP ${method} animation` },
|
||||
);
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
commitMutation,
|
||||
projectIdRef,
|
||||
showToast,
|
||||
sdkSession,
|
||||
writeProjectFile,
|
||||
editHistory,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
],
|
||||
[activeCompPath, commitMutation, projectIdRef, showToast, sdkSession, sdkDeps],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { Composition } from "@hyperframes/sdk";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import { executeOptimistic } from "../utils/optimisticUpdate";
|
||||
import { sdkGsapKeyframePersist } from "../utils/sdkCutover";
|
||||
import { sdkGsapKeyframePersist, type CutoverDeps } from "../utils/sdkCutover";
|
||||
import type { KeyframeCacheEntry } from "../player/store/playerStore";
|
||||
import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
|
||||
import { readKeyframeSnapshot, writeKeyframeCache } from "./gsapKeyframeCacheHelpers";
|
||||
@@ -12,7 +12,6 @@ import type {
|
||||
SafeGsapCommitMutation,
|
||||
TrackGsapSaveFailure,
|
||||
} from "./gsapScriptCommitTypes";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
|
||||
function executeOptimisticKeyframeCacheUpdate(options: {
|
||||
sourceFile: string;
|
||||
@@ -35,17 +34,7 @@ function executeOptimisticKeyframeCacheUpdate(options: {
|
||||
|
||||
interface SdkKeyframeDeps {
|
||||
sdkSession?: Composition | null;
|
||||
writeProjectFile?: (path: string, content: string) => Promise<void>;
|
||||
editHistory?: {
|
||||
recordEdit: (entry: {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
reloadPreview?: () => void;
|
||||
domEditSaveTimestampRef?: React.MutableRefObject<number>;
|
||||
sdkDeps?: CutoverDeps | null;
|
||||
}
|
||||
|
||||
interface GsapKeyframeOpsParams extends SdkKeyframeDeps {
|
||||
@@ -61,10 +50,7 @@ export function useGsapKeyframeOps({
|
||||
commitMutationSafely,
|
||||
trackGsapSaveFailure,
|
||||
sdkSession,
|
||||
writeProjectFile,
|
||||
editHistory,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
sdkDeps,
|
||||
}: GsapKeyframeOpsParams) {
|
||||
const addKeyframe = useCallback(
|
||||
(
|
||||
@@ -84,27 +70,37 @@ export function useGsapKeyframeOps({
|
||||
void executeOptimisticKeyframeCacheUpdate({
|
||||
sourceFile,
|
||||
elementId: selection.id,
|
||||
apply: (prev) => ({
|
||||
...prev,
|
||||
keyframes: [...prev.keyframes, { percentage, properties: { [property]: value } }].sort(
|
||||
(a, b) => a.percentage - b.percentage,
|
||||
),
|
||||
}),
|
||||
// Merge into an existing keyframe at this percentage rather than
|
||||
// appending a duplicate — matches addKeyframeToScript, which writes one
|
||||
// keyframe per percentage (merging properties).
|
||||
apply: (prev) => {
|
||||
const idx = prev.keyframes.findIndex(
|
||||
(kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) < 0.001,
|
||||
);
|
||||
if (idx >= 0) {
|
||||
const keyframes = prev.keyframes.slice();
|
||||
keyframes[idx] = {
|
||||
...keyframes[idx],
|
||||
properties: { ...keyframes[idx].properties, [property]: value },
|
||||
};
|
||||
return { ...prev, keyframes };
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
keyframes: [...prev.keyframes, { percentage, properties: { [property]: value } }].sort(
|
||||
(a, b) => a.percentage - b.percentage,
|
||||
),
|
||||
};
|
||||
},
|
||||
persist: async () => {
|
||||
if (
|
||||
sdkSession &&
|
||||
writeProjectFile &&
|
||||
editHistory &&
|
||||
reloadPreview &&
|
||||
domEditSaveTimestampRef
|
||||
) {
|
||||
if (sdkSession && sdkDeps) {
|
||||
const handled = await sdkGsapKeyframePersist(
|
||||
sourceFile,
|
||||
animationId,
|
||||
percentage,
|
||||
{ [property]: value },
|
||||
sdkSession,
|
||||
{ editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef },
|
||||
sdkDeps,
|
||||
{
|
||||
label: `Add keyframe at ${percentage}%`,
|
||||
coalesceKey: `gsap:${animationId}:kf:${percentage}`,
|
||||
@@ -121,16 +117,7 @@ export function useGsapKeyframeOps({
|
||||
trackGsapSaveFailure(error, selection, mutation, `Add keyframe at ${percentage}%`);
|
||||
});
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
commitMutation,
|
||||
trackGsapSaveFailure,
|
||||
sdkSession,
|
||||
writeProjectFile,
|
||||
editHistory,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
],
|
||||
[activeCompPath, commitMutation, trackGsapSaveFailure, sdkSession, sdkDeps],
|
||||
);
|
||||
|
||||
const addKeyframeBatch = useCallback(
|
||||
@@ -140,13 +127,7 @@ export function useGsapKeyframeOps({
|
||||
percentage: number,
|
||||
properties: Record<string, number | string>,
|
||||
) => {
|
||||
if (
|
||||
sdkSession &&
|
||||
writeProjectFile &&
|
||||
editHistory &&
|
||||
reloadPreview &&
|
||||
domEditSaveTimestampRef
|
||||
) {
|
||||
if (sdkSession && sdkDeps) {
|
||||
const sourceFile = selection.sourceFile || activeCompPath || "index.html";
|
||||
const handled = await sdkGsapKeyframePersist(
|
||||
sourceFile,
|
||||
@@ -154,7 +135,7 @@ export function useGsapKeyframeOps({
|
||||
percentage,
|
||||
properties,
|
||||
sdkSession,
|
||||
{ editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef },
|
||||
sdkDeps,
|
||||
{ label: `Add keyframe at ${percentage}%` },
|
||||
);
|
||||
if (handled) return;
|
||||
@@ -165,15 +146,7 @@ export function useGsapKeyframeOps({
|
||||
{ label: `Add keyframe at ${percentage}%`, softReload: true },
|
||||
);
|
||||
},
|
||||
[
|
||||
commitMutation,
|
||||
activeCompPath,
|
||||
sdkSession,
|
||||
writeProjectFile,
|
||||
editHistory,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
],
|
||||
[commitMutation, activeCompPath, sdkSession, sdkDeps],
|
||||
);
|
||||
|
||||
const removeKeyframe = useCallback(
|
||||
|
||||
@@ -1,32 +1,21 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type { Composition } from "@hyperframes/sdk";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import { sdkGsapTweenPersist } from "../utils/sdkCutover";
|
||||
import { sdkGsapTweenPersist, type CutoverDeps } from "../utils/sdkCutover";
|
||||
import { PROPERTY_DEFAULTS } from "./gsapScriptCommitHelpers";
|
||||
import type { SafeGsapCommitMutation } from "./gsapScriptCommitTypes";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
|
||||
const DEBOUNCE_MS = 150;
|
||||
|
||||
interface SdkPropertyDeps {
|
||||
sdkSession?: Composition | null;
|
||||
writeProjectFile?: (path: string, content: string) => Promise<void>;
|
||||
editHistory?: {
|
||||
recordEdit: (entry: {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
reloadPreview?: () => void;
|
||||
domEditSaveTimestampRef?: React.MutableRefObject<number>;
|
||||
sdkDeps?: CutoverDeps | null;
|
||||
activeCompPath?: string | null;
|
||||
}
|
||||
|
||||
export function useGsapPropertyDebounce(
|
||||
commitMutationSafely: SafeGsapCommitMutation,
|
||||
sdkDeps?: SdkPropertyDeps,
|
||||
sdk?: SdkPropertyDeps,
|
||||
) {
|
||||
const pendingPropertyEditRef = useRef<{
|
||||
selection: DomEditSelection;
|
||||
@@ -36,51 +25,33 @@ export function useGsapPropertyDebounce(
|
||||
} | null>(null);
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const flushPendingPropertyEdit = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
async () => {
|
||||
const pending = pendingPropertyEditRef.current;
|
||||
if (!pending) return;
|
||||
pendingPropertyEditRef.current = null;
|
||||
const { selection, animationId, property, value } = pending;
|
||||
const {
|
||||
const flushPendingPropertyEdit = useCallback(async () => {
|
||||
const pending = pendingPropertyEditRef.current;
|
||||
if (!pending) return;
|
||||
pendingPropertyEditRef.current = null;
|
||||
const { selection, animationId, property, value } = pending;
|
||||
const { sdkSession, sdkDeps, activeCompPath } = sdk ?? {};
|
||||
if (sdkSession && sdkDeps) {
|
||||
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
||||
const handled = await sdkGsapTweenPersist(
|
||||
targetPath,
|
||||
{ kind: "set", animationId, properties: { properties: { [property]: value } } },
|
||||
sdkSession,
|
||||
writeProjectFile,
|
||||
editHistory,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
activeCompPath,
|
||||
} = sdkDeps ?? {};
|
||||
if (
|
||||
sdkSession &&
|
||||
writeProjectFile &&
|
||||
editHistory &&
|
||||
reloadPreview &&
|
||||
domEditSaveTimestampRef
|
||||
) {
|
||||
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
||||
const handled = await sdkGsapTweenPersist(
|
||||
targetPath,
|
||||
{ kind: "set", animationId, properties: { properties: { [property]: value } } },
|
||||
sdkSession,
|
||||
{ editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef },
|
||||
{ label: `Edit GSAP ${property}`, coalesceKey: `gsap:${animationId}:${property}` },
|
||||
);
|
||||
if (handled) return;
|
||||
}
|
||||
commitMutationSafely(
|
||||
selection,
|
||||
{ type: "update-property", animationId, property, value },
|
||||
{
|
||||
label: `Edit GSAP ${property}`,
|
||||
coalesceKey: `gsap:${animationId}:${property}`,
|
||||
softReload: true,
|
||||
},
|
||||
sdkDeps,
|
||||
{ label: `Edit GSAP ${property}`, coalesceKey: `gsap:${animationId}:${property}` },
|
||||
);
|
||||
},
|
||||
[commitMutationSafely, sdkDeps],
|
||||
);
|
||||
if (handled) return;
|
||||
}
|
||||
commitMutationSafely(
|
||||
selection,
|
||||
{ type: "update-property", animationId, property, value },
|
||||
{
|
||||
label: `Edit GSAP ${property}`,
|
||||
coalesceKey: `gsap:${animationId}:${property}`,
|
||||
softReload: true,
|
||||
},
|
||||
);
|
||||
}, [commitMutationSafely, sdk]);
|
||||
|
||||
const updateGsapProperty = useCallback(
|
||||
(
|
||||
@@ -118,27 +89,14 @@ export function useGsapPropertyDebounce(
|
||||
const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
|
||||
defaultValue = cs ? Number.parseFloat(cs.opacity) || 1 : 1;
|
||||
}
|
||||
const {
|
||||
sdkSession,
|
||||
writeProjectFile,
|
||||
editHistory,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
activeCompPath,
|
||||
} = sdkDeps ?? {};
|
||||
if (
|
||||
sdkSession &&
|
||||
writeProjectFile &&
|
||||
editHistory &&
|
||||
reloadPreview &&
|
||||
domEditSaveTimestampRef
|
||||
) {
|
||||
const { sdkSession, sdkDeps, activeCompPath } = sdk ?? {};
|
||||
if (sdkSession && sdkDeps) {
|
||||
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
||||
const handled = await sdkGsapTweenPersist(
|
||||
targetPath,
|
||||
{ kind: "set", animationId, properties: { properties: { [property]: defaultValue } } },
|
||||
sdkSession,
|
||||
{ editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef },
|
||||
sdkDeps,
|
||||
{ label: `Add GSAP ${property}` },
|
||||
);
|
||||
if (handled) return;
|
||||
@@ -149,7 +107,7 @@ export function useGsapPropertyDebounce(
|
||||
{ label: `Add GSAP ${property}` },
|
||||
);
|
||||
},
|
||||
[commitMutationSafely, sdkDeps],
|
||||
[commitMutationSafely, sdk],
|
||||
);
|
||||
|
||||
const removeGsapProperty = useCallback(
|
||||
@@ -164,36 +122,21 @@ export function useGsapPropertyDebounce(
|
||||
[commitMutationSafely],
|
||||
);
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const updateGsapFromProperty = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
async (
|
||||
selection: DomEditSelection,
|
||||
animationId: string,
|
||||
property: string,
|
||||
value: number | string,
|
||||
) => {
|
||||
const {
|
||||
sdkSession,
|
||||
writeProjectFile,
|
||||
editHistory,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
activeCompPath,
|
||||
} = sdkDeps ?? {};
|
||||
if (
|
||||
sdkSession &&
|
||||
writeProjectFile &&
|
||||
editHistory &&
|
||||
reloadPreview &&
|
||||
domEditSaveTimestampRef
|
||||
) {
|
||||
const { sdkSession, sdkDeps, activeCompPath } = sdk ?? {};
|
||||
if (sdkSession && sdkDeps) {
|
||||
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
||||
const handled = await sdkGsapTweenPersist(
|
||||
targetPath,
|
||||
{ kind: "set", animationId, properties: { fromProperties: { [property]: value } } },
|
||||
sdkSession,
|
||||
{ editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef },
|
||||
sdkDeps,
|
||||
{
|
||||
label: `Edit GSAP from-${property}`,
|
||||
coalesceKey: `gsap:${animationId}:from:${property}`,
|
||||
@@ -210,29 +153,14 @@ export function useGsapPropertyDebounce(
|
||||
},
|
||||
);
|
||||
},
|
||||
[commitMutationSafely, sdkDeps],
|
||||
[commitMutationSafely, sdk],
|
||||
);
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const addGsapFromProperty = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
async (selection: DomEditSelection, animationId: string, property: string) => {
|
||||
const defaultValue = PROPERTY_DEFAULTS[property] ?? 0;
|
||||
const {
|
||||
sdkSession,
|
||||
writeProjectFile,
|
||||
editHistory,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
activeCompPath,
|
||||
} = sdkDeps ?? {};
|
||||
if (
|
||||
sdkSession &&
|
||||
writeProjectFile &&
|
||||
editHistory &&
|
||||
reloadPreview &&
|
||||
domEditSaveTimestampRef
|
||||
) {
|
||||
const { sdkSession, sdkDeps, activeCompPath } = sdk ?? {};
|
||||
if (sdkSession && sdkDeps) {
|
||||
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
||||
const handled = await sdkGsapTweenPersist(
|
||||
targetPath,
|
||||
@@ -242,7 +170,7 @@ export function useGsapPropertyDebounce(
|
||||
properties: { fromProperties: { [property]: defaultValue } },
|
||||
},
|
||||
sdkSession,
|
||||
{ editHistory, writeProjectFile, reloadPreview, domEditSaveTimestampRef },
|
||||
sdkDeps,
|
||||
{ label: `Add GSAP from-${property}` },
|
||||
);
|
||||
if (handled) return;
|
||||
@@ -253,7 +181,7 @@ export function useGsapPropertyDebounce(
|
||||
{ label: `Add GSAP from-${property}` },
|
||||
);
|
||||
},
|
||||
[commitMutationSafely, sdkDeps],
|
||||
[commitMutationSafely, sdk],
|
||||
);
|
||||
|
||||
const removeGsapFromProperty = useCallback(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import { applySoftReload } from "../utils/gsapSoftReload";
|
||||
import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload";
|
||||
import type { CutoverDeps } from "../utils/sdkCutover";
|
||||
import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers";
|
||||
import { createKeyedSerializer } from "./serializeByKey";
|
||||
import {
|
||||
@@ -44,7 +45,7 @@ async function mutateGsapScript(
|
||||
|
||||
// oxfmt-ignore
|
||||
// fallow-ignore-next-line complexity
|
||||
export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, writeProjectFile }: GsapScriptCommitsParams) {
|
||||
export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, writeProjectFile, forceReloadSdkSession }: GsapScriptCommitsParams) {
|
||||
// Serializer for per-key commits (options.serializeKey). Keyed by
|
||||
// `gsap:${animationId}:meta`, it chains a meta commit onto the prior one for
|
||||
// the same animationId so their POSTs can't interleave. Held in a ref so the
|
||||
@@ -75,6 +76,9 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
||||
await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } });
|
||||
}
|
||||
if (result.after != null) onFileContentChanged?.(targetPath, result.after);
|
||||
// Server wrote the file; the in-memory SDK doc is now stale. Resync it so a
|
||||
// later SDK-routed edit doesn't serialize the pre-write doc and revert this.
|
||||
forceReloadSdkSession?.();
|
||||
if (options.skipReload) return;
|
||||
if (result.parsed?.animations) updateKeyframeCacheFromParsed(result.parsed.animations, targetPath, selection.id ?? undefined, mutation);
|
||||
options.beforeReload?.();
|
||||
@@ -84,7 +88,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
||||
reloadPreview();
|
||||
}
|
||||
onCacheInvalidate();
|
||||
}, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast]);
|
||||
}, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, forceReloadSdkSession]);
|
||||
// Every GSAP-script commit is a read-modify-write of one file. Overlapping
|
||||
// commits to the SAME file (any op type, any animation) interleave server-side,
|
||||
// so serialize per target file by default; an explicit serializeKey overrides.
|
||||
@@ -98,12 +102,44 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
||||
);
|
||||
const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
|
||||
const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast);
|
||||
|
||||
// One stable SDK-deps object shared by all GSAP child hooks. Memoized so the
|
||||
// hooks' callbacks keep a stable identity (an inline literal here re-fired the
|
||||
// property-debounce flush on every render). refresh() soft-reloads (preserving
|
||||
// the playhead) and invalidates the panel cache, matching the server path.
|
||||
const sdkRefresh = useCallback(
|
||||
(after: string) => {
|
||||
const script = extractGsapScriptText(after);
|
||||
if (!(script && applySoftReload(previewIframeRef.current, script))) reloadPreview();
|
||||
onCacheInvalidate();
|
||||
},
|
||||
[previewIframeRef, reloadPreview, onCacheInvalidate],
|
||||
);
|
||||
const sdkDeps = useMemo<CutoverDeps | null>(
|
||||
() =>
|
||||
writeProjectFile
|
||||
? {
|
||||
editHistory: { recordEdit: editHistory.recordEdit },
|
||||
writeProjectFile,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
refresh: sdkRefresh,
|
||||
compositionPath: activeCompPath,
|
||||
}
|
||||
: null,
|
||||
[
|
||||
editHistory.recordEdit,
|
||||
writeProjectFile,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
sdkRefresh,
|
||||
activeCompPath,
|
||||
],
|
||||
);
|
||||
|
||||
const propertyOps = useGsapPropertyDebounce(commitMutationSafely, {
|
||||
sdkSession,
|
||||
writeProjectFile,
|
||||
editHistory,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
sdkDeps,
|
||||
activeCompPath,
|
||||
});
|
||||
const animationOps = useGsapAnimationOps({
|
||||
@@ -113,10 +149,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
||||
commitMutationSafely,
|
||||
showToast,
|
||||
sdkSession,
|
||||
writeProjectFile,
|
||||
editHistory,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
sdkDeps,
|
||||
});
|
||||
const keyframeOps = useGsapKeyframeOps({
|
||||
activeCompPath,
|
||||
@@ -124,10 +157,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
||||
commitMutationSafely,
|
||||
trackGsapSaveFailure,
|
||||
sdkSession,
|
||||
writeProjectFile,
|
||||
editHistory,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
sdkDeps,
|
||||
});
|
||||
const arcPathOps = useGsapArcPathOps(commitMutationSafely);
|
||||
return { commitMutation, ...propertyOps, ...animationOps, ...keyframeOps, ...arcPathOps };
|
||||
|
||||
@@ -21,12 +21,8 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string
|
||||
* (projectId, activeCompPath) change, disposes the old one on cleanup, and
|
||||
* re-opens it when the active composition file changes on disk (code editor,
|
||||
* agent, or server-side patch) so the in-memory linkedom document never goes
|
||||
* stale. The persist queue writes back to `activeCompPath` (not the
|
||||
* "composition.html" default).
|
||||
*
|
||||
* The session is idle until Step 3c routes dispatch ops through it; re-opening
|
||||
* is therefore purely additive — no SDK self-write exists yet, so there is no
|
||||
* persist echo. Step 3c must add self-write suppression once dispatch writes.
|
||||
* stale. The session has NO persist queue — Studio is the sole file writer; see
|
||||
* the open effect below.
|
||||
*/
|
||||
// Time-window heuristic: suppress file-change reloads for 2 s after our own
|
||||
// SDK cutover write, to avoid an echo-reload on the write we just committed.
|
||||
@@ -95,13 +91,13 @@ export function useSdkSession(
|
||||
.read(activeCompPath)
|
||||
.then(async (content) => {
|
||||
if (cancelled || typeof content !== "string") return;
|
||||
const comp = await openComposition(content, {
|
||||
persist: adapter,
|
||||
persistPath: activeCompPath,
|
||||
});
|
||||
comp.on("persist:error", (e) => {
|
||||
console.warn("[sdk] persist:error", e.error);
|
||||
});
|
||||
// No persist queue: Studio's writeProjectFile (via sdkCutover's
|
||||
// persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist
|
||||
// queue too would double-write the file (queue auto-writes on every
|
||||
// 'change' AND Studio writes explicitly) and race on disk; it would
|
||||
// also write the full active-composition serialization to the fixed
|
||||
// persistPath even when an edit targeted a sub-composition file.
|
||||
const comp = await openComposition(content);
|
||||
// Cleanup may have fired while openComposition was awaited; dispose immediately.
|
||||
if (cancelled) {
|
||||
comp.dispose();
|
||||
@@ -116,8 +112,9 @@ export function useSdkSession(
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
const c = compRef.current;
|
||||
if (c) void c.flush().finally(() => c.dispose());
|
||||
// No queue to flush; dispose only. (Flushing here would serialize the
|
||||
// pre-undo in-memory doc and race the revert write on undo/redo reload.)
|
||||
compRef.current?.dispose();
|
||||
};
|
||||
}, [projectId, activeCompPath, reloadToken]);
|
||||
|
||||
|
||||
@@ -58,6 +58,8 @@ interface UseTimelineEditingOptions {
|
||||
isRecordingRef?: React.RefObject<boolean>;
|
||||
/** Stage 7 §3.2: SDK session for routing timing ops through setTiming. */
|
||||
sdkSession?: Composition | null;
|
||||
/** Resync the SDK session after a server-authoritative timeline write. */
|
||||
forceReloadSdkSession?: () => void;
|
||||
}
|
||||
|
||||
// ── Hook ──
|
||||
@@ -76,6 +78,7 @@ export function useTimelineEditing({
|
||||
uploadProjectFiles,
|
||||
isRecordingRef,
|
||||
sdkSession,
|
||||
forceReloadSdkSession,
|
||||
}: UseTimelineEditingOptions) {
|
||||
const projectIdRef = useRef(projectId);
|
||||
projectIdRef.current = projectId;
|
||||
@@ -95,19 +98,24 @@ export function useTimelineEditing({
|
||||
}
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return Promise.resolve();
|
||||
const queued = editQueueRef.current.then(() =>
|
||||
persistTimelineEdit({
|
||||
projectId: pid,
|
||||
element,
|
||||
activeCompPath,
|
||||
label,
|
||||
buildPatches,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
}),
|
||||
);
|
||||
const queued = editQueueRef.current
|
||||
.then(() =>
|
||||
persistTimelineEdit({
|
||||
projectId: pid,
|
||||
element,
|
||||
activeCompPath,
|
||||
label,
|
||||
buildPatches,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
}),
|
||||
)
|
||||
.then(() => {
|
||||
// Server wrote the file; resync the stale in-memory SDK doc.
|
||||
forceReloadSdkSession?.();
|
||||
});
|
||||
editQueueRef.current = queued.catch((error) => {
|
||||
console.error(`[Timeline] Failed to persist: ${label}`, error);
|
||||
});
|
||||
@@ -121,6 +129,7 @@ export function useTimelineEditing({
|
||||
pendingTimelineEditPathRef,
|
||||
showToast,
|
||||
isRecordingRef,
|
||||
forceReloadSdkSession,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -151,7 +160,13 @@ export function useTimelineEditing({
|
||||
targetPath,
|
||||
{ start: updates.start, trackIndex: updates.track },
|
||||
sdkSession,
|
||||
{ editHistory: { recordEdit }, writeProjectFile, reloadPreview, domEditSaveTimestampRef },
|
||||
{
|
||||
editHistory: { recordEdit },
|
||||
writeProjectFile,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
compositionPath: activeCompPath,
|
||||
},
|
||||
{ label: "Move timeline clip", coalesceKey: `timeline-move:${element.hfId}` },
|
||||
).then((handled) => {
|
||||
if (!handled) return enqueueEdit(element, "Move timeline clip", buildMovePatches);
|
||||
@@ -215,7 +230,13 @@ export function useTimelineEditing({
|
||||
targetPath,
|
||||
{ start: updates.start, duration: updates.duration },
|
||||
sdkSession,
|
||||
{ editHistory: { recordEdit }, writeProjectFile, reloadPreview, domEditSaveTimestampRef },
|
||||
{
|
||||
editHistory: { recordEdit },
|
||||
writeProjectFile,
|
||||
reloadPreview,
|
||||
domEditSaveTimestampRef,
|
||||
compositionPath: activeCompPath,
|
||||
},
|
||||
{ label: "Resize timeline clip", coalesceKey: `timeline-resize:${element.hfId}` },
|
||||
).then((handled) => {
|
||||
if (!handled) return enqueueEdit(element, "Resize timeline clip", buildResizePatches);
|
||||
@@ -292,6 +313,7 @@ export function useTimelineEditing({
|
||||
timelineElements.filter((te) => (te.key ?? te.id) !== (element.key ?? element.id)),
|
||||
);
|
||||
usePlayerStore.getState().setSelectedElementId(null);
|
||||
forceReloadSdkSession?.();
|
||||
reloadPreview();
|
||||
showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
|
||||
} catch (error) {
|
||||
@@ -308,6 +330,7 @@ export function useTimelineEditing({
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
forceReloadSdkSession,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -376,6 +399,7 @@ export function useTimelineEditing({
|
||||
recordEdit,
|
||||
});
|
||||
|
||||
forceReloadSdkSession?.();
|
||||
reloadPreview();
|
||||
} catch (error) {
|
||||
const message =
|
||||
@@ -392,6 +416,7 @@ export function useTimelineEditing({
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
forceReloadSdkSession,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -31,6 +31,19 @@ function findGsapScriptElements(doc: Document): HTMLScriptElement[] {
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the GSAP timeline script text from a serialized HTML document, for
|
||||
* feeding into applySoftReload. Returns null when zero or multiple GSAP scripts
|
||||
* are present (ambiguous — caller should fall back to a full reload), matching
|
||||
* applySoftReload's own single-script requirement.
|
||||
*/
|
||||
export function extractGsapScriptText(html: string): string | null {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
const scripts = findGsapScriptElements(doc);
|
||||
if (scripts.length !== 1) return null;
|
||||
return scripts[0].textContent || null;
|
||||
}
|
||||
|
||||
/** Check that the new script repopulated __timelines with at least one entry. */
|
||||
function verifyTimelinesPopulated(win: IframeWindow): boolean {
|
||||
const tlKeys = win.__timelines
|
||||
@@ -73,6 +86,7 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
|
||||
// full iframe reload that destroys the very WebGL context we're preserving.
|
||||
let deferredToAsync = false;
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const doReload = () => {
|
||||
const timelines = win.__timelines;
|
||||
const allTargets: Element[] = [];
|
||||
|
||||
@@ -104,7 +104,12 @@ describe("sdkCutoverPersist", () => {
|
||||
({
|
||||
getElement: vi.fn().mockReturnValue(hasEl ? { inlineStyles: {} } : null),
|
||||
dispatch: vi.fn(),
|
||||
serialize: vi.fn().mockReturnValue("<html></html>"),
|
||||
// Distinct before/after so the no-op guard (after === before → fall back)
|
||||
// treats this as a real change; "after" matches the write assertions.
|
||||
serialize: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce("<html>before</html>")
|
||||
.mockReturnValue("<html></html>"),
|
||||
batch: vi.fn((fn: () => void) => fn()),
|
||||
}) as unknown as Parameters<typeof sdkCutoverPersist>[4];
|
||||
|
||||
@@ -318,7 +323,11 @@ describe("sdkDeletePersist", () => {
|
||||
({
|
||||
getElement: vi.fn().mockReturnValue(hasEl ? { id: "hf-abc" } : null),
|
||||
removeElement: vi.fn(),
|
||||
serialize: vi.fn().mockReturnValue("<html>after</html>"),
|
||||
serialize: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce("<html>before-snap</html>")
|
||||
.mockReturnValue("<html>after</html>"),
|
||||
batch: vi.fn((fn: () => void) => fn()),
|
||||
}) as unknown as Parameters<typeof sdkDeletePersist>[3];
|
||||
|
||||
it("returns false when session is null", async () => {
|
||||
@@ -390,6 +399,7 @@ describe("sdkTimingPersist", () => {
|
||||
.fn()
|
||||
.mockReturnValueOnce("<html>before</html>")
|
||||
.mockReturnValue("<html>after</html>"),
|
||||
batch: vi.fn((fn: () => void) => fn()),
|
||||
}) as unknown as Parameters<typeof sdkTimingPersist>[3];
|
||||
|
||||
it("returns false when session is null", async () => {
|
||||
@@ -466,6 +476,7 @@ describe("sdkGsapTweenPersist", () => {
|
||||
.fn()
|
||||
.mockReturnValueOnce("<html>before</html>")
|
||||
.mockReturnValue("<html>after</html>"),
|
||||
batch: vi.fn((fn: () => void) => fn()),
|
||||
}) as unknown as Parameters<typeof sdkGsapTweenPersist>[2];
|
||||
|
||||
it("returns false when session is null", async () => {
|
||||
@@ -573,6 +584,7 @@ describe("sdkGsapKeyframePersist", () => {
|
||||
.fn()
|
||||
.mockReturnValueOnce("<html>before</html>")
|
||||
.mockReturnValue("<html>after</html>"),
|
||||
batch: vi.fn((fn: () => void) => fn()),
|
||||
}) as unknown as Parameters<typeof sdkGsapKeyframePersist>[4];
|
||||
|
||||
it("returns false when session is null", async () => {
|
||||
|
||||
@@ -64,7 +64,7 @@ export function shouldUseSdkCutover(
|
||||
);
|
||||
}
|
||||
|
||||
interface CutoverDeps {
|
||||
export interface CutoverDeps {
|
||||
editHistory: {
|
||||
recordEdit: (entry: {
|
||||
label: string;
|
||||
@@ -76,22 +76,44 @@ interface CutoverDeps {
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
reloadPreview: () => void;
|
||||
domEditSaveTimestampRef: MutableRefObject<number>;
|
||||
/**
|
||||
* Optional post-write refresh. When provided, it REPLACES the default
|
||||
* reloadPreview() — the GSAP path passes one that soft-reloads (preserving
|
||||
* the playhead) and invalidates the keyframe/gsap panel cache. Receives the
|
||||
* serialized document just written.
|
||||
*/
|
||||
refresh?: (after: string) => void;
|
||||
/**
|
||||
* Path of the composition the SDK session was opened for. The session models
|
||||
* ONLY this file (serialize() emits the whole active composition), so any edit
|
||||
* whose targetPath differs (a sub-composition file) must take the server path
|
||||
* — otherwise we'd write the full active-comp serialization into that file.
|
||||
*/
|
||||
compositionPath?: string | null;
|
||||
}
|
||||
|
||||
/** True when targetPath isn't the composition the SDK session models. */
|
||||
function wrongCompositionFile(deps: CutoverDeps, targetPath: string): boolean {
|
||||
return deps.compositionPath != null && targetPath !== deps.compositionPath;
|
||||
}
|
||||
|
||||
interface CutoverOptions {
|
||||
label?: string;
|
||||
coalesceKey?: string;
|
||||
/** Skip the preview reload (mirrors the server path's skipRefresh). */
|
||||
skipRefresh?: boolean;
|
||||
}
|
||||
|
||||
// ponytail: internal; export only if a third caller appears
|
||||
// ponytail: internal; export only if a third caller appears.
|
||||
// `after` is serialized once by the caller (which also did the no-op check
|
||||
// against its pre-dispatch snapshot), so this never re-serializes.
|
||||
async function persistSdkSerialize(
|
||||
sdkSession: Composition,
|
||||
after: string,
|
||||
targetPath: string,
|
||||
originalContent: string,
|
||||
deps: CutoverDeps,
|
||||
options?: CutoverOptions,
|
||||
): Promise<void> {
|
||||
const after = sdkSession.serialize();
|
||||
deps.domEditSaveTimestampRef.current = Date.now();
|
||||
await deps.writeProjectFile(targetPath, after);
|
||||
await deps.editHistory.recordEdit({
|
||||
@@ -100,7 +122,8 @@ async function persistSdkSerialize(
|
||||
...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}),
|
||||
files: { [targetPath]: { before: originalContent, after } },
|
||||
});
|
||||
deps.reloadPreview();
|
||||
if (deps.refresh) deps.refresh(after);
|
||||
else if (!options?.skipRefresh) deps.reloadPreview();
|
||||
}
|
||||
|
||||
export async function sdkCutoverPersist(
|
||||
@@ -118,13 +141,17 @@ export async function sdkCutoverPersist(
|
||||
const hfId = selection.hfId;
|
||||
if (!hfId) return false;
|
||||
if (!sdkSession.getElement(hfId)) return false;
|
||||
if (wrongCompositionFile(deps, targetPath)) return false;
|
||||
try {
|
||||
const before = sdkSession.serialize();
|
||||
sdkSession.batch(() => {
|
||||
for (const editOp of patchOpsToSdkEditOps(hfId, ops)) {
|
||||
sdkSession.dispatch(editOp);
|
||||
}
|
||||
});
|
||||
await persistSdkSerialize(sdkSession, targetPath, originalContent, deps, options);
|
||||
const after = sdkSession.serialize();
|
||||
if (after === before) return false;
|
||||
await persistSdkSerialize(after, targetPath, originalContent, deps, options);
|
||||
trackStudioEvent("sdk_cutover_success", { hfId, opCount: ops.length });
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -145,10 +172,13 @@ export async function sdkTimingPersist(
|
||||
options?: CutoverOptions,
|
||||
): Promise<boolean> {
|
||||
if (!sdkSession || !sdkSession.getElement(hfId)) return false;
|
||||
if (wrongCompositionFile(deps, targetPath)) return false;
|
||||
try {
|
||||
const before = sdkSession.serialize();
|
||||
sdkSession.setTiming(hfId, timingUpdate);
|
||||
await persistSdkSerialize(sdkSession, targetPath, before, deps, options);
|
||||
sdkSession.batch(() => sdkSession.setTiming(hfId, timingUpdate));
|
||||
const after = sdkSession.serialize();
|
||||
if (after === before) return false;
|
||||
await persistSdkSerialize(after, targetPath, before, deps, options);
|
||||
trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 });
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -170,17 +200,26 @@ export async function sdkGsapTweenPersist(
|
||||
options?: CutoverOptions,
|
||||
): Promise<boolean> {
|
||||
if (!sdkSession) return false;
|
||||
if (wrongCompositionFile(deps, targetPath)) return false;
|
||||
try {
|
||||
if (op.kind === "add" && !sdkSession.getElement(op.target)) return false;
|
||||
const before = sdkSession.serialize();
|
||||
if (op.kind === "add") {
|
||||
if (!sdkSession.getElement(op.target)) return false;
|
||||
sdkSession.addGsapTween(op.target, op.spec);
|
||||
} else if (op.kind === "set") {
|
||||
sdkSession.setGsapTween(op.animationId, op.properties);
|
||||
} else {
|
||||
sdkSession.removeGsapTween(op.animationId);
|
||||
}
|
||||
await persistSdkSerialize(sdkSession, targetPath, before, deps, options);
|
||||
sdkSession.batch(() => {
|
||||
if (op.kind === "add") {
|
||||
sdkSession.addGsapTween(op.target, op.spec);
|
||||
} else if (op.kind === "set") {
|
||||
sdkSession.setGsapTween(op.animationId, op.properties);
|
||||
} else {
|
||||
sdkSession.removeGsapTween(op.animationId);
|
||||
}
|
||||
});
|
||||
const after = sdkSession.serialize();
|
||||
// No-op (stale animationId, unsupported shape e.g. from-prop on a plain
|
||||
// tween): fall back to the server path so it surfaces the proper error
|
||||
// instead of writing a phantom before==after undo step. Subsumes a
|
||||
// per-op existence guard for the set/remove branches.
|
||||
if (after === before) return false;
|
||||
await persistSdkSerialize(after, targetPath, before, deps, options);
|
||||
trackStudioEvent("sdk_cutover_success", { opCount: 1 });
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -199,10 +238,15 @@ export async function sdkGsapKeyframePersist(
|
||||
options?: CutoverOptions,
|
||||
): Promise<boolean> {
|
||||
if (!sdkSession) return false;
|
||||
if (wrongCompositionFile(deps, targetPath)) return false;
|
||||
try {
|
||||
const before = sdkSession.serialize();
|
||||
sdkSession.dispatch({ type: "addGsapKeyframe", animationId, position, value });
|
||||
await persistSdkSerialize(sdkSession, targetPath, before, deps, options);
|
||||
sdkSession.batch(() =>
|
||||
sdkSession.dispatch({ type: "addGsapKeyframe", animationId, position, value }),
|
||||
);
|
||||
const after = sdkSession.serialize();
|
||||
if (after === before) return false;
|
||||
await persistSdkSerialize(after, targetPath, before, deps, options);
|
||||
trackStudioEvent("sdk_cutover_success", { opCount: 1 });
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -219,9 +263,13 @@ export async function sdkDeletePersist(
|
||||
deps: CutoverDeps,
|
||||
): Promise<boolean> {
|
||||
if (!sdkSession || !sdkSession.getElement(hfId)) return false;
|
||||
if (wrongCompositionFile(deps, targetPath)) return false;
|
||||
try {
|
||||
sdkSession.removeElement(hfId);
|
||||
await persistSdkSerialize(sdkSession, targetPath, originalContent, deps, {
|
||||
const before = sdkSession.serialize();
|
||||
sdkSession.batch(() => sdkSession.removeElement(hfId));
|
||||
const after = sdkSession.serialize();
|
||||
if (after === before) return false;
|
||||
await persistSdkSerialize(after, targetPath, originalContent, deps, {
|
||||
label: "Delete element",
|
||||
});
|
||||
trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 });
|
||||
|
||||
Reference in New Issue
Block a user