mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +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>;
|
||||
/** Stage 7 Step 3b: called after a successful server-side element patch. */
|
||||
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. */
|
||||
onTrySdkPersist?: (
|
||||
selection: DomEditSelection,
|
||||
@@ -84,6 +82,8 @@ export interface UseDomEditCommitsParams {
|
||||
originalContent: string,
|
||||
targetPath: string,
|
||||
) => 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({
|
||||
@@ -105,8 +105,8 @@ export function useDomEditCommits({
|
||||
refreshDomEditSelectionFromPreview,
|
||||
buildDomSelectionFromTarget,
|
||||
onDomEditPersisted,
|
||||
onElementDeleted,
|
||||
onTrySdkPersist,
|
||||
onTrySdkDelete,
|
||||
}: UseDomEditCommitsParams) {
|
||||
const resolveImportedFontAsset = useCallback(
|
||||
(fontFamilyValue: string): ImportedFontAsset | null => {
|
||||
@@ -316,8 +316,8 @@ export function useDomEditCommits({
|
||||
projectIdRef,
|
||||
reloadPreview,
|
||||
clearDomSelection,
|
||||
onTrySdkDelete,
|
||||
commitPositionPatchToHtml,
|
||||
onElementDeleted,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 } from "../utils/sdkCutover";
|
||||
import { sdkCutoverPersist, sdkDeletePersist } from "../utils/sdkCutover";
|
||||
import { useAskAgentModal } from "./useAskAgentModal";
|
||||
import { useDomSelection } from "./useDomSelection";
|
||||
import { usePreviewInteraction } from "./usePreviewInteraction";
|
||||
@@ -241,6 +241,15 @@ export function useDomEditSession({
|
||||
domEditSaveTimestampRef,
|
||||
})
|
||||
: 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 ──
|
||||
|
||||
@@ -26,6 +26,8 @@ interface UseElementLifecycleOpsParams {
|
||||
projectIdRef: React.MutableRefObject<string | null>;
|
||||
reloadPreview: () => 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: (
|
||||
selection: DomEditSelection,
|
||||
patches: PatchOperation[],
|
||||
@@ -44,6 +46,7 @@ export function useElementLifecycleOps({
|
||||
projectIdRef,
|
||||
reloadPreview,
|
||||
clearDomSelection,
|
||||
onTrySdkDelete,
|
||||
commitPositionPatchToHtml,
|
||||
onElementDeleted,
|
||||
}: UseElementLifecycleOpsParams) {
|
||||
@@ -74,6 +77,16 @@ export function useElementLifecycleOps({
|
||||
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();
|
||||
const removeResponse = await fetch(
|
||||
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
|
||||
@@ -118,6 +131,7 @@ export function useElementLifecycleOps({
|
||||
clearDomSelection,
|
||||
domEditSaveTimestampRef,
|
||||
editHistory.recordEdit,
|
||||
onTrySdkDelete,
|
||||
onElementDeleted,
|
||||
projectIdRef,
|
||||
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(
|
||||
(
|
||||
entries: Array<{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { shouldUseSdkCutover, sdkCutoverPersist } from "./sdkCutover";
|
||||
import { shouldUseSdkCutover, sdkCutoverPersist, sdkDeletePersist } from "./sdkCutover";
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory";
|
||||
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)", () => {
|
||||
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
|
||||
const makeDeps = () => ({
|
||||
|
||||
@@ -83,6 +83,26 @@ interface CutoverOptions {
|
||||
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(
|
||||
selection: DomEditSelection,
|
||||
ops: PatchOperation[],
|
||||
@@ -104,16 +124,7 @@ export async function sdkCutoverPersist(
|
||||
sdkSession.dispatch(editOp);
|
||||
}
|
||||
});
|
||||
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();
|
||||
await persistSdkSerialize(sdkSession, targetPath, originalContent, deps, options);
|
||||
trackStudioEvent("sdk_cutover_success", { hfId, opCount: ops.length });
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -124,3 +135,24 @@ export async function sdkCutoverPersist(
|
||||
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