Skip to content
Merged
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
10 changes: 0 additions & 10 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,6 @@
"packages/studio/src/components/nle/TimelineEditorNotice.tsx",
// Zoom hook extracted for downstream razor-blade PRs (#1330, #1331).
"packages/studio/src/player/components/useTimelineZoom.ts",
// Cached O(1) GSAP target lookup, replacing O(n²) inline checks.
// Consumers migrate in a follow-up once useDomGeometryCommits adopts it.
"packages/studio/src/hooks/gsapTargetCache.ts",
// Preview helper consumed dynamically from the studio iframe bridge.
"packages/studio/src/hooks/gsapRuntimePreview.ts",
],
Expand Down Expand Up @@ -370,13 +367,6 @@
"file": "packages/studio/src/player/lib/timelineDOM.ts",
"exports": ["*"],
},
// gsapTargetCache: cached O(1) GSAP target lookup, consumed by
// useDomEditCommits and intended to replace the local copy in
// useDomGeometryCommits once callers migrate.
{
"file": "packages/studio/src/hooks/gsapTargetCache.ts",
"exports": ["isElementGsapTargeted"],
},
// Re-exports from useDomEditCommits: barrel-style re-exports
// consumed by downstream studio code.
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { applyStudioBoxSize, applyStudioPathOffset, readStudioBoxSize } from "./manualEditsDom";
import { reapplyPositionEditsAfterSeek } from "./manualEditsSeekReapply";
import { buildBoxSizePatches, buildPathOffsetPatches } from "./manualEditsDomPatches";
import { createManualOffsetDragMember, applyManualOffsetDragCommit } from "./manualOffsetDrag";
import { computeNextResizeAnchor } from "./domEditResizeLocal";
import type { PatchOperation } from "../../utils/sourcePatcher";
import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";

/**
* Center-anchored corner resize (CapCut model): the element scales about its
Expand Down Expand Up @@ -38,40 +32,6 @@ afterEach(() => {
document.body.innerHTML = "";
});

/** Apply a built PatchOperation[] to a live element, mirroring sourcePatcher's
* inline-style / attribute application — i.e. what the persisted source carries
* when it is re-parsed into the DOM on the next preview load. */
function applyPatchesToElement(el: HTMLElement, ops: PatchOperation[]): void {
for (const op of ops) {
if (op.type === "inline-style") {
if (op.value === null) el.style.removeProperty(op.property);
else el.style.setProperty(op.property, op.value);
} else if (op.type === "attribute") {
if (op.value === null) el.removeAttribute(op.property);
else el.setAttribute(op.property, op.value);
}
}
}

/** Net translate applied to an element, resolving the studio offset var()
* expression to its px value so we compare the actually-rendered translation. */
function resolvedTranslatePx(el: HTMLElement): { x: number; y: number } {
const raw = el.style.getPropertyValue("translate").trim();
if (!raw || raw === "none") return { x: 0, y: 0 };
const vx = Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-x")) || 0;
const vy = Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-y")) || 0;
const parts = splitTopLevelWhitespace(raw);
const parseAxis = (part: string, varVal: number): number => {
if (part && part.includes("--hf-studio-offset")) return varVal;
const n = Number.parseFloat(part);
return Number.isFinite(n) ? n : 0;
};
return {
x: parseAxis(parts[0] ?? "", vx),
y: parseAxis(parts[1] ?? "", vy),
};
}

