Skip to content

Commit e78bb5b

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

5 files changed

Lines changed: 228 additions & 16 deletions

File tree

packages/studio/src/hooks/useDomEditCommits.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +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;
78+
/** Stage 7 Step 3c: called before the server-side patch path; returns true if SDK handled it. */
79+
onTrySdkPersist?: (
80+
selection: DomEditSelection,
81+
operations: PatchOperation[],
82+
originalContent: string,
83+
targetPath: string,
84+
) => 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>;
8087
}
8188

8289
export function useDomEditCommits({
@@ -98,7 +105,8 @@ export function useDomEditCommits({
98105
refreshDomEditSelectionFromPreview,
99106
buildDomSelectionFromTarget,
100107
onDomEditPersisted,
101-
onElementDeleted,
108+
onTrySdkPersist,
109+
onTrySdkDelete,
102110
}: UseDomEditCommitsParams) {
103111
const resolveImportedFontAsset = useCallback(
104112
(fontFamilyValue: string): ImportedFontAsset | null => {
@@ -295,6 +303,7 @@ export function useDomEditCommits({
295303
projectIdRef,
296304
reloadPreview,
297305
clearDomSelection,
306+
onTrySdkDelete,
298307
commitPositionPatchToHtml,
299308
onElementDeleted,
300309
});

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";
@@ -242,6 +242,15 @@ export function useDomEditSession({
242242
domEditSaveTimestampRef,
243243
})
244244
: undefined,
245+
onTrySdkDelete: sdkSession
246+
? (hfId, originalContent, targetPath) =>
247+
sdkDeletePersist(hfId, originalContent, targetPath, sdkSession, {
248+
editHistory,
249+
writeProjectFile,
250+
reloadPreview,
251+
domEditSaveTimestampRef,
252+
})
253+
: undefined,
245254
});
246255

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

packages/studio/src/hooks/useElementLifecycleOps.ts

Lines changed: 17 additions & 1 deletion
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,14 +131,17 @@ export function useElementLifecycleOps({
118131
clearDomSelection,
119132
domEditSaveTimestampRef,
120133
editHistory.recordEdit,
121-
onElementDeleted,
134+
onTrySdkDelete,
122135
projectIdRef,
123136
reloadPreview,
124137
showToast,
125138
writeProjectFile,
126139
],
127140
);
128141

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

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

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { describe, expect, it, vi } from "vitest";
2-
import { shouldUseSdkCutover, sdkCutoverPersist } from "./sdkCutover";
2+
import { shouldUseSdkCutover, sdkCutoverPersist, sdkDeletePersist } from "./sdkCutover";
3+
import { openComposition } from "@hyperframes/sdk";
4+
import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory";
35
import type { PatchOperation } from "./sourcePatcher";
46
import type { MutableRefObject } from "react";
57

@@ -154,4 +156,148 @@ describe("sdkCutoverPersist", () => {
154156
expect(result).toBe(false);
155157
expect(deps.reloadPreview).not.toHaveBeenCalled();
156158
});
159+
160+
it("wraps all dispatches in session.batch() for atomic rollback", async () => {
161+
const deps = makeDeps();
162+
const session = makeSession(true);
163+
const sel = { hfId: "hf-abc" } as never;
164+
await sdkCutoverPersist(
165+
sel,
166+
[styleOp("color", "red"), styleOp("opacity", "0.5")],
167+
"before",
168+
"/comp.html",
169+
session,
170+
deps,
171+
);
172+
expect(
173+
(session as unknown as { batch: ReturnType<typeof vi.fn> }).batch,
174+
).toHaveBeenCalledOnce();
175+
});
176+
177+
it("returns false when second dispatch throws (batch prevents partial mutation)", async () => {
178+
// inline-style ops coalesce into one setStyle dispatch; use style+text to produce two dispatches.
179+
const deps = makeDeps();
180+
const session = makeSession(true);
181+
let callCount = 0;
182+
(session!.dispatch as ReturnType<typeof vi.fn>).mockImplementation(() => {
183+
callCount++;
184+
if (callCount === 2) throw new Error("2nd op failed");
185+
});
186+
const sel = { hfId: "hf-abc" } as never;
187+
const result = await sdkCutoverPersist(
188+
sel,
189+
[styleOp("color", "red"), textOp("hello")],
190+
"before",
191+
"/comp.html",
192+
session,
193+
deps,
194+
);
195+
expect(result).toBe(false);
196+
expect(deps.writeProjectFile).not.toHaveBeenCalled();
197+
expect(deps.reloadPreview).not.toHaveBeenCalled();
198+
});
199+
});
200+
201+
describe("sdkDeletePersist", () => {
202+
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
203+
const makeDeps = () => ({
204+
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
205+
writeProjectFile: vi.fn().mockResolvedValue(undefined),
206+
reloadPreview: vi.fn(),
207+
domEditSaveTimestampRef: makeRef(0),
208+
});
209+
210+
const makeSession = (hasEl = true) =>
211+
({
212+
getElement: vi.fn().mockReturnValue(hasEl ? { id: "hf-abc" } : null),
213+
removeElement: vi.fn(),
214+
serialize: vi.fn().mockReturnValue("<html>after</html>"),
215+
}) as unknown as Parameters<typeof sdkDeletePersist>[3];
216+
217+
it("returns false when session is null", async () => {
218+
expect(await sdkDeletePersist("hf-abc", "before", "/comp.html", null, makeDeps())).toBe(false);
219+
});
220+
221+
it("returns false when element not found in session", async () => {
222+
const session = makeSession(false);
223+
expect(await sdkDeletePersist("hf-abc", "before", "/comp.html", session, makeDeps())).toBe(
224+
false,
225+
);
226+
});
227+
228+
it("calls removeElement and writes serialized content", async () => {
229+
const deps = makeDeps();
230+
const session = makeSession(true);
231+
const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
232+
expect(result).toBe(true);
233+
expect(session!.removeElement).toHaveBeenCalledWith("hf-abc");
234+
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
235+
});
236+
237+
it("records edit history with before/after diff", async () => {
238+
const deps = makeDeps();
239+
const session = makeSession(true);
240+
await sdkDeletePersist("hf-abc", "before-content", "/comp.html", session, deps);
241+
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
242+
expect.objectContaining({
243+
label: "Delete element",
244+
files: { "/comp.html": { before: "before-content", after: "<html>after</html>" } },
245+
}),
246+
);
247+
});
248+
249+
it("calls reloadPreview on success", async () => {
250+
const deps = makeDeps();
251+
const session = makeSession(true);
252+
await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
253+
expect(deps.reloadPreview).toHaveBeenCalled();
254+
});
255+
256+
it("returns false and does not write on removeElement error", async () => {
257+
const deps = makeDeps();
258+
const session = makeSession(true);
259+
(session!.removeElement as ReturnType<typeof vi.fn>).mockImplementation(() => {
260+
throw new Error("remove failed");
261+
});
262+
const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
263+
expect(result).toBe(false);
264+
expect(deps.writeProjectFile).not.toHaveBeenCalled();
265+
expect(deps.reloadPreview).not.toHaveBeenCalled();
266+
});
267+
});
268+
269+
describe("sdkCutoverPersist — GSAP script preservation (integration)", () => {
270+
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
271+
const makeDeps = () => ({
272+
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
273+
writeProjectFile: vi.fn().mockResolvedValue(undefined),
274+
reloadPreview: vi.fn(),
275+
domEditSaveTimestampRef: makeRef(0),
276+
});
277+
278+
it("preserves GSAP <script> block and data-position-mode through setStyle dispatch", async () => {
279+
const html = `<!DOCTYPE html><html><head></head><body>
280+
<div data-hf-id="hf-layer" style="color: blue; opacity: 1"></div>
281+
<script data-hf-gsap data-position-mode="relative">
282+
gsap.timeline().to('[data-hf-id="hf-layer"]', { duration: 1, x: 100 });
283+
</script>
284+
</body></html>`;
285+
const comp = await openComposition(html, { persist: createMemoryAdapter() });
286+
const deps = makeDeps();
287+
const sel = { hfId: "hf-layer" } as never;
288+
const result = await sdkCutoverPersist(
289+
sel,
290+
[{ type: "inline-style", property: "color", value: "red" }],
291+
html,
292+
"/comp.html",
293+
comp,
294+
deps,
295+
);
296+
expect(result).toBe(true);
297+
const written = (deps.writeProjectFile as ReturnType<typeof vi.fn>).mock
298+
.calls[0]?.[1] as string;
299+
expect(written).toContain("data-hf-gsap");
300+
expect(written).toContain('data-position-mode="relative"');
301+
expect(written).toContain("gsap.timeline()");
302+
});
157303
});

