feat(studio): route element delete through SDK removeElement (§3.1) (#1465)

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
Vance Ingalls
2026-06-17 16:38:38 -07:00
committed by GitHub
co-authored by Miguel Ángel
parent 8585fffc92
commit bce571c2a1
5 changed files with 142 additions and 16 deletions
@@ -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 {
+10 -1
View File
@@ -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<{
+69 -1
View File
@@ -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 = () => ({
+42 -10
View File
@@ -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;
}
}