mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(sdk): restore saved edits for dotted element IDs (#6287)
* fix(sdk): restore saved edits for dotted element IDs * fix(sdk): skip unknown saved override values
This commit is contained in:
@@ -81,6 +81,11 @@ A `null` value is a removal marker. When the SDK serializes the composition it o
|
||||
|
||||
Sub-composition elements use scoped IDs — `"hf-host/hf-leaf.text"` — where the host and leaf are separated by `/`.
|
||||
|
||||
Periods in an authored ID stay part of that ID: editing `hf-title.main` produces
|
||||
`"hf-title.main.text"`, which restores the edit to that same element when reopened.
|
||||
Non-null whole-element values must be snapshots returned by `getOverrides()`. Opening a
|
||||
delta skips scalar values and incomplete snapshots under whole-element keys.
|
||||
|
||||
### Font and image variable overrides
|
||||
|
||||
Variable overrides (from `setVariableValue`) live under the `var.{id}` key. Font and image values are objects, not strings:
|
||||
|
||||
@@ -142,6 +142,12 @@ export function applyOverrideSet(parsed: ParsedDocument, overrides: OverrideSet)
|
||||
for (const [key, value] of entries) {
|
||||
const path = keyToPath(key);
|
||||
if (!path) continue;
|
||||
if (
|
||||
value !== null &&
|
||||
/^\/elements\/[^/]+$/.test(path) &&
|
||||
!isElementSnapshotOverride(key, value)
|
||||
)
|
||||
continue;
|
||||
if (value === null) {
|
||||
patches.push({ op: "remove", path });
|
||||
} else {
|
||||
@@ -166,9 +172,13 @@ export function applyOverrideSet(parsed: ParsedDocument, overrides: OverrideSet)
|
||||
export function isElementSnapshotOverride(
|
||||
key: string,
|
||||
value: OverrideSet[string],
|
||||
): value is Record<string, unknown> & { siblingIndex: number } {
|
||||
): value is Record<string, unknown> & { html: string; siblingIndex: number } {
|
||||
if (value === null || typeof value !== "object") return false;
|
||||
return /^\/elements\/[^/]+$/.test(keyToPath(key) ?? "") && typeof value.siblingIndex === "number";
|
||||
return (
|
||||
/^\/elements\/[^/]+$/.test(keyToPath(key) ?? "") &&
|
||||
typeof value.html === "string" &&
|
||||
typeof value.siblingIndex === "number"
|
||||
);
|
||||
}
|
||||
|
||||
function restoreOverridePositions(parsed: ParsedDocument, overrides: OverrideSet): void {
|
||||
|
||||
@@ -148,31 +148,41 @@ export function pathToKey(path: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of pathToKey — maps an override-set key back to its RFC 6902 path.
|
||||
* Used to replay a stored override-set onto a fresh base document (T3 init).
|
||||
*/
|
||||
// Exhaustive key-family dispatcher — same shape as apply-patches.ts parsePath.
|
||||
// Exhaustive element-key dispatch keeps both ID grammars on the same path rules.
|
||||
// fallow-ignore-next-line complexity
|
||||
function elementKeyToPath(key: string, allowDots = false): string | null {
|
||||
const separator = /\.(style|attr|timing|hold)\./.exec(key);
|
||||
if (!separator) {
|
||||
const id = key.endsWith(".text") ? key.slice(0, -5) : "";
|
||||
return id && (allowDots || !id.includes(".")) ? textPath(id) : null;
|
||||
}
|
||||
|
||||
const id = key.slice(0, separator.index);
|
||||
const prop = key.slice(separator.index + separator[0].length);
|
||||
if (!id || !prop || (!allowDots && id.includes("."))) return null;
|
||||
|
||||
switch (separator[1]) {
|
||||
case "style":
|
||||
return stylePath(id, prop);
|
||||
case "attr":
|
||||
// Attribute names are already RFC 6902-encoded by pathToKey.
|
||||
return `/elements/${escapeIdForPath(id)}/attributes/${prop}`;
|
||||
case "timing":
|
||||
if (prop === "start" || prop === "end" || prop === "duration" || prop === "trackIndex")
|
||||
return timingPath(id, prop);
|
||||
return null;
|
||||
case "hold":
|
||||
if (prop === "start" || prop === "end" || prop === "fill") return holdPath(id, prop);
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a stored override key to its RFC 6902 path when reopening a base document. */
|
||||
export function keyToPath(key: string): string | null {
|
||||
const style = /^([^.]+)\.style\.(.+)$/.exec(key);
|
||||
if (style?.[1] && style[2]) return stylePath(style[1], style[2]);
|
||||
|
||||
const text = /^([^.]+)\.text$/.exec(key);
|
||||
if (text?.[1]) return textPath(text[1]);
|
||||
|
||||
const attr = /^([^.]+)\.attr\.(.+)$/.exec(key);
|
||||
// The attr name segment in the key is already RFC 6902-encoded (pathToKey stored it verbatim).
|
||||
// The id may be a scoped id (contains "/") so we must escape it, but must NOT re-escape
|
||||
// the already-encoded attr segment. Reconstruct manually.
|
||||
if (attr?.[1] && attr[2]) return `/elements/${escapeIdForPath(attr[1])}/attributes/${attr[2]}`;
|
||||
|
||||
const timing = /^([^.]+)\.timing\.(start|end|duration|trackIndex)$/.exec(key);
|
||||
if (timing?.[1])
|
||||
return timingPath(timing[1], timing[2] as "start" | "end" | "duration" | "trackIndex");
|
||||
|
||||
const hold = /^([^.]+)\.hold\.(start|end|fill)$/.exec(key);
|
||||
if (hold?.[1]) return holdPath(hold[1], hold[2] as "start" | "end" | "fill");
|
||||
const element = elementKeyToPath(key);
|
||||
if (element) return element;
|
||||
|
||||
const varDecl = /^varDecl\.(.+)$/.exec(key);
|
||||
if (varDecl?.[1]) return variableDeclPath(varDecl[1]);
|
||||
@@ -186,10 +196,8 @@ export function keyToPath(key: string): string | null {
|
||||
if (key === "script.gsap") return gsapScriptPath();
|
||||
if (key === "style.css") return styleSheetPath();
|
||||
|
||||
// Bare element id — removal marker key.
|
||||
if (!key.includes(".")) return elementPath(key);
|
||||
|
||||
return null;
|
||||
// Existing namespaces keep precedence over the dotted-ID fallback.
|
||||
return elementKeyToPath(key, true) ?? elementPath(key);
|
||||
}
|
||||
|
||||
// ─── Patch event builder ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -138,6 +138,89 @@ describe("history coalescing", () => {
|
||||
// ─── T3 override replay ───────────────────────────────────────────────────────
|
||||
|
||||
describe("override-set replay on open", () => {
|
||||
it.each([
|
||||
{ key: "hf-title.timing.bogus", value: 2 },
|
||||
{ key: "meta.fps", value: 30 },
|
||||
{ key: "style.foo", value: "x" },
|
||||
{ key: "future.element", value: { html: '<p data-hf-id="hf-extra">Extra</p>' } },
|
||||
{ key: "future.element", value: { siblingIndex: 0 } },
|
||||
])("ignores an unrecognized non-snapshot override: $key", async ({ key, value }) => {
|
||||
const base = await openComposition(BASE_HTML);
|
||||
const reopened = await openComposition(BASE_HTML, { overrides: { [key]: value } });
|
||||
|
||||
expect(reopened.serialize()).toBe(base.serialize());
|
||||
});
|
||||
|
||||
it.each([false, true])("replays edits with dotted IDs, scoped: %s", async (scoped) => {
|
||||
const title = '<h1 data-hf-id="hf-title.main" style="color:red" title="Original">Original</h1>';
|
||||
const html = `<div data-hf-id="hf-stage" data-hf-root>${
|
||||
scoped
|
||||
? `<div data-hf-id="hf-host.one" data-composition-file="scene.html">${title}</div>`
|
||||
: title
|
||||
}</div>`;
|
||||
const id = scoped ? "hf-host.one/hf-title.main" : "hf-title.main";
|
||||
const comp = await openComposition(html, { overrides: {} });
|
||||
comp.setText(id, "Updated");
|
||||
comp.setStyle(id, { color: "blue" });
|
||||
comp.setAttribute(id, "title", null);
|
||||
comp.setAttribute(id, "data-app.attr.label", "Saved");
|
||||
comp.setTiming(id, { start: 1, duration: 2 });
|
||||
comp.setHold(id, { start: 0.5, end: 0.25, fill: "freeze" });
|
||||
|
||||
const reopened = await openComposition(html, {
|
||||
overrides: JSON.parse(JSON.stringify(comp.getOverrides())),
|
||||
});
|
||||
|
||||
expect(reopened.getElement(id)?.text).toBe("Updated");
|
||||
expect(reopened.getElement(id)?.inlineStyles["color"]).toBe("blue");
|
||||
expect(reopened.getElement(id)?.attributes["title"]).toBeUndefined();
|
||||
expect(reopened.getElement(id)?.attributes["data-app.attr.label"]).toBe("Saved");
|
||||
expect(reopened.getElement(id)?.attributes).toMatchObject({
|
||||
"data-hold-start": "0.5",
|
||||
"data-hold-end": "0.25",
|
||||
"data-hold-fill": "freeze",
|
||||
});
|
||||
expect(reopened.getElementTimings()[id]).toMatchObject({ enterAt: 1, exitAt: 3 });
|
||||
expect(reopened.serialize()).toBe(comp.serialize());
|
||||
});
|
||||
|
||||
it("replays a removal marker for a dotted element ID", async () => {
|
||||
const html = '<div data-hf-root><p data-hf-id="hf-copy.main">Remove me</p></div>';
|
||||
const comp = await openComposition(html, { overrides: {} });
|
||||
comp.removeElement("hf-copy.main");
|
||||
|
||||
const reopened = await openComposition(html, {
|
||||
overrides: JSON.parse(JSON.stringify(comp.getOverrides())),
|
||||
});
|
||||
|
||||
expect(reopened.getElement("hf-copy.main")).toBeNull();
|
||||
expect(reopened.serialize()).toBe(comp.serialize());
|
||||
});
|
||||
|
||||
it("keeps edit-family words inside an attribute name", async () => {
|
||||
const comp = await openComposition(BASE_HTML, { overrides: {} });
|
||||
comp.setAttribute("hf-title", "data-app.style.color", "Saved");
|
||||
comp.setAttribute("hf-title", "data-app.text", "Label");
|
||||
|
||||
const reopened = await openComposition(BASE_HTML, { overrides: comp.getOverrides() });
|
||||
|
||||
expect(reopened.getElement("hf-title")?.attributes["data-app.style.color"]).toBe("Saved");
|
||||
expect(reopened.getElement("hf-title")?.attributes["data-app.text"]).toBe("Label");
|
||||
expect(reopened.serialize()).toBe(comp.serialize());
|
||||
});
|
||||
|
||||
it("keeps ordinary element edits ahead of the variable namespace", async () => {
|
||||
const html = '<div data-hf-root><p data-hf-id="var">Original</p></div>';
|
||||
const comp = await openComposition(html, { overrides: {} });
|
||||
comp.setText("var", "Updated");
|
||||
comp.setStyle("var", { color: "blue" });
|
||||
|
||||
const reopened = await openComposition(html, { overrides: comp.getOverrides() });
|
||||
|
||||
expect(reopened.getElement("var")?.text).toBe("Updated");
|
||||
expect(reopened.getElement("var")?.inlineStyles["color"]).toBe("blue");
|
||||
});
|
||||
|
||||
it("applies style, text, and attribute overrides to the base document", async () => {
|
||||
const comp = await openComposition(BASE_HTML, {
|
||||
overrides: {
|
||||
|
||||
Reference in New Issue
Block a user