Files
hyperframes/packages/studio/src/hooks/useDomEditSession.ts
T

552 lines
17 KiB
TypeScript

import { useCallback } from "react";
import { trackStudioEvent } from "../utils/studioTelemetry";
import type { SelectElementOptions, TimelineElement } from "../player";
import type { ImportedFontAsset } from "../components/editor/fontAssets";
import type { EditHistoryKind } from "../utils/editHistory";
import type { RightPanelTab } from "../utils/studioHelpers";
import type { PatchTarget } from "../utils/sourcePatcher";
import type { SidebarTab } from "../components/sidebar/LeftSidebar";
import type { Composition } from "@hyperframes/sdk";
import { sdkCutoverPersist, sdkDeletePersist, type PublishSdkSession } from "../utils/sdkCutover";
import { runResolverShadow, recordResolverParity } from "../utils/sdkResolverShadow";
import { useAskAgentModal } from "./useAskAgentModal";
import { useDomSelection } from "./useDomSelection";
import { usePreviewInteraction } from "./usePreviewInteraction";
import { useDomEditCommits } from "./useDomEditCommits";
import { useGroupCommits } from "./useGroupCommits";
import { useGsapScriptCommits } from "./useGsapScriptCommits";
import { useGsapCacheVersion } from "./useGsapTweenCache";
import { useDomEditWiring } from "./useDomEditWiring";
import { useGsapAwareEditing } from "./useGsapAwareEditing";
import { useStudioSelectionPublisher } from "./useStudioSelectionPublisher";
import { useKeyframeEaseCommits } from "./useKeyframeEaseCommits";
interface RecordEditInput {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
files: Record<string, { before: string; after: string }>;
}
export interface UseDomEditSessionParams {
projectId: string | null;
activeCompPath: string | null;
isMasterView: boolean;
compIdToSrc: Map<string, string>;
captionEditMode: boolean;
compositionLoading: boolean;
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
timelineElements: TimelineElement[];
setSelectedTimelineElementId: (id: string | null, options?: SelectElementOptions) => void;
setRightCollapsed: (collapsed: boolean) => void;
setRightPanelTab: (tab: RightPanelTab) => void;
showToast: (message: string, tone?: "error" | "info") => void;
refreshPreviewDocumentVersion: () => void;
queueDomEditSave: <T>(save: () => Promise<T>) => Promise<T>;
readProjectFile: (path: string) => Promise<string>;
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
updateEditingFileContent: (path: string, content: string) => void;
domEditSaveTimestampRef: React.MutableRefObject<number>;
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
fileTree: string[];
importedFontAssetsRef: React.MutableRefObject<ImportedFontAsset[]>;
projectDir: string | null;
projectIdRef: React.MutableRefObject<string | null>;
previewIframe: HTMLIFrameElement | null;
refreshKey: number;
previewDocumentVersion: number;
rightPanelTab: RightPanelTab;
applyStudioManualEditsToPreviewRef: React.MutableRefObject<
(iframe: HTMLIFrameElement) => Promise<void>
>;
syncPreviewHistoryHotkey: (iframe: HTMLIFrameElement | null) => void;
reloadPreview: () => void;
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void;
selectSidebarTab?: (tab: SidebarTab) => void;
getSidebarTab?: () => SidebarTab;
sdkSession?: Composition | null;
publishSdkSession?: PublishSdkSession;
forceReloadSdkSession?: () => void;
}
export function useDomEditSession({
projectId,
activeCompPath,
isMasterView,
compIdToSrc,
captionEditMode,
compositionLoading,
previewIframeRef,
timelineElements,
setSelectedTimelineElementId,
setRightCollapsed,
setRightPanelTab,
showToast,
refreshPreviewDocumentVersion,
queueDomEditSave,
readProjectFile,
writeProjectFile,
updateEditingFileContent,
domEditSaveTimestampRef,
editHistory,
fileTree,
importedFontAssetsRef,
projectDir,
projectIdRef,
previewIframe,
refreshKey,
previewDocumentVersion,
rightPanelTab,
applyStudioManualEditsToPreviewRef,
syncPreviewHistoryHotkey,
reloadPreview,
setRefreshKey: _setRefreshKey,
openSourceForSelection,
selectSidebarTab,
getSidebarTab,
sdkSession,
publishSdkSession,
forceReloadSdkSession,
}: UseDomEditSessionParams) {
void _setRefreshKey;
const {
domEditSelection,
domEditGroupSelections,
domEditHoverSelection,
activeGroupElement,
domEditSelectionRef,
domEditGroupSelectionsRef,
setActiveGroupElement,
applyDomSelection,
clearDomSelection,
buildDomSelectionFromTarget,
resolveDomSelectionFromPreviewPoint,
resolveAllDomSelectionsFromPreviewPoint,
updateDomEditHoverSelection,
buildDomSelectionForTimelineElement,
handleTimelineElementSelect,
refreshDomEditSelectionFromPreview,
applyMarqueeSelection,
} = useDomSelection({
projectId,
activeCompPath,
isMasterView,
compIdToSrc,
captionEditMode,
previewIframeRef,
timelineElements,
setSelectedTimelineElementId,
setRightCollapsed,
setRightPanelTab,
previewIframe,
refreshKey,
rightPanelTab,
});
const {
agentModalOpen,
agentModalAnchorPoint,
copiedAgentPrompt,
agentPromptSelectionContext,
setAgentModalOpen,
setAgentPromptSelectionContext,
setAgentModalAnchorPoint,
handleAskAgent,
handleAgentModalSubmit,
} = useAskAgentModal({
projectId,
activeCompPath,
projectDir,
projectIdRef,
showToast,
domEditSelectionRef,
domEditSelection,
});
useStudioSelectionPublisher({
projectId,
domEditSelection,
domEditSelectionRef,
refreshKey,
previewDocumentVersion,
refreshDomEditSelectionFromPreview,
});
// ── GSAP cache (hoisted so both useGsapScriptCommits and useDomEditWiring share the same instance) ──
const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion();
// ── GSAP script commits ──
const {
commitMutation: gsapCommitMutation,
updateGsapProperty,
updateGsapMeta,
deleteGsapAnimation,
deleteAllForSelector,
addGsapAnimation,
addGsapProperty,
removeGsapProperty,
updateGsapFromProperty,
addGsapFromProperty,
removeGsapFromProperty,
addKeyframe,
addKeyframeBatch,
removeKeyframe,
moveKeyframe,
resizeKeyframedTween,
convertToKeyframes,
removeAllKeyframes,
setArcPath,
updateArcSegment,
} = useGsapScriptCommits({
projectIdRef,
activeCompPath,
previewIframeRef,
editHistory,
domEditSaveTimestampRef,
reloadPreview,
onCacheInvalidate: bumpGsapCache,
onFileContentChanged: updateEditingFileContent,
showToast,
sdkSession,
publishSdkSession,
writeProjectFile,
forceReloadSdkSession,
});
// ── DOM commit handlers ──
const {
resolveImportedFontAsset,
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomAttributeLiveCommit,
handleDomHtmlAttributeCommit,
handleDomAttributesCommit,
handleDomTextCommit,
handleDomTextFieldStyleCommit,
handleDomAddTextField,
handleDomRemoveTextField,
handleDomBoxSizeCommit,
handleDomManualEditsReset,
handleDomEditElementDelete,
handleDomZIndexReorderCommit,
} = useDomEditCommits({
activeCompPath,
previewIframeRef,
showToast,
queueDomEditSave,
writeProjectFile,
domEditSaveTimestampRef,
editHistory,
fileTree,
importedFontAssetsRef,
projectId,
projectIdRef,
reloadPreview,
domEditSelection,
applyDomSelection,
clearDomSelection,
refreshDomEditSelectionFromPreview,
buildDomSelectionFromTarget,
forceReloadSdkSession,
onTrySdkPersist: sdkSession
? (selection, operations, originalContent, targetPath, options) => {
// Resolver shadow runs regardless of the cutover flag — decoupled tripwire.
// Pass originalContent so the runtime-node filter can suppress hf-ids
// absent from source (script-created nodes the SDK can't model), and
// the paths so cross-file edits (session models only the active comp)
// skip instead of emitting structural element_not_found noise.
runResolverShadow(sdkSession, selection.hfId, operations, originalContent, {
targetPath,
compositionPath: activeCompPath,
});
return sdkCutoverPersist(
selection,
operations,
originalContent,
targetPath,
sdkSession,
{
editHistory,
writeProjectFile,
reloadPreview,
domEditSaveTimestampRef,
compositionPath: activeCompPath,
readProjectFile,
publishSession: publishSdkSession,
},
options,
);
}
: undefined,
onTrySdkDelete: sdkSession
? (hfId, originalContent, targetPath) =>
sdkDeletePersist(hfId, originalContent, targetPath, sdkSession, {
editHistory,
writeProjectFile,
reloadPreview,
domEditSaveTimestampRef,
compositionPath: activeCompPath,
readProjectFile,
publishSession: publishSdkSession,
})
: undefined,
// Resolver shadow for the z-index reorder edit: it takes the server path (no
// SDK persist), but the tripwire is decoupled from cutover — record whether
// the SDK resolves each reordered element (the reorderElements op's targets).
onReorderShadow: sdkSession
? (targets: string[]) => {
// Single-flight: every target in one reorder batch shares the same file, so
// memoize the read instead of firing one fetch per unresolved target.
let reorderSrcPromise: Promise<string> | undefined;
const reorderSrc = activeCompPath
? () => (reorderSrcPromise ??= readProjectFile(activeCompPath))
: undefined;
for (const target of targets)
void recordResolverParity(sdkSession, target, "reorderElements", reorderSrc);
}
: undefined,
});
// ── Element groups (wrap selected elements in a data-hf-group div) ──
const { groupSelection, ungroupSelection } = useGroupCommits({
activeCompPath,
showToast,
writeProjectFile,
domEditSaveTimestampRef,
editHistory,
projectIdRef,
reloadPreview,
clearDomSelection,
forceReloadSdkSession,
});
const handleGroupSelection = useCallback(() => {
const group = domEditGroupSelectionsRef.current;
const single = domEditSelectionRef.current;
const members = group.length > 0 ? group : single ? [single] : [];
if (members.length < 2) {
showToast("Select at least 2 elements to group", "info");
return;
}
trackStudioEvent("group", { action: "create", count: members.length });
void groupSelection(members);
}, [domEditGroupSelectionsRef, domEditSelectionRef, groupSelection, showToast]);
const handleUngroupSelection = useCallback(() => {
const sel = domEditSelectionRef.current;
if (!sel?.element.hasAttribute("data-hf-group")) {
showToast("Select a group to ungroup", "info");
return;
}
// Dissolving the group exits any drill-in (the wrapper is about to vanish).
trackStudioEvent("group", { action: "ungroup" });
setActiveGroupElement(null);
void ungroupSelection(sel);
}, [domEditSelectionRef, ungroupSelection, setActiveGroupElement, showToast]);
// ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──
const {
onClickToSource,
selectedGsapAnimations,
gsapMultipleTimelines,
gsapUnsupportedTimelinePattern,
trackGsapInteractionFailure,
makeFetchFallback,
handleGsapUpdateProperty,
handleGsapUpdateMeta,
handleGsapDeleteAnimation,
handleGsapDeleteAllForElement,
handleGsapAddAnimation,
handleGsapAddProperty,
handleGsapRemoveProperty,
handleGsapUpdateFromProperty,
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
handleGsapAddKeyframe,
handleGsapAddKeyframeBatch,
handleGsapRemoveKeyframe,
handleGsapMoveKeyframeToPlayhead,
handleGsapMoveKeyframe,
handleGsapResizeKeyframedTween,
handleGsapConvertToKeyframes,
handleGsapRemoveAllKeyframes,
handleResetSelectedElementKeyframes,
} = useDomEditWiring({
// fallow-ignore-next-line code-duplication
projectId,
activeCompPath,
domEditSelection,
domEditSelectionRef,
previewIframeRef,
previewIframe,
captionEditMode,
refreshKey,
gsapCacheVersion,
bumpGsapCache,
showToast,
refreshPreviewDocumentVersion,
syncPreviewHistoryHotkey,
applyStudioManualEditsToPreviewRef,
applyDomSelection,
buildDomSelectionFromTarget,
openSourceForSelection,
selectSidebarTab,
getSidebarTab,
updateGsapProperty,
updateGsapMeta,
deleteGsapAnimation,
deleteAllForSelector,
addGsapAnimation,
addGsapProperty,
removeGsapProperty,
updateGsapFromProperty,
addGsapFromProperty,
removeGsapFromProperty,
addKeyframe,
addKeyframeBatch,
removeKeyframe,
moveKeyframe,
resizeKeyframedTween,
convertToKeyframes,
removeAllKeyframes,
handleDomManualEditsReset,
});
const {
handlePreviewCanvasMouseDown,
handlePreviewCanvasPointerMove,
handlePreviewCanvasPointerLeave,
handleBlockedDomMove,
handleDomManualDragStart,
} = usePreviewInteraction({
captionEditMode,
compositionLoading,
previewIframeRef,
showToast,
applyDomSelection,
resolveDomSelectionFromPreviewPoint,
resolveAllDomSelectionsFromPreviewPoint,
updateDomEditHoverSelection,
setActiveGroupElement,
onClickToSource,
});
const {
handleGsapAwarePathOffsetCommit,
handleGsapAwareGroupPathOffsetCommit,
handleGsapAwareBoxSizeCommit,
handleGsapAwareRotationCommit,
commitAnimatedProperty,
commitAnimatedProperties,
handleSetArcPath,
handleUpdateArcSegment,
handleUnroll,
commitMutation,
} = useGsapAwareEditing({
domEditSelection,
selectedGsapAnimations,
gsapCommitMutation,
previewIframeRef,
showToast,
bumpGsapCache,
makeFetchFallback,
trackGsapInteractionFailure,
handleDomBoxSizeCommit,
addGsapAnimation,
convertToKeyframes,
setArcPath,
updateArcSegment,
});
const { handleUpdateSegmentEase, handleUpdateKeyframeEase, handleSetAllKeyframeEases } =
useKeyframeEaseCommits({ gsapCommitMutation, domEditSelectionRef });
return {
// State
domEditSelection,
domEditGroupSelections,
domEditHoverSelection,
activeGroupElement,
agentModalOpen,
agentModalAnchorPoint,
copiedAgentPrompt,
agentPromptSelectionContext,
// Refs
domEditSelectionRef,
// Callbacks
handleTimelineElementSelect,
handlePreviewCanvasMouseDown,
handlePreviewCanvasPointerMove,
handlePreviewCanvasPointerLeave,
applyDomSelection,
clearDomSelection,
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomAttributeLiveCommit,
handleDomHtmlAttributeCommit,
handleDomAttributesCommit,
handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit,
handleDomGroupPathOffsetCommit: handleGsapAwareGroupPathOffsetCommit,
handleDomZIndexReorderCommit,
handleDomBoxSizeCommit: handleGsapAwareBoxSizeCommit,
handleDomRotationCommit: handleGsapAwareRotationCommit,
handleDomManualEditsReset,
handleDomTextCommit,
handleDomTextFieldStyleCommit,
handleDomAddTextField,
handleDomRemoveTextField,
handleAskAgent,
handleAgentModalSubmit,
handleBlockedDomMove,
handleDomManualDragStart,
handleDomEditElementDelete,
handleGroupSelection,
handleUngroupSelection,
setActiveGroupElement,
buildDomSelectionFromTarget,
buildDomSelectionForTimelineElement,
updateDomEditHoverSelection,
applyMarqueeSelection,
resolveImportedFontAsset,
setAgentModalOpen,
setAgentPromptSelectionContext,
setAgentModalAnchorPoint,
// GSAP script editing
selectedGsapAnimations,
gsapMultipleTimelines,
gsapUnsupportedTimelinePattern,
handleGsapUpdateProperty,
handleGsapUpdateMeta,
handleGsapDeleteAnimation,
handleGsapDeleteAllForElement,
handleGsapAddAnimation,
handleGsapAddProperty,
handleGsapRemoveProperty,
handleGsapUpdateFromProperty,
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
handleGsapAddKeyframe,
handleGsapAddKeyframeBatch,
handleGsapRemoveKeyframe,
handleGsapMoveKeyframeToPlayhead,
handleGsapMoveKeyframe,
handleGsapResizeKeyframedTween,
handleGsapConvertToKeyframes,
handleGsapRemoveAllKeyframes,
handleResetSelectedElementKeyframes,
handleUpdateKeyframeEase,
handleUpdateSegmentEase,
handleSetAllKeyframeEases,
commitAnimatedProperty,
commitAnimatedProperties,
handleSetArcPath,
handleUpdateArcSegment,
handleUnroll,
invalidateGsapCache: bumpGsapCache,
previewIframeRef,
commitMutation,
};
}