packages/studio/src/utils/sdkCutover.ts

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,26 @@ interface CutoverOptions {
7676
coalesceKey?: string;
7777
}
7878

79+
// ponytail: internal; export only if a third caller appears
80+
async function persistSdkSerialize(
81+
sdkSession: Composition,
82+
targetPath: string,
83+
originalContent: string,
84+
deps: CutoverDeps,
85+
options?: CutoverOptions,
86+
): Promise<void> {
87+
const after = sdkSession.serialize();
88+
deps.domEditSaveTimestampRef.current = Date.now();
89+
await deps.writeProjectFile(targetPath, after);
90+
await deps.editHistory.recordEdit({
91+
label: options?.label ?? "Edit layer",
92+
kind: "manual",
93+
...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}),
94+
files: { [targetPath]: { before: originalContent, after } },
95+
});
96+
deps.reloadPreview();
97+
}
98+
7999
export async function sdkCutoverPersist(
80100
selection: DomEditSelection,
81101
ops: PatchOperation[],
@@ -96,16 +116,7 @@ export async function sdkCutoverPersist(
96116
sdkSession.dispatch(editOp);
97117
}
98118
});
99-
const after = sdkSession.serialize();
100-
deps.domEditSaveTimestampRef.current = Date.now();
101-
await deps.writeProjectFile(targetPath, after);
102-
await deps.editHistory.recordEdit({
103-
label: options?.label ?? "Edit layer",
104-
kind: "manual",
105-
...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}),
106-
files: { [targetPath]: { before: originalContent, after } },
107-
});
108-
deps.reloadPreview();
119+
await persistSdkSerialize(sdkSession, targetPath, originalContent, deps, options);
109120
trackStudioEvent("sdk_cutover_success", { hfId, opCount: ops.length });
110121
return true;
111122
} catch (err) {
@@ -116,3 +127,24 @@ export async function sdkCutoverPersist(
116127
return false;
117128
}
118129
}
130+
131+
export async function sdkDeletePersist(
132+
hfId: string,
133+
originalContent: string,
134+
targetPath: string,
135+
sdkSession: Composition | null | undefined,
136+
deps: CutoverDeps,
137+
): Promise<boolean> {
138+
if (!sdkSession || !sdkSession.getElement(hfId)) return false;
139+
try {
140+
sdkSession.removeElement(hfId);
141+
await persistSdkSerialize(sdkSession, targetPath, originalContent, deps, {
142+
label: "Delete element",
143+
});
144+
trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 });
145+
return true;
146+
} catch (err) {
147+
trackStudioEvent("sdk_cutover_fallback", { hfId, error: String(err) });
148+
return false;
149+
}
150+
}

0 commit comments

Comments
 (0)