fix(studio): continuation of #2280 (#2285)

* 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.

---------

Co-authored-by: ukimsanov <ular.kimsanov@heygen.com>
This commit is contained in:
Miguel Ángel
2026-07-12 00:19:00 -04:00
committed by GitHub
co-authored by ukimsanov
parent 44ffe4b41f
commit 8e5b18f740
4 changed files with 242 additions and 0 deletions
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import { findClipForAsset, isPointerClick, DRAG_THRESHOLD_PX } from "./assetClickBehavior";
import type { TimelineElement } from "../player/store/playerStore";
// Minimal TimelineElement factory — only the fields the function inspects.
function makeEl(overrides: Partial<TimelineElement> & { id: string }): TimelineElement {
return {
tag: "div",
start: 0,
duration: 5,
track: 0,
...overrides,
};
}
describe("findClipForAsset", () => {
it("returns null when elements array is empty", () => {
expect(findClipForAsset([], "assets/foo.mp4")).toBeNull();
});
it("returns null when no element matches", () => {
const el = makeEl({ id: "el1", src: "assets/other.mp4" });
expect(findClipForAsset([el], "assets/foo.mp4")).toBeNull();
});
it("matches a bare relative src against the project-relative asset path", () => {
const el = makeEl({ id: "el1", src: "assets/clip.mp4", start: 2 });
expect(findClipForAsset([el], "assets/clip.mp4")).toBe(el);
});
it("matches a src with a ./ prefix", () => {
const el = makeEl({ id: "el1", src: "./assets/logo.png" });
expect(findClipForAsset([el], "assets/logo.png")).toBe(el);
});
it("matches a server-relative /api/projects/…/preview/ src", () => {
const el = makeEl({ id: "el1", src: "/api/projects/demo/preview/assets/bgm.mp3" });
expect(findClipForAsset([el], "assets/bgm.mp3")).toBe(el);
});
it("matches a fully-absolute URL (as produced by the core runtime)", () => {
const el = makeEl({
id: "el1",
src: "http://localhost:3012/api/projects/demo/preview/assets/clip.mp4",
});
expect(findClipForAsset([el], "assets/clip.mp4")).toBe(el);
});
it("decodes percent-encoded filenames when matching", () => {
const el = makeEl({
id: "el1",
src: "http://localhost:3012/api/projects/p/preview/assets/my%20file%20(1).mp4",
});
expect(findClipForAsset([el], "assets/my file (1).mp4")).toBe(el);
});
it("strips query strings from src before matching", () => {
const el = makeEl({ id: "el1", src: "assets/clip.mp4?v=2" });
expect(findClipForAsset([el], "assets/clip.mp4")).toBe(el);
});
it("returns the element with the earliest start when multiple clips match", () => {
const later = makeEl({ id: "late", src: "assets/clip.mp4", start: 10 });
const earlier = makeEl({ id: "early", src: "assets/clip.mp4", start: 2 });
const first = makeEl({ id: "first", src: "assets/clip.mp4", start: 0 });
expect(findClipForAsset([later, earlier, first], "assets/clip.mp4")).toBe(first);
});
it("prefers the key over id when the element has both", () => {
// findClipForAsset returns the element object itself; callers do `clip.key ?? clip.id`
const el = makeEl({ id: "el1", key: "clip-key", src: "assets/img.png" });
const found = findClipForAsset([el], "assets/img.png");
expect(found?.key).toBe("clip-key");
});
it("skips elements with no src", () => {
const noSrc = makeEl({ id: "nosrc" });
const withSrc = makeEl({ id: "withsrc", src: "assets/img.png" });
expect(findClipForAsset([noSrc, withSrc], "assets/img.png")).toBe(withSrc);
});
});
describe("isPointerClick", () => {
it("returns true for zero movement", () => {
expect(isPointerClick(0, 0)).toBe(true);
});
it("returns true for movement within the threshold", () => {
expect(isPointerClick(DRAG_THRESHOLD_PX - 1, 0)).toBe(true);
expect(isPointerClick(0, DRAG_THRESHOLD_PX - 1)).toBe(true);
expect(isPointerClick(DRAG_THRESHOLD_PX - 1, DRAG_THRESHOLD_PX - 1)).toBe(true);
});
it("returns false at or beyond the threshold", () => {
expect(isPointerClick(DRAG_THRESHOLD_PX, 0)).toBe(false);
expect(isPointerClick(0, DRAG_THRESHOLD_PX)).toBe(false);
expect(isPointerClick(DRAG_THRESHOLD_PX + 10, 0)).toBe(false);
});
it("handles negative movement (pointer moved left/up)", () => {
expect(isPointerClick(-(DRAG_THRESHOLD_PX - 1), 0)).toBe(true);
expect(isPointerClick(-DRAG_THRESHOLD_PX, 0)).toBe(false);
});
});
@@ -0,0 +1,75 @@
/**
* Pure helpers for CapCut-style asset card click behavior.
*
* Clicking an asset card that is ALREADY ADDED to the timeline selects the
* corresponding clip. Clicking one NOT yet in the timeline opens a lightweight
* preview overlay. Both behaviors are gated on "this was a click, not a drag".
*
* Pure — unit-tested.
*/
import type { TimelineElement } from "../player/store/playerStore";
/**
* Find the TimelineElement that references `assetPath`, returning the one with
* the earliest start time when multiple clips share the same source.
*
* Matching mirrors `deriveUsedPaths` in AssetsTab: an element's `src` may be a
* fully-absolute URL, a server-relative `/api/projects/…/preview/…` path, a
* `./`-prefixed relative path, or a bare relative path — all normalised to the
* project-relative form that `assetPath` carries.
*
* Returns `null` when no element matches.
*/
export function findClipForAsset(
elements: TimelineElement[],
assetPath: string,
): TimelineElement | null {
let best: TimelineElement | null = null;
for (const el of elements) {
if (!el.src) continue;
if (normalizeSrc(el.src) !== assetPath) continue;
if (best === null || el.start < best.start) best = el;
}
return best;
}
/**
* Normalise a raw element `src` to the bare project-relative path so it can be
* compared against the asset-list strings (which have no leading slash, no
* origin, no query string).
*
* Mirrors the logic in `deriveUsedPaths` (AssetsTab.tsx) — keep in sync.
*/
function normalizeSrc(src: string): string {
let s = src;
try {
const u = new URL(s);
s = u.pathname;
} catch {
// Not an absolute URL — leave as-is
}
s = s
.replace(/^\/api\/projects\/[^/]+\/preview\//, "")
.replace(/^\.?\//, "")
.split(/[?#]/)[0];
try {
s = decodeURIComponent(s);
} catch {
// Malformed encoding — use as-is
}
return s;
}
/** Drag-detection threshold in pixels — movements within this are treated as clicks. */
export const DRAG_THRESHOLD_PX = 4;
/**
* Determine whether a pointer-up event should be treated as a click given the
* total pointer displacement since pointer-down.
*
* @param dx Horizontal distance moved in pixels.
* @param dy Vertical distance moved in pixels.
*/
export function isPointerClick(dx: number, dy: number): boolean {
return Math.abs(dx) < DRAG_THRESHOLD_PX && Math.abs(dy) < DRAG_THRESHOLD_PX;
}
@@ -0,0 +1,31 @@
import { beforeEach, describe, expect, it } from "vitest";
import { __resetForTests, acquireCanvasNudgeKeys, canvasNudgeKeysClaimed } from "./canvasNudgeGate";
describe("canvasNudgeGate", () => {
// The claim counter is module-level state; reset it so an unbalanced claim in one
// test can't leak into the next.
beforeEach(() => {
__resetForTests();
});
it("reports claimed while at least one claim is held", () => {
expect(canvasNudgeKeysClaimed()).toBe(false);
const releaseA = acquireCanvasNudgeKeys();
const releaseB = acquireCanvasNudgeKeys();
expect(canvasNudgeKeysClaimed()).toBe(true);
releaseA();
expect(canvasNudgeKeysClaimed()).toBe(true);
releaseB();
expect(canvasNudgeKeysClaimed()).toBe(false);
});
it("makes release idempotent so an effect re-run cannot underflow", () => {
const release = acquireCanvasNudgeKeys();
release();
release();
expect(canvasNudgeKeysClaimed()).toBe(false);
const releaseNext = acquireCanvasNudgeKeys();
expect(canvasNudgeKeysClaimed()).toBe(true);
releaseNext();
});
});
@@ -0,0 +1,32 @@
/**
* Arrow-key ownership gate between the canvas nudge (DomEditOverlay) and the
* playback frame-step shortcuts (usePlaybackKeyboard). Both listen for arrow
* keys with window capture listeners, and their relative order depends on
* mount order (DomEditOverlay remounts when caption edit mode toggles), so
* `event.defaultPrevented` alone can't arbitrate. While a nudgeable canvas
* selection holds a claim, the playback handler skips ArrowLeft/ArrowRight.
*/
let claims = 0;
/** Claim the arrow keys for canvas nudging. Returns an idempotent release. */
export function acquireCanvasNudgeKeys(): () => void {
claims += 1;
let released = false;
return () => {
if (released) return;
released = true;
claims -= 1;
};
}
/** True while a nudgeable canvas selection owns the arrow keys. */
export function canvasNudgeKeysClaimed(): boolean {
return claims > 0;
}
/** Test-only: reset the module-level claim counter so leaked claims from one test
* can't bleed into the next (call in a `beforeEach`). */
export function __resetForTests(): void {
claims = 0;
}