feat(core): applyPositionEdits gains force option and undo reset path

This commit is contained in:
Vance Ingalls
2026-07-15 14:50:46 -07:00
parent 4682da14f1
commit 049f72d4d9
3 changed files with 133 additions and 8 deletions
@@ -239,3 +239,97 @@ describe("installPositionEditsSeekReapply", () => {
).not.toThrow();
});
});
describe("applyPositionEdits — force option", () => {
it("skips a clobbered translate non-forced, re-applies with force", () => {
const el = makeElement({
"data-x": "10",
"data-y": "0",
[EDIT_BASE_X_ATTR]: "0",
[EDIT_BASE_Y_ATTR]: "0",
});
applyPositionEdits(document);
expect(el.style.getPropertyValue("translate")).toBe("10px 0px");
// External clobber (GSAP folding it into a cached transform, a draft
// write, …) — the non-forced fold-guard must skip, force must overwrite.
el.style.setProperty("translate", "999px 0px");
applyPositionEdits(document);
expect(el.style.getPropertyValue("translate")).toBe("999px 0px");
applyPositionEdits(document, { force: true });
expect(el.style.getPropertyValue("translate")).toBe("10px 0px");
el.remove();
});
});
describe("applyPositionEdits — reset path", () => {
it("restores the captured pre-edit translate when base attrs were removed (undo)", () => {
const el = makeElement(
{
"data-x": "10",
"data-y": "5",
[EDIT_BASE_X_ATTR]: "0",
[EDIT_BASE_Y_ATTR]: "0",
},
"translate: 3px 4px",
);
applyPositionEdits(document);
expect(el.style.getPropertyValue("translate")).toBe("13px 9px");
expect(el.getAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR)).toBe("3px 4px");
// Undo: the host removes the whole edit channel.
el.removeAttribute("data-x");
el.removeAttribute("data-y");
el.removeAttribute(EDIT_BASE_X_ATTR);
el.removeAttribute(EDIT_BASE_Y_ATTR);
applyPositionEdits(document);
expect(el.style.getPropertyValue("translate")).toBe("3px 4px");
expect(el.hasAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR)).toBe(false);
el.remove();
});
it("removes the inline translate entirely when the captured original was none", () => {
const el = makeElement({
"data-x": "10",
"data-y": "0",
[EDIT_BASE_X_ATTR]: "0",
[EDIT_BASE_Y_ATTR]: "0",
});
applyPositionEdits(document);
expect(el.style.getPropertyValue("translate")).toBe("10px 0px");
el.removeAttribute("data-x");
el.removeAttribute("data-y");
el.removeAttribute(EDIT_BASE_X_ATTR);
el.removeAttribute(EDIT_BASE_Y_ATTR);
applyPositionEdits(document);
expect(el.style.getPropertyValue("translate")).toBe("");
expect(el.hasAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR)).toBe(false);
el.remove();
});
it("a redo after reset re-captures a clean baseline", () => {
const el = makeElement({
"data-x": "10",
"data-y": "0",
[EDIT_BASE_X_ATTR]: "0",
[EDIT_BASE_Y_ATTR]: "0",
});
applyPositionEdits(document);
el.removeAttribute("data-x");
el.removeAttribute("data-y");
el.removeAttribute(EDIT_BASE_X_ATTR);
el.removeAttribute(EDIT_BASE_Y_ATTR);
applyPositionEdits(document); // reset
// Redo: the host restores the edit channel with a new position.
el.setAttribute("data-x", "20");
el.setAttribute("data-y", "0");
el.setAttribute(EDIT_BASE_X_ATTR, "0");
el.setAttribute(EDIT_BASE_Y_ATTR, "0");
applyPositionEdits(document, { force: true });
expect(el.style.getPropertyValue("translate")).toBe("20px 0px");
el.remove();
});
});
+38 -7
View File
@@ -153,9 +153,21 @@ export function applyPositionEditToElement(el: HTMLElement, opts?: { force?: boo
/**
* Apply all pending position edits in the document. Returns the number of
* elements updated.
*
* Runs the RESET path first: an element still carrying the captured pre-edit
* translate marker (EDIT_ORIGINAL_TRANSLATE_ATTR) but NO base attrs had its
* edit undone — the attrs were removed, so it no longer matches the apply
* selector, and the inline translate written by an earlier application would
* stay orphaned (the element visually displaced after its edit was reverted).
* Restore the captured translate and clear the marker so a later redo
* re-captures a clean baseline.
*
* `force` forwards to applyPositionEditToElement: re-apply even when the
* previously written translate was clobbered externally. Hosts replaying
* undo/redo should force — after a reset the fold-guard's bookkeeping no
* longer matches and the non-forced path would silently skip the redo.
*/
export function applyPositionEdits(doc: Document): number {
const marked = doc.querySelectorAll(`[${EDIT_BASE_X_ATTR}], [${EDIT_BASE_Y_ATTR}]`);
export function applyPositionEdits(doc: Document, opts?: { force?: boolean }): number {
// Not `instanceof HTMLElement`: `doc` is frequently an iframe's document (the
// SDK's edit preview, a host embedding a composition), and its elements are
// HTMLElement instances of THAT frame's realm — never this module's. A
@@ -163,14 +175,33 @@ export function applyPositionEdits(doc: Document): number {
// cross-realm. Use the document's own realm's constructor; duck-type on
// `.style` when defaultView is unavailable (a detached/synthetic document).
const RealmHTMLElement = doc.defaultView?.HTMLElement;
const isStylable = (el: Element): el is HTMLElement =>
RealmHTMLElement
? el instanceof RealmHTMLElement
: typeof (el as HTMLElement).style?.setProperty === "function";
const orphaned = doc.querySelectorAll(
`[${EDIT_ORIGINAL_TRANSLATE_ATTR}]:not([${EDIT_BASE_X_ATTR}]):not([${EDIT_BASE_Y_ATTR}])`,
);
for (let i = 0; i < orphaned.length; i++) {
const el = orphaned[i];
if (!isStylable(el)) continue;
const original = el.getAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR) ?? "";
if (original === "") {
el.style.removeProperty("translate");
} else {
el.style.setProperty("translate", original);
}
el.removeAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR);
lastAppliedTranslate.delete(el);
}
const marked = doc.querySelectorAll(`[${EDIT_BASE_X_ATTR}], [${EDIT_BASE_Y_ATTR}]`);
let applied = 0;
for (let i = 0; i < marked.length; i++) {
const el = marked[i];
const isStylable = RealmHTMLElement
? el instanceof RealmHTMLElement
: typeof (el as HTMLElement).style?.setProperty === "function";
if (!isStylable) continue;
applyPositionEditToElement(el as HTMLElement);
if (!isStylable(el)) continue;
applyPositionEditToElement(el, opts);
applied += 1;
}
return applied;