mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
* feat(studio): timeline collision and placement model What: new pure module timelineCollision — zone-aware drop placement (clampTrackToZone, resolveZoneDropPlacement, resolveInsertRow, resolvePlacement, lane/overlap predicates) with its full test suite. Why: the no-overlap core of the NLE clip-drag engine; plain functions, no DOM, no React, no store writes. How: new files only; type-only imports from the existing playerStore. First runtime consumer arrives with the drag-engine PRs. Test plan: bunx vitest run timelineCollision.test.ts; tsc --noEmit; fallow audit clean (all exports test-consumed). * feat(studio): timeline magnetic snapping What: new pure module timelineSnapping — snap-target collection and pixel-threshold time snapping (collectTimelineSnapTargets, snapTimelineTime, snapMoveToTargets) with tests. Why: the magnet math for clip drags/trims, reviewable standalone. How: new files only; type-only playerStore imports; consumers land with the drag engine. Test plan: bunx vitest run timelineSnapping.test.ts; tsc --noEmit; fallow audit clean. * feat(studio): multi-clip drag preview math What: new pure module timelineMultiDragPreview — group-drag passenger offsets and clamped group deltas (isMultiDragActive, multiDragDeltaSeconds, multiDragPassengerOffsetPx, clampGroupMoveDelta) with tests. Why: the group-drag math, standalone and DOM-free. How: new files only; consumed later by TimelineLanes. Test plan: bunx vitest run timelineMultiDragPreview.test.ts; tsc --noEmit; fallow audit clean. * feat(studio): timeline z-stacking sync model What: new pure module timelineStackingSync — lane order ↔ z-index reconciliation (laneIsAbove, computeStackingPatches) with tests. Why: the single source of truth for how timeline lane order maps to canvas stacking; the ordering rules and tie-breaks live here. How: new files only; consumed later by timelineZones and the stacking-sync hook. Test plan: bunx vitest run timelineStackingSync.test.ts; tsc --noEmit; fallow audit clean. * feat(studio): timeline lane-zone model What: new pure module timelineZones — visual/audio track-zone classification (classifyZone) and normalizeToZones, which re-packs lanes into zone-consistent rows; tests cover the stacking/zones interaction. Why: completes the z-model started in the stacking-sync PR. How: new files; consumes isAudioTimelineElement (leaf-helpers PR) and computeStackingPatches (stacking-sync PR); type-only playerStore imports. Test plan: bunx vitest run timelineZones.test.ts; tsc --noEmit; fallow audit clean. * feat(studio): asset click policy and canvas nudge gate What: two small pure modules with tests — assetClickBehavior (click vs double-click policy for sidebar assets) and canvasNudgeGate (debounce gate for arrow-key canvas nudges). Why: policy dependencies of the upcoming asset card and nudge hook, reviewable as plain decision tables. How: new files only. Test plan: bunx vitest run on both test files; tsc --noEmit; fallow audit clean. * test(studio): characterization suites for resize commit and razor history What: two test-only suites pinning CURRENT behavior before the NLE swap: anchoredResizeReleaseShift.test.ts (manual-offset resize release commits) and useRazorSplit.history.test.tsx (razor split undo/redo history). Why: regression tripwires — the later glue-swap PRs must keep these green. How: test files only; they import existing main modules unchanged and pass against them as-is. Test plan: bunx vitest run on both suites; fallow audit clean. --------- Co-authored-by: ukimsanov <ular.kimsanov@heygen.com>
This commit is contained in:
co-authored by
ukimsanov
parent
8e5b18f740
commit
c6a508a9bc
@@ -0,0 +1,112 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyStudioBoxSize,
|
||||
applyStudioPathOffset,
|
||||
readStudioBoxSize,
|
||||
reapplyPositionEditsAfterSeek,
|
||||
} from "./manualEditsDom";
|
||||
import { buildBoxSizePatches, buildPathOffsetPatches } from "./manualEditsDomPatches";
|
||||
import { createManualOffsetDragMember, applyManualOffsetDragCommit } from "./manualOffsetDrag";
|
||||
import type { PatchOperation } from "../../utils/sourcePatcher";
|
||||
import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
|
||||
|
||||
/**
|
||||
* Center-anchored corner resize (CapCut model): the element scales about its
|
||||
* CENTER, which must stay planted across the whole gesture — including after
|
||||
* release, on every corner and at any rotation.
|
||||
*
|
||||
* This file lands here with ONLY the persist round-trip test, which exercises the
|
||||
* real apply → persist → reload symbols that already exist at this point in the
|
||||
* stack. The two center-anchor CONVERGENCE tests (the release-shift root cause)
|
||||
* drive the exported `computeNextResizeAnchor` accumulator, which is extracted from
|
||||
* the resize pointermove branch of `useDomEditOverlayGestures.ts`. That gesture code
|
||||
* lands later in the stack (with the canvas glue swap), so those two tests are added
|
||||
* to this file at that point — importing the real production helper rather than a
|
||||
* test-local copy, so they can never pass against a stand-in that drifts from the
|
||||
* shipped math.
|
||||
*/
|
||||
|
||||
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("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, tested with the gesture code, 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user