Skip to content

Commit 4d77e7f

Browse files
feat(studio): route element delete through SDK removeElement (§3.1)
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
1 parent 39c2cac commit 4d77e7f

5 files changed

Lines changed: 142 additions & 16 deletions

File tree

packages/studio/src/hooks/useDomEditCommits.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,15 +75,15 @@ export interface UseDomEditCommitsParams {
7575
) => Promise<DomEditSelection | null>;
7676
/** Stage 7 Step 3b: called after a successful server-side element patch. */
7777
onDomEditPersisted?: (selection: DomEditSelection, operations: PatchOperation[]) => void;
78-
/** Stage 7 Step 3b: called after a successful server-side element delete. */
79-
onElementDeleted?: (selection: DomEditSelection) => void;
8078
/** Stage 7 Step 3c: called before the server-side patch path; returns true if SDK handled it. */
8179
onTrySdkPersist?: (
8280
selection: DomEditSelection,
8381
operations: PatchOperation[],
8482
originalContent: string,
8583
targetPath: string,
8684
) => Promise<boolean>;
85+
/** Stage 7 §3.1: called before the server-side delete path; returns true if SDK handled it. */
86+
onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise<boolean>;
8787
}
8888

8989
export function useDomEditCommits({
@@ -105,8 +105,8 @@ export function useDomEditCommits({
105105
refreshDomEditSelectionFromPreview,
106106
buildDomSelectionFromTarget,
107107
onDomEditPersisted,
108-
onElementDeleted,
109108
onTrySdkPersist,
109+
onTrySdkDelete,
110110
}: UseDomEditCommitsParams) {
111111
const resolveImportedFontAsset = useCallback(
112112
(fontFamilyValue: string): ImportedFontAsset | null => {
@@ -316,8 +316,8 @@ export function useDomEditCommits({
316316
projectIdRef,
317317
reloadPreview,
318318
clearDomSelection,
319+
onTrySdkDelete,
319320
commitPositionPatchToHtml,
320-
onElementDeleted,
321321
});
322322

323323
return {

packages/studio/src/hooks/useDomEditSession.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { RightPanelTab } from "../utils/studioHelpers";
55
import type { PatchTarget } from "../utils/sourcePatcher";
66
import type { SidebarTab } from "../components/sidebar/LeftSidebar";
77
import type { Composition } from "@hyperframes/sdk";
8-
import { sdkCutoverPersist } from "../utils/sdkCutover";
8+
import { sdkCutoverPersist, sdkDeletePersist } from "../utils/sdkCutover";
99
import { useAskAgentModal } from "./useAskAgentModal";
1010
import { useDomSelection } from "./useDomSelection";
1111
import { usePreviewInteraction } from "./usePreviewInteraction";
@@ -241,6 +241,15 @@ export function useDomEditSession({
241241
domEditSaveTimestampRef,
242242
})
243243
: undefined,
244+
onTrySdkDelete: sdkSession
245+
? (hfId, originalContent, targetPath) =>
246+
sdkDeletePersist(hfId, originalContent, targetPath, sdkSession, {
247+
editHistory,
248+
writeProjectFile,
249+
reloadPreview,
250+
domEditSaveTimestampRef,
251+
})
252+
: undefined,
244253
});
245254

246255
// ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──

packages/studio/src/hooks/useElementLifecycleOps.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ interface UseElementLifecycleOpsParams {
2626
projectIdRef: React.MutableRefObject<string | null>;
2727
reloadPreview: () => void;
2828
clearDomSelection: () => void;
29+
/** Route delete through SDK when session resolves the hf-id; returns true if handled. */
30+
onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise<boolean>;
2931
commitPositionPatchToHtml: (
3032
selection: DomEditSelection,
3133
patches: PatchOperation[],
@@ -44,6 +46,7 @@ export function useElementLifecycleOps({
4446
projectIdRef,
4547
reloadPreview,
4648
clearDomSelection,
49+
onTrySdkDelete,
4750
commitPositionPatchToHtml,
4851
onElementDeleted,
4952
}: UseElementLifecycleOpsParams) {
@@ -74,6 +77,16 @@ export function useElementLifecycleOps({
7477
throw new Error("Selected element has no patchable target");
7578
}
7679

80+
if (onTrySdkDelete && selection.hfId) {
81+
const handled = await onTrySdkDelete(selection.hfId, originalContent, targetPath);
82+
if (handled) {
83+
clearDomSelection();
84+
usePlayerStore.getState().setSelectedElementId(null);
85+
showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
86+
return;
87+
}
88+
}
89+
7790
domEditSaveTimestampRef.current = Date.now();
7891
const removeResponse = await fetch(
7992
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
@@ -118,6 +131,7 @@ export function useElementLifecycleOps({
118131
clearDomSelection,
119132
domEditSaveTimestampRef,
120133
editHistory.recordEdit,
134+
onTrySdkDelete,
121135
onElementDeleted,
122136
projectIdRef,
123137
reloadPreview,
@@ -126,6 +140,9 @@ export function useElementLifecycleOps({
126140
],
127141
);
128142

143+
// ponytail: z-index reorder writes inline-style patches via commitPositionPatchToHtml →
144+
// persistDomEditOperations → onTrySdkPersist, so it is already SDK-cut-over as setStyle.
145+
// No SDK reorder/reparent op exists; DOM sibling order stays server-authoritative if ever needed.
129146
const handleDomZIndexReorderCommit = useCallback(
130147
(
131148
entries: Array<{

packages/studio/src/utils/sdkCutover.test.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it, vi } from "vitest";
2-
import { shouldUseSdkCutover, sdkCutoverPersist } from "./sdkCutover";
2+
import { shouldUseSdkCutover, sdkCutoverPersist, sdkDeletePersist } from "./sdkCutover";
33
import { openComposition } from "@hyperframes/sdk";
44
import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory";
55
import type { PatchOperation } from "./sourcePatcher";
@@ -298,6 +298,74 @@ describe("sdkCutoverPersist", () => {
298298
});
299299
});
300300

301+
describe("sdkDeletePersist", () => {
302+
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
303+
const makeDeps = () => ({
304+
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
305+
writeProjectFile: vi.fn().mockResolvedValue(undefined),
306+
reloadPreview: vi.fn(),
307+
domEditSaveTimestampRef: makeRef(0),
308+
});
309+
310+
const makeSession = (hasEl = true) =>
311+
({
312+
getElement: vi.fn().mockReturnValue(hasEl ? { id: "hf-abc" } : null),
313+
removeElement: vi.fn(),
314+
serialize: vi.fn().mockReturnValue("<html>after</html>"),
315+
}) as unknown as Parameters<typeof sdkDeletePersist>[3];
316+
317+
it("returns false when session is null", async () => {
318+
expect(await sdkDeletePersist("hf-abc", "before", "/comp.html", null, makeDeps())).toBe(false);
319+
});
320+
321+
it("returns false when element not found in session", async () => {
322+
const session = makeSession(false);
323+
expect(await sdkDeletePersist("hf-abc", "before", "/comp.html", session, makeDeps())).toBe(
324+
false,
325+
);
326+
});
327+
328+
it("calls removeElement and writes serialized content", async () => {
329+
const deps = makeDeps();
330+
const session = makeSession(true);
331+
const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
332+
expect(result).toBe(true);
333+
expect(session!.removeElement).toHaveBeenCalledWith("hf-abc");
334+
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
335+
});
336+
337+
it("records edit history with before/after diff", async () => {
338+
const deps = makeDeps();
339+
const session = makeSession(true);
340+
await sdkDeletePersist("hf-abc", "before-content", "/comp.html", session, deps);
341+
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
342+
expect.objectContaining({
343+
label: "Delete element",
344+
files: { "/comp.html": { before: "before-content", after: "<html>after</html>" } },
345+
}),
346+
);
347+
});
348+
349+
it("calls reloadPreview on success", async () => {
350+
const deps = makeDeps();
351+
const session = makeSession(true);
352+
await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
353+
expect(deps.reloadPreview).toHaveBeenCalled();
354+
});
355+
356+
it("returns false and does not write on removeElement error", async () => {
357+
const deps = makeDeps();
358+
const session = makeSession(true);
359+
(session!.removeElement as ReturnType<typeof vi.fn>).mockImplementation(() => {
360+
throw new Error("remove failed");
361+
});
362+
const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
363+
expect(result).toBe(false);
364+
expect(deps.writeProjectFile).not.toHaveBeenCalled();
365+
expect(deps.reloadPreview).not.toHaveBeenCalled();
366+
});
367+
});
368+
301369
describe("sdkCutoverPersist — GSAP script preservation (integration)", () => {
302370
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
303371
const makeDeps = () => ({

packages/studio/src/utils/sdkCutover.ts

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,26 @@ interface CutoverOptions {
8383
coalesceKey?: string;
8484
}
8585

86+
// ponytail: internal; export only if a third caller appears
87+
async function persistSdkSerialize(
88+
sdkSession: Composition,
89+
targetPath: string,
90+
originalContent: string,
91+
deps: CutoverDeps,
92+
options?: CutoverOptions,
93+
): Promise<void> {
94+
const after = sdkSession.serialize();
95+
deps.domEditSaveTimestampRef.current = Date.now();
96+
await deps.writeProjectFile(targetPath, after);
97+
await deps.editHistory.recordEdit({
98+
label: options?.label ?? "Edit layer",
99+
kind: "manual",
100+
...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}),
101+
files: { [targetPath]: { before: originalContent, after } },
102+
});
103+
deps.reloadPreview();
104+
}
105+
86106
export async function sdkCutoverPersist(
87107
selection: DomEditSelection,
88108
ops: PatchOperation[],
@@ -104,16 +124,7 @@ export async function sdkCutoverPersist(
104124
sdkSession.dispatch(editOp);
105125
}
106126
});
107-
const after = sdkSession.serialize();
108-
deps.domEditSaveTimestampRef.current = Date.now();
109-
await deps.writeProjectFile(targetPath, after);
110-
await deps.editHistory.recordEdit({
111-
label: options?.label ?? "Edit layer",
112-
kind: "manual",
113-
...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}),
114-
files: { [targetPath]: { before: originalContent, after } },
115-
});
116-
deps.reloadPreview();
127+
await persistSdkSerialize(sdkSession, targetPath, originalContent, deps, options);
117128
trackStudioEvent("sdk_cutover_success", { hfId, opCount: ops.length });
118129
return true;
119130
} catch (err) {
@@ -124,3 +135,24 @@ export async function sdkCutoverPersist(
124135
return false;
125136
}
126137
}
138+
139+
export async function sdkDeletePersist(
140+
hfId: string,
141+
originalContent: string,
142+
targetPath: string,
143+
sdkSession: Composition | null | undefined,
144+
deps: CutoverDeps,
145+
): Promise<boolean> {
146+
if (!sdkSession || !sdkSession.getElement(hfId)) return false;
147+
try {
148+
sdkSession.removeElement(hfId);
149+
await persistSdkSerialize(sdkSession, targetPath, originalContent, deps, {
150+
label: "Delete element",
151+
});
152+
trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 });
153+
return true;
154+
} catch (err) {
155+
trackStudioEvent("sdk_cutover_fallback", { hfId, error: String(err) });
156+
return false;
157+
}
158+
}

0 commit comments

Comments
 (0)