@hyperframes/studio 0.8.94 → 0.8.95
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-Bkm-3G56.js → hyperframes-player-DJJKab4_.js} +1 -1
- package/dist/assets/{index-n2ZRiO44.js → index-C1dXTZvs.js} +1 -1
- package/dist/assets/{index-BupvpoiM.js → index-Cc4by8r7.js} +176 -176
- package/dist/assets/{index-BUn5H_np.js → index-DE1dn3QB.js} +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.html +1 -1
- package/dist/index.js +54 -13
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/editor/manualEditsSeekReapply.ts +5 -1
- package/src/player/components/Timeline.ghostLane.test.tsx +126 -0
- package/src/player/components/Timeline.rulerSeek.test.tsx +7 -0
- package/src/player/components/Timeline.test.ts +3 -3
- package/src/player/components/TimelineCanvas.tsx +30 -2
- package/src/player/components/TimelineProvider.tsx +1 -0
- package/src/player/components/timelineDragDrop.test.tsx +14 -12
- package/src/player/components/timelineLayout.test.ts +40 -11
- package/src/player/components/timelineLayout.ts +6 -6
- package/src/player/components/useTimelineProviderState.tsx +3 -1
- package/src/utils/gsapSoftReload.test.ts +51 -2
- package/src/utils/gsapSoftReload.ts +24 -10
- package/src/utils/gsapUndoRestore.test.ts +49 -0
- package/src/utils/gsapUndoRestore.ts +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.95",
|
|
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/core": "0.8.
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/core": "0.8.95",
|
|
64
|
+
"@hyperframes/parsers": "0.8.95",
|
|
65
|
+
"@hyperframes/sdk": "0.8.95",
|
|
66
|
+
"@hyperframes/player": "0.8.95",
|
|
67
|
+
"@hyperframes/studio-server": "0.8.95",
|
|
68
|
+
"@hyperframes/shader-transitions": "0.8.95"
|
|
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.95"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -67,7 +67,11 @@ function reapplyRotations(doc: Document): void {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
// Every mark a reapply below acts on, the legacy double-prefixed ones included.
|
|
70
|
-
const STUDIO_EDIT_ATTRS = [
|
|
70
|
+
export const STUDIO_EDIT_ATTRS = [
|
|
71
|
+
STUDIO_PATH_OFFSET_ATTR,
|
|
72
|
+
STUDIO_BOX_SIZE_ATTR,
|
|
73
|
+
STUDIO_ROTATION_ATTR,
|
|
74
|
+
]
|
|
71
75
|
.flatMap((attr) => [attr, `data-${attr}`])
|
|
72
76
|
.concat(STUDIO_MOTION_ATTR);
|
|
73
77
|
// One selector each: Chrome answers a lone attribute selector without walking the DOM, a comma list it walks.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { Timeline } from "./Timeline";
|
|
7
|
+
import { installTimelineMountEnv } from "./timelineMountTestEnv";
|
|
8
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
9
|
+
import { CLIP_Y, TRACK_H, getTimelineRowTop } from "./timelineLayout";
|
|
10
|
+
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
11
|
+
import type { TimelineProps } from "./TimelineTypes";
|
|
12
|
+
|
|
13
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
|
+
|
|
15
|
+
installTimelineMountEnv();
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
document.body.innerHTML = "";
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const GHOST_MID = getTimelineRowTop(3) + TRACK_H / 2;
|
|
22
|
+
const ROW1_MID = getTimelineRowTop(1) + TRACK_H / 2;
|
|
23
|
+
|
|
24
|
+
async function mountThreeTracks(props: TimelineProps = {}) {
|
|
25
|
+
usePlayerStore.setState({
|
|
26
|
+
duration: 10,
|
|
27
|
+
currentTime: 0,
|
|
28
|
+
timelineReady: true,
|
|
29
|
+
gsapAnimations: new Map(),
|
|
30
|
+
selectedElementId: null,
|
|
31
|
+
selectedElementIds: new Set(),
|
|
32
|
+
elements: [0, 1, 2].map((track) => ({
|
|
33
|
+
id: `c${track}`,
|
|
34
|
+
key: `c${track}`,
|
|
35
|
+
domId: `c${track}`,
|
|
36
|
+
tag: "div",
|
|
37
|
+
start: 0,
|
|
38
|
+
duration: 2,
|
|
39
|
+
track,
|
|
40
|
+
})),
|
|
41
|
+
});
|
|
42
|
+
const host = document.createElement("div");
|
|
43
|
+
document.body.append(host);
|
|
44
|
+
const root = createRoot(host);
|
|
45
|
+
await act(async () => root.render(<Timeline {...props} />));
|
|
46
|
+
const ghost = () => host.querySelector<HTMLElement>('[data-testid="timeline-ghost-lane"]');
|
|
47
|
+
return { host, root, ghost };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("Timeline ghost lane", () => {
|
|
51
|
+
it("draws one empty track below the last with a drop hint", async () => {
|
|
52
|
+
const { root, ghost } = await mountThreeTracks({ onAssetDrop: vi.fn() });
|
|
53
|
+
expect(ghost()?.textContent).toBe("Drop media here");
|
|
54
|
+
expect(ghost()?.getAttribute("aria-hidden")).toBe("true");
|
|
55
|
+
expect(ghost()?.style.top).toBe(`${getTimelineRowTop(3) + CLIP_Y}px`);
|
|
56
|
+
expect(ghost()?.dataset.active).toBeUndefined();
|
|
57
|
+
act(() => root.unmount());
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("drops the hint without a drop handler and the lane without a bottom pad", async () => {
|
|
61
|
+
const readOnly = await mountThreeTracks();
|
|
62
|
+
expect(readOnly.ghost()?.textContent).toBe("");
|
|
63
|
+
act(() => readOnly.root.unmount());
|
|
64
|
+
const unpadded = await mountThreeTracks({ trackPadding: { bottom: 0 } });
|
|
65
|
+
expect(unpadded.ghost()).toBeNull();
|
|
66
|
+
act(() => unpadded.root.unmount());
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("lights up under a file dragged into it and opens a new track on drop", async () => {
|
|
70
|
+
const onAssetDrop = vi.fn();
|
|
71
|
+
const { host, root, ghost } = await mountThreeTracks({ onAssetDrop });
|
|
72
|
+
const viewport = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
|
|
73
|
+
const drag = (type: string, clientY: number) => {
|
|
74
|
+
const event = new Event(type, { bubbles: true, cancelable: true });
|
|
75
|
+
Object.defineProperties(event, {
|
|
76
|
+
clientX: { value: 400 },
|
|
77
|
+
clientY: { value: clientY },
|
|
78
|
+
dataTransfer: {
|
|
79
|
+
value: {
|
|
80
|
+
types: [TIMELINE_ASSET_MIME],
|
|
81
|
+
files: [],
|
|
82
|
+
dropEffect: "none",
|
|
83
|
+
getData: (mime: string) => (mime === TIMELINE_ASSET_MIME ? '{"path":"a.png"}' : ""),
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
act(() => void viewport.dispatchEvent(event));
|
|
88
|
+
};
|
|
89
|
+
drag("dragover", ROW1_MID);
|
|
90
|
+
expect(ghost()?.dataset.active).toBeUndefined();
|
|
91
|
+
drag("dragover", GHOST_MID);
|
|
92
|
+
expect(ghost()?.dataset.active).toBe("true");
|
|
93
|
+
drag("drop", GHOST_MID);
|
|
94
|
+
expect(onAssetDrop).toHaveBeenCalledWith("a.png", expect.objectContaining({ track: 3 }));
|
|
95
|
+
act(() => root.unmount());
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it.each([0, 2])("lights up under clip c%i dragged into it", async (track) => {
|
|
99
|
+
const { host, root, ghost } = await mountThreeTracks({
|
|
100
|
+
onMoveElement: vi.fn(),
|
|
101
|
+
onResizeElement: vi.fn(),
|
|
102
|
+
});
|
|
103
|
+
const pointer = (target: EventTarget, type: string, clientY: number) =>
|
|
104
|
+
act(
|
|
105
|
+
() =>
|
|
106
|
+
void target.dispatchEvent(
|
|
107
|
+
new PointerEvent(type, {
|
|
108
|
+
bubbles: true,
|
|
109
|
+
button: 0,
|
|
110
|
+
pointerId: 1,
|
|
111
|
+
clientX: 400,
|
|
112
|
+
clientY,
|
|
113
|
+
}),
|
|
114
|
+
),
|
|
115
|
+
);
|
|
116
|
+
const clipMid = getTimelineRowTop(track) + TRACK_H / 2;
|
|
117
|
+
pointer(host.querySelector(`[data-clip][data-el-id="c${track}"]`)!, "pointerdown", clipMid);
|
|
118
|
+
pointer(window, "pointermove", ROW1_MID);
|
|
119
|
+
expect(ghost()?.dataset.active).toBeUndefined();
|
|
120
|
+
pointer(window, "pointermove", GHOST_MID);
|
|
121
|
+
expect(ghost()?.dataset.active).toBe("true");
|
|
122
|
+
// Dragging the last track's clip adds a preview row; the lane must stay under the pointer.
|
|
123
|
+
expect(ghost()?.style.top).toBe(`${getTimelineRowTop(3) + CLIP_Y}px`);
|
|
124
|
+
act(() => root.unmount());
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -108,4 +108,11 @@ describe("Timeline ruler origin", () => {
|
|
|
108
108
|
expect(origin).toBe("80px");
|
|
109
109
|
expect(published).toBe("80");
|
|
110
110
|
});
|
|
111
|
+
|
|
112
|
+
it("seeks on a press when the host omits sessionEpoch after a session began", async () => {
|
|
113
|
+
usePlayerStore.getState().beginTimelineSession("host-project");
|
|
114
|
+
expect(usePlayerStore.getState().timelineSessionEpoch).toBeGreaterThan(0);
|
|
115
|
+
const { time, tickTime } = await seekOnFirstTick({}, false);
|
|
116
|
+
expect(time).toBeCloseTo(tickTime, 1);
|
|
117
|
+
});
|
|
111
118
|
});
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
getTimelineDisplayContentWidth,
|
|
36
36
|
getTimelineFitPps,
|
|
37
37
|
getTimelineLaneTop,
|
|
38
|
+
getTimelineRowTop,
|
|
38
39
|
createTimelineRowGeometry,
|
|
39
40
|
} from "./timelineLayout";
|
|
40
41
|
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
|
@@ -1292,9 +1293,8 @@ describe("resolveTimelineAssetDrop", () => {
|
|
|
1292
1293
|
trackOrder: [0, 3, 7],
|
|
1293
1294
|
},
|
|
1294
1295
|
432, // rectLeft(100) + GUTTER(32) + 3s*100pps (contentOrigin = GUTTER)
|
|
1295
|
-
// clientY: rectTop(200) +
|
|
1296
|
-
|
|
1297
|
-
368,
|
|
1296
|
+
// clientY: rectTop(200) + the middle of row 1 → track 3.
|
|
1297
|
+
200 + getTimelineRowTop(1) + TRACK_H / 2,
|
|
1298
1298
|
),
|
|
1299
1299
|
).toEqual({ start: 3, track: 3 });
|
|
1300
1300
|
});
|
|
@@ -24,6 +24,13 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
24
24
|
const dropRowIndex = dropTrackIndex < 0 ? displayTrackOrder.length : dropTrackIndex;
|
|
25
25
|
const insertLineRow =
|
|
26
26
|
(draggedClip?.started ? draggedClip.insertRow : null) ?? props.dropPreview?.insertRow ?? null;
|
|
27
|
+
const ghostLaneActive =
|
|
28
|
+
(insertLineRow ?? -1) >= props.trackOrder.length ||
|
|
29
|
+
(props.dropPreview?.insertRow == null && props.dropPreview != null && dropTrackIndex < 0);
|
|
30
|
+
// A host bottom pad shorter than a track leaves no lane to draw.
|
|
31
|
+
const showGhostLane = props.rowGeometry.padding.bottom >= TRACK_H;
|
|
32
|
+
// Past the last committed track: a drag's new bottom-track preview row is the lane itself.
|
|
33
|
+
const ghostLaneRow = props.rowGeometry.getRowIndex(props.trackOrder.at(-1) ?? Number.NaN) + 1;
|
|
27
34
|
const draggedRowHeight = getTimelineRowHeight(draggedRowIndex, props.rowHeights);
|
|
28
35
|
// A clip bar in an EXPANDED row still renders at TRACK_H (the property lanes
|
|
29
36
|
// occupy the rest of the row — see TimelineLanes' clipHeight), so the drag
|
|
@@ -61,14 +68,35 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
61
68
|
onRazorSplitAll={props.onRazorSplitAll}
|
|
62
69
|
/>
|
|
63
70
|
|
|
64
|
-
{/* Breathing room below the last track lane (
|
|
71
|
+
{/* Breathing room below the last track lane (one track by default) — a real
|
|
65
72
|
scrollable surface, so a clip can be dragged into the void to create a
|
|
66
|
-
new bottom track
|
|
73
|
+
new bottom track (see TimelineRowGeometry.padding / canvasHeight). */}
|
|
67
74
|
<div
|
|
68
75
|
aria-hidden="true"
|
|
69
76
|
style={{ height: props.rowsVirtualized ? 0 : props.rowGeometry.padding.bottom }}
|
|
70
77
|
/>
|
|
71
78
|
|
|
79
|
+
{showGhostLane && (
|
|
80
|
+
<div
|
|
81
|
+
aria-hidden="true"
|
|
82
|
+
data-testid="timeline-ghost-lane"
|
|
83
|
+
data-active={ghostLaneActive || undefined}
|
|
84
|
+
className={`pointer-events-none absolute flex items-center justify-center rounded-lg border border-dashed text-[11px] transition-colors duration-150 ${
|
|
85
|
+
ghostLaneActive
|
|
86
|
+
? "border-studio-accent/60 bg-studio-accent/6 text-studio-accent"
|
|
87
|
+
: "border-neutral-700/50 text-neutral-500"
|
|
88
|
+
}`}
|
|
89
|
+
style={{
|
|
90
|
+
top: props.rowGeometry.getRowTop(ghostLaneRow) + CLIP_Y,
|
|
91
|
+
left: props.contentOrigin,
|
|
92
|
+
width: props.trackContentWidth,
|
|
93
|
+
height: TRACK_H - CLIP_Y * 2,
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
{props.acceptsMediaDrop && "Drop media here"}
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
|
|
72
100
|
{/* Gap strips — loud dashed fill for the gap(s) a hovered "Close gap(s)"
|
|
73
101
|
menu row would collapse; a quiet tint for every gap on the selected
|
|
74
102
|
clip's lane. Geometry mirrors the drop placeholder (row top + clip
|
|
@@ -51,6 +51,7 @@ export type TimelineCanvasState = Omit<
|
|
|
51
51
|
playheadRef: React.RefObject<HTMLDivElement | null>;
|
|
52
52
|
laneGapStrips: TimelineLaneGapStrips[];
|
|
53
53
|
dropPreview: TimelineDropPlacement | null;
|
|
54
|
+
acceptsMediaDrop: boolean;
|
|
54
55
|
setRangeSelection: (value: TimelineRangeSelection | null) => void;
|
|
55
56
|
setResizingClip: (value: ResizingClipState | null) => void;
|
|
56
57
|
setDraggedClip: (value: TimelineLaneBaseProps["draggedClip"]) => void;
|
|
@@ -12,6 +12,8 @@ import { configureTimelineTestViewport } from "./timelineTestViewport";
|
|
|
12
12
|
|
|
13
13
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
14
|
|
|
15
|
+
const ROW0_MID_Y = getTimelineRowTop(0) + TRACK_H / 2;
|
|
16
|
+
|
|
15
17
|
interface DropTransfer {
|
|
16
18
|
types: string[];
|
|
17
19
|
files: File[];
|
|
@@ -124,7 +126,7 @@ describe("useTimelineAssetDrop", () => {
|
|
|
124
126
|
const parent = document.createElement("div");
|
|
125
127
|
const child = document.createElement("div");
|
|
126
128
|
parent.append(child);
|
|
127
|
-
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400,
|
|
129
|
+
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, ROW0_MID_Y)));
|
|
128
130
|
act(() =>
|
|
129
131
|
view.api.handleAssetDragLeave({
|
|
130
132
|
relatedTarget: child,
|
|
@@ -160,8 +162,8 @@ describe("useTimelineAssetDrop", () => {
|
|
|
160
162
|
const transfer = assetTransfer(JSON.stringify({ path: "/media/hero.mp4" }));
|
|
161
163
|
|
|
162
164
|
act(() => {
|
|
163
|
-
view.api.handleAssetDragOver(dragEvent(transfer, 80,
|
|
164
|
-
view.api.handleAssetDrop(dragEvent(transfer, 80,
|
|
165
|
+
view.api.handleAssetDragOver(dragEvent(transfer, 80, ROW0_MID_Y));
|
|
166
|
+
view.api.handleAssetDrop(dragEvent(transfer, 80, ROW0_MID_Y));
|
|
165
167
|
});
|
|
166
168
|
|
|
167
169
|
// pps=40, clientX=80 -> 2s, far from the 50s playhead: proves start tracks
|
|
@@ -175,12 +177,12 @@ describe("useTimelineAssetDrop", () => {
|
|
|
175
177
|
const view = renderHarness(onAssetDrop, 1);
|
|
176
178
|
const transfer = assetTransfer("not-json");
|
|
177
179
|
|
|
178
|
-
act(() => view.api.handleAssetDragOver(dragEvent(transfer, 400,
|
|
180
|
+
act(() => view.api.handleAssetDragOver(dragEvent(transfer, 400, ROW0_MID_Y)));
|
|
179
181
|
expect(view.api.isDragOver).toBe(true);
|
|
180
182
|
view.rerender(2);
|
|
181
183
|
expect(view.api.isDragOver).toBe(false);
|
|
182
184
|
|
|
183
|
-
act(() => view.api.handleAssetDrop(dragEvent(transfer, 400,
|
|
185
|
+
act(() => view.api.handleAssetDrop(dragEvent(transfer, 400, ROW0_MID_Y)));
|
|
184
186
|
expect(onAssetDrop).not.toHaveBeenCalled();
|
|
185
187
|
act(() => view.root.unmount());
|
|
186
188
|
});
|
|
@@ -202,8 +204,8 @@ describe("useTimelineAssetDrop", () => {
|
|
|
202
204
|
};
|
|
203
205
|
|
|
204
206
|
act(() => {
|
|
205
|
-
view.api.handleAssetDragOver(dragEvent(transfer, 400,
|
|
206
|
-
view.api.handleAssetDrop(dragEvent(transfer, 400,
|
|
207
|
+
view.api.handleAssetDragOver(dragEvent(transfer, 400, ROW0_MID_Y));
|
|
208
|
+
view.api.handleAssetDrop(dragEvent(transfer, 400, ROW0_MID_Y));
|
|
207
209
|
});
|
|
208
210
|
|
|
209
211
|
expect(onAssetDrop).not.toHaveBeenCalled();
|
|
@@ -214,7 +216,7 @@ describe("useTimelineAssetDrop", () => {
|
|
|
214
216
|
|
|
215
217
|
it("clears an escaped drag after StrictMode effect replay", () => {
|
|
216
218
|
const view = renderHarness(vi.fn(), 1, { strict: true });
|
|
217
|
-
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400,
|
|
219
|
+
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, ROW0_MID_Y)));
|
|
218
220
|
expect(view.api.isDragOver).toBe(true);
|
|
219
221
|
|
|
220
222
|
act(() => window.dispatchEvent(new Event("dragend")));
|
|
@@ -227,11 +229,11 @@ describe("useTimelineAssetDrop", () => {
|
|
|
227
229
|
const view = renderHarness(onAssetDrop);
|
|
228
230
|
const payload = JSON.stringify({ path: "assets/a.png" });
|
|
229
231
|
|
|
230
|
-
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer(payload), 400,
|
|
232
|
+
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer(payload), 400, ROW0_MID_Y)));
|
|
231
233
|
const preview = view.api.dropPreview;
|
|
232
234
|
expect(preview).toEqual({ start: 10, track: 0 });
|
|
233
235
|
|
|
234
|
-
act(() => view.api.handleAssetDrop(dragEvent(assetTransfer(payload), 400,
|
|
236
|
+
act(() => view.api.handleAssetDrop(dragEvent(assetTransfer(payload), 400, ROW0_MID_Y)));
|
|
235
237
|
expect(onAssetDrop).toHaveBeenCalledExactlyOnceWith("assets/a.png", preview);
|
|
236
238
|
expect(view.api.dropPreview).toBeNull();
|
|
237
239
|
act(() => view.root.unmount());
|
|
@@ -239,8 +241,8 @@ describe("useTimelineAssetDrop", () => {
|
|
|
239
241
|
|
|
240
242
|
it("moves the preview with the pointer and drops it when the drag leaves", () => {
|
|
241
243
|
const view = renderHarness(vi.fn());
|
|
242
|
-
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400,
|
|
243
|
-
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 800,
|
|
244
|
+
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, ROW0_MID_Y)));
|
|
245
|
+
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 800, ROW0_MID_Y)));
|
|
244
246
|
expect(view.api.dropPreview?.start).toBe(20);
|
|
245
247
|
|
|
246
248
|
act(() =>
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
getTimelineBeatEntries,
|
|
20
20
|
} from "./timelineLayout";
|
|
21
21
|
import { generateTicks, getTimelineMajorTickInterval } from "./timelineRulerGeometry";
|
|
22
|
+
import { resolveInsertRow } from "./timelineCollision";
|
|
22
23
|
import { getTimelineRenderTimeRange } from "./timelineViewportGeometry";
|
|
23
24
|
|
|
24
25
|
describe("horizontal timeline window", () => {
|
|
@@ -124,27 +125,27 @@ describe("variable timeline row geometry", () => {
|
|
|
124
125
|
|
|
125
126
|
describe("collapsed timeline row geometry characterization", () => {
|
|
126
127
|
it.each([
|
|
127
|
-
[0,
|
|
128
|
-
[1,
|
|
129
|
-
[4,
|
|
128
|
+
[0, 32],
|
|
129
|
+
[1, 80],
|
|
130
|
+
[4, 224],
|
|
130
131
|
])("keeps row %i at content y=%i", (row, expectedTop) => {
|
|
131
132
|
expect(getTimelineRowTop(row)).toBe(expectedTop);
|
|
132
133
|
});
|
|
133
134
|
|
|
134
135
|
it.each([
|
|
135
|
-
[
|
|
136
|
-
[
|
|
137
|
-
[
|
|
138
|
-
[
|
|
136
|
+
[32, 0],
|
|
137
|
+
[44, 0.25],
|
|
138
|
+
[104, 1.5],
|
|
139
|
+
[248, 4.5],
|
|
139
140
|
])("maps content y=%i to fractional row %f", (contentY, expectedRow) => {
|
|
140
141
|
expect(getTimelineRowFromY(contentY)).toBe(expectedRow);
|
|
141
142
|
});
|
|
142
143
|
|
|
143
144
|
it.each([
|
|
144
|
-
[0,
|
|
145
|
-
[1,
|
|
146
|
-
[3,
|
|
147
|
-
[5,
|
|
145
|
+
[0, 80],
|
|
146
|
+
[1, 128],
|
|
147
|
+
[3, 224],
|
|
148
|
+
[5, 320],
|
|
148
149
|
])("keeps the %i-track canvas height at %i", (trackCount, expectedHeight) => {
|
|
149
150
|
expect(getTimelineCanvasHeight(baseRows(trackCount))).toBe(expectedHeight);
|
|
150
151
|
});
|
|
@@ -294,6 +295,34 @@ describe("host track padding", () => {
|
|
|
294
295
|
});
|
|
295
296
|
});
|
|
296
297
|
|
|
298
|
+
describe("default track pads", () => {
|
|
299
|
+
const rows = [TRACK_H, TRACK_H, TRACK_H];
|
|
300
|
+
const lastLaneBottom = RULER_H + 8 + 3 * TRACK_H;
|
|
301
|
+
|
|
302
|
+
it("sits the first row 8px under the ruler and leaves one track below the last", () => {
|
|
303
|
+
expect(getTimelineRowTop(0)).toBe(RULER_H + 8);
|
|
304
|
+
expect(getTimelineCanvasHeight(rows)).toBe(lastLaneBottom + TRACK_H);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("opens a new track from anywhere in the bottom lane", () => {
|
|
308
|
+
const drop = {
|
|
309
|
+
rectLeft: 0,
|
|
310
|
+
rectTop: 0,
|
|
311
|
+
scrollLeft: 0,
|
|
312
|
+
scrollTop: 0,
|
|
313
|
+
contentOrigin: GUTTER,
|
|
314
|
+
pixelsPerSecond: 100,
|
|
315
|
+
rowHeights: rows,
|
|
316
|
+
trackOrder: [0, 1, 2],
|
|
317
|
+
};
|
|
318
|
+
for (const y of [lastLaneBottom, getTimelineCanvasHeight(rows) - 1]) {
|
|
319
|
+
expect(resolveTimelineAssetDrop(drop, GUTTER, y).track).toBe(3);
|
|
320
|
+
expect(resolveInsertRow(getTimelineRowFromY(y, rows), rows.length)).toBe(3);
|
|
321
|
+
}
|
|
322
|
+
expect(resolveTimelineAssetDrop(drop, GUTTER, lastLaneBottom - 1).track).toBe(2);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
297
326
|
describe("getTimelineScrubTime", () => {
|
|
298
327
|
const at = (clientX: number, duration = 10) =>
|
|
299
328
|
getTimelineScrubTime({
|
|
@@ -58,13 +58,13 @@ export function getTimelineLaneTop(laneIndex: number): number {
|
|
|
58
58
|
* Default breathing room INSIDE the scroll area (CapCut-style). A host overrides
|
|
59
59
|
* it per Timeline; rows always read TimelineRowGeometry.padding, never these.
|
|
60
60
|
*
|
|
61
|
-
* - TRACKS_TOP_PAD: empty space between the (sticky) ruler and the first track
|
|
62
|
-
*
|
|
63
|
-
* - TRACKS_BOTTOM_PAD: empty
|
|
64
|
-
*
|
|
61
|
+
* - TRACKS_TOP_PAD: empty space between the (sticky) ruler and the first track,
|
|
62
|
+
* just enough that the first clip isn't jammed under the ruler.
|
|
63
|
+
* - TRACKS_BOTTOM_PAD: one empty track below the last, drawn as a ghost lane;
|
|
64
|
+
* dropping a clip or file there creates a new bottom track.
|
|
65
65
|
*/
|
|
66
|
-
export const TRACKS_TOP_PAD =
|
|
67
|
-
export const TRACKS_BOTTOM_PAD =
|
|
66
|
+
export const TRACKS_TOP_PAD = 8;
|
|
67
|
+
export const TRACKS_BOTTOM_PAD = TRACK_H;
|
|
68
68
|
export interface TimelineTrackPadding {
|
|
69
69
|
top?: number;
|
|
70
70
|
bottom?: number;
|
|
@@ -66,7 +66,7 @@ export function useTimelineProviderState({
|
|
|
66
66
|
showAudioEffects = true,
|
|
67
67
|
showKeyframes = true,
|
|
68
68
|
trackPadding,
|
|
69
|
-
sessionEpoch
|
|
69
|
+
sessionEpoch: sessionEpochProp,
|
|
70
70
|
previewIframeRef,
|
|
71
71
|
onZIndexReorder,
|
|
72
72
|
}: TimelineProps = {}): TimelineContextValue {
|
|
@@ -108,6 +108,7 @@ export function useTimelineProviderState({
|
|
|
108
108
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
109
109
|
const beatDragging = usePlayerStore((s) => s.beatDragging);
|
|
110
110
|
const timelineSessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
|
|
111
|
+
const sessionEpoch = sessionEpochProp ?? timelineSessionEpoch;
|
|
111
112
|
const setFocusedEaseSegment = usePlayerStore((s) => s.setFocusedEaseSegment);
|
|
112
113
|
const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
|
|
113
114
|
const playheadRef = useRef<HTMLDivElement>(null);
|
|
@@ -433,6 +434,7 @@ export function useTimelineProviderState({
|
|
|
433
434
|
marqueeRect,
|
|
434
435
|
laneGapStrips,
|
|
435
436
|
dropPreview: assetDrop.dropPreview,
|
|
437
|
+
acceptsMediaDrop: !!(onFileDrop || onAssetDrop),
|
|
436
438
|
theme,
|
|
437
439
|
showAudioEffects,
|
|
438
440
|
displayTrackOrder: displayLayout.displayTrackOrder,
|
|
@@ -426,8 +426,8 @@ describe("ensureMotionPathPluginLoaded", () => {
|
|
|
426
426
|
// to its AUTHORED value — from the after-write file HTML when provided, else
|
|
427
427
|
// from the parse-time stamp. Otherwise a runtime transient (the color-grading
|
|
428
428
|
// hide's 0, a mid-flight tween value) becomes a permanent tween bound.
|
|
429
|
-
describe("applySoftReload authored-
|
|
430
|
-
function buildIframeWithTarget(el:
|
|
429
|
+
describe("applySoftReload authored-style restore", () => {
|
|
430
|
+
function buildIframeWithTarget(el: Element, overrides: Record<string, unknown> = {}) {
|
|
431
431
|
const scriptEl = document.createElement("script");
|
|
432
432
|
scriptEl.textContent =
|
|
433
433
|
'const tl = gsap.timeline({ paused: true }); tl.to("#box", { opacity: 0.5 });';
|
|
@@ -532,4 +532,53 @@ describe("applySoftReload authored-opacity restore", () => {
|
|
|
532
532
|
|
|
533
533
|
expect(restoreOpacity(el)).toBe("");
|
|
534
534
|
});
|
|
535
|
+
|
|
536
|
+
it("restores the file's inline translate, rotate and scale and drops GSAP's transform", () => {
|
|
537
|
+
const el = document.createElement("div");
|
|
538
|
+
el.setAttribute("data-hf-id", "hf-1");
|
|
539
|
+
el.style.cssText =
|
|
540
|
+
"left: 10px; translate: none; rotate: none; scale: none; transform: translate(9px, 9px)";
|
|
541
|
+
const { iframe } = buildIframeWithTarget(el);
|
|
542
|
+
const authoredHtml = `<html><body><div data-hf-id="hf-1" style="translate: 60px 40px; rotate: 15deg; scale: 1.5"></div></body></html>`;
|
|
543
|
+
expect(applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml })).toBe("applied");
|
|
544
|
+
const read = (prop: string) => el.style.getPropertyValue(prop);
|
|
545
|
+
expect(["translate", "rotate", "scale", "transform"].map(read)).toEqual([
|
|
546
|
+
"60px 40px",
|
|
547
|
+
"15deg",
|
|
548
|
+
"1.5",
|
|
549
|
+
"",
|
|
550
|
+
]);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it("keeps an SVG child's authored inline transform", () => {
|
|
554
|
+
const el = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
|
555
|
+
el.setAttribute("data-hf-id", "hf-1");
|
|
556
|
+
el.setAttribute("style", "transform: translate(9px, 9px) rotate(20deg)");
|
|
557
|
+
const { iframe } = buildIframeWithTarget(el);
|
|
558
|
+
const authoredHtml = `<html><body><svg><rect data-hf-id="hf-1" style="transform: rotate(20deg)"></rect></svg></body></html>`;
|
|
559
|
+
applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml });
|
|
560
|
+
expect(el.style.transform).toBe("rotate(20deg)");
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("the finalize seek cannot paint the killed timeline over the restored transform", () => {
|
|
564
|
+
const el = document.createElement("div");
|
|
565
|
+
el.style.cssText = "translate: none; transform: translate3d(77.5px, 40px, 0px)";
|
|
566
|
+
const children = [{ targets: () => [el] }];
|
|
567
|
+
const killed = {
|
|
568
|
+
kill: vi.fn(),
|
|
569
|
+
getChildren: () => children,
|
|
570
|
+
clear: () => void children.splice(0),
|
|
571
|
+
};
|
|
572
|
+
// The runtime still seeks the timeline it captured at load until the rebind swaps it.
|
|
573
|
+
const seek = () =>
|
|
574
|
+
children.forEach(() => (el.style.transform = "translate3d(77.5px, 0px, 0px)"));
|
|
575
|
+
let transformAtRebind: string | null = null;
|
|
576
|
+
const { iframe } = buildIframeWithTarget(el, {
|
|
577
|
+
__timelines: { root: killed },
|
|
578
|
+
__player: { getTime: () => 1, seek },
|
|
579
|
+
__hfForceTimelineRebind: () => (transformAtRebind = el.style.transform),
|
|
580
|
+
});
|
|
581
|
+
applySoftReload(iframe, SCRIPT_TEXT);
|
|
582
|
+
expect(transformAtRebind).toBe("");
|
|
583
|
+
});
|
|
535
584
|
});
|
|
@@ -131,6 +131,18 @@ function verifyTimelinesPopulated(win: IframeWindow, targetKeys: string[]): bool
|
|
|
131
131
|
return Object.keys(timelines).filter((k) => k !== "__proxied").length > 0;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
// GSAP masks a folded CSS translate/rotate/scale with `none`; a fresh load has only what the file authors.
|
|
135
|
+
function restoreAuthoredTransforms(
|
|
136
|
+
style: CSSStyleDeclaration,
|
|
137
|
+
authored: CSSStyleDeclaration | null,
|
|
138
|
+
) {
|
|
139
|
+
style.transform = authored?.transform ?? "";
|
|
140
|
+
if (!authored) return;
|
|
141
|
+
for (const prop of ["translate", "rotate", "scale"]) {
|
|
142
|
+
style.setProperty(prop, authored.getPropertyValue(prop));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
134
146
|
/**
|
|
135
147
|
* Outcome of a soft-reload attempt. Callers must distinguish PERMANENT failures
|
|
136
148
|
* (the preview genuinely can't be soft-updated — escalate to a full reload) from
|
|
@@ -181,7 +193,7 @@ export interface SoftReloadOptions {
|
|
|
181
193
|
onAsyncFailure?: () => void;
|
|
182
194
|
/** Seek target for the rebuilt timeline; defaults to the iframe player time. */
|
|
183
195
|
currentTimeOverride?: number;
|
|
184
|
-
/** After-write file HTML — the primary source for authored
|
|
196
|
+
/** After-write file HTML — the primary source for the authored opacity and transform restore. */
|
|
185
197
|
authoredHtml?: string;
|
|
186
198
|
}
|
|
187
199
|
|
|
@@ -299,7 +311,7 @@ export function applySoftReload(
|
|
|
299
311
|
// parse-time stamp (data-hf-authored-opacity, installAuthoredOpacityCapture)
|
|
300
312
|
// covers elements the file lookup can't resolve. Parsed lazily, at most once.
|
|
301
313
|
let authoredDoc: Document | null | undefined;
|
|
302
|
-
const
|
|
314
|
+
const findAuthoredStyle = (el: HTMLElement): CSSStyleDeclaration | null => {
|
|
303
315
|
if (authoredDoc === undefined) {
|
|
304
316
|
try {
|
|
305
317
|
authoredDoc = authoredHtml ? parseSavedSource(authoredHtml) : null;
|
|
@@ -307,13 +319,12 @@ export function applySoftReload(
|
|
|
307
319
|
authoredDoc = null;
|
|
308
320
|
}
|
|
309
321
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
const source = findAuthoredSource(el);
|
|
314
|
-
if (source instanceof HTMLElement) return source.style.opacity;
|
|
315
|
-
return readStampedAuthoredOpacity(el);
|
|
322
|
+
const source = authoredDoc ? findAuthoredElement(authoredDoc, el) : null;
|
|
323
|
+
// The parsed file lives in this realm, so instanceof holds here, unlike for the iframe nodes below.
|
|
324
|
+
return source instanceof HTMLElement || source instanceof SVGElement ? source.style : null;
|
|
316
325
|
};
|
|
326
|
+
const readAuthoredOpacity = (el: HTMLElement): string | null =>
|
|
327
|
+
findAuthoredStyle(el)?.opacity ?? readStampedAuthoredOpacity(el);
|
|
317
328
|
|
|
318
329
|
// fallow-ignore-next-line complexity
|
|
319
330
|
const doReload = () => {
|
|
@@ -327,6 +338,7 @@ export function applySoftReload(
|
|
|
327
338
|
const tl = timelines[key] as
|
|
328
339
|
| {
|
|
329
340
|
kill?: () => void;
|
|
341
|
+
clear?: () => void;
|
|
330
342
|
getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>;
|
|
331
343
|
}
|
|
332
344
|
| undefined;
|
|
@@ -341,6 +353,8 @@ export function applySoftReload(
|
|
|
341
353
|
} catch {}
|
|
342
354
|
}
|
|
343
355
|
try {
|
|
356
|
+
// kill() keeps the children, and the finalize seek renders this timeline until the rebind swaps it.
|
|
357
|
+
tl.clear?.();
|
|
344
358
|
tl.kill?.();
|
|
345
359
|
} catch {}
|
|
346
360
|
delete timelines[key];
|
|
@@ -369,7 +383,7 @@ export function applySoftReload(
|
|
|
369
383
|
// Reset GSAP's internal transform cache so from() tweens don't read stale
|
|
370
384
|
// end values. `clearProps: "all"` is needed to flush the cache, but it also
|
|
371
385
|
// nukes the element's CSS base (position, width, height, etc.) from the
|
|
372
|
-
// HTML `style=""` attribute. Save → clear → restore →
|
|
386
|
+
// HTML `style=""` attribute. Save → clear → restore → authored transform props.
|
|
373
387
|
if (allTargets.length > 0 && win.gsap?.set) {
|
|
374
388
|
const saved: Array<[HTMLElement, string]> = [];
|
|
375
389
|
for (const el of allTargets) {
|
|
@@ -384,7 +398,7 @@ export function applySoftReload(
|
|
|
384
398
|
for (const [el, css] of saved) {
|
|
385
399
|
const s = el.style;
|
|
386
400
|
s.cssText = css;
|
|
387
|
-
s
|
|
401
|
+
restoreAuthoredTransforms(s, findAuthoredStyle(el));
|
|
388
402
|
// The restored cssText carries RUNTIME opacity, not authored opacity:
|
|
389
403
|
// a mid-flight tween's interpolated value, or the color-grading hide
|
|
390
404
|
// (`opacity: 0 !important`). The re-run script's tweens re-initialize
|