Skip to content
Closed
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
104 changes: 104 additions & 0 deletions packages/studio/src/utils/assetClickBehavior.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
75 changes: 75 additions & 0 deletions packages/studio/src/utils/assetClickBehavior.ts
Original file line number Diff line number Diff line change
@@ -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;
}
31 changes: 31 additions & 0 deletions packages/studio/src/utils/canvasNudgeGate.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
32 changes: 32 additions & 0 deletions packages/studio/src/utils/canvasNudgeGate.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading