mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 10:14:30 +00:00
* fix(studio): stop a Studio edit from reloading the preview as if it were external Every mutation route wrote the file without leaving a write receipt, so the watcher's broadcast of Studio's own edit arrived with no identity on it. The external-change coordinator could not tell that echo from an agent or an editor writing the file behind Studio's back, so it took the safe branch and did a full iframe reload. That reload hides the stage for the length of the reload, which is what the flash after a text edit was. Every mutation write now goes through one helper that records the receipt, and the client claims the write before the request goes out rather than after it: the server writes and the watcher fires while the request is still in flight, so a token marked from the response can arrive after the echo it was meant to match. Reproduced in the browser before and after, with the reload path traced end to end. Before, a patch-element write logged `token: null` then a reload from the coordinator; after, the same write logs the token and `suppressed: own write token`, with no reload. Adds `hf-reload-debug` (localStorage, off by default) alongside the existing `hf-resize-debug`: it records each file-change decision and its reason, plus the stack of whoever asked for a full reload. * fix(studio): claim the timeline and caption writes too, not just the DOM ones The receipt only helps when the client marked the token it sent, and the GSAP mutation writers never sent one. A drag commits through gsap-mutations, so the server minted a token the client had never seen, the change came back looking like someone else's, and the preview did the full reload the receipt was meant to prevent. Same one-line claim on both GSAP mutation writers, the timing sync's mutation call, and the caption auto-save PUT. The rollback call stays deliberately unclaimed and says why: it runs because a mutation did not converge, so the preview is on bytes nobody can vouch for and the reload is the point. Verified live: a drag-shaped update-properties on the timeline now logs `suppressed: own write token` with no reload, where it logged a coordinator reload before. * refactor(studio): keep timelineTimingSync under the size cap Claiming the timeline writes pushed this file one line past the 600-line gate. Same change as the branch made later, landed with the commit that caused it. * fix(studio): cover remaining write receipt paths * fix(studio): preserve batch write receipts * fix(cli): emit every file in a watcher burst
169 lines
6.0 KiB
TypeScript
169 lines
6.0 KiB
TypeScript
import { useCallback } from "react";
|
|
import {
|
|
readProjectFileContent,
|
|
saveProjectFilesWithHistory,
|
|
type DomEditCommitBaseParams,
|
|
} from "../utils/studioFileHistory";
|
|
import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
|
|
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
|
|
interface UseGroupCommitsParams extends DomEditCommitBaseParams {
|
|
/** Resync the SDK session after a server-side write (the wrapper/unwrap changes
|
|
* structure the in-memory doc doesn't know about). */
|
|
forceReloadSdkSession?: () => void;
|
|
}
|
|
|
|
interface PatchTarget {
|
|
id?: string | null;
|
|
hfId?: string;
|
|
selector?: string;
|
|
selectorIndex?: number;
|
|
}
|
|
|
|
interface GroupGeometry {
|
|
bbox: { left: number; top: number; width: number; height: number };
|
|
targets: PatchTarget[];
|
|
rebases: Array<{ target: PatchTarget; left: number; top: number }>;
|
|
}
|
|
|
|
// Wrapper sits at the members' bounding box top-left; each member is rebased so
|
|
// its absolute position is unchanged. offsetLeft/Top are layout coordinates in
|
|
// composition space (transforms excluded), exactly the space the rebase formula
|
|
// `left_new = left_old - W.left` operates in — GSAP x/y and offset vars are
|
|
// transform deltas and stay correct without adjustment.
|
|
function computeGroupGeometry(members: DomEditSelection[]): GroupGeometry {
|
|
const boxes = members.map((m) => ({
|
|
target: buildDomEditPatchTarget(m),
|
|
left: m.element.offsetLeft,
|
|
top: m.element.offsetTop,
|
|
right: m.element.offsetLeft + m.element.offsetWidth,
|
|
bottom: m.element.offsetTop + m.element.offsetHeight,
|
|
}));
|
|
const left = Math.min(...boxes.map((b) => b.left));
|
|
const top = Math.min(...boxes.map((b) => b.top));
|
|
const width = Math.max(...boxes.map((b) => b.right)) - left;
|
|
const height = Math.max(...boxes.map((b) => b.bottom)) - top;
|
|
return {
|
|
bbox: { left, top, width, height },
|
|
targets: boxes.map((b) => b.target),
|
|
rebases: boxes.map((b) => ({ target: b.target, left: b.left - left, top: b.top - top })),
|
|
};
|
|
}
|
|
|
|
// Shared read → mutate-route → save-with-history → reload pipeline for both
|
|
// wrap (group) and unwrap (ungroup). Mirrors the structural-mutation pattern in
|
|
// useElementLifecycleOps (delete). Returns the route's JSON, or throws.
|
|
async function commitStructuralMutation(
|
|
pid: string,
|
|
targetPath: string,
|
|
route: "wrap-elements" | "unwrap-elements",
|
|
body: unknown,
|
|
label: string,
|
|
deps: Pick<
|
|
UseGroupCommitsParams,
|
|
| "writeProjectFile"
|
|
| "editHistory"
|
|
| "domEditSaveTimestampRef"
|
|
| "clearDomSelection"
|
|
| "forceReloadSdkSession"
|
|
| "reloadPreview"
|
|
>,
|
|
): Promise<{ content?: string; groupId?: string }> {
|
|
const originalContent = await readProjectFileContent(pid, targetPath);
|
|
|
|
deps.domEditSaveTimestampRef.current = Date.now();
|
|
const mutateResponse = await fetch(
|
|
`/api/projects/${pid}/file-mutations/${route}/${encodeURIComponent(targetPath)}`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
|
body: JSON.stringify(body),
|
|
},
|
|
);
|
|
if (!mutateResponse.ok) {
|
|
const errBody = (await mutateResponse.json().catch(() => null)) as { error?: string } | null;
|
|
throw new Error(errBody?.error ?? `Failed to ${label.toLowerCase()} in ${targetPath}`);
|
|
}
|
|
const mutateData = (await mutateResponse.json()) as { content?: string; groupId?: string };
|
|
const patchedContent =
|
|
typeof mutateData.content === "string" ? mutateData.content : originalContent;
|
|
|
|
await saveProjectFilesWithHistory({
|
|
projectId: pid,
|
|
label,
|
|
kind: "manual",
|
|
files: { [targetPath]: patchedContent },
|
|
readFile: async () => originalContent,
|
|
writeFile: deps.writeProjectFile,
|
|
recordEdit: deps.editHistory.recordEdit,
|
|
});
|
|
deps.clearDomSelection();
|
|
deps.forceReloadSdkSession?.();
|
|
deps.reloadPreview();
|
|
return mutateData;
|
|
}
|
|
|
|
export function useGroupCommits(params: UseGroupCommitsParams) {
|
|
const { activeCompPath, showToast, projectIdRef } = params;
|
|
|
|
const groupSelection = useCallback(
|
|
async (members: DomEditSelection[]): Promise<string | null> => {
|
|
const pid = projectIdRef.current;
|
|
if (!pid || members.length === 0) return null;
|
|
|
|
// All members must live in the same source file — the wrapper is one node
|
|
// in one document. (Cross-file grouping is out of scope.)
|
|
const targetPath = members[0].sourceFile || activeCompPath || "index.html";
|
|
if (members.some((m) => (m.sourceFile || activeCompPath || "index.html") !== targetPath)) {
|
|
showToast("Can't group elements from different files", "error");
|
|
return null;
|
|
}
|
|
|
|
// Auto-name "Group N" by the count of existing groups in the document.
|
|
const doc = members[0].element.ownerDocument;
|
|
const groupId = `Group ${doc.querySelectorAll("[data-hf-group]").length + 1}`;
|
|
const { bbox, targets, rebases } = computeGroupGeometry(members);
|
|
|
|
try {
|
|
const data = await commitStructuralMutation(
|
|
pid,
|
|
targetPath,
|
|
"wrap-elements",
|
|
{ targets, groupId, bbox, rebases },
|
|
"Group elements",
|
|
params,
|
|
);
|
|
return data.groupId ?? groupId;
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : "Failed to group elements", "error");
|
|
return null;
|
|
}
|
|
},
|
|
[activeCompPath, projectIdRef, showToast, params],
|
|
);
|
|
|
|
const ungroupSelection = useCallback(
|
|
async (group: DomEditSelection): Promise<void> => {
|
|
const pid = projectIdRef.current;
|
|
if (!pid) return;
|
|
const targetPath = group.sourceFile || activeCompPath || "index.html";
|
|
|
|
try {
|
|
await commitStructuralMutation(
|
|
pid,
|
|
targetPath,
|
|
"unwrap-elements",
|
|
{ target: buildDomEditPatchTarget(group) },
|
|
"Ungroup elements",
|
|
params,
|
|
);
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : "Failed to ungroup elements", "error");
|
|
}
|
|
},
|
|
[activeCompPath, projectIdRef, showToast, params],
|
|
);
|
|
|
|
return { groupSelection, ungroupSelection };
|
|
}
|