Files
hyperframes/packages/studio/src/hooks/useDomGeometryCommits.ts
T
Miguel Ángel 552419c52d fix(studio): make inspector commits transactional (#2987)
* fix(studio): make inspector commits transactional

* fix(studio): make inspector persistence atomic

* fix(studio): preserve synchronous gesture semantics
2026-08-04 20:26:43 +00:00

158 lines
6.1 KiB
TypeScript

import { useCallback } from "react";
import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing";
import {
applyStudioPathOffset,
applyStudioBoxSize,
applyStudioRotation,
captureStudioPathOffset,
captureStudioBoxSize,
captureStudioRotation,
restoreStudioPathOffset,
restoreStudioBoxSize,
restoreStudioRotation,
clearStudioPathOffset,
clearStudioBoxSize,
clearStudioRotation,
} from "../components/editor/manualEdits";
import {
buildPathOffsetPatches,
buildBoxSizePatches,
buildRotationPatches,
buildClearPathOffsetPatches,
buildClearBoxSizePatches,
buildClearRotationPatches,
} from "../components/editor/manualEditsDomPatches";
import type { PatchOperation } from "../utils/sourcePatcher";
import { isElementGsapTargeted } from "./gsapTargetCache";
const GSAP_CSS_FALLBACK_BLOCKED_MESSAGE =
"This element is GSAP-animated — dragging via CSS would corrupt keyframes";
// ── Hook ──
interface UseDomGeometryCommitsParams {
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
showToast: (message: string, tone?: "error" | "info") => void;
commitPositionPatchToHtml: (
selection: DomEditSelection,
patches: PatchOperation[],
options: { label: string; coalesceKey: string; skipRefresh?: boolean },
) => Promise<void>;
}
export function useDomGeometryCommits({
previewIframeRef,
showToast,
commitPositionPatchToHtml,
}: UseDomGeometryCommitsParams) {
const handleDomPathOffsetCommit = useCallback(
(selection: DomEditSelection, next: { x: number; y: number }) => {
// ponytail: GSAP-targeted elements are blocked (no SDK position-in-script op); CSS-path
// elements fall through to commitPositionPatchToHtml → persistDomEditOperations →
// onTrySdkPersist and are already SDK-cut-over as setStyle/setAttribute (§3.3 done).
// Upgrade path for GSAP: add a moveElementGsap SDK op in a separate SDK PR.
const gsapTargeted = isElementGsapTargeted(previewIframeRef.current, selection.element);
if (gsapTargeted) {
const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
showToast(error.message, "error");
return Promise.reject(error);
}
const before = captureStudioPathOffset(selection.element);
applyStudioPathOffset(selection.element, next);
return commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
label: "Move layer",
coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
}).catch((error) => {
restoreStudioPathOffset(selection.element, before);
throw error;
});
},
[commitPositionPatchToHtml, previewIframeRef, showToast],
);
const handleDomBoxSizeCommit = useCallback(
(
selection: DomEditSelection,
next: { width: number; height: number },
offset?: { x: number; y: number },
) => {
if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
showToast(error.message, "error");
return Promise.reject(error);
}
const beforeSize = captureStudioBoxSize(selection.element);
const beforeOffset = offset ? captureStudioPathOffset(selection.element) : null;
applyStudioBoxSize(selection.element, next);
// Anchored-corner resize (NW/NE/SW) also moves the element to keep the
// opposite corner fixed. Apply the offset and emit BOTH patch sets in a
// SINGLE commit: one persist = one undo entry, and there is no
// intermediate re-stamp where the new size is in source but the anchor
// offset is not (that frame was the release "jump"). Both builders read
// the already-mutated live element, so concatenation is safe.
const patches = buildBoxSizePatches(selection.element);
if (offset) {
applyStudioPathOffset(selection.element, offset);
patches.push(...buildPathOffsetPatches(selection.element));
}
return commitPositionPatchToHtml(selection, patches, {
label: "Resize layer box",
coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
}).catch((error) => {
restoreStudioBoxSize(selection.element, beforeSize);
if (beforeOffset) restoreStudioPathOffset(selection.element, beforeOffset);
throw error;
});
},
[commitPositionPatchToHtml, previewIframeRef, showToast],
);
const handleDomRotationCommit = useCallback(
(selection: DomEditSelection, next: { angle: number }) => {
if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
showToast(error.message, "error");
return Promise.reject(error);
}
const before = captureStudioRotation(selection.element);
applyStudioRotation(selection.element, next);
return commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), {
label: "Rotate layer",
coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
}).catch((error) => {
restoreStudioRotation(selection.element, before);
throw error;
});
},
[commitPositionPatchToHtml, previewIframeRef, showToast],
);
const handleDomManualEditsReset = useCallback(
(selection: DomEditSelection) => {
const element = selection.element;
const clearPatches = [
...buildClearPathOffsetPatches(element),
...buildClearBoxSizePatches(element),
...buildClearRotationPatches(element),
];
clearStudioPathOffset(element);
clearStudioBoxSize(element);
clearStudioRotation(element);
// skipRefresh:false triggers reloadPreview() which re-syncs selection on load
void commitPositionPatchToHtml(selection, clearPatches, {
label: "Reset layer edits",
coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`,
skipRefresh: false,
}).catch(() => undefined);
},
[commitPositionPatchToHtml],
);
return {
handleDomPathOffsetCommit,
handleDomBoxSizeCommit,
handleDomRotationCommit,
handleDomManualEditsReset,
};
}