@hyperframes/studio 0.8.121 → 0.8.123
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-GkN9yzzq.js → hyperframes-player-yABdb6yk.js} +1 -1
- package/dist/assets/{index-ByV72rWx.js → index-D77MIjSJ.js} +1 -1
- package/dist/assets/{index-BpKhiuzT.js → index-DWjF2Kxw.js} +1 -1
- package/dist/assets/{index-CyFqMHGS.js → index-Dt7lWtY7.js} +184 -184
- package/dist/assets/{index-t1LH6H5H.css → index-LrNlT5Tg.css} +1 -1
- package/dist/{chunk-J6Q572FA.js → chunk-246VB2G3.js} +3 -1
- package/dist/chunk-246VB2G3.js.map +1 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.html +2 -2
- package/dist/index.js +1611 -1310
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/FrameyGlyph.tsx +52 -0
- package/src/components/OpenInDesktopButton.dom.test.tsx +113 -0
- package/src/components/OpenInDesktopButton.tsx +153 -0
- package/src/components/StudioHeader.dom.test.tsx +2 -0
- package/src/components/StudioHeader.tsx +2 -0
- package/src/components/TimelineToolbar.test.tsx +25 -0
- package/src/components/TimelineToolbar.tsx +5 -1
- package/src/components/editor/MotionPathNode.tsx +2 -1
- package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +99 -0
- package/src/components/editor/MotionPathOverlay.tsx +6 -0
- package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +57 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +1 -1
- package/src/components/editor/motionPathGeometry.test.ts +10 -0
- package/src/components/editor/motionPathGeometry.ts +8 -5
- package/src/components/editor/motionPathLayerNode.test.ts +82 -0
- package/src/components/editor/motionPathLayerNode.ts +28 -0
- package/src/components/editor/useDomEditNudge.test.tsx +53 -1
- package/src/components/editor/useDomEditNudge.ts +2 -2
- package/src/components/editor/useMotionPathData.test.tsx +30 -0
- package/src/components/editor/useMotionPathData.ts +3 -1
- package/src/hooks/gsapValueAtPlayhead.test.ts +46 -3
- package/src/hooks/gsapValueAtPlayhead.ts +12 -2
- package/src/hooks/linkShortcuts.test.ts +16 -0
- package/src/hooks/linkShortcuts.ts +2 -0
- package/src/hooks/useAppHotkeys.ts +1 -2
- package/src/index.ts +2 -0
- package/src/player/components/TimelineClipFades.tsx +1 -0
- package/src/player/components/timelineClipDragPreview.test.ts +68 -0
- package/src/player/components/timelineClipDragPreview.ts +39 -14
- package/src/player/components/timelineLayout.test.ts +24 -1
- package/src/player/components/timelineRulerGeometry.ts +24 -3
- package/src/player/components/timelineSnapping.test.ts +43 -12
- package/src/player/components/timelineSnapping.ts +33 -15
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +54 -54
- package/src/player/components/useTimelineClipDrag.ts +17 -29
- package/src/player/components/useTimelineProviderState.tsx +0 -1
- package/src/player/components/useTimelineTicks.ts +6 -3
- package/src/player/hooks/timelinePlayerTestHarness.ts +2 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +66 -0
- package/src/player/hooks/useTimelinePlayer.ts +6 -2
- package/src/player/lib/time.ts +3 -0
- package/src/player/store/previewFrameStore.ts +48 -0
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/components.css +51 -0
- package/src/styles/theme.css +3 -0
- package/src/timelineChromeExports.test.tsx +6 -0
- package/src/utils/gsapSoftReload.test.ts +50 -0
- package/src/utils/gsapSoftReload.ts +22 -2
- package/dist/chunk-J6Q572FA.js.map +0 -1
|
@@ -78,7 +78,10 @@ export function nearestPointOnPath(
|
|
|
78
78
|
return best;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
export function buildMotionPathGeometry(
|
|
81
|
+
export function buildMotionPathGeometry(
|
|
82
|
+
read: ReadTween | null,
|
|
83
|
+
base: { x: number; y: number } = { x: 0, y: 0 },
|
|
84
|
+
): MotionPathGeometry | null {
|
|
82
85
|
if (!read) return null;
|
|
83
86
|
const isArc = Boolean(read.arcPath);
|
|
84
87
|
const nodes: MotionPathNode[] = [];
|
|
@@ -87,8 +90,8 @@ export function buildMotionPathGeometry(read: ReadTween | null): MotionPathGeome
|
|
|
87
90
|
// anchor. Arc waypoints always carry x/y (never filtered), so source index
|
|
88
91
|
// and node order stay aligned.
|
|
89
92
|
// Which axes does the tween animate at all? A single-axis tween (e.g.
|
|
90
|
-
// `to({ x: -260 })`) only carries x; its y stays at
|
|
91
|
-
//
|
|
93
|
+
// `to({ x: -260 })`) only carries x; its y stays at `base`, GSAP's live y (a CSS
|
|
94
|
+
// translate it folded in, else 0), so we default it and still draw a path. But if the tween
|
|
92
95
|
// DOES animate an axis and a given keyframe omits it, that value is interpolated
|
|
93
96
|
// (not 0) and can't be placed here → skip that node (the prior behavior).
|
|
94
97
|
const finite = (v: unknown): v is number => typeof v === "number" && isFinite(v);
|
|
@@ -100,8 +103,8 @@ export function buildMotionPathGeometry(read: ReadTween | null): MotionPathGeome
|
|
|
100
103
|
if (tweenHasX && !finite(kf.properties.x)) return;
|
|
101
104
|
if (tweenHasY && !finite(kf.properties.y)) return;
|
|
102
105
|
nodes.push({
|
|
103
|
-
x: tweenHasX ? (kf.properties.x as number) :
|
|
104
|
-
y: tweenHasY ? (kf.properties.y as number) :
|
|
106
|
+
x: tweenHasX ? (kf.properties.x as number) : base.x,
|
|
107
|
+
y: tweenHasY ? (kf.properties.y as number) : base.y,
|
|
105
108
|
ref: isArc ? { type: "waypoint", index: i } : { type: "keyframe", pct: kf.percentage },
|
|
106
109
|
});
|
|
107
110
|
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { expect, it } from "vitest";
|
|
3
|
+
import { pressBelongsToLayer, pressSelectedLayer } from "./motionPathLayerNode";
|
|
4
|
+
|
|
5
|
+
// Keyframe nodes at x 60 and 120 (y 30), drawn from home (500, 400); dots of radius 6.
|
|
6
|
+
const atLayer = { x: 60, y: 30, ax: 560, ay: 430 };
|
|
7
|
+
const other = { x: 120, y: 30, ax: 620, ay: 430 };
|
|
8
|
+
const live = { x: 60, y: 30 };
|
|
9
|
+
|
|
10
|
+
const box = document.createElement("div");
|
|
11
|
+
box.setAttribute("data-dom-edit-selection-box", "true");
|
|
12
|
+
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
|
13
|
+
document.body.append(box, circle);
|
|
14
|
+
|
|
15
|
+
/** A press on `circle` at (x, y), with `under` what the document hit-tests there. */
|
|
16
|
+
function press(x: number, y: number, under: Element[]) {
|
|
17
|
+
document.elementsFromPoint = () => [circle, ...under];
|
|
18
|
+
return { currentTarget: circle, clientX: x, clientY: y } as unknown as React.PointerEvent;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
it("the node GSAP renders the layer at is the layer, inside the box or out", () => {
|
|
22
|
+
expect(pressBelongsToLayer(press(562, 431, [box]), { x: 562, y: 431 }, atLayer, 6, live)).toBe(
|
|
23
|
+
true,
|
|
24
|
+
);
|
|
25
|
+
expect(pressBelongsToLayer(press(570, 440, []), { x: 570, y: 440 }, atLayer, 6, live)).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("inside the layer's box another node's grab ring is the layer's; its dot stays the node's", () => {
|
|
29
|
+
expect(pressBelongsToLayer(press(610, 430, [box]), { x: 610, y: 430 }, other, 6, live)).toBe(
|
|
30
|
+
true,
|
|
31
|
+
);
|
|
32
|
+
expect(pressBelongsToLayer(press(617, 430, [box]), { x: 617, y: 430 }, other, 6, live)).toBe(
|
|
33
|
+
false,
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("outside the layer's box a node's whole grab ring is the node's", () => {
|
|
38
|
+
expect(pressBelongsToLayer(press(610, 430, []), { x: 610, y: 430 }, other, 6, live)).toBe(false);
|
|
39
|
+
expect(pressBelongsToLayer(press(610, 430, [box]), { x: 610, y: 430 }, other, 6, null)).toBe(
|
|
40
|
+
true,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("hands a press to the selected layer's box with its pointer and position", () => {
|
|
45
|
+
const got: number[][] = [];
|
|
46
|
+
const listen = (e: Event) => {
|
|
47
|
+
const p = e as PointerEvent;
|
|
48
|
+
got.push([p.pointerId, p.clientX, p.clientY, p.button]);
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
};
|
|
51
|
+
box.addEventListener("pointerdown", listen);
|
|
52
|
+
const nativeEvent = new PointerEvent("pointerdown", {
|
|
53
|
+
pointerId: 7,
|
|
54
|
+
clientX: 12,
|
|
55
|
+
clientY: 34,
|
|
56
|
+
button: 0,
|
|
57
|
+
bubbles: true,
|
|
58
|
+
cancelable: true,
|
|
59
|
+
});
|
|
60
|
+
const down = { currentTarget: circle, nativeEvent } as unknown as React.PointerEvent;
|
|
61
|
+
expect(pressSelectedLayer(down)).toBe(true);
|
|
62
|
+
expect(got).toEqual([[7, 12, 34, 0]]);
|
|
63
|
+
box.removeEventListener("pointerdown", listen);
|
|
64
|
+
box.remove();
|
|
65
|
+
expect(pressSelectedLayer(down)).toBe(false);
|
|
66
|
+
document.body.append(box);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("keeps the press when the box does not start a gesture with it, or takes no pointer", () => {
|
|
70
|
+
let got = 0;
|
|
71
|
+
const listen = () => void got++;
|
|
72
|
+
box.addEventListener("pointerdown", listen);
|
|
73
|
+
const nativeEvent = new PointerEvent("pointerdown", { bubbles: true, cancelable: true });
|
|
74
|
+
const down = { currentTarget: circle, nativeEvent } as unknown as React.PointerEvent;
|
|
75
|
+
expect(pressSelectedLayer(down)).toBe(false);
|
|
76
|
+
expect(got).toBe(1);
|
|
77
|
+
box.style.pointerEvents = "none";
|
|
78
|
+
expect(pressSelectedLayer(down)).toBe(false);
|
|
79
|
+
expect(got).toBe(1);
|
|
80
|
+
box.style.pointerEvents = "";
|
|
81
|
+
box.removeEventListener("pointerdown", listen);
|
|
82
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
type Point = { x: number; y: number };
|
|
2
|
+
type DrawnNode = Point & { ax: number; ay: number };
|
|
3
|
+
|
|
4
|
+
const LAYER_BOX = '[data-dom-edit-selection-box="true"]';
|
|
5
|
+
|
|
6
|
+
/** How far a node's dot reaches; a selected node draws it larger. */
|
|
7
|
+
export const dotRadius = (r: number, selected: boolean) => (selected ? r * 1.5 : r);
|
|
8
|
+
|
|
9
|
+
/** A press on the layer's own node, or in its box but off a node's dot, belongs to the layer. */
|
|
10
|
+
export function pressBelongsToLayer(
|
|
11
|
+
e: React.PointerEvent,
|
|
12
|
+
point: Point,
|
|
13
|
+
pressed: DrawnNode,
|
|
14
|
+
dotR: number,
|
|
15
|
+
live: Point | null,
|
|
16
|
+
): boolean {
|
|
17
|
+
if (live && Math.abs(pressed.x - live.x) < 0.5 && Math.abs(pressed.y - live.y) < 0.5) return true;
|
|
18
|
+
const hits = e.currentTarget.ownerDocument.elementsFromPoint(e.clientX, e.clientY);
|
|
19
|
+
const inBox = hits.some((el) => el.closest(LAYER_BOX));
|
|
20
|
+
return inBox && Math.hypot(point.x - pressed.ax, point.y - pressed.ay) > dotR;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Hands a press to the selected layer's box; true when the box started a gesture with it. */
|
|
24
|
+
export function pressSelectedLayer(e: React.PointerEvent): boolean {
|
|
25
|
+
const box = e.currentTarget.ownerDocument.querySelector(LAYER_BOX);
|
|
26
|
+
if (!box || getComputedStyle(box).pointerEvents === "none") return false;
|
|
27
|
+
return !box.dispatchEvent(new PointerEvent("pointerdown", e.nativeEvent));
|
|
28
|
+
}
|
|
@@ -34,9 +34,11 @@ let flushNudge = () => {};
|
|
|
34
34
|
function Harness({
|
|
35
35
|
selection,
|
|
36
36
|
onPathOffsetCommit,
|
|
37
|
+
onManualDragStart = () => {},
|
|
37
38
|
}: {
|
|
38
39
|
selection: DomEditSelection | null;
|
|
39
40
|
onPathOffsetCommit: UseDomEditNudgeParams["onPathOffsetCommitRef"]["current"];
|
|
41
|
+
onManualDragStart?: () => void;
|
|
40
42
|
}) {
|
|
41
43
|
flushNudge = useDomEditNudge({
|
|
42
44
|
selection,
|
|
@@ -48,7 +50,7 @@ function Harness({
|
|
|
48
50
|
gestureRef: makeRef(null),
|
|
49
51
|
groupGestureRef: makeRef(null),
|
|
50
52
|
blockedMoveRef: makeRef(null),
|
|
51
|
-
onManualDragStartRef: makeRef(
|
|
53
|
+
onManualDragStartRef: makeRef(onManualDragStart),
|
|
52
54
|
onBlockedMoveRef: makeRef(() => {}),
|
|
53
55
|
onPathOffsetCommitRef: makeRef(onPathOffsetCommit),
|
|
54
56
|
onGroupPathOffsetCommitRef: makeRef(async () => {}),
|
|
@@ -340,6 +342,56 @@ describe("useDomEditNudge carries the route its press chose", () => {
|
|
|
340
342
|
});
|
|
341
343
|
});
|
|
342
344
|
|
|
345
|
+
describe("useDomEditNudge pauses playback before it snapshots the timelines", () => {
|
|
346
|
+
let reactRoot: ReturnType<typeof createRoot> | null = null;
|
|
347
|
+
const pause = vi.fn();
|
|
348
|
+
const mount = (selection: DomEditSelection | null) => {
|
|
349
|
+
reactRoot = createRoot(document.createElement("div"));
|
|
350
|
+
act(() => {
|
|
351
|
+
reactRoot!.render(
|
|
352
|
+
React.createElement(Harness, {
|
|
353
|
+
selection,
|
|
354
|
+
onPathOffsetCommit: vi.fn(),
|
|
355
|
+
onManualDragStart: pause,
|
|
356
|
+
}),
|
|
357
|
+
);
|
|
358
|
+
});
|
|
359
|
+
};
|
|
360
|
+
beforeEach(() => {
|
|
361
|
+
vi.useFakeTimers();
|
|
362
|
+
__resetForTests();
|
|
363
|
+
pause.mockReset();
|
|
364
|
+
});
|
|
365
|
+
afterEach(() => {
|
|
366
|
+
act(() => reactRoot?.unmount());
|
|
367
|
+
reactRoot = null;
|
|
368
|
+
document.body.innerHTML = "";
|
|
369
|
+
vi.useRealTimers();
|
|
370
|
+
delete (window as { __timelines?: unknown }).__timelines;
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("does not resume a timeline the playback pause stopped", () => {
|
|
374
|
+
let playing = true;
|
|
375
|
+
const root = { pause: () => void (playing = false), paused: () => !playing };
|
|
376
|
+
(window as { __timelines?: unknown }).__timelines = { root };
|
|
377
|
+
pause.mockImplementation(root.pause);
|
|
378
|
+
const element = document.body.appendChild(document.createElement("div"));
|
|
379
|
+
mount(makeSelection("Dot", element));
|
|
380
|
+
|
|
381
|
+
act(() => dispatchArrowRight());
|
|
382
|
+
|
|
383
|
+
expect(element.hasAttribute("data-hf-drag-paused-timelines")).toBe(false);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("leaves playback alone when no layer can move", () => {
|
|
387
|
+
mount(null);
|
|
388
|
+
|
|
389
|
+
act(() => dispatchArrowRight());
|
|
390
|
+
|
|
391
|
+
expect(pause).not.toHaveBeenCalled();
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
343
395
|
describe("useDomEditNudge — undo right after a burst", () => {
|
|
344
396
|
it("undo's drain commits a burst still inside its debounce and waits for its save", async () => {
|
|
345
397
|
__resetForTests();
|
|
@@ -212,6 +212,8 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
|
|
|
212
212
|
? resolveGroupNudgeTargets(groupItems)
|
|
213
213
|
: resolveSingleNudgeTarget(p.selectionRef.current, p.overlayRectRef.current);
|
|
214
214
|
if (!targets) return null;
|
|
215
|
+
// Same side effect a drag start has (pauses preview playback).
|
|
216
|
+
p.onManualDragStartRef.current?.();
|
|
215
217
|
const members: ManualOffsetDragMember[] = [];
|
|
216
218
|
for (const target of targets) {
|
|
217
219
|
const result = createManualOffsetDragMember({ ...target, gesture: "nudge" });
|
|
@@ -223,8 +225,6 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
|
|
|
223
225
|
members.push(result.member);
|
|
224
226
|
}
|
|
225
227
|
if (members.length === 0) return null;
|
|
226
|
-
// Same side effect a drag start has (pauses preview playback).
|
|
227
|
-
p.onManualDragStartRef.current?.();
|
|
228
228
|
const edit = beginStudioPendingEdit(manualOffsetMoveRevert(members));
|
|
229
229
|
return { members, isGroup, accum: { x: 0, y: 0 }, timer: null, edit };
|
|
230
230
|
};
|
|
@@ -8,6 +8,12 @@ import { usePlayerStore } from "../../player/store/playerStore";
|
|
|
8
8
|
|
|
9
9
|
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
10
10
|
|
|
11
|
+
const runtime = vi.hoisted(() => ({ read: null as unknown, gsapPosition: null as unknown }));
|
|
12
|
+
vi.mock("../../hooks/gsapRuntimeKeyframes", () => ({ readRuntimeKeyframes: () => runtime.read }));
|
|
13
|
+
vi.mock("../../hooks/gsapPositionDetection", () => ({
|
|
14
|
+
readGsapPositionFromIframe: () => runtime.gsapPosition,
|
|
15
|
+
}));
|
|
16
|
+
|
|
11
17
|
const originalRaf = window.requestAnimationFrame;
|
|
12
18
|
let frames: Array<() => void> = [];
|
|
13
19
|
const runFrames = (count: number) => {
|
|
@@ -36,6 +42,30 @@ afterEach(() => {
|
|
|
36
42
|
vi.useRealTimers();
|
|
37
43
|
usePlayerStore.setState({ previewBooted: false, motionPathArmed: false });
|
|
38
44
|
document.body.innerHTML = "";
|
|
45
|
+
runtime.read = runtime.gsapPosition = null;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("draws an axis the tween leaves alone where GSAP renders it, so the playhead node sits on the layer", () => {
|
|
49
|
+
runtime.read = {
|
|
50
|
+
keyframes: [
|
|
51
|
+
{ percentage: 66.667, properties: { x: 60 } },
|
|
52
|
+
{ percentage: 100, properties: { x: 120 } },
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
runtime.gsapPosition = { x: 60, y: 30 };
|
|
56
|
+
let points: string | undefined;
|
|
57
|
+
function Probe() {
|
|
58
|
+
const ref = useRef(document.createElement("iframe"));
|
|
59
|
+
points = useMotionPathData(ref, "#box").geometry?.points;
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const root = createRoot(document.body.appendChild(document.createElement("div")));
|
|
63
|
+
try {
|
|
64
|
+
act(() => root.render(<Probe />));
|
|
65
|
+
expect(points).toBe("60,30 120,30");
|
|
66
|
+
} finally {
|
|
67
|
+
act(() => root.unmount());
|
|
68
|
+
}
|
|
39
69
|
});
|
|
40
70
|
|
|
41
71
|
it("reads no layout per frame until there is a path or a create ring to draw", () => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, type RefObject } from "react";
|
|
2
2
|
import { readRuntimeKeyframes } from "../../hooks/gsapRuntimeKeyframes";
|
|
3
|
+
import { readGsapPositionFromIframe } from "../../hooks/gsapPositionDetection";
|
|
3
4
|
import { isElementVisibleForOverlay } from "./domEditOverlayGeometry";
|
|
4
5
|
import { buildMotionPathGeometry, type MotionPathGeometry } from "./motionPathGeometry";
|
|
5
6
|
import { subscribeOverlayFrame } from "./overlayFrameLoop";
|
|
@@ -169,7 +170,8 @@ export function useMotionPathData(
|
|
|
169
170
|
const recompute = () => {
|
|
170
171
|
// Position-only: never let a co-located size/scale tween shadow the path.
|
|
171
172
|
const read = readRuntimeKeyframes(iframeRef.current, selector, undefined, ["x", "y"]);
|
|
172
|
-
const
|
|
173
|
+
const base = read ? readGsapPositionFromIframe(iframeRef.current, selector) : null;
|
|
174
|
+
const next = buildMotionPathGeometry(read, base ?? undefined);
|
|
173
175
|
setGeometry((prev) =>
|
|
174
176
|
prev?.points === next?.points && prev?.kind === next?.kind ? prev : next,
|
|
175
177
|
);
|
|
@@ -527,9 +527,52 @@ describe("planValueAtPlayhead", () => {
|
|
|
527
527
|
]);
|
|
528
528
|
});
|
|
529
529
|
|
|
530
|
-
|
|
531
|
-
const
|
|
532
|
-
|
|
530
|
+
describe("keyframes eased as a whole", () => {
|
|
531
|
+
const eased = {
|
|
532
|
+
...kf([
|
|
533
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
534
|
+
{ percentage: 100, properties: { x: 20 } },
|
|
535
|
+
]),
|
|
536
|
+
ease: "power2.out",
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
it("changes the keyframe under the playhead and writes the ease back", () => {
|
|
540
|
+
const result = plan({ anim: eased, at: { time: 4 }, values: { x: 5 } });
|
|
541
|
+
expect(result.ok && result.mutation.keyframes.map((k) => k.properties.x)).toEqual([0, 5]);
|
|
542
|
+
expect(result.ok && result.mutation.ease).toBe("power2.out");
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("writes the keyframes' own ease as the tween's, as GSAP prefers it", () => {
|
|
546
|
+
const anim = { ...eased, keyframes: { ...eased.keyframes!, ease: "expo.in" } };
|
|
547
|
+
const result = plan({ anim, at: { time: 0 }, values: { x: 5 } });
|
|
548
|
+
expect(result.ok && result.mutation.ease).toBe("expo.in");
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it("refuses an inner keyframe, which the ease shows at another time", () => {
|
|
552
|
+
const anim = {
|
|
553
|
+
...kf([
|
|
554
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
555
|
+
{ percentage: 50, properties: { x: 100 } },
|
|
556
|
+
{ percentage: 100, properties: { x: 300 } },
|
|
557
|
+
]),
|
|
558
|
+
ease: "power2.out",
|
|
559
|
+
};
|
|
560
|
+
expect(plan({ anim, at: { time: 2 }, values: { x: 5 } })).toEqual({
|
|
561
|
+
ok: false,
|
|
562
|
+
reason: "eased-keyframes",
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it("refuses to add a keyframe, whose time the ease would move", () => {
|
|
567
|
+
expect(plan({ anim: eased, at: { time: 2 }, values: { x: 5 } })).toEqual({
|
|
568
|
+
ok: false,
|
|
569
|
+
reason: "eased-keyframes",
|
|
570
|
+
});
|
|
571
|
+
expect(plan({ anim: eased, at: { time: 6 }, values: { x: 5 } })).toEqual({
|
|
572
|
+
ok: false,
|
|
573
|
+
reason: "eased-keyframes",
|
|
574
|
+
});
|
|
575
|
+
});
|
|
533
576
|
});
|
|
534
577
|
});
|
|
535
578
|
|
|
@@ -55,6 +55,7 @@ export type PlayheadEditPlan =
|
|
|
55
55
|
position: number;
|
|
56
56
|
duration: number;
|
|
57
57
|
keyframes: Keyframe[];
|
|
58
|
+
ease?: string;
|
|
58
59
|
easeEach?: string;
|
|
59
60
|
};
|
|
60
61
|
/** A keyframe was added, not only changed: keyframe usage counts it as an add. */
|
|
@@ -70,6 +71,7 @@ const isLinear = (ease: string | undefined) => !ease || ease === "none" || ease
|
|
|
70
71
|
|
|
71
72
|
interface Normalized {
|
|
72
73
|
keyframes: Keyframe[];
|
|
74
|
+
runEase?: string;
|
|
73
75
|
easeEach?: string;
|
|
74
76
|
}
|
|
75
77
|
|
|
@@ -82,10 +84,10 @@ function normalize(edit: PlayheadEdit): Normalized | { reason: PlayheadEditRefus
|
|
|
82
84
|
const { anim } = edit;
|
|
83
85
|
const data = anim.keyframes;
|
|
84
86
|
if (data) {
|
|
85
|
-
if (!isLinear(anim.ease) || !isLinear(data.ease)) return { reason: "eased-keyframes" };
|
|
86
87
|
if (data.format === "simple-array") return { reason: "simple-array-keyframes" };
|
|
87
88
|
const arrayStep = data.format === "object-array";
|
|
88
89
|
return {
|
|
90
|
+
runEase: data.ease ?? anim.ease,
|
|
89
91
|
keyframes: data.keyframes.map((kf) => ({
|
|
90
92
|
...kf,
|
|
91
93
|
properties: { ...kf.properties },
|
|
@@ -126,8 +128,11 @@ function valueAt(keyframes: Keyframe[], prop: string, side: "first" | "last") {
|
|
|
126
128
|
return kf ? { percentage: kf.percentage, value: kf.properties[prop]! } : null;
|
|
127
129
|
}
|
|
128
130
|
|
|
131
|
+
const keyframeAt = (keyframes: Keyframe[], percentage: number) =>
|
|
132
|
+
keyframes.find((kf) => Math.abs(kf.percentage - percentage) <= KEYFRAME_PCT_MATCH);
|
|
133
|
+
|
|
129
134
|
function upsert(keyframes: Keyframe[], percentage: number, properties: Props, ease?: string) {
|
|
130
|
-
const hit = keyframes
|
|
135
|
+
const hit = keyframeAt(keyframes, percentage);
|
|
131
136
|
if (hit) {
|
|
132
137
|
Object.assign(hit.properties, properties);
|
|
133
138
|
return hit;
|
|
@@ -195,14 +200,18 @@ export function planValueAtPlayhead(edit: PlayheadEdit): PlayheadEditPlan {
|
|
|
195
200
|
"percentage" in edit.at
|
|
196
201
|
? edit.at.percentage
|
|
197
202
|
: roundPct(((edit.at.time - start) / duration) * 100);
|
|
203
|
+
const eased = !isLinear(norm.runEase);
|
|
198
204
|
if (pct >= -KEYFRAME_PCT_MATCH && pct <= 100 + KEYFRAME_PCT_MATCH) {
|
|
199
205
|
const at = Math.min(100, Math.max(0, pct));
|
|
206
|
+
const hit = keyframeAt(keyframes, at);
|
|
207
|
+
if (eased && hit?.percentage !== 0 && hit?.percentage !== 100) return refuse("eased-keyframes");
|
|
200
208
|
const next = [...keyframes]
|
|
201
209
|
.sort((a, b) => a.percentage - b.percentage)
|
|
202
210
|
.find((kf) => kf.percentage > at + KEYFRAME_PCT_MATCH);
|
|
203
211
|
// The new keyframe splits next's segment, so it takes next's ease.
|
|
204
212
|
upsert(keyframes, at, { ...values }, next?.ease);
|
|
205
213
|
} else {
|
|
214
|
+
if (eased) return refuse("eased-keyframes");
|
|
206
215
|
const time = (edit.at as { time: number }).time;
|
|
207
216
|
const before = time < start;
|
|
208
217
|
const held = heldEnds(norm, before ? "start" : "end", edit.implicitEndValue, backfilled);
|
|
@@ -232,6 +241,7 @@ export function planValueAtPlayhead(edit: PlayheadEdit): PlayheadEditPlan {
|
|
|
232
241
|
position: roundTo3(position),
|
|
233
242
|
duration: roundTo3(span),
|
|
234
243
|
keyframes,
|
|
244
|
+
...(norm.runEase ? { ease: norm.runEase } : {}),
|
|
235
245
|
...(norm.easeEach ? { easeEach: norm.easeEach } : {}),
|
|
236
246
|
},
|
|
237
247
|
added: keyframes.length > authored,
|
|
@@ -61,6 +61,22 @@ describe("dispatchLinkShortcut", () => {
|
|
|
61
61
|
expect(handleTimelineElementDeleteOnly).toHaveBeenCalledWith(video);
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
it("leaves ⌥⌫ to a text field the user is typing in", () => {
|
|
65
|
+
const video = clip("talk", "video", { muted: true, link: "lk-1" });
|
|
66
|
+
select([video, clip("talk-audio", "audio", { link: "lk-1" })], "talk");
|
|
67
|
+
const input = document.createElement("input");
|
|
68
|
+
document.body.append(input);
|
|
69
|
+
const event = key({ altKey: true, key: "Backspace", bubbles: true });
|
|
70
|
+
const handleTimelineElementDeleteOnly = vi.fn();
|
|
71
|
+
input.addEventListener("keydown", (e) =>
|
|
72
|
+
dispatchLinkShortcut(e, { handleLinkEdit: vi.fn(), handleTimelineElementDeleteOnly }),
|
|
73
|
+
);
|
|
74
|
+
input.dispatchEvent(event);
|
|
75
|
+
input.remove();
|
|
76
|
+
expect(handleTimelineElementDeleteOnly).not.toHaveBeenCalled();
|
|
77
|
+
expect(event.preventDefault).not.toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
|
|
64
80
|
it("leaves the key alone when no link item applies", () => {
|
|
65
81
|
select([clip("title", "div")], "title");
|
|
66
82
|
const event = key({ metaKey: true, key: "l" });
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { usePlayerStore } from "../player";
|
|
2
2
|
import type { TimelineEditCallbacks } from "../player/components/timelineCallbacks";
|
|
3
3
|
import { resolveLinkMenuItems } from "../player/components/clipMenuLinkItems";
|
|
4
|
+
import { isTypingTarget } from "../utils/typingTarget";
|
|
4
5
|
|
|
5
6
|
export interface LinkShortcutCallbacks {
|
|
6
7
|
handleLinkEdit?: TimelineEditCallbacks["onLinkEdit"];
|
|
@@ -22,6 +23,7 @@ function shortcutOf(event: KeyboardEvent): string | null {
|
|
|
22
23
|
|
|
23
24
|
/** ⌘L unlink/link, ⌥⇧D detach audio, ⌥⌫ delete one linked clip — the clip menu's shortcuts. */
|
|
24
25
|
export function dispatchLinkShortcut(event: KeyboardEvent, cb: LinkShortcutCallbacks): boolean {
|
|
26
|
+
if (isTypingTarget(event.target)) return false;
|
|
25
27
|
const shortcut = shortcutOf(event);
|
|
26
28
|
if (!shortcut) return false;
|
|
27
29
|
const { elements, selectedElementId, selectedElementIds } = usePlayerStore.getState();
|
|
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef } from "react";
|
|
|
2
2
|
import { usePlayerStore } from "../player";
|
|
3
3
|
import type { TimelineElement } from "../player";
|
|
4
4
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
5
|
-
import { isTypingTarget } from "../utils/typingTarget";
|
|
6
5
|
import { dispatchLinkShortcut, type LinkShortcutCallbacks } from "./linkShortcuts";
|
|
7
6
|
import { useCaptionStore } from "../captions/store";
|
|
8
7
|
import {
|
|
@@ -226,7 +225,7 @@ export function useAppHotkeys({
|
|
|
226
225
|
const handleAppKeyDown = useCallback((event: KeyboardEvent) => {
|
|
227
226
|
const cb = cbRef.current;
|
|
228
227
|
const key = event.key.toLowerCase();
|
|
229
|
-
if (
|
|
228
|
+
if (dispatchLinkShortcut(event, cb)) return;
|
|
230
229
|
if (event.metaKey || event.ctrlKey) {
|
|
231
230
|
dispatchModifierKey(event, key, cb);
|
|
232
231
|
return;
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,8 @@ export type { EditorShellProps } from "./components/EditorShell";
|
|
|
7
7
|
export { NLEPreview } from "./components/nle/NLEPreview";
|
|
8
8
|
export { DEFAULT_SHORTCUT_SECTIONS } from "./player/components/studioShortcuts";
|
|
9
9
|
export { ShortcutsButton } from "./player/components/ShortcutsPanel";
|
|
10
|
+
export { dispatchLinkShortcut } from "./hooks/linkShortcuts";
|
|
11
|
+
export type { LinkShortcutCallbacks } from "./hooks/linkShortcuts";
|
|
10
12
|
export type { ShortcutsButtonProps } from "./player/components/ShortcutsPanel";
|
|
11
13
|
export type { ShortcutHint, ShortcutSection } from "./player/components/studioShortcuts";
|
|
12
14
|
export { CompositionBreadcrumb } from "./components/nle/CompositionBreadcrumb";
|
|
@@ -3,6 +3,7 @@ import type { TimelineElement } from "../store/playerStore";
|
|
|
3
3
|
import {
|
|
4
4
|
computeDragPreview,
|
|
5
5
|
computeResizePreview,
|
|
6
|
+
guideIfSaved,
|
|
6
7
|
getTimelineDragOverlayPosition,
|
|
7
8
|
type DragPreviewContext,
|
|
8
9
|
} from "./timelineClipDragPreview";
|
|
@@ -293,6 +294,52 @@ describe("computeDragPreview — a clip landing on an empty main track keeps its
|
|
|
293
294
|
});
|
|
294
295
|
});
|
|
295
296
|
|
|
297
|
+
describe("guideIfSaved — a guide only where the clip saves", () => {
|
|
298
|
+
const grid = { time: 3.25, type: "grid" as const };
|
|
299
|
+
|
|
300
|
+
it("drops the guide when start and duration round apart from the target", () => {
|
|
301
|
+
// 1440 px/s: 1.125s + 2.125s saves as 1.13s + 2.13s, 14px past the 3.25s guide.
|
|
302
|
+
const el = clip("a", 0, 1.125, 2, 0, "div");
|
|
303
|
+
expect(guideIfSaved(el, { start: 1.125, duration: 2.125 }, grid, 1440)).toBeNull();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("drops the guide when a nested clip's local start rounds off it", () => {
|
|
307
|
+
const el = { ...clip("a", 0, 2, 1, 0, "div"), parentCompositionStart: 1 / 30 };
|
|
308
|
+
const target = { time: 2.1, type: "playhead" as const };
|
|
309
|
+
expect(guideIfSaved(el, { start: 2.1, duration: 1 }, target, 1440)).toBeNull();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("keeps the guide when the saved edge is on it", () => {
|
|
313
|
+
const el = clip("a", 0, 1, 2, 0, "div");
|
|
314
|
+
expect(guideIfSaved(el, { start: 1, duration: 2.25 }, grid, 1440)).toBe(grid);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("judges a move by the duration it keeps, not a rounded one", () => {
|
|
318
|
+
// A 31/30 s clip moved so its end meets 3.25s saves its start at 2.22s and ends 4.8px past.
|
|
319
|
+
const el = clip("a", 0, 1, 31 / 30, 0, "div");
|
|
320
|
+
const edge = { time: 3.25, type: "clip-edge" as const };
|
|
321
|
+
expect(guideIfSaved(el, { start: 2.217 }, edge, 1440)).toBeNull();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("draws no guide for a move whose saved start misses the target", () => {
|
|
325
|
+
const nested = { ...moodboard, parentCompositionStart: 1 / 30 };
|
|
326
|
+
const { drag } = horizontalDrag(nested, 0.5, 0);
|
|
327
|
+
const context = {
|
|
328
|
+
...ctx(),
|
|
329
|
+
pps: 1440,
|
|
330
|
+
buildSnapTargets: () => [{ time: 21, type: "playhead" as const }],
|
|
331
|
+
};
|
|
332
|
+
const next = computeDragPreview(
|
|
333
|
+
drag,
|
|
334
|
+
drag.originClientX + 2 * 1440,
|
|
335
|
+
drag.originClientY,
|
|
336
|
+
context,
|
|
337
|
+
);
|
|
338
|
+
expect(next.previewStart).toBe(21);
|
|
339
|
+
expect(next.snapTime).toBeNull();
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
296
343
|
describe("computeResizePreview — composition source continuity", () => {
|
|
297
344
|
it("seeds a legacy composition offset and advances it at playback rate", () => {
|
|
298
345
|
const element = {
|
|
@@ -320,6 +367,27 @@ describe("computeResizePreview — composition source continuity", () => {
|
|
|
320
367
|
});
|
|
321
368
|
});
|
|
322
369
|
|
|
370
|
+
it("draws no guide when the trimmed start cannot save onto the target", () => {
|
|
371
|
+
// 1440 px/s: a playhead on frame 31 (1.033s) saves a start of 1.03s, 4px off the guide.
|
|
372
|
+
const result = computeResizePreview(
|
|
373
|
+
{
|
|
374
|
+
element: clip("a", 0, 1, 2, 0, "div"),
|
|
375
|
+
edge: "start",
|
|
376
|
+
originClientX: 0,
|
|
377
|
+
previewStart: 1,
|
|
378
|
+
previewDuration: 2,
|
|
379
|
+
started: true,
|
|
380
|
+
},
|
|
381
|
+
48,
|
|
382
|
+
{
|
|
383
|
+
scroll: fakeScroll(),
|
|
384
|
+
pps: 1440,
|
|
385
|
+
buildSnapTargets: () => [{ time: 1.033, type: "playhead" }],
|
|
386
|
+
},
|
|
387
|
+
);
|
|
388
|
+
expect(result).toMatchObject({ previewStart: 1.03, snapTime: null, snapType: null });
|
|
389
|
+
});
|
|
390
|
+
|
|
323
391
|
it("does not let a tail snap shrink a clip below the drag's minimum duration", () => {
|
|
324
392
|
const result = computeResizePreview(
|
|
325
393
|
{
|