describe("center-anchored corner resize — no shift after release", () => {
it("the per-frame center anchor converges (does NOT oscillate) — the release-shift root cause", () => {
// Model the pointermove anchor loop that pins the element's CENTER. The physical
Expand Down Expand Up @@ -157,48 +117,4 @@ describe("center-anchored corner resize — no shift after release", () => {
}
}
});

it("net translate after persist+reload equals the committed anchor offset (non-GSAP)", () => {
// The committed offset flows through the real apply → persist → reload chain
// unchanged (this hop was proved clean; the shift is upstream in the anchor
// loop above, not in persistence).
const el = document.createElement("div");
el.style.setProperty("width", "200px");
el.style.setProperty("height", "100px");
document.body.appendChild(el);

const anchorDx = -30;
const anchorDy = -18;
const finalSize = { width: 240, height: 130 };

applyStudioBoxSize(el, finalSize);
const memberResult = createManualOffsetDragMember({
key: "k",
selection: { element: el } as never,
element: el,
rect: { left: 0, top: 0, width: 240, height: 130, editScaleX: 1, editScaleY: 1 },
});
expect(memberResult.ok).toBe(true);
if (!memberResult.ok) return;

const finalOffset = applyManualOffsetDragCommit(memberResult.member, anchorDx, anchorDy);

applyStudioBoxSize(el, finalSize);
const patches = buildBoxSizePatches(el);
applyStudioPathOffset(el, finalOffset);
patches.push(...buildPathOffsetPatches(el));

expect(resolvedTranslatePx(el)).toEqual({ x: anchorDx, y: anchorDy });

// Persist → fresh element re-parsed from source → reload re-stamp.
const reloaded = document.createElement("div");
reloaded.style.setProperty("width", "200px");
reloaded.style.setProperty("height", "100px");
document.body.appendChild(reloaded);
applyPatchesToElement(reloaded, patches);
reapplyPositionEditsAfterSeek(reloaded.ownerDocument);

expect(resolvedTranslatePx(reloaded)).toEqual({ x: anchorDx, y: anchorDy });
expect(readStudioBoxSize(reloaded)).toEqual(finalSize);
});
});
6 changes: 3 additions & 3 deletions packages/studio/src/components/editor/manualEditsDom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,9 +253,9 @@ function stripGsapTranslateFromTransform(element: HTMLElement): void {
// — as the non-GSAP path does — composes ON TOP of GSAP's transform, and the
// subsequent strip/reapply math compounds into a runaway matrix that flings the
// element off-canvas. So for GSAP-animated elements we keep `translate: none`
// and push the offset straight into GSAP's x/y via gsap.set; the var() offset is
// still persisted (buildPathOffsetPatches), and GSAP re-reads it at init on
// reload. Returns true when handled as GSAP (caller must skip the CSS path).
// and push the offset straight into GSAP's x/y via gsap.set; the var() offset an
// older Studio saved stays in the file, and GSAP re-reads it at init on reload.
// Returns true when handled as GSAP (caller must skip the CSS path).
// fallow-ignore-next-line complexity
function applyStudioPathOffsetViaGsap(
element: HTMLElement,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,14 @@ import {
STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR,
} from "./studioMotionTypes";
import {
buildPathOffsetPatches,
buildClearPathOffsetPatches,
buildBoxSizePatches,
buildClearBoxSizePatches,
buildClearRotationPatches,
buildMotionPatches,
buildClearMotionPatches,
} from "./manualEditsDomPatches";
import { applyStudioBoxSize, applyStudioPathOffset } from "./manualEditsDom";
import { applyStudioBoxSize } from "./manualEditsDom";

/* ── helpers ── */

Expand All @@ -71,39 +70,7 @@ function assertClearCoversKeys(buildOps: PatchOperation[], clearOps: PatchOperat

/* ── Path offset ─────────────────────────────────────────────────────────── */

describe("buildPathOffsetPatches / buildClearPathOffsetPatches", () => {
function populatedPathEl(): HTMLElement {
const e = div();
e.style.setProperty(STUDIO_OFFSET_X_PROP, "10px");
e.style.setProperty(STUDIO_OFFSET_Y_PROP, "20px");
e.style.setProperty("translate", "10px 20px");
e.setAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR, "5px 10px");
e.setAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, "3px");
e.style.setProperty("display", "flex");
e.setAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, "block");
return e;
}

it("populated: captures offset styles, attrs, display, and transform-display marker in declaration order", () => {
const ops = buildPathOffsetPatches(populatedPathEl());
expect(ops).toEqual([
{ type: "inline-style", property: STUDIO_OFFSET_X_PROP, value: "10px" },
{ type: "inline-style", property: STUDIO_OFFSET_Y_PROP, value: "20px" },
{ type: "inline-style", property: "translate", value: "10px 20px" },
{ type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: "true" },
{ type: "attribute", property: STUDIO_ORIGINAL_TRANSLATE_ATTR, value: "5px 10px" },
{ type: "attribute", property: STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, value: "3px" },
{ type: "inline-style", property: "display", value: "flex" },
{ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: "block" },
]);
});

it("empty: bare element yields only the path-offset marker", () => {
expect(buildPathOffsetPatches(div())).toEqual([
{ type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: "true" },
]);
});

describe("buildClearPathOffsetPatches", () => {
it("clear: restores translate from STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR and display from STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR", () => {
const e = div();
e.setAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, "5px");
Expand All @@ -127,11 +94,6 @@ describe("buildPathOffsetPatches / buildClearPathOffsetPatches", () => {
const ops = buildClearPathOffsetPatches(e);
expect(ops.find((o) => o.property === "translate")?.value).toBeNull();
});

it("build/clear symmetry: clear addresses every {type,property} key that build emits", () => {
const e = populatedPathEl();
assertClearCoversKeys(buildPathOffsetPatches(e), buildClearPathOffsetPatches(e));
});
});

/* ── Box size ────────────────────────────────────────────────────────────── */
Expand Down Expand Up @@ -286,52 +248,6 @@ describe("buildBoxSizePatches / buildClearBoxSizePatches", () => {
});
});

/* ── Combined box-size + path-offset (anchored-corner resize) ──────────────── */

describe("anchored-corner combined patch: [...buildBoxSizePatches, ...buildPathOffsetPatches]", () => {
// NW/NE/SW resize commits size AND anchor offset in ONE persist. The two
// builders read the same already-mutated element and are concatenated; this
// is only safe if their {type,property} keys are disjoint (no builder
// overwrites the other's op when the source patcher applies them in order).
it("concatenation of both builders emits disjoint {type,property} keys (no collision)", () => {
const e = div();
applyStudioBoxSize(e, { width: 300, height: 200 });
applyStudioPathOffset(e, { x: 10, y: 20 });

const combined = [...buildBoxSizePatches(e), ...buildPathOffsetPatches(e)];
const keys = combined.map(opKey);
expect(new Set(keys).size, `duplicate {type,property} key in combined patch: ${keys}`).toBe(
keys.length,
);
});

it("combined patch carries BOTH markers so a soft-reload re-hydrates size and offset together", () => {
const e = div();
applyStudioBoxSize(e, { width: 300, height: 200 });
applyStudioPathOffset(e, { x: 10, y: 20 });

const combined = [...buildBoxSizePatches(e), ...buildPathOffsetPatches(e)];
const has = (property: string) =>
combined.some((op) => op.type === "attribute" && op.property === property);
expect(has(STUDIO_BOX_SIZE_ATTR)).toBe(true);
expect(has(STUDIO_PATH_OFFSET_ATTR)).toBe(true);
});

it("order is size-first: every box-size op precedes every path-offset op", () => {
const e = div();
applyStudioBoxSize(e, { width: 300, height: 200 });
applyStudioPathOffset(e, { x: 10, y: 20 });

const boxKeys = new Set(buildBoxSizePatches(e).map(opKey));
const combined = [...buildBoxSizePatches(e), ...buildPathOffsetPatches(e)];
const lastBoxIdx = combined.reduce((acc, op, i) => (boxKeys.has(opKey(op)) ? i : acc), -1);
const firstOffsetIdx = combined.findIndex(
(op) => op.type === "attribute" && op.property === STUDIO_PATH_OFFSET_ATTR,
);
expect(firstOffsetIdx).toBeGreaterThan(lastBoxIdx);
});
});

/* ── Rotation ────────────────────────────────────────────────────────────── */

describe("buildClearRotationPatches", () => {
Expand Down
30 changes: 0 additions & 30 deletions packages/studio/src/components/editor/manualEditsDomPatches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,36 +72,6 @@ function appendTransformDisplayOps(element: HTMLElement, ops: PatchOperation[]):

/* ── Path offset patches ─────────────────────────────────────────── */

export function buildPathOffsetPatches(element: HTMLElement): PatchOperation[] {
const ops: PatchOperation[] = [];
collectInlineStyleOps(element, [STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP], ops);
// When GSAP owns the element's transform, the live inline translate is kept
// at "none" (the offset lives in GSAP's cache — see applyStudioPathOffset).
// Persist the var() expression in that case, so a reload re-folds the offset.
const inlineTranslate = element.style.getPropertyValue("translate");
const hasOffsetVars =
element.style.getPropertyValue(STUDIO_OFFSET_X_PROP) ||
element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
const translateValue =
inlineTranslate && inlineTranslate !== "none"
? inlineTranslate
: hasOffsetVars
? `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`
: null;
if (translateValue) {
ops.push({ type: "inline-style", property: "translate", value: translateValue });
}
ops.push({ type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: "true" });
collectAttributeOps(
element,
[STUDIO_ORIGINAL_TRANSLATE_ATTR, STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR],
ops,
);
collectInlineStyleOps(element, ["display"], ops);
collectAttributeOps(element, [STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR], ops);
return ops;
}

export function buildClearPathOffsetPatches(element: HTMLElement): PatchOperation[] {
const originalInlineTranslate = element.getAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR);
const ops: PatchOperation[] = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ import {
buildDomEditStylePatchOperation,
buildDomEditTextPatchOperation,
} from "./domEditingLayers";
import { buildPathOffsetPatches } from "./manualEditsDomPatches";
import { STUDIO_OFFSET_X_PROP, STUDIO_PATH_OFFSET_ATTR } from "./manualEditsTypes";
import { makeSelection } from "../../hooks/domSelectionTestHarness";
import { buildTextFieldChildOperations } from "../../hooks/domEditTextFieldCommitOps";

Expand Down Expand Up @@ -126,22 +124,6 @@ describe("persist seam source mutation", () => {
expect(findByHfId(html, "qa-image").getAttribute("style")).toContain("opacity: 0.4");
});

it("persists detached jsdom path offset operations", () => {
const element = document.createElement("div");
element.style.setProperty(STUDIO_OFFSET_X_PROP, "24px");

const html = patchAndExpectChange(
indexHtml,
clientTarget({ id: "qa-shape", hfId: "qa-shape", tagName: "div" }),
buildPathOffsetPatches(element),
);
const shape = findByHfId(html, "qa-shape");

expect(shape.getAttribute("style")).toContain(`${STUDIO_OFFSET_X_PROP}: 24px`);
expect(shape.getAttribute("style")).toContain("translate: var(--hf-studio-offset-x, 0px)");
expect(shape.getAttribute(STUDIO_PATH_OFFSET_ATTR)).toBe("true");
});

it("persists timeline data-start attribute operation", () => {
const html = patchAndExpectChange(
indexHtml,
Expand Down
62 changes: 0 additions & 62 deletions packages/studio/src/hooks/gsapTargetCache.ts

This file was deleted.

Loading
Loading