diff --git a/packages/studio/src/utils/assetClickBehavior.test.ts b/packages/studio/src/utils/assetClickBehavior.test.ts new file mode 100644 index 0000000000..e1ae21ac40 --- /dev/null +++ b/packages/studio/src/utils/assetClickBehavior.test.ts @@ -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 & { 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); + }); +}); diff --git a/packages/studio/src/utils/assetClickBehavior.ts b/packages/studio/src/utils/assetClickBehavior.ts new file mode 100644 index 0000000000..d007f1b0f3 --- /dev/null +++ b/packages/studio/src/utils/assetClickBehavior.ts @@ -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; +} diff --git a/packages/studio/src/utils/canvasNudgeGate.test.ts b/packages/studio/src/utils/canvasNudgeGate.test.ts new file mode 100644 index 0000000000..d0e70c48ed --- /dev/null +++ b/packages/studio/src/utils/canvasNudgeGate.test.ts @@ -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(); + }); +}); diff --git a/packages/studio/src/utils/canvasNudgeGate.ts b/packages/studio/src/utils/canvasNudgeGate.ts new file mode 100644 index 0000000000..6d3e121c4c --- /dev/null +++ b/packages/studio/src/utils/canvasNudgeGate.ts @@ -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; +}