mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
The QA fleet's 295 findings were replayed against the merge-base. Most were pre-existing, but these were caused by this stack: Deleting one keyframe destroyed the whole tween. The lane-header remove toggle escalated a flat tween to a whole-animation delete, which took the authored `tl.to(...)` and its source comment with it on a single click. The base build posts remove-keyframe and lets the writer refuse it; restore that. A keyframed layer could not be hidden at all. The visibility eye had moved off the always-mounted layer row onto a hover-gated property-group row, so it only existed while the lanes were expanded AND the pointer was over that lane. A keyboard-only user could reach no eye at all, and its label named a track its row did not act on. It goes back on the layer row. A drag from the centre of a clip bar did nothing, because the 16px inline ease button sits exactly there and swallowed the press. It now lets the press through to the clip and keeps only the click, dropped if the pointer travelled. Dragging a diamond onto a neighbour silently discarded the retime. The clamp bounded the dragged keyframe by the whole merged row, so two animations colliding at one percentage pinned each other in place and the drag resolved back to a click. Clamp against the dragged keyframe's own tween instead. Also: floor the diamond hit box at 12px (the gap-derived size fell to ~7px at the zoom floor), round the diamond tooltip percentage, and prune the keyframe caches when a composition switch drops a file from the scan set — each file only ever cleared its own entries, so the previous composition leaked every element into both keyframeCache and gsapAnimations, with nothing to evict it.
657 lines
22 KiB
TypeScript
657 lines
22 KiB
TypeScript
// @vitest-environment happy-dom
|
|
|
|
import React, { act } from "react";
|
|
import { createRoot } from "react-dom/client";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { TimelineClipDiamonds, TimelineDiamondLane } from "./TimelineClipDiamonds";
|
|
import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity";
|
|
|
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
function pointerEvent(type: string, init: PointerEventInit): Event {
|
|
if (typeof PointerEvent === "function") return new PointerEvent(type, init);
|
|
return new MouseEvent(type, init);
|
|
}
|
|
|
|
function renderDiamonds(onClickKeyframe = vi.fn()) {
|
|
const host = document.createElement("div");
|
|
document.body.append(host);
|
|
const root = createRoot(host);
|
|
act(() => {
|
|
root.render(
|
|
<TimelineClipDiamonds
|
|
keyframesData={{
|
|
format: "percentage",
|
|
keyframes: [
|
|
{ percentage: 0, properties: { x: 0 } },
|
|
{ percentage: 50, properties: { x: 100 } },
|
|
],
|
|
}}
|
|
clipWidthPx={200}
|
|
clipHeightPx={48}
|
|
accentColor="#4ba3d2"
|
|
isSelected
|
|
currentPercentage={0}
|
|
elementId="clip-1"
|
|
selectedKeyframes={new Set()}
|
|
onClickKeyframe={onClickKeyframe}
|
|
/>,
|
|
);
|
|
});
|
|
return { host, root, onClickKeyframe };
|
|
}
|
|
|
|
describe("TimelineClipDiamonds", () => {
|
|
// Dense rows narrow the DIAMOND so neighbours stay individually readable, but
|
|
// the hit box floors at KF_MIN_HIT_W — a gap-sized target gets unusable
|
|
// (~7px) at the zoom floor.
|
|
it("narrows dense keyframe visuals while flooring their hit regions", () => {
|
|
const host = document.createElement("div");
|
|
document.body.append(host);
|
|
const root = createRoot(host);
|
|
act(() => {
|
|
root.render(
|
|
<TimelineDiamondLane
|
|
keyframesData={{
|
|
format: "percentage",
|
|
keyframes: [30, 60, 90].map((percentage) => ({
|
|
percentage,
|
|
propertyGroup: "position",
|
|
properties: { x: percentage },
|
|
})),
|
|
}}
|
|
clipWidthPx={36}
|
|
clipHeightPx={48}
|
|
accentColor="#4ba3d2"
|
|
isSelected
|
|
currentPercentage={0}
|
|
elementId="clip-1"
|
|
selectedKeyframes={new Set()}
|
|
groupAware
|
|
/>,
|
|
);
|
|
});
|
|
|
|
const diamonds = Array.from(host.querySelectorAll<HTMLButtonElement>("button[title]"));
|
|
expect(diamonds).toHaveLength(3);
|
|
for (const diamond of diamonds) {
|
|
expect(Number.parseFloat(diamond.style.width)).toBeCloseTo(12);
|
|
expect(Number(diamond.querySelector("svg")?.getAttribute("width"))).toBeCloseTo(8.8);
|
|
}
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("treats primary pointerup without drag as a keyframe click", () => {
|
|
const { host, root, onClickKeyframe } = renderDiamonds();
|
|
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
|
expect(diamond).not.toBeNull();
|
|
|
|
act(() => {
|
|
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0 }));
|
|
});
|
|
|
|
expect(onClickKeyframe).toHaveBeenCalledWith(
|
|
"clip-1",
|
|
expect.objectContaining({ percentage: 50 }),
|
|
);
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
// The collapsed clip row and the expanded property lanes read the same cache,
|
|
// so a keyframe that carries a property group has to hash to the same key in
|
|
// both — otherwise collapsing a track silently drops the selection.
|
|
it("keys a grouped keyframe the same way collapsed as expanded", () => {
|
|
const groupedKeyframe = {
|
|
percentage: 50,
|
|
tweenPercentage: 25,
|
|
propertyGroup: "position",
|
|
animationId: "anim-1",
|
|
properties: { x: 100 },
|
|
};
|
|
const sharedKey = timelineKeyframeSelectionKey("clip-1", groupedKeyframe);
|
|
const host = document.createElement("div");
|
|
document.body.append(host);
|
|
const root = createRoot(host);
|
|
const onClickKeyframe = vi.fn();
|
|
act(() => {
|
|
root.render(
|
|
<TimelineClipDiamonds
|
|
keyframesData={{ format: "percentage", keyframes: [groupedKeyframe] }}
|
|
clipWidthPx={200}
|
|
clipHeightPx={48}
|
|
accentColor="#4ba3d2"
|
|
isSelected
|
|
currentPercentage={-10}
|
|
elementId="clip-1"
|
|
selectedKeyframes={new Set([sharedKey])}
|
|
onClickKeyframe={onClickKeyframe}
|
|
/>,
|
|
);
|
|
});
|
|
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
|
// Highlighted from the shared key alone (the playhead is off-clip here).
|
|
expect(diamond?.querySelector("path:last-child")?.getAttribute("fill")).toBe("#4ba3d2");
|
|
|
|
act(() => {
|
|
diamond?.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0 }));
|
|
});
|
|
expect(onClickKeyframe).toHaveBeenCalledWith("clip-1", {
|
|
percentage: 50,
|
|
tweenPercentage: 25,
|
|
propertyGroup: "position",
|
|
animationId: "anim-1",
|
|
});
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("does not treat secondary pointerup as a keyframe click", () => {
|
|
const { host, root, onClickKeyframe } = renderDiamonds();
|
|
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
|
expect(diamond).not.toBeNull();
|
|
|
|
act(() => {
|
|
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 2 }));
|
|
});
|
|
|
|
expect(onClickKeyframe).not.toHaveBeenCalled();
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
// Regression: once the clip is selected, canDrag arms on every diamond
|
|
// press. A real click's few px of mouse/trackpad jitter then resolves (via
|
|
// the neighbour clamp) back onto ~the same position — "noop", not "move" —
|
|
// which fell through neither branch and silently did nothing: no
|
|
// selection, no retime. It must still count as the click it was.
|
|
it("treats a drag-armed press that resolves to a no-op move as a click", () => {
|
|
const onClickKeyframe = vi.fn();
|
|
const onMoveKeyframe = vi.fn();
|
|
const host = document.createElement("div");
|
|
document.body.append(host);
|
|
const root = createRoot(host);
|
|
act(() => {
|
|
root.render(
|
|
<TimelineDiamondLane
|
|
keyframesData={{
|
|
format: "percentage",
|
|
keyframes: [
|
|
{
|
|
percentage: 0,
|
|
tweenPercentage: 0,
|
|
propertyGroup: "position",
|
|
animationId: "anim-1",
|
|
properties: { x: 0 },
|
|
},
|
|
{
|
|
percentage: 50,
|
|
tweenPercentage: 100,
|
|
propertyGroup: "position",
|
|
animationId: "anim-1",
|
|
properties: { x: 100 },
|
|
},
|
|
],
|
|
}}
|
|
clipWidthPx={5000}
|
|
clipHeightPx={48}
|
|
accentColor="#4ba3d2"
|
|
isSelected
|
|
currentPercentage={0}
|
|
elementId="clip-1"
|
|
selectedKeyframes={new Set()}
|
|
onClickKeyframe={onClickKeyframe}
|
|
onMoveKeyframe={onMoveKeyframe}
|
|
groupAware
|
|
/>,
|
|
);
|
|
});
|
|
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
|
expect(diamond).not.toBeNull();
|
|
|
|
act(() => {
|
|
diamond!.dispatchEvent(
|
|
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
|
|
);
|
|
// 4px of travel at a 5000px clip width is ~0.08 clip-% — above the drag
|
|
// threshold (so resolveKeyframeDrag doesn't short-circuit to "click"
|
|
// itself) but below the no-op epsilon once neighbour-clamped.
|
|
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 104 }));
|
|
});
|
|
|
|
expect(onClickKeyframe).toHaveBeenCalledWith({
|
|
percentage: 50,
|
|
tweenPercentage: 100,
|
|
propertyGroup: "position",
|
|
animationId: "anim-1",
|
|
});
|
|
expect(onMoveKeyframe).not.toHaveBeenCalled();
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
// Regression: a genuine retime (drag far enough to actually move the
|
|
// keyframe) committed the move but never selected/parked on the result —
|
|
// the diamond it was just dragged looked exactly like one nothing happened
|
|
// to. Select it at its NEW position too.
|
|
it("reselects a retimed keyframe with its post-move tween percentage", () => {
|
|
const onClickKeyframe = vi.fn();
|
|
const onMoveKeyframe = vi.fn().mockResolvedValue(true);
|
|
const host = document.createElement("div");
|
|
document.body.append(host);
|
|
const root = createRoot(host);
|
|
act(() => {
|
|
root.render(
|
|
<TimelineDiamondLane
|
|
keyframesData={{
|
|
format: "percentage",
|
|
keyframes: [
|
|
{
|
|
percentage: 20,
|
|
tweenPercentage: 0,
|
|
propertyGroup: "position",
|
|
animationId: "anim-1",
|
|
properties: { x: 0 },
|
|
},
|
|
{
|
|
percentage: 40,
|
|
tweenPercentage: 50,
|
|
propertyGroup: "position",
|
|
animationId: "anim-1",
|
|
properties: { x: 100 },
|
|
},
|
|
{
|
|
percentage: 60,
|
|
tweenPercentage: 100,
|
|
propertyGroup: "position",
|
|
animationId: "anim-1",
|
|
properties: { x: 200 },
|
|
},
|
|
],
|
|
}}
|
|
clipWidthPx={200}
|
|
clipHeightPx={48}
|
|
accentColor="#4ba3d2"
|
|
isSelected
|
|
currentPercentage={0}
|
|
elementId="clip-1"
|
|
selectedKeyframes={new Set()}
|
|
onClickKeyframe={onClickKeyframe}
|
|
onMoveKeyframe={onMoveKeyframe}
|
|
groupAware
|
|
/>,
|
|
);
|
|
});
|
|
const diamond = host.querySelector<HTMLButtonElement>('button[title="40%"]');
|
|
expect(diamond).not.toBeNull();
|
|
|
|
act(() => {
|
|
diamond!.dispatchEvent(
|
|
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 80 }),
|
|
);
|
|
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 100 }));
|
|
});
|
|
|
|
expect(onMoveKeyframe).toHaveBeenCalledWith(
|
|
{
|
|
percentage: 40,
|
|
tweenPercentage: 50,
|
|
propertyGroup: "position",
|
|
animationId: "anim-1",
|
|
},
|
|
50,
|
|
);
|
|
expect(onClickKeyframe).toHaveBeenCalledWith({
|
|
percentage: 50,
|
|
tweenPercentage: 75,
|
|
propertyGroup: "position",
|
|
animationId: "anim-1",
|
|
});
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("composes a rapid second retime from the pending position", () => {
|
|
const onMoveKeyframe = vi.fn().mockResolvedValue(true);
|
|
const host = document.createElement("div");
|
|
document.body.append(host);
|
|
const root = createRoot(host);
|
|
act(() => {
|
|
root.render(
|
|
<TimelineDiamondLane
|
|
keyframesData={{
|
|
format: "percentage",
|
|
keyframes: [
|
|
{
|
|
percentage: 0,
|
|
tweenPercentage: 0,
|
|
propertyGroup: "position",
|
|
animationId: "anim-1",
|
|
properties: { x: 0 },
|
|
},
|
|
{
|
|
percentage: 50,
|
|
tweenPercentage: 50,
|
|
propertyGroup: "position",
|
|
animationId: "anim-1",
|
|
properties: { x: 100 },
|
|
},
|
|
{
|
|
percentage: 100,
|
|
tweenPercentage: 100,
|
|
propertyGroup: "position",
|
|
animationId: "anim-1",
|
|
properties: { x: 200 },
|
|
},
|
|
],
|
|
}}
|
|
clipWidthPx={200}
|
|
clipHeightPx={48}
|
|
accentColor="#4ba3d2"
|
|
isSelected
|
|
currentPercentage={0}
|
|
elementId="clip-1"
|
|
selectedKeyframes={new Set()}
|
|
onMoveKeyframe={onMoveKeyframe}
|
|
groupAware
|
|
/>,
|
|
);
|
|
});
|
|
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
|
expect(diamond).not.toBeNull();
|
|
|
|
act(() => {
|
|
diamond!.dispatchEvent(
|
|
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
|
|
);
|
|
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 }));
|
|
|
|
// The cache still exposes 50%, but this second +10% drag starts at the
|
|
// pending 75% destination and must therefore land at 85%, not 60%.
|
|
diamond!.dispatchEvent(
|
|
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 150 }),
|
|
);
|
|
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 170 }));
|
|
});
|
|
|
|
// The second move must identify the FROM keyframe by the pending (already-
|
|
// moved) position 75%, not the stale rendered 50%; otherwise the serialized
|
|
// mutation can't locate the keyframe the first move relocated.
|
|
expect(onMoveKeyframe).toHaveBeenNthCalledWith(
|
|
2,
|
|
{
|
|
percentage: 75,
|
|
tweenPercentage: 75,
|
|
propertyGroup: "position",
|
|
animationId: "anim-1",
|
|
},
|
|
85,
|
|
);
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it.each([
|
|
["returns false", () => Promise.resolve(false)],
|
|
["rejects", () => Promise.reject(new Error("retime failed"))],
|
|
])("clears a failed pending retime when the callback %s", async (_label, settle) => {
|
|
const onMoveKeyframe = vi.fn().mockImplementationOnce(settle).mockResolvedValue(true);
|
|
const host = document.createElement("div");
|
|
document.body.append(host);
|
|
const root = createRoot(host);
|
|
act(() => {
|
|
root.render(
|
|
<TimelineDiamondLane
|
|
keyframesData={{
|
|
format: "percentage",
|
|
keyframes: [
|
|
{
|
|
percentage: 0,
|
|
tweenPercentage: 0,
|
|
propertyGroup: "position",
|
|
animationId: "anim-1",
|
|
properties: { x: 0 },
|
|
},
|
|
{
|
|
percentage: 50,
|
|
tweenPercentage: 50,
|
|
propertyGroup: "position",
|
|
animationId: "anim-1",
|
|
properties: { x: 100 },
|
|
},
|
|
{
|
|
percentage: 100,
|
|
tweenPercentage: 100,
|
|
propertyGroup: "position",
|
|
animationId: "anim-1",
|
|
properties: { x: 200 },
|
|
},
|
|
],
|
|
}}
|
|
clipWidthPx={200}
|
|
clipHeightPx={48}
|
|
accentColor="#4ba3d2"
|
|
isSelected
|
|
currentPercentage={0}
|
|
elementId="clip-1"
|
|
selectedKeyframes={new Set()}
|
|
onMoveKeyframe={onMoveKeyframe}
|
|
groupAware
|
|
/>,
|
|
);
|
|
});
|
|
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
|
expect(diamond).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
diamond!.dispatchEvent(
|
|
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
|
|
);
|
|
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 }));
|
|
await Promise.resolve();
|
|
});
|
|
|
|
act(() => {
|
|
diamond!.dispatchEvent(
|
|
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
|
|
);
|
|
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120 }));
|
|
});
|
|
|
|
expect(onMoveKeyframe).toHaveBeenNthCalledWith(
|
|
2,
|
|
{
|
|
percentage: 50,
|
|
tweenPercentage: 50,
|
|
propertyGroup: "position",
|
|
animationId: "anim-1",
|
|
},
|
|
60,
|
|
);
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("cancels an in-flight retime on Escape without committing or selecting", () => {
|
|
const onClickKeyframe = vi.fn();
|
|
const onMoveKeyframe = vi.fn().mockResolvedValue(true);
|
|
const host = document.createElement("div");
|
|
document.body.append(host);
|
|
const root = createRoot(host);
|
|
act(() => {
|
|
root.render(
|
|
<TimelineDiamondLane
|
|
keyframesData={{
|
|
format: "percentage",
|
|
keyframes: [
|
|
{ percentage: 20, tweenPercentage: 0, properties: { x: 0 } },
|
|
{ percentage: 40, tweenPercentage: 50, properties: { x: 100 } },
|
|
{ percentage: 60, tweenPercentage: 100, properties: { x: 200 } },
|
|
],
|
|
}}
|
|
clipWidthPx={200}
|
|
clipHeightPx={48}
|
|
accentColor="#4ba3d2"
|
|
isSelected
|
|
currentPercentage={0}
|
|
elementId="clip-1"
|
|
selectedKeyframes={new Set()}
|
|
onClickKeyframe={onClickKeyframe}
|
|
onMoveKeyframe={onMoveKeyframe}
|
|
/>,
|
|
);
|
|
});
|
|
const diamond = host.querySelector<HTMLButtonElement>('button[title="40%"]');
|
|
expect(diamond).not.toBeNull();
|
|
|
|
act(() => {
|
|
diamond!.dispatchEvent(
|
|
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 80 }),
|
|
);
|
|
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 100 }));
|
|
});
|
|
|
|
// Escape ends the gesture: no retime is written, and the release is not
|
|
// reinterpreted as a click that would park the playhead on the keyframe.
|
|
expect(onMoveKeyframe).not.toHaveBeenCalled();
|
|
expect(onClickKeyframe).not.toHaveBeenCalled();
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
// Regression: onClickKeyframe's state updates can re-render the diamond
|
|
// button out from under the gesture before the browser auto-synthesizes the
|
|
// "click" event that follows a button's pointerdown+pointerup. That orphaned
|
|
// click then bubbles to the ancestor clip's onClick, which toggles selection
|
|
// off whenever the clip is already selected — the state a diamond click
|
|
// always happens in — so every keyframe click immediately deselected its
|
|
// own clip. suppressClickRef lets that ancestor ignore the stray click.
|
|
it("arms suppressClickRef synchronously on a keyframe click", () => {
|
|
const suppressClickRef = { current: false };
|
|
const host = document.createElement("div");
|
|
document.body.append(host);
|
|
const root = createRoot(host);
|
|
act(() => {
|
|
root.render(
|
|
<TimelineClipDiamonds
|
|
keyframesData={{
|
|
format: "percentage",
|
|
keyframes: [{ percentage: 50, properties: { x: 100 } }],
|
|
}}
|
|
clipWidthPx={200}
|
|
clipHeightPx={48}
|
|
accentColor="#4ba3d2"
|
|
isSelected
|
|
currentPercentage={0}
|
|
elementId="clip-1"
|
|
selectedKeyframes={new Set()}
|
|
onClickKeyframe={vi.fn()}
|
|
suppressClickRef={suppressClickRef}
|
|
/>,
|
|
);
|
|
});
|
|
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
|
expect(diamond).not.toBeNull();
|
|
|
|
act(() => {
|
|
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0 }));
|
|
});
|
|
|
|
expect(suppressClickRef.current).toBe(true);
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
const renderSegmentLane = (lastAmbiguous: boolean) => {
|
|
const host = document.createElement("div");
|
|
document.body.append(host);
|
|
const root = createRoot(host);
|
|
const kf = (percentage: number, extra: Record<string, unknown> = {}) => ({
|
|
percentage,
|
|
tweenPercentage: percentage,
|
|
propertyGroup: "position",
|
|
animationId: "anim-1",
|
|
properties: { x: percentage },
|
|
...extra,
|
|
});
|
|
act(() => {
|
|
root.render(
|
|
<TimelineDiamondLane
|
|
keyframesData={{
|
|
format: "percentage",
|
|
keyframes: [kf(0), kf(50), kf(100, { easeAmbiguous: lastAmbiguous })],
|
|
}}
|
|
clipWidthPx={200}
|
|
clipHeightPx={48}
|
|
accentColor="#4ba3d2"
|
|
isSelected
|
|
currentPercentage={0}
|
|
elementId="clip-1"
|
|
selectedKeyframes={new Set()}
|
|
onSelectSegment={vi.fn()}
|
|
groupAware
|
|
/>,
|
|
);
|
|
});
|
|
return { host, root };
|
|
};
|
|
|
|
it("hides the inline ease button on an ambiguous merged segment", () => {
|
|
// Segments 0->50 and 50->100; the 50->100 segment ends on the ambiguous
|
|
// keyframe, so its hover/ease-button area is not rendered.
|
|
const { host, root } = renderSegmentLane(true);
|
|
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(1);
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("keeps the inline ease button on unambiguous merged segments", () => {
|
|
const { host, root } = renderSegmentLane(false);
|
|
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("keeps the ease button above nearby diamonds without blocking the segment", () => {
|
|
const { host, root } = renderSegmentLane(false);
|
|
const segment = host.querySelector<HTMLElement>("[data-keyframe-ease-segment]");
|
|
const ease = segment?.querySelector<HTMLButtonElement>("[data-keyframe-ease-button]");
|
|
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
|
|
|
expect(segment?.style.pointerEvents).toBe("none");
|
|
expect(ease?.style.pointerEvents).toBe("auto");
|
|
expect(Number(segment?.style.zIndex)).toBeGreaterThan(Number(diamond?.style.zIndex));
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("hides the inline ease button on a segment with no source animation id", () => {
|
|
// A runtime-scanned keyframe has no animationId, so there is no tween to
|
|
// target; the segment ending on it must not render a (dead) ease button.
|
|
const host = document.createElement("div");
|
|
document.body.append(host);
|
|
const root = createRoot(host);
|
|
const kf = (percentage: number, animationId?: string) => ({
|
|
percentage,
|
|
tweenPercentage: percentage,
|
|
propertyGroup: "position",
|
|
...(animationId ? { animationId } : {}),
|
|
properties: { x: percentage },
|
|
});
|
|
act(() => {
|
|
root.render(
|
|
<TimelineDiamondLane
|
|
keyframesData={{
|
|
format: "percentage",
|
|
keyframes: [kf(0, "anim-1"), kf(50, "anim-1"), kf(100)],
|
|
}}
|
|
clipWidthPx={200}
|
|
clipHeightPx={48}
|
|
accentColor="#4ba3d2"
|
|
isSelected
|
|
currentPercentage={0}
|
|
elementId="clip-1"
|
|
selectedKeyframes={new Set()}
|
|
onSelectSegment={vi.fn()}
|
|
groupAware
|
|
/>,
|
|
);
|
|
});
|
|
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(1);
|
|
act(() => root.unmount());
|
|
});
|
|
});
|