@hyperframes/studio 0.8.120 → 0.8.122
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-i4gqkhjz.js → hyperframes-player-DMzBHhbH.js} +1 -1
- package/dist/assets/{index-Ce3Lr-Gt.js → index-B-YDp8hN.js} +174 -174
- package/dist/assets/{index-uWOhRqxe.js → index-CBRX2eZX.js} +1 -1
- package/dist/assets/{index-C7LHDM18.js → index-lyjhSEzi.js} +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 +1 -1
- package/dist/index.js +416 -301
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- 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/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
package/dist/player.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.122",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
"dockview-react": "8.3.1",
|
|
61
61
|
"gsap": "^3.13.0",
|
|
62
62
|
"mediabunny": "^1.45.3",
|
|
63
|
-
"@hyperframes/
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/shader-transitions": "0.8.
|
|
67
|
-
"@hyperframes/studio-server": "0.8.
|
|
68
|
-
"@hyperframes/player": "0.8.
|
|
63
|
+
"@hyperframes/parsers": "0.8.122",
|
|
64
|
+
"@hyperframes/sdk": "0.8.122",
|
|
65
|
+
"@hyperframes/core": "0.8.122",
|
|
66
|
+
"@hyperframes/shader-transitions": "0.8.122",
|
|
67
|
+
"@hyperframes/studio-server": "0.8.122",
|
|
68
|
+
"@hyperframes/player": "0.8.122"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"vite": "^6.4.2",
|
|
82
82
|
"vitest": "^4.1.11",
|
|
83
83
|
"zustand": "^5.0.0",
|
|
84
|
-
"@hyperframes/producer": "0.8.
|
|
84
|
+
"@hyperframes/producer": "0.8.122"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -436,3 +436,28 @@ describe("TimelineToolbar — host right actions", () => {
|
|
|
436
436
|
act(() => root.unmount());
|
|
437
437
|
});
|
|
438
438
|
});
|
|
439
|
+
|
|
440
|
+
describe("TimelineToolbar split hint", () => {
|
|
441
|
+
async function splitTooltip(props: Partial<React.ComponentProps<typeof TimelineToolbar>>) {
|
|
442
|
+
usePlayerStore.setState({
|
|
443
|
+
elements: [{ id: "a", domId: "a", tag: "div", start: 0, duration: 4, track: 0 }],
|
|
444
|
+
selectedElementId: "a",
|
|
445
|
+
currentTime: 2,
|
|
446
|
+
});
|
|
447
|
+
const { host, root } = renderToolbar(undefined, { onSplitElement: vi.fn(), ...props });
|
|
448
|
+
const button = host.querySelector<HTMLButtonElement>('button[aria-label="Split at playhead"]');
|
|
449
|
+
act(() => button?.focus());
|
|
450
|
+
await act(async () => {
|
|
451
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
452
|
+
});
|
|
453
|
+
const label = document.querySelector('[role="tooltip"]')?.textContent;
|
|
454
|
+
act(() => root.unmount());
|
|
455
|
+
usePlayerStore.setState({ elements: [], selectedElementId: null, currentTime: 0 });
|
|
456
|
+
return label;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
it("names S unless the host binds Split to another key", async () => {
|
|
460
|
+
expect(await splitTooltip({})).toBe("Split at playhead (S)");
|
|
461
|
+
expect(await splitTooltip({ splitShortcut: "⌘B" })).toBe("Split at playhead (⌘B)");
|
|
462
|
+
});
|
|
463
|
+
});
|
|
@@ -35,6 +35,8 @@ import { LinkedSelectionToggle } from "./LinkedSelectionToggle";
|
|
|
35
35
|
import { useProjectHasAudio } from "../utils/audioMeterMath";
|
|
36
36
|
import { canAddBeatAt, addBeatAtCompositionTime } from "../utils/beatEditActions";
|
|
37
37
|
import { isTypingTarget } from "../utils/typingTarget";
|
|
38
|
+
import { SPLIT_SHORTCUT_HINT } from "../player/components/studioShortcuts";
|
|
39
|
+
import type { TimelineProps } from "../player/components/TimelineTypes";
|
|
38
40
|
|
|
39
41
|
interface DomEditSessionSlice extends EnableKeyframesSession {
|
|
40
42
|
domEditSelection: DomEditSelection | null;
|
|
@@ -44,6 +46,7 @@ interface DomEditSessionSlice extends EnableKeyframesSession {
|
|
|
44
46
|
export interface TimelineToolbarProps {
|
|
45
47
|
domEditSession?: DomEditSessionSlice;
|
|
46
48
|
onSplitElement?: (element: TimelineElement, splitTime: number) => void;
|
|
49
|
+
splitShortcut?: TimelineProps["splitShortcut"];
|
|
47
50
|
history?: TimelineHistoryButtonsProps;
|
|
48
51
|
showHistory?: boolean;
|
|
49
52
|
showSelectAroundPlayhead?: boolean;
|
|
@@ -145,6 +148,7 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
|
|
|
145
148
|
export const TimelineToolbar = memo(function TimelineToolbar({
|
|
146
149
|
domEditSession,
|
|
147
150
|
onSplitElement,
|
|
151
|
+
splitShortcut = SPLIT_SHORTCUT_HINT,
|
|
148
152
|
history,
|
|
149
153
|
showHistory = true,
|
|
150
154
|
showSelectAroundPlayhead = true,
|
|
@@ -382,7 +386,7 @@ export const TimelineToolbar = memo(function TimelineToolbar({
|
|
|
382
386
|
<Tooltip
|
|
383
387
|
label={
|
|
384
388
|
canSplit
|
|
385
|
-
?
|
|
389
|
+
? `Split at playhead (${splitShortcut})`
|
|
386
390
|
: splittable
|
|
387
391
|
? "Move the playhead inside the clip to split"
|
|
388
392
|
: "Select a clip to split"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type React from "react";
|
|
2
|
+
import { dotRadius } from "./motionPathLayerNode";
|
|
2
3
|
|
|
3
4
|
// Editor primary color (themeable via --hf-accent). Applied through inline
|
|
4
5
|
// style because CSS var() isn't valid in SVG presentation attributes.
|
|
@@ -29,7 +30,7 @@ export function MotionPathNode(props: {
|
|
|
29
30
|
// Diamond matching the timeline keyframe (a 45°-rotated rounded square).
|
|
30
31
|
// `side` is chosen so the diamond's points reach ~`r` from center, matching the
|
|
31
32
|
// old dot's footprint; selection is shown by enlarging it (no extra shape).
|
|
32
|
-
const side = (
|
|
33
|
+
const side = dotRadius(r, selected) * 1.414;
|
|
33
34
|
return (
|
|
34
35
|
<g onPointerEnter={props.onEnter} onPointerLeave={props.onLeave}>
|
|
35
36
|
<rect
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { expect, it, vi } from "vitest";
|
|
5
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
6
|
+
import { MotionPathOverlay } from "./MotionPathOverlay";
|
|
7
|
+
import type { DomEditSelection } from "./domEditing";
|
|
8
|
+
|
|
9
|
+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
10
|
+
|
|
11
|
+
vi.mock("../../contexts/DomEditContext", () => ({
|
|
12
|
+
useDomEditContext: () => ({ selectedGsapAnimations: [], commitMutation: vi.fn() }),
|
|
13
|
+
}));
|
|
14
|
+
vi.mock("./motionPathSelection", () => ({
|
|
15
|
+
selectorFor: () => "#box",
|
|
16
|
+
editableAnimationId: () => "a1",
|
|
17
|
+
}));
|
|
18
|
+
// GSAP renders the layer at the first keyframe: the playhead is on it.
|
|
19
|
+
vi.mock("../../hooks/gsapPositionDetection", () => ({
|
|
20
|
+
readGsapPositionFromIframe: () => ({ x: 60, y: 30 }),
|
|
21
|
+
}));
|
|
22
|
+
vi.mock("./useMotionPathData", async (importOriginal) => ({
|
|
23
|
+
...(await importOriginal<typeof import("./useMotionPathData")>()),
|
|
24
|
+
useMotionPathData: () => ({
|
|
25
|
+
rect: { left: 0, top: 0, width: 1920, height: 1080 },
|
|
26
|
+
geometry: {
|
|
27
|
+
kind: "linear",
|
|
28
|
+
points: "60,30 120,30",
|
|
29
|
+
nodes: [
|
|
30
|
+
{ x: 60, y: 30, ref: { type: "keyframe", pct: 66.667 } },
|
|
31
|
+
{ x: 120, y: 30, ref: { type: "keyframe", pct: 100 } },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
geometryResolved: true,
|
|
35
|
+
visibleInPreview: true,
|
|
36
|
+
home: { x: 0, y: 0 },
|
|
37
|
+
pScale: 1,
|
|
38
|
+
}),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
it("the layer's node and another node's ring inside the layer's box press the layer; a dot keeps its node", () => {
|
|
42
|
+
const box = document.createElement("div");
|
|
43
|
+
box.setAttribute("data-dom-edit-selection-box", "true");
|
|
44
|
+
const host = document.createElement("div");
|
|
45
|
+
document.body.append(box, host);
|
|
46
|
+
const boxPresses: number[] = [];
|
|
47
|
+
let boxStarts = true;
|
|
48
|
+
box.addEventListener("pointerdown", (e) => {
|
|
49
|
+
boxPresses.push((e as PointerEvent).clientX);
|
|
50
|
+
if (boxStarts) e.preventDefault();
|
|
51
|
+
});
|
|
52
|
+
const captured = vi.spyOn(Element.prototype, "setPointerCapture").mockImplementation(() => {});
|
|
53
|
+
const root = createRoot(host);
|
|
54
|
+
const selection = { element: document.createElement("div") } as unknown as DomEditSelection;
|
|
55
|
+
// Scale 1: client px are composition px. `inBox`: the layer's box is under the press.
|
|
56
|
+
const press = (node: number, x: number, inBox: boolean) => {
|
|
57
|
+
const hit = [...host.querySelectorAll("circle.pointer-events-auto")].find(
|
|
58
|
+
(c) => c.getAttribute("cx") === String(node),
|
|
59
|
+
)!;
|
|
60
|
+
document.elementsFromPoint = () => (inBox ? [hit, box] : [hit]);
|
|
61
|
+
const down = { bubbles: true, cancelable: true, button: 0, clientX: x, clientY: 30 };
|
|
62
|
+
act(() => void hit.dispatchEvent(new PointerEvent("pointerdown", down)));
|
|
63
|
+
};
|
|
64
|
+
try {
|
|
65
|
+
act(() =>
|
|
66
|
+
root.render(
|
|
67
|
+
<MotionPathOverlay
|
|
68
|
+
iframeRef={{ current: null }}
|
|
69
|
+
selection={selection}
|
|
70
|
+
compositionSize={{ width: 1920, height: 1080 }}
|
|
71
|
+
isPlaying={false}
|
|
72
|
+
/>,
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
press(60, 63, false);
|
|
76
|
+
press(120, 110, true);
|
|
77
|
+
expect(boxPresses).toEqual([63, 110]);
|
|
78
|
+
press(120, 118, true);
|
|
79
|
+
press(120, 110, false);
|
|
80
|
+
expect(boxPresses).toEqual([63, 110]);
|
|
81
|
+
// A selected node draws its dot 1.5x as large, and the larger dot is the node's.
|
|
82
|
+
act(() => usePlayerStore.setState({ activeKeyframePct: 100 }));
|
|
83
|
+
press(120, 112, true);
|
|
84
|
+
expect(boxPresses).toEqual([63, 110]);
|
|
85
|
+
act(() => usePlayerStore.setState({ activeKeyframePct: null }));
|
|
86
|
+
press(120, 112, true);
|
|
87
|
+
expect(boxPresses).toEqual([63, 110, 112]);
|
|
88
|
+
// A box that starts no gesture leaves the press to the node.
|
|
89
|
+
boxStarts = false;
|
|
90
|
+
captured.mockClear();
|
|
91
|
+
press(60, 63, false);
|
|
92
|
+
expect(captured).toHaveBeenCalledTimes(1);
|
|
93
|
+
} finally {
|
|
94
|
+
captured.mockRestore();
|
|
95
|
+
act(() => root.unmount());
|
|
96
|
+
box.remove();
|
|
97
|
+
host.remove();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
@@ -10,6 +10,8 @@ import { parkPlayheadOnKeyframe } from "../../hooks/gsapDragCommit";
|
|
|
10
10
|
import { commitWholePropertyOffset } from "../../hooks/gsapWholePropertyOffsetCommit";
|
|
11
11
|
import { nearestPointOnPath, type MotionNodeRef } from "./motionPathGeometry";
|
|
12
12
|
import { editableAnimationId, selectorFor } from "./motionPathSelection";
|
|
13
|
+
import { dotRadius, pressBelongsToLayer, pressSelectedLayer } from "./motionPathLayerNode";
|
|
14
|
+
import { readGsapPositionFromIframe } from "../../hooks/gsapPositionDetection";
|
|
13
15
|
import { ACCENT, MotionPathNode } from "./MotionPathNode";
|
|
14
16
|
import {
|
|
15
17
|
KeyframeDiamondContextMenu,
|
|
@@ -310,6 +312,10 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
|
310
312
|
if (!interactive) return;
|
|
311
313
|
if (e.button !== 0) return;
|
|
312
314
|
e.stopPropagation();
|
|
315
|
+
const live = readGsapPositionFromIframe(iframeRef.current, selectorFor(selection) ?? "");
|
|
316
|
+
const dotR = dotRadius(nodeR, ref.type === "keyframe" && ref.pct === activeKeyframePct);
|
|
317
|
+
if (pressBelongsToLayer(e, clientToComp(e), abs[index]!, dotR, live) && pressSelectedLayer(e))
|
|
318
|
+
return;
|
|
313
319
|
(e.target as Element).setPointerCapture(e.pointerId);
|
|
314
320
|
dragRef.current = {
|
|
315
321
|
index,
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { expect, it, vi } from "vitest";
|
|
3
|
+
import type { DomEditSelection } from "./domEditing";
|
|
4
|
+
import { readTranslatePx } from "./plainTranslate";
|
|
5
|
+
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
|
|
6
|
+
|
|
7
|
+
vi.mock("./plainTranslate", async (importOriginal) => ({
|
|
8
|
+
...(await importOriginal<typeof import("./plainTranslate")>()),
|
|
9
|
+
readTranslatePx: vi.fn(() => ({ x: Number.NaN, y: 0 })),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
it("a drag press the layer cannot take gets the notice, counts as handled, and leaves the box live", () => {
|
|
13
|
+
const element = document.body.appendChild(document.createElement("div"));
|
|
14
|
+
const ref = <T>(current: T) => ({ current });
|
|
15
|
+
const onBlockedMove = vi.fn();
|
|
16
|
+
const opts = {
|
|
17
|
+
selectionRef: ref({
|
|
18
|
+
element,
|
|
19
|
+
capabilities: { canApplyManualOffset: true },
|
|
20
|
+
} as unknown as DomEditSelection),
|
|
21
|
+
overlayRectRef: ref({ left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 }),
|
|
22
|
+
boxRef: ref(document.createElement("div")),
|
|
23
|
+
overlayRef: ref(null),
|
|
24
|
+
iframeRef: ref(null),
|
|
25
|
+
gestureRef: ref(null),
|
|
26
|
+
rafPausedRef: ref(false),
|
|
27
|
+
onManualDragStartRef: ref(vi.fn()),
|
|
28
|
+
onBlockedMoveRef: ref(onBlockedMove),
|
|
29
|
+
onPathOffsetCommitRef: ref(vi.fn()),
|
|
30
|
+
snapGuidesRef: ref(null),
|
|
31
|
+
groupGestureRef: ref(null),
|
|
32
|
+
blockedMoveRef: ref(null),
|
|
33
|
+
setOverlayRect: vi.fn(),
|
|
34
|
+
suppressNextBoxClickRef: ref(false),
|
|
35
|
+
hoverSelectionRef: ref(null),
|
|
36
|
+
onCanvasMouseDown: vi.fn(),
|
|
37
|
+
};
|
|
38
|
+
const press = new PointerEvent("pointerdown", { cancelable: true });
|
|
39
|
+
const e = {
|
|
40
|
+
clientX: 10,
|
|
41
|
+
clientY: 10,
|
|
42
|
+
pointerId: 1,
|
|
43
|
+
button: 0,
|
|
44
|
+
preventDefault: () => press.preventDefault(),
|
|
45
|
+
stopPropagation() {},
|
|
46
|
+
currentTarget: { setPointerCapture() {} },
|
|
47
|
+
};
|
|
48
|
+
const handlers = createDomEditOverlayGestureHandlers(opts as never);
|
|
49
|
+
expect(handlers.startGesture("drag", e as never)).toBe(false);
|
|
50
|
+
expect(onBlockedMove).toHaveBeenCalledTimes(1);
|
|
51
|
+
expect(press.defaultPrevented).toBe(true);
|
|
52
|
+
// The release lands outside the box, so nothing would ever resume it.
|
|
53
|
+
expect(opts.rafPausedRef.current).toBe(false);
|
|
54
|
+
// Playback pauses before setup reads which timelines are running.
|
|
55
|
+
const [paused] = opts.onManualDragStartRef.current.mock.invocationCallOrder;
|
|
56
|
+
expect(paused).toBeLessThan(vi.mocked(readTranslatePx).mock.invocationCallOrder[0]!);
|
|
57
|
+
});
|
|
@@ -185,7 +185,6 @@ export function startGesture(
|
|
|
185
185
|
|
|
186
186
|
if (kind === "drag") {
|
|
187
187
|
opts.onManualDragStartRef.current?.();
|
|
188
|
-
opts.rafPausedRef.current = true;
|
|
189
188
|
const result = createManualOffsetDragMember({
|
|
190
189
|
key: selectionCacheKey(sel),
|
|
191
190
|
selection: sel,
|
|
@@ -195,6 +194,7 @@ export function startGesture(
|
|
|
195
194
|
});
|
|
196
195
|
if (!result.ok) {
|
|
197
196
|
opts.onBlockedMoveRef.current(result.selection, result.reason);
|
|
197
|
+
e.preventDefault();
|
|
198
198
|
return false;
|
|
199
199
|
}
|
|
200
200
|
pathOffsetMember = result.member;
|
|
@@ -17,6 +17,16 @@ describe("buildMotionPathGeometry", () => {
|
|
|
17
17
|
]);
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
+
it("puts an axis the tween does not animate where GSAP renders it, a folded CSS translate", () => {
|
|
21
|
+
const read: ReadTween = {
|
|
22
|
+
keyframes: [
|
|
23
|
+
{ percentage: 66.667, properties: { x: 60 } },
|
|
24
|
+
{ percentage: 100, properties: { x: 120 } },
|
|
25
|
+
],
|
|
26
|
+
};
|
|
27
|
+
expect(buildMotionPathGeometry(read, { x: 40, y: 30 })!.points).toBe("60,30 120,30");
|
|
28
|
+
});
|
|
29
|
+
|
|
20
30
|
it("preserves order and percentages for intermediate keyframes", () => {
|
|
21
31
|
const read: ReadTween = { keyframes: [kf(0, 0, 0), kf(50, 50, 90), kf(100, 100, 0)] };
|
|
22
32
|
const geo = buildMotionPathGeometry(read);
|
|
@@ -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
|
);
|