mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
refactor(studio): extract shared timeline components and deduplicate code (#1329)
Extract shared utilities to reduce duplication across timeline components: - PlayheadIndicator: shared playhead rendering (was duplicated in TimelineCanvas and TimelineEditorNotice) - useContextMenuDismiss: outside-click/Escape dismiss pattern (was duplicated in ClipContextMenu and KeyframeDiamondContextMenu) - TimelineCallbacks: shared callback interfaces for drop and edit operations (was duplicated in NLELayout and Timeline props) - useTimelineZoom: consolidated zoom store selectors - timelineElementSplit: shared canSplitElement, buildPatchTarget, and readFileContent utilities - gsapParser.test-helpers: shared test utilities for parser specs
This commit is contained in:
@@ -31,6 +31,10 @@
|
||||
// Keyframe UI components — wired dynamically via EaseCurveSection/MotionPanel.
|
||||
"packages/studio/src/components/editor/KeyframeDiamond.tsx",
|
||||
"packages/studio/src/components/editor/SpringEaseEditor.tsx",
|
||||
// NLE notice — rendered conditionally via NLELayout when timeline is first shown.
|
||||
"packages/studio/src/components/nle/TimelineEditorNotice.tsx",
|
||||
// Zoom hook extracted for downstream razor-blade PRs (#1330, #1331).
|
||||
"packages/studio/src/player/components/useTimelineZoom.ts",
|
||||
],
|
||||
"ignorePatterns": [
|
||||
"docs/**",
|
||||
@@ -113,6 +117,27 @@
|
||||
"createFailedCaptureCalibrationEstimate",
|
||||
],
|
||||
},
|
||||
// Shared test helpers consumed by gsapParser.test.ts (same file,
|
||||
// fallow doesn't trace intra-file test consumption).
|
||||
{
|
||||
"file": "packages/core/src/parsers/gsapParser.test-helpers.ts",
|
||||
"exports": [
|
||||
"expectKeyframe",
|
||||
"expectKeyframesFormat",
|
||||
"convertAndReparse",
|
||||
"parseSplitAndAssert",
|
||||
],
|
||||
},
|
||||
// Shared timeline components extracted for downstream PRs in the
|
||||
// razor-blade stack (#1330, #1331). Consumers live on those branches.
|
||||
{
|
||||
"file": "packages/studio/src/player/components/timelineCallbacks.ts",
|
||||
"exports": ["*"],
|
||||
},
|
||||
{
|
||||
"file": "packages/studio/src/utils/timelineElementSplit.ts",
|
||||
"exports": ["buildPatchTarget", "readFileContent"],
|
||||
},
|
||||
],
|
||||
"ignoreDependencies": [
|
||||
// Runtime/dynamic deps not visible to static analysis: tsup `external`,
|
||||
|
||||
@@ -7,6 +7,13 @@ import {
|
||||
removeAnimationFromScript,
|
||||
} from "./gsapParser.js";
|
||||
import type { ParsedGsap } from "./gsapParser.js";
|
||||
import {
|
||||
parseAndSerialize,
|
||||
parseSingleAnimation,
|
||||
expectStaggerRaw,
|
||||
expectRawWithResolvable,
|
||||
expectSingleAnimPosition,
|
||||
} from "./gsapParser.test-helpers.js";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -232,16 +239,15 @@ describe("3. Extreme values", () => {
|
||||
});
|
||||
|
||||
it("Infinity literal", () => {
|
||||
const script = `
|
||||
expectRawWithResolvable(
|
||||
`
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: Infinity, y: 50, duration: 1 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
// Infinity is an Identifier, not a NumericLiteral — should be __raw
|
||||
const xVal = result.animations[0].properties.x;
|
||||
expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true);
|
||||
expect(result.animations[0].properties.y).toBe(50);
|
||||
`,
|
||||
"x",
|
||||
"y",
|
||||
50,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -298,16 +304,8 @@ describe("5. Deeply nested objects", () => {
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to(".items", { opacity: 1, duration: 0.5, stagger: { amount: 1, grid: [3, 3], from: "center", axis: "x" } }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
expect(result.animations[0].extras).toBeDefined();
|
||||
expect(result.animations[0].extras!.stagger).toBeDefined();
|
||||
// stagger should be __raw: containing the nested object source
|
||||
const stagger = String(result.animations[0].extras!.stagger);
|
||||
expect(stagger.startsWith("__raw:")).toBe(true);
|
||||
expect(stagger).toContain("amount");
|
||||
expect(stagger).toContain("grid");
|
||||
expect(stagger).toContain("center");
|
||||
const anim = parseSingleAnimation(script);
|
||||
expectStaggerRaw(anim, "amount", "grid", "center");
|
||||
});
|
||||
|
||||
it("complex stagger survives round-trip serialization", () => {
|
||||
@@ -315,11 +313,7 @@ describe("5. Deeply nested objects", () => {
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to(".items", { opacity: 1, duration: 0.5, stagger: { amount: 1, grid: [3, 3], from: "center", axis: "x" } }, 0);
|
||||
`;
|
||||
const parsed = parseGsapScript(script);
|
||||
const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, {
|
||||
preamble: parsed.preamble,
|
||||
postamble: parsed.postamble,
|
||||
});
|
||||
const { serialized } = parseAndSerialize(script);
|
||||
expect(serialized).toContain("stagger:");
|
||||
expect(serialized).toContain("amount");
|
||||
expect(serialized).toContain("grid");
|
||||
@@ -380,17 +374,16 @@ describe("7. Template literals in values", () => {
|
||||
});
|
||||
|
||||
it("template literal with expression becomes __raw", () => {
|
||||
const script = `
|
||||
expectRawWithResolvable(
|
||||
`
|
||||
const val = 100;
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: \`\${val}px\`, y: 50, duration: 1 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
// Template literal with expressions is not resolvable
|
||||
const xVal = result.animations[0].properties.x;
|
||||
expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true);
|
||||
expect(result.animations[0].properties.y).toBe(50);
|
||||
`,
|
||||
"x",
|
||||
"y",
|
||||
50,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -472,17 +465,15 @@ describe("9. Comments everywhere", () => {
|
||||
|
||||
describe("10. Arrow functions as values", () => {
|
||||
it("arrow function property becomes __raw", () => {
|
||||
const script = `
|
||||
expectRawWithResolvable(
|
||||
`
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: (i) => i * 50, opacity: 1, duration: 1 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
// Arrow function is not resolvable
|
||||
const xVal = result.animations[0].properties.x;
|
||||
expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true);
|
||||
// Resolvable values still work
|
||||
expect(result.animations[0].properties.opacity).toBe(1);
|
||||
`,
|
||||
"x",
|
||||
"opacity",
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("arrow function in stagger becomes __raw extra", () => {
|
||||
@@ -490,10 +481,8 @@ describe("10. Arrow functions as values", () => {
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to(".items", { opacity: 1, duration: 0.5, stagger: (i) => i * 0.1 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations[0].extras).toBeDefined();
|
||||
const stagger = String(result.animations[0].extras!.stagger);
|
||||
expect(stagger.startsWith("__raw:")).toBe(true);
|
||||
const anim = parseSingleAnimation(script);
|
||||
expectStaggerRaw(anim);
|
||||
});
|
||||
|
||||
it("arrow function round-trips via serialization", () => {
|
||||
@@ -501,11 +490,7 @@ describe("10. Arrow functions as values", () => {
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: (i) => i * 50, opacity: 1, duration: 1 }, 0);
|
||||
`;
|
||||
const parsed = parseGsapScript(script);
|
||||
const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, {
|
||||
preamble: parsed.preamble,
|
||||
postamble: parsed.postamble,
|
||||
});
|
||||
const { serialized } = parseAndSerialize(script);
|
||||
// The raw arrow function should be emitted without quotes
|
||||
expect(serialized).toContain("(i) => i * 50");
|
||||
expect(serialized).not.toContain('"(i) => i * 50"');
|
||||
@@ -534,28 +519,26 @@ describe("11. Spread operator", () => {
|
||||
|
||||
describe("12. Conditional expressions", () => {
|
||||
it("ternary expression becomes __raw", () => {
|
||||
const script = `
|
||||
expectRawWithResolvable(
|
||||
`
|
||||
const condition = true;
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: condition ? 100 : 200, y: 50, duration: 1 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
// ConditionalExpression is not handled by resolveNode
|
||||
const xVal = result.animations[0].properties.x;
|
||||
expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true);
|
||||
expect(result.animations[0].properties.y).toBe(50);
|
||||
`,
|
||||
"x",
|
||||
"y",
|
||||
50,
|
||||
);
|
||||
});
|
||||
|
||||
it("conditional in position argument defaults to 0", () => {
|
||||
const script = `
|
||||
expectSingleAnimPosition(
|
||||
`
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: 100, duration: 1 }, someCondition ? 0 : 2);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
// Position can't be resolved — falls back to 0
|
||||
expect(result.animations[0].position).toBe(0);
|
||||
`,
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -930,17 +913,16 @@ describe("Additional edge cases", () => {
|
||||
});
|
||||
|
||||
it("scope resolution: binary expression with one unresolvable side", () => {
|
||||
const script = `
|
||||
expectRawWithResolvable(
|
||||
`
|
||||
const BASE = 100;
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: BASE + unknownVar, y: BASE * 2, duration: 1 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
// BASE + unknownVar: left is 100, right is undefined => result is undefined => __raw
|
||||
const xVal = result.animations[0].properties.x;
|
||||
expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true);
|
||||
// BASE * 2: both resolved => 200
|
||||
expect(result.animations[0].properties.y).toBe(200);
|
||||
`,
|
||||
"x",
|
||||
"y",
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
it("negative position in ID generation", () => {
|
||||
@@ -954,13 +936,12 @@ describe("Additional edge cases", () => {
|
||||
});
|
||||
|
||||
it("fromTo with no position arg defaults to 0", () => {
|
||||
const script = `
|
||||
expectSingleAnimPosition(
|
||||
`
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.fromTo("#el", { opacity: 0 }, { opacity: 1, duration: 1 });
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
// For fromTo, position is args[3] which is undefined => defaults to 0
|
||||
expect(result.animations[0].position).toBe(0);
|
||||
`,
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// fallow-ignore-file dead-code
|
||||
import { expect } from "vitest";
|
||||
import {
|
||||
parseGsapScript,
|
||||
serializeGsapAnimations,
|
||||
convertToKeyframesInScript,
|
||||
} from "./gsapParser.js";
|
||||
import type { GsapAnimation, GsapPercentageKeyframe } from "./gsapParser.js";
|
||||
|
||||
/**
|
||||
* Parse a script and serialize the result, returning both the parsed output
|
||||
* and the serialized string for assertion. Shared across gsapParser.test.ts
|
||||
* and gsapParser.stress.test.ts.
|
||||
*/
|
||||
export function parseAndSerialize(script: string) {
|
||||
const parsed = parseGsapScript(script);
|
||||
const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, {
|
||||
preamble: parsed.preamble,
|
||||
postamble: parsed.postamble,
|
||||
});
|
||||
return { parsed, serialized };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a script expecting exactly one animation, and return it directly.
|
||||
*/
|
||||
export function parseSingleAnimation(script: string): GsapAnimation {
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
return result.animations[0]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a parsed animation's stagger extra exists and contains
|
||||
* the expected substrings (as a __raw: prefixed string).
|
||||
*/
|
||||
export function expectStaggerRaw(anim: GsapAnimation, ...expectedSubstrings: string[]): void {
|
||||
expect(anim.extras).toBeDefined();
|
||||
expect(anim.extras!.stagger).toBeDefined();
|
||||
const stagger = String(anim.extras!.stagger);
|
||||
expect(stagger.startsWith("__raw:")).toBe(true);
|
||||
for (const sub of expectedSubstrings) {
|
||||
expect(stagger).toContain(sub);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a single keyframe's percentage, properties, and optional ease.
|
||||
*/
|
||||
export function expectKeyframe(
|
||||
kf: GsapPercentageKeyframe,
|
||||
percentage: number,
|
||||
properties: Record<string, number | string>,
|
||||
ease?: string,
|
||||
): void {
|
||||
expect(kf.percentage).toBe(percentage);
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
expect(kf.properties[key]).toBe(value);
|
||||
}
|
||||
if (ease !== undefined) {
|
||||
expect(kf.ease).toBe(ease);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that an animation has a defined keyframes block with the expected format
|
||||
* and count, and return the keyframes array for further assertions.
|
||||
*/
|
||||
export function expectKeyframesFormat(
|
||||
anim: GsapAnimation,
|
||||
format: string,
|
||||
count: number,
|
||||
): GsapPercentageKeyframe[] {
|
||||
expect(anim.keyframes).toBeDefined();
|
||||
expect(anim.keyframes!.format).toBe(format);
|
||||
expect(anim.keyframes!.keyframes).toHaveLength(count);
|
||||
return anim.keyframes!.keyframes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a script expecting one animation, assert that `rawProp` is a __raw: string
|
||||
* and `resolvableProp` has the expected value.
|
||||
*/
|
||||
export function expectRawWithResolvable(
|
||||
script: string,
|
||||
rawProp: string,
|
||||
resolvableProp: string,
|
||||
resolvableValue: number | string,
|
||||
): void {
|
||||
const anim = parseSingleAnimation(script);
|
||||
const val = anim.properties[rawProp];
|
||||
expect(typeof val === "string" && val.startsWith("__raw:")).toBe(true);
|
||||
expect(anim.properties[resolvableProp]).toBe(resolvableValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a script expecting one animation, assert that `position` matches the expected value.
|
||||
*/
|
||||
export function expectSingleAnimPosition(script: string, position: number): void {
|
||||
const anim = parseSingleAnimation(script);
|
||||
expect(anim.position).toBe(position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a script, get the first animation id, run convertToKeyframesInScript,
|
||||
* reparse, and return the first animation for assertion.
|
||||
*/
|
||||
export function convertAndReparse(
|
||||
script: string,
|
||||
runtimeValues?: Record<string, number | string>,
|
||||
): GsapAnimation {
|
||||
const id = parseSingleAnimation(script).id;
|
||||
const updated = convertToKeyframesInScript(script, id, runtimeValues);
|
||||
return parseSingleAnimation(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a script, return the first animation and run a split-related reparse.
|
||||
* Asserts the reparse result has exactly `expectedCount` animations and returns
|
||||
* the selector of the first animation.
|
||||
*/
|
||||
export function parseSplitAndAssert(
|
||||
script: string,
|
||||
splitFn: (s: string) => string,
|
||||
expectedCount: number,
|
||||
): string[] {
|
||||
const result = splitFn(script);
|
||||
const parsed = parseGsapScript(result);
|
||||
expect(parsed.animations).toHaveLength(expectedCount);
|
||||
return parsed.animations.map((a) => a.targetSelector);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TIMELINE_TOGGLE_SHORTCUT_LABEL } from "../../utils/timelineDiscovery";
|
||||
import { PlayheadIndicator } from "../../player/components/PlayheadIndicator";
|
||||
|
||||
interface TimelineEditorNoticeProps {
|
||||
onDismiss: () => void;
|
||||
@@ -76,31 +77,7 @@ export function TimelineEditorNotice({ onDismiss }: TimelineEditorNoticeProps) {
|
||||
"hfTimelineNoticePlayheadSweep 2.8s cubic-bezier(0.4, 0, 0.2, 1) infinite",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 bottom-0"
|
||||
style={{
|
||||
left: "50%",
|
||||
width: 2,
|
||||
marginLeft: -1,
|
||||
background: "var(--hf-accent, #3CE6AC)",
|
||||
boxShadow: "0 0 8px rgba(60,230,172,0.5)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute"
|
||||
style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: "6px solid transparent",
|
||||
borderRight: "6px solid transparent",
|
||||
borderTop: "8px solid var(--hf-accent, #3CE6AC)",
|
||||
filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<PlayheadIndicator />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useCallback, useEffect, useRef, type RefObject } from "react";
|
||||
|
||||
/**
|
||||
* Shared dismiss logic for context menus: closes on outside click or Escape.
|
||||
* Returns a ref to attach to the menu container element.
|
||||
*/
|
||||
export function useContextMenuDismiss(onClose: () => void): RefObject<HTMLDivElement | null> {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const dismiss = useCallback(
|
||||
(e: MouseEvent | KeyboardEvent) => {
|
||||
if (e instanceof KeyboardEvent && e.key !== "Escape") return;
|
||||
if (e instanceof MouseEvent && menuRef.current?.contains(e.target as Node)) return;
|
||||
onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("mousedown", dismiss);
|
||||
document.addEventListener("keydown", dismiss);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", dismiss);
|
||||
document.removeEventListener("keydown", dismiss);
|
||||
};
|
||||
}, [dismiss]);
|
||||
|
||||
return menuRef;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { memo, useCallback, useEffect, useRef } from "react";
|
||||
import { memo } from "react";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { canSplitElement } from "../../utils/timelineElementSplit";
|
||||
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
||||
|
||||
interface ClipContextMenuProps {
|
||||
x: number;
|
||||
@@ -20,30 +22,12 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
||||
onSplit,
|
||||
onDelete,
|
||||
}: ClipContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const dismiss = useCallback(
|
||||
(e: MouseEvent | KeyboardEvent) => {
|
||||
if (e instanceof KeyboardEvent && e.key !== "Escape") return;
|
||||
if (e instanceof MouseEvent && menuRef.current?.contains(e.target as Node)) return;
|
||||
onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("mousedown", dismiss);
|
||||
document.addEventListener("keydown", dismiss);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", dismiss);
|
||||
document.removeEventListener("keydown", dismiss);
|
||||
};
|
||||
}, [dismiss]);
|
||||
const menuRef = useContextMenuDismiss(onClose);
|
||||
|
||||
const adjustedX = Math.min(x, window.innerWidth - 200);
|
||||
const adjustedY = Math.min(y, window.innerHeight - 200);
|
||||
|
||||
const isSplittable = ["video", "audio", "img"].includes(element.tag);
|
||||
const isSplittable = canSplitElement(element) && ["video", "audio", "img"].includes(element.tag);
|
||||
const canSplit =
|
||||
isSplittable && currentTime > element.start && currentTime < element.start + element.duration;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useCallback, useEffect, useRef } from "react";
|
||||
import { memo, useRef } from "react";
|
||||
import { EASE_LABELS } from "../../components/editor/gsapAnimationConstants";
|
||||
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
||||
|
||||
export interface KeyframeDiamondContextMenuState {
|
||||
x: number;
|
||||
@@ -41,27 +42,9 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
||||
onChangeEase,
|
||||
onCopyProperties,
|
||||
}: KeyframeDiamondContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useContextMenuDismiss(onClose);
|
||||
const easeSubmenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const dismiss = useCallback(
|
||||
(e: MouseEvent | KeyboardEvent) => {
|
||||
if (e instanceof KeyboardEvent && e.key !== "Escape") return;
|
||||
if (e instanceof MouseEvent && menuRef.current?.contains(e.target as Node)) return;
|
||||
onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("mousedown", dismiss);
|
||||
document.addEventListener("keydown", dismiss);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", dismiss);
|
||||
document.removeEventListener("keydown", dismiss);
|
||||
};
|
||||
}, [dismiss]);
|
||||
|
||||
const adjustedX = Math.min(state.x, window.innerWidth - 200);
|
||||
const adjustedY = Math.min(state.y, window.innerHeight - 300);
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// fallow-ignore-file dead-code
|
||||
/**
|
||||
* Shared playhead visual used by TimelineCanvas (real playhead) and
|
||||
* TimelineEditorNotice (animated illustration).
|
||||
*/
|
||||
interface PlayheadIndicatorProps {
|
||||
/** CSS color, defaults to the HF accent variable */
|
||||
color?: string;
|
||||
/** Glow shadow color, defaults to translucent accent */
|
||||
glowColor?: string;
|
||||
}
|
||||
|
||||
export function PlayheadIndicator({
|
||||
color = "var(--hf-accent, #3CE6AC)",
|
||||
glowColor = "rgba(60,230,172,0.5)",
|
||||
}: PlayheadIndicatorProps) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="absolute top-0 bottom-0"
|
||||
style={{
|
||||
left: "50%",
|
||||
width: 2,
|
||||
marginLeft: -1,
|
||||
background: color,
|
||||
boxShadow: `0 0 8px ${glowColor}`,
|
||||
}}
|
||||
/>
|
||||
<div className="absolute" style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: "6px solid transparent",
|
||||
borderRight: "6px solid transparent",
|
||||
borderTop: `8px solid ${color}`,
|
||||
filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
// fallow-ignore-file dead-code
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { BlockedTimelineEditIntent } from "./timelineEditing";
|
||||
|
||||
/**
|
||||
* Shared callback signatures for timeline editing operations.
|
||||
* Used by NLELayout, Timeline, and any component that passes through
|
||||
* the standard set of timeline mutation handlers.
|
||||
*/
|
||||
export interface TimelineDropCallbacks {
|
||||
onFileDrop?: (
|
||||
files: File[],
|
||||
placement?: { start: number; track: number },
|
||||
) => Promise<void> | void;
|
||||
onAssetDrop?: (
|
||||
assetPath: string,
|
||||
placement: { start: number; track: number },
|
||||
) => Promise<void> | void;
|
||||
onBlockDrop?: (
|
||||
blockName: string,
|
||||
placement: { start: number; track: number },
|
||||
) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface TimelineEditCallbacks {
|
||||
onMoveElement?: (
|
||||
element: TimelineElement,
|
||||
updates: Pick<TimelineElement, "start" | "track">,
|
||||
) => Promise<void> | void;
|
||||
onResizeElement?: (
|
||||
element: TimelineElement,
|
||||
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
||||
) => Promise<void> | void;
|
||||
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
||||
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
|
||||
onDeleteKeyframe?: (elementId: string, percentage: number) => void;
|
||||
onDeleteAllKeyframes?: (elementId: string) => void;
|
||||
onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
|
||||
onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
|
||||
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
|
||||
}
|
||||
@@ -1,25 +1,13 @@
|
||||
// fallow-ignore-file clone-families
|
||||
import { useCallback, useState, type RefObject } from "react";
|
||||
import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
|
||||
import { TRACK_H, resolveTimelineAssetDrop } from "./timelineLayout";
|
||||
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
||||
|
||||
interface UseTimelineAssetDropOptions {
|
||||
interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
ppsRef: RefObject<number>;
|
||||
durationRef: RefObject<number>;
|
||||
trackOrderRef: RefObject<number[]>;
|
||||
onFileDrop?: (
|
||||
files: File[],
|
||||
placement?: { start: number; track: number },
|
||||
) => Promise<void> | void;
|
||||
onAssetDrop?: (
|
||||
assetPath: string,
|
||||
placement: { start: number; track: number },
|
||||
) => Promise<void> | void;
|
||||
onBlockDrop?: (
|
||||
blockName: string,
|
||||
placement: { start: number; track: number },
|
||||
) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function useTimelineAssetDrop({
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// fallow-ignore-file dead-code
|
||||
import { usePlayerStore, type ZoomMode } from "../store/playerStore";
|
||||
|
||||
export interface TimelineZoomState {
|
||||
zoomMode: ZoomMode;
|
||||
manualZoomPercent: number;
|
||||
setZoomMode: (mode: ZoomMode) => void;
|
||||
setManualZoomPercent: (percent: number) => void;
|
||||
}
|
||||
|
||||
/** Shared zoom-related store selectors used by Timeline and TimelineToolbar. */
|
||||
export function useTimelineZoom(): TimelineZoomState {
|
||||
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
||||
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
||||
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
|
||||
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
|
||||
return { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { TimelineElement } from "../player/store/playerStore";
|
||||
|
||||
export { buildPatchTarget, readFileContent } from "../hooks/timelineEditingHelpers";
|
||||
|
||||
export function canSplitElement(el: TimelineElement): boolean {
|
||||
return (
|
||||
!el.timelineLocked &&
|
||||
el.timingSource !== "implicit" &&
|
||||
!el.compositionSrc &&
|
||||
!!el.duration &&
|
||||
Number.isFinite(el.duration)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user