Files
hyperframes/packages/studio/src/hooks/useDomEditSession.ts
T
Vance IngallsandClaude Opus 4.8 28bfe09f21 feat(sdk): ws-3 — reorderElements op (batch z-index update) (#1502)
* feat(sdk): ws-3 — reorderElements op (batch z-index update)

Adds the reorderElements EditOp: each entry sets inline zIndex on one element.
Last-write-wins per target so a duplicated target collapses to a single zIndex
patch. Positioning is unchanged — z-index only takes effect on non-static
elements, so the caller must ensure the target is positioned.

Also fixes single-dispatch undo to reverse the inverse patch list (parity with
batch()): an op emitting multiple patches whose undo order matters — a duplicated
reorderElements target, an aliased multi-target, or a nested parent+child
removeElement — must undo in reverse application order, or undo lands on an
intermediate value / drops a subtree.

validateOp resolves every entry target (E_TARGET_NOT_FOUND for unknown ids;
empty entries is a clean no-op). Tests cover set/inverse/validate/duplicate-target.

Rebuilt standalone on main (reorderElements only depends on handleSetStyle).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sdk): remove unused HTTP persist adapter

The HTTP PersistAdapter (createHttpAdapter) was dead weight after the studio
cutover went single-writer (ws-4): Studio's writeProjectFile is the sole writer
and useSdkSession opens with no persist queue, so the adapter's write/flush/
listVersions/loadFrom were never used — only read() was, to fetch the
composition source. Replace those two read() calls with a direct optional fetch
(GET /files/<path>?optional=1) and drop the adapter + its export-map entries.

