mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
feat(studio): route element delete through SDK removeElement (§3.1) (#1465)
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
co-authored by
Miguel Ángel
parent
8585fffc92
commit
bce571c2a1
@@ -75,8 +75,6 @@ export interface UseDomEditCommitsParams {
|
|||||||
) => Promise<DomEditSelection | null>;
|
) => Promise<DomEditSelection | null>;
|
||||||
/** Stage 7 Step 3b: called after a successful server-side element patch. */
|
/** Stage 7 Step 3b: called after a successful server-side element patch. */
|
||||||
onDomEditPersisted?: (selection: DomEditSelection, operations: PatchOperation[]) => void;
|
onDomEditPersisted?: (selection: DomEditSelection, operations: PatchOperation[]) => void;
|
||||||
/** Stage 7 Step 3b: called after a successful server-side element delete. */
|
|
||||||
onElementDeleted?: (selection: DomEditSelection) => void;
|
|
||||||
/** Stage 7 Step 3c: called before the server-side patch path; returns true if SDK handled it. */
|
/** Stage 7 Step 3c: called before the server-side patch path; returns true if SDK handled it. */
|
||||||
onTrySdkPersist?: (
|
onTrySdkPersist?: (
|
||||||
selection: DomEditSelection,
|
selection: DomEditSelection,
|
||||||
@@ -84,6 +82,8 @@ export interface UseDomEditCommitsParams {
|
|||||||
originalContent: string,
|
originalContent: string,
|
||||||
targetPath: string,
|
targetPath: string,
|
||||||
) => Promise<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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDomEditCommits({
|
export function useDomEditCommits({
|
||||||
@@ -105,8 +105,8 @@ export function useDomEditCommits({
|
|||||||
refreshDomEditSelectionFromPreview,
|
refreshDomEditSelectionFromPreview,
|
||||||
buildDomSelectionFromTarget,
|
buildDomSelectionFromTarget,
|
||||||
onDomEditPersisted,
|
onDomEditPersisted,
|
||||||
onElementDeleted,
|
|
||||||
onTrySdkPersist,
|
onTrySdkPersist,
|
||||||
|
onTrySdkDelete,
|
||||||
}: UseDomEditCommitsParams) {
|
}: UseDomEditCommitsParams) {
|
||||||
const resolveImportedFontAsset = useCallback(
|
const resolveImportedFontAsset = useCallback(
|
||||||
(fontFamilyValue: string): ImportedFontAsset | null => {
|
(fontFamilyValue: string): ImportedFontAsset | null => {
|
||||||
@@ -316,8 +316,8 @@ export function useDomEditCommits({
|
|||||||
projectIdRef,
|
projectIdRef,
|
||||||
reloadPreview,
|
reloadPreview,
|
||||||
clearDomSelection,
|
clearDomSelection,
|
||||||
|
onTrySdkDelete,
|
||||||
commitPositionPatchToHtml,
|
commitPositionPatchToHtml,
|
||||||
onElementDeleted,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { RightPanelTab } from "../utils/studioHelpers";
|
|||||||
import type { PatchTarget } from "../utils/sourcePatcher";
|
import type { PatchTarget } from "../utils/sourcePatcher";
|
||||||
import type { SidebarTab } from "../components/sidebar/LeftSidebar";
|
import type { SidebarTab } from "../components/sidebar/LeftSidebar";
|
||||||
import type { Composition } from "@hyperframes/sdk";
|
import type { Composition } from "@hyperframes/sdk";
|
||||||
import { sdkCutoverPersist } from "../utils/sdkCutover";
|
import { sdkCutoverPersist, sdkDeletePersist } from "../utils/sdkCutover";
|
||||||
import { useAskAgentModal } from "./useAskAgentModal";
|
import { useAskAgentModal } from "./useAskAgentModal";
|
||||||
import { useDomSelection } from "./useDomSelection";
|
import { useDomSelection } from "./useDomSelection";
|
||||||
import { usePreviewInteraction } from "./usePreviewInteraction";
|
import { usePreviewInteraction } from "./usePreviewInteraction";
|
||||||
@@ -241,6 +241,15 @@ export function useDomEditSession({
|
|||||||
domEditSaveTimestampRef,
|
domEditSaveTimestampRef,
|
||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
|
onTrySdkDelete: sdkSession
|
||||||
|
? (hfId, originalContent, targetPath) =>
|
||||||
|
sdkDeletePersist(hfId, originalContent, targetPath, sdkSession, {
|
||||||
|
editHistory,
|
||||||
|
writeProjectFile,
|
||||||
|
reloadPreview,
|
||||||
|
domEditSaveTimestampRef,
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──
|
// ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ interface UseElementLifecycleOpsParams {
|
|||||||
projectIdRef: React.MutableRefObject<string | null>;
|
projectIdRef: React.MutableRefObject<string | null>;
|
||||||
reloadPreview: () => void;
|
reloadPreview: () => void;
|
||||||
clearDomSelection: () => void;
|
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>;
|
||||||
commitPositionPatchToHtml: (
|
commitPositionPatchToHtml: (
|
||||||
selection: DomEditSelection,
|
selection: DomEditSelection,
|
||||||
patches: PatchOperation[],
|
patches: PatchOperation[],
|
||||||
@@ -44,6 +46,7 @@ export function useElementLifecycleOps({
|
|||||||
projectIdRef,
|
projectIdRef,
|
||||||
reloadPreview,
|
reloadPreview,
|
||||||
clearDomSelection,
|
clearDomSelection,
|
||||||
|
onTrySdkDelete,
|
||||||
commitPositionPatchToHtml,
|
commitPositionPatchToHtml,
|
||||||
onElementDeleted,
|
onElementDeleted,
|
||||||
}: UseElementLifecycleOpsParams) {
|
}: UseElementLifecycleOpsParams) {
|
||||||
@@ -74,6 +77,16 @@ export function useElementLifecycleOps({
|
|||||||
throw new Error("Selected element has no patchable target");
|
throw new Error("Selected element has no patchable target");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (onTrySdkDelete && selection.hfId) {
|
||||||
|
const handled = await onTrySdkDelete(selection.hfId, originalContent, targetPath);
|
||||||
|
if (handled) {
|
||||||
|
clearDomSelection();
|
||||||
|
usePlayerStore.getState().setSelectedElementId(null);
|
||||||
|
showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
domEditSaveTimestampRef.current = Date.now();
|
domEditSaveTimestampRef.current = Date.now();
|
||||||
const removeResponse = await fetch(
|
const removeResponse = await fetch(
|
||||||
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
|
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
|
||||||
@@ -118,6 +131,7 @@ export function useElementLifecycleOps({
|
|||||||
clearDomSelection,
|
clearDomSelection,
|
||||||
domEditSaveTimestampRef,
|
domEditSaveTimestampRef,
|
||||||
editHistory.recordEdit,
|
editHistory.recordEdit,
|
||||||
|
onTrySdkDelete,
|
||||||
onElementDeleted,
|
onElementDeleted,
|
||||||
projectIdRef,
|
projectIdRef,
|
||||||
reloadPreview,
|
reloadPreview,
|
||||||
@@ -126,6 +140,9 @@ export function useElementLifecycleOps({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ponytail: z-index reorder writes inline-style patches via commitPositionPatchToHtml →
|
||||||
|
// persistDomEditOperations → onTrySdkPersist, so it is already SDK-cut-over as setStyle.
|
||||||
|
// No SDK reorder/reparent op exists; DOM sibling order stays server-authoritative if ever needed.
|
||||||
const handleDomZIndexReorderCommit = useCallback(
|
const handleDomZIndexReorderCommit = useCallback(
|
||||||
(
|
(
|
||||||
entries: Array<{
|
entries: Array<{
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { shouldUseSdkCutover, sdkCutoverPersist } from "./sdkCutover";
|
import { shouldUseSdkCutover, sdkCutoverPersist, sdkDeletePersist } from "./sdkCutover";
|
||||||
import { openComposition } from "@hyperframes/sdk";
|
import { openComposition } from "@hyperframes/sdk";
|
||||||
import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory";
|
import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory";
|
||||||
import type { PatchOperation } from "./sourcePatcher";
|
import type { PatchOperation } from "./sourcePatcher";
|
||||||
@@ -298,6 +298,74 @@ describe("sdkCutoverPersist", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("sdkDeletePersist", () => {
|
||||||
|
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
|
||||||
|
const makeDeps = () => ({
|
||||||
|
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
||||||
|
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
||||||
|
reloadPreview: vi.fn(),
|
||||||
|
domEditSaveTimestampRef: makeRef(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeSession = (hasEl = true) =>
|
||||||
|
({
|
||||||
|
getElement: vi.fn().mockReturnValue(hasEl ? { id: "hf-abc" } : null),
|
||||||
|
removeElement: vi.fn(),
|
||||||
|
serialize: vi.fn().mockReturnValue("<html>after</html>"),
|
||||||
|
}) as unknown as Parameters<typeof sdkDeletePersist>[3];
|
||||||
|
|
||||||
|
it("returns false when session is null", async () => {
|
||||||
|
expect(await sdkDeletePersist("hf-abc", "before", "/comp.html", null, makeDeps())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when element not found in session", async () => {
|
||||||
|
const session = makeSession(false);
|
||||||
|
expect(await sdkDeletePersist("hf-abc", "before", "/comp.html", session, makeDeps())).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls removeElement and writes serialized content", async () => {
|
||||||
|
const deps = makeDeps();
|
||||||
|
const session = makeSession(true);
|
||||||
|
const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(session!.removeElement).toHaveBeenCalledWith("hf-abc");
|
||||||
|
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records edit history with before/after diff", async () => {
|
||||||
|
const deps = makeDeps();
|
||||||
|
const session = makeSession(true);
|
||||||
|
await sdkDeletePersist("hf-abc", "before-content", "/comp.html", session, deps);
|
||||||
|
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
label: "Delete element",
|
||||||
|
files: { "/comp.html": { before: "before-content", after: "<html>after</html>" } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls reloadPreview on success", async () => {
|
||||||
|
const deps = makeDeps();
|
||||||
|
const session = makeSession(true);
|
||||||
|
await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
|
||||||
|
expect(deps.reloadPreview).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false and does not write on removeElement error", async () => {
|
||||||
|
const deps = makeDeps();
|
||||||
|
const session = makeSession(true);
|
||||||
|
(session!.removeElement as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||||
|
throw new Error("remove failed");
|
||||||
|
});
|
||||||
|
const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
||||||
|
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("sdkCutoverPersist — GSAP script preservation (integration)", () => {
|
describe("sdkCutoverPersist — GSAP script preservation (integration)", () => {
|
||||||
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
|
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
|
||||||
const makeDeps = () => ({
|
const makeDeps = () => ({
|
||||||
|
|||||||
@@ -83,6 +83,26 @@ interface CutoverOptions {
|
|||||||
coalesceKey?: string;
|
coalesceKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ponytail: internal; export only if a third caller appears
|
||||||
|
async function persistSdkSerialize(
|
||||||
|
sdkSession: Composition,
|
||||||
|
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({
|
||||||
|
label: options?.label ?? "Edit layer",
|
||||||
|
kind: "manual",
|
||||||
|
...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}),
|
||||||
|
files: { [targetPath]: { before: originalContent, after } },
|
||||||
|
});
|
||||||
|
deps.reloadPreview();
|
||||||
|
}
|
||||||
|
|
||||||
export async function sdkCutoverPersist(
|
export async function sdkCutoverPersist(
|
||||||
selection: DomEditSelection,
|
selection: DomEditSelection,
|
||||||
ops: PatchOperation[],
|
ops: PatchOperation[],
|
||||||
@@ -104,16 +124,7 @@ export async function sdkCutoverPersist(
|
|||||||
sdkSession.dispatch(editOp);
|
sdkSession.dispatch(editOp);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const after = sdkSession.serialize();
|
await persistSdkSerialize(sdkSession, targetPath, originalContent, deps, options);
|
||||||
deps.domEditSaveTimestampRef.current = Date.now();
|
|
||||||
await deps.writeProjectFile(targetPath, after);
|
|
||||||
await deps.editHistory.recordEdit({
|
|
||||||
label: options?.label ?? "Edit layer",
|
|
||||||
kind: "manual",
|
|
||||||
...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}),
|
|
||||||
files: { [targetPath]: { before: originalContent, after } },
|
|
||||||
});
|
|
||||||
deps.reloadPreview();
|
|
||||||
trackStudioEvent("sdk_cutover_success", { hfId, opCount: ops.length });
|
trackStudioEvent("sdk_cutover_success", { hfId, opCount: ops.length });
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -124,3 +135,24 @@ export async function sdkCutoverPersist(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sdkDeletePersist(
|
||||||
|
hfId: string,
|
||||||
|
originalContent: string,
|
||||||
|
targetPath: string,
|
||||||
|
sdkSession: Composition | null | undefined,
|
||||||
|
deps: CutoverDeps,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!sdkSession || !sdkSession.getElement(hfId)) return false;
|
||||||
|
try {
|
||||||
|
sdkSession.removeElement(hfId);
|
||||||
|
await persistSdkSerialize(sdkSession, targetPath, originalContent, deps, {
|
||||||
|
label: "Delete element",
|
||||||
|
});
|
||||||
|
trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
trackStudioEvent("sdk_cutover_fallback", { hfId, error: String(err) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user