Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/studio/src/hooks/useDomEditCommits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,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({
Expand All @@ -107,6 +109,7 @@ export function useDomEditCommits({
buildDomSelectionFromTarget,
onDomEditPersisted,
onTrySdkPersist,
onTrySdkDelete,
}: UseDomEditCommitsParams) {
const resolveImportedFontAsset = useCallback(
(fontFamilyValue: string): ImportedFontAsset | null => {
Expand Down Expand Up @@ -306,6 +309,7 @@ export function useDomEditCommits({
projectIdRef,
reloadPreview,
clearDomSelection,
onTrySdkDelete,
commitPositionPatchToHtml,
});

Expand Down
11 changes: 10 additions & 1 deletion packages/studio/src/hooks/useDomEditSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -240,6 +240,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 ──
Expand Down
17 changes: 17 additions & 0 deletions packages/studio/src/hooks/useElementLifecycleOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand All @@ -42,6 +44,7 @@ export function useElementLifecycleOps({
projectIdRef,
reloadPreview,
clearDomSelection,
onTrySdkDelete,
commitPositionPatchToHtml,
}: UseElementLifecycleOpsParams) {
// fallow-ignore-next-line complexity
Expand Down Expand Up @@ -71,6 +74,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)}`,
Expand Down Expand Up @@ -114,13 +127,17 @@ export function useElementLifecycleOps({
clearDomSelection,
domEditSaveTimestampRef,
editHistory.recordEdit,
onTrySdkDelete,
projectIdRef,
reloadPreview,
showToast,
writeProjectFile,
],
);

// 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<{
Expand Down
70 changes: 69 additions & 1 deletion packages/studio/src/utils/sdkCutover.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -291,6 +291,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 = () => ({
Expand Down
52 changes: 42 additions & 10 deletions packages/studio/src/utils/sdkCutover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,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[],
Expand All @@ -96,16 +116,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) {
Expand All @@ -116,3 +127,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;
}
}
Loading