Saved for later re-introduction (when a non-Studio SDK host needs server-backed
persist) at docs/hyperframes/plans/sdk-http-adapter/ (outside the repo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(studio): resolver shadow for z-index reorder targets

The z-index reorder commit takes the server path (no SDK persist), but the
resolver-shadow tripwire is decoupled from cutover — so it should still record
whether the SDK resolves each reordered element (reorderElements' targets), the
same as timing/delete already do before their cutover gate. This gives wild
resolver-parity telemetry on z-index targets before z-index reorder is cut over.

Threads an onReorderShadow callback (sdkSession-bound, mirrors onTrySdkDelete)
from useDomEditSession → useDomEditCommits → useElementLifecycleOps, called with
the reordered elements' hf-ids in handleDomZIndexReorderCommit. Read-only,
divergence-only, never throws — same contract as recordResolverParity elsewhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(studio): guard project-file read path against traversal (CodeQL CSRF)

readProjectFileOptional interpolated a user-influenced composition path into the
fetch URL, which CodeQL flagged as client-side request forgery. Reject NUL/`..`
up front (mirrors the existing guard in timelineEditingHelpers) and
encodeURIComponent the projectId too, so both values stay confined to single
segments of the same-origin URL. Unsafe path → undefined (graceful for the
optional read).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 23:51:47 -07:00

470 lines
13 KiB
TypeScript

import type { 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 } 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 { useGsapScriptCommits } from "./useGsapScriptCommits";
import { useGsapCacheVersion } from "./useGsapTweenCache";
import { useDomEditWiring } from "./useDomEditWiring";
import { useGsapAwareEditing } from "./useGsapAwareEditing";
// ── Types ──
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) => void;
setRightCollapsed: (collapsed: boolean) => void;
setRightPanelTab: (tab: RightPanelTab) => void;
showToast: (message: string, tone?: "error" | "info") => void;
refreshPreviewDocumentVersion: () => void;
queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
readProjectFile: (path: string) => Promise<string>;
writeProjectFile: (path: string, content: 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;
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;
forceReloadSdkSession?: () => void;
}
// ── Hook ──
export function useDomEditSession({
projectId,
activeCompPath,
isMasterView,
compIdToSrc,
captionEditMode,
compositionLoading,
previewIframeRef,
timelineElements,
setSelectedTimelineElementId,
setRightCollapsed,
setRightPanelTab,
showToast,
refreshPreviewDocumentVersion,
queueDomEditSave,
readProjectFile: _readProjectFile,
writeProjectFile,
updateEditingFileContent,
domEditSaveTimestampRef,
editHistory,
fileTree,
importedFontAssetsRef,
projectDir,
projectIdRef,
previewIframe,
refreshKey,
rightPanelTab,
applyStudioManualEditsToPreviewRef,
syncPreviewHistoryHotkey,
reloadPreview,
setRefreshKey: _setRefreshKey,
openSourceForSelection,
selectSidebarTab,
getSidebarTab,
sdkSession,
forceReloadSdkSession,
}: UseDomEditSessionParams) {
void _setRefreshKey;
void _readProjectFile;
// ── Selection ──
const {
domEditSelection,
domEditGroupSelections,
domEditHoverSelection,
domEditSelectionRef,
applyDomSelection,
clearDomSelection,
buildDomSelectionFromTarget,
resolveDomSelectionFromPreviewPoint,
resolveAllDomSelectionsFromPreviewPoint,
updateDomEditHoverSelection,
buildDomSelectionForTimelineElement,
handleTimelineElementSelect,
refreshDomEditSelectionFromPreview,
} = useDomSelection({
projectId,
activeCompPath,
isMasterView,
compIdToSrc,
captionEditMode,
previewIframeRef,
timelineElements,
setSelectedTimelineElementId,
setRightCollapsed,
setRightPanelTab,
previewIframe,
refreshKey,
rightPanelTab,
});
// ── Agent modal ──
const {
agentModalOpen,
agentModalAnchorPoint,
copiedAgentPrompt,
agentPromptSelectionContext,
setAgentModalOpen,
setAgentPromptSelectionContext,
setAgentModalAnchorPoint,
handleAskAgent,
handleAgentModalSubmit,
} = useAskAgentModal({
projectId,
activeCompPath,
projectDir,
projectIdRef,
showToast,
domEditSelectionRef,
domEditSelection,
});
// ── 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,
convertToKeyframes,
removeAllKeyframes,
setArcPath,
updateArcSegment,
} = useGsapScriptCommits({
projectIdRef,
activeCompPath,
previewIframeRef,
editHistory,
domEditSaveTimestampRef,
reloadPreview,
onCacheInvalidate: bumpGsapCache,
onFileContentChanged: updateEditingFileContent,
showToast,
sdkSession,
writeProjectFile,
forceReloadSdkSession,
});
// ── DOM commit handlers ──
const {
resolveImportedFontAsset,
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomAttributeLiveCommit,
handleDomHtmlAttributeCommit,
handleDomTextCommit,
handleDomTextFieldStyleCommit,
handleDomAddTextField,
handleDomRemoveTextField,
handleDomPathOffsetCommit,
handleDomGroupPathOffsetCommit,
handleDomBoxSizeCommit,
handleDomRotationCommit,
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.
runResolverShadow(sdkSession, selection.hfId, operations);
return sdkCutoverPersist(
selection,
operations,
originalContent,
targetPath,
sdkSession,
{
editHistory,
writeProjectFile,
reloadPreview,
domEditSaveTimestampRef,
compositionPath: activeCompPath,
},
options,
);
}
: undefined,
onTrySdkDelete: sdkSession
? (hfId, originalContent, targetPath) =>
sdkDeletePersist(hfId, originalContent, targetPath, sdkSession, {
editHistory,
writeProjectFile,
reloadPreview,
domEditSaveTimestampRef,
compositionPath: activeCompPath,
})
: 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[]) => {
for (const target of targets) recordResolverParity(sdkSession, target, "reorderElements");
}
: undefined,
});
// ── 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,
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,
convertToKeyframes,
removeAllKeyframes,
handleDomManualEditsReset,
});
// ── Preview interaction ──
const {
handlePreviewCanvasMouseDown,
handlePreviewCanvasPointerMove,
handlePreviewCanvasPointerLeave,
handleBlockedDomMove,
handleDomManualDragStart,
} = usePreviewInteraction({
captionEditMode,
compositionLoading,
previewIframeRef,
showToast,
applyDomSelection,
resolveDomSelectionFromPreviewPoint,
resolveAllDomSelectionsFromPreviewPoint,
updateDomEditHoverSelection,
onClickToSource,
});
// ── GSAP-aware geometry intercepts + animated property commit ──
const {
handleGsapAwarePathOffsetCommit,
handleGsapAwareBoxSizeCommit,
handleGsapAwareRotationCommit,
commitAnimatedProperty,
handleSetArcPath,
handleUpdateArcSegment,
handleUnroll,
commitMutation,
} = useGsapAwareEditing({
domEditSelection,
selectedGsapAnimations,
gsapCommitMutation,
previewIframeRef,
showToast,
bumpGsapCache,
makeFetchFallback,
trackGsapInteractionFailure,
handleDomPathOffsetCommit,
handleDomBoxSizeCommit,
handleDomRotationCommit,
addGsapAnimation,
convertToKeyframes,
setArcPath,
updateArcSegment,
});
return {
// State
domEditSelection,
domEditGroupSelections,
domEditHoverSelection,
agentModalOpen,
agentModalAnchorPoint,
copiedAgentPrompt,
agentPromptSelectionContext,
// Refs
domEditSelectionRef,
// Callbacks
handleTimelineElementSelect,
handlePreviewCanvasMouseDown,
handlePreviewCanvasPointerMove,
handlePreviewCanvasPointerLeave,
applyDomSelection,
clearDomSelection,
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomAttributeLiveCommit,
handleDomHtmlAttributeCommit,
handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit,
handleDomGroupPathOffsetCommit,
handleDomZIndexReorderCommit,
handleDomBoxSizeCommit: handleGsapAwareBoxSizeCommit,
handleDomRotationCommit: handleGsapAwareRotationCommit,
handleDomManualEditsReset,
handleDomTextCommit,
handleDomTextFieldStyleCommit,
handleDomAddTextField,
handleDomRemoveTextField,
handleAskAgent,
handleAgentModalSubmit,
handleBlockedDomMove,
handleDomManualDragStart,
handleDomEditElementDelete,
buildDomSelectionFromTarget,
buildDomSelectionForTimelineElement,
updateDomEditHoverSelection,
resolveImportedFontAsset,
setAgentModalOpen,
setAgentPromptSelectionContext,
setAgentModalAnchorPoint,
// GSAP script editing
selectedGsapAnimations,
gsapMultipleTimelines,
gsapUnsupportedTimelinePattern,
handleGsapUpdateProperty,
handleGsapUpdateMeta,
handleGsapDeleteAnimation,
handleGsapDeleteAllForElement,
handleGsapAddAnimation,
handleGsapAddProperty,
handleGsapRemoveProperty,
handleGsapUpdateFromProperty,
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
handleGsapAddKeyframe,
handleGsapAddKeyframeBatch,
handleGsapRemoveKeyframe,
handleGsapConvertToKeyframes,
handleGsapRemoveAllKeyframes,
handleResetSelectedElementKeyframes,
commitAnimatedProperty,
handleSetArcPath,
handleUpdateArcSegment,
handleUnroll,
invalidateGsapCache: bumpGsapCache,
previewIframeRef,
commitMutation,
};
}