@hyperframes/studio 0.8.90 → 0.8.91
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-DDjER_ii.js → hyperframes-player-zT2Bdx_l.js} +1 -1
- package/dist/assets/{index-5SFeUXP8.js → index-DSOLcE8X.js} +1 -1
- package/dist/assets/{index-CVsCxnlz.js → index-DV-z19zB.js} +1 -1
- package/dist/assets/{index-BKE3_N4v.js → index-V-0rfhe6.js} +168 -168
- package/dist/{chunk-KU4F7QZ3.js → chunk-QPSOTK6M.js} +8 -1
- package/dist/chunk-QPSOTK6M.js.map +1 -0
- package/dist/index.d.ts +12 -3
- package/dist/index.html +1 -1
- package/dist/index.js +1298 -1003
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/components/TimelineToolbar.test.tsx +12 -0
- package/src/components/editor/MotionPathOverlay.tsx +5 -1
- package/src/components/editor/commitPreflightHandles.test.tsx +7 -20
- package/src/components/editor/elementPositionOffset.test.ts +62 -0
- package/src/components/editor/elementPositionOffset.ts +64 -0
- package/src/components/editor/motionPathCommit.test.ts +25 -5
- package/src/components/editor/motionPathSelection.ts +10 -3
- package/src/components/editor/useLayerRevealOverride.test.ts +8 -0
- package/src/components/editor/useLayerRevealOverride.ts +2 -1
- package/src/components/nle/NLEContext.tsx +1 -1
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +53 -0
- package/src/hooks/domEditPersistFailure.ts +8 -6
- package/src/hooks/elementOffsetStager.ts +65 -0
- package/src/hooks/gestureTransaction.ts +4 -2
- package/src/hooks/gsapDragCommit.ts +1 -0
- package/src/hooks/gsapEditOutcome.ts +2 -1
- package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +157 -0
- package/src/hooks/gsapRuntimeBridge.ts +50 -14
- package/src/hooks/gsapShared.test.ts +1 -1
- package/src/hooks/gsapShared.ts +16 -12
- package/src/hooks/gsapTweenReach.ts +30 -0
- package/src/hooks/timelineEditingHelpers.ts +2 -1
- package/src/hooks/useClipboard.source.test.ts +58 -0
- package/src/hooks/useClipboard.ts +12 -3
- package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditPositionPatchCommit.ts +4 -1
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomGeometryCommit.test.tsx +102 -0
- package/src/hooks/useDomGeometryCommit.ts +42 -1
- package/src/hooks/useDomGeometryCommits.test.tsx +27 -0
- package/src/hooks/useDomGeometryCommits.ts +14 -1
- package/src/hooks/useGsapAwareEditing.test.tsx +113 -5
- package/src/hooks/useGsapAwareEditing.ts +44 -7
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -1
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +8 -4
- package/src/hooks/useRazorSplit.test.ts +5 -5
- package/src/hooks/useTimelineEditing.test.tsx +384 -0
- package/src/hooks/useTimelineEditing.ts +12 -12
- package/src/hooks/useTimelineGroupEditing.ts +16 -7
- package/src/player/components/Timeline.rulerSeek.test.tsx +111 -0
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/TimelineProvider.tsx +1 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
- package/src/player/components/TimelineTypes.ts +1 -1
- package/src/player/components/timelineClipDragCommit.test.ts +18 -8
- package/src/player/components/timelineClipDragCommit.ts +5 -0
- package/src/player/components/timelineClipDragPreview.test.ts +70 -2
- package/src/player/components/timelineClipDragPreview.ts +15 -12
- package/src/player/components/timelineEditing.ts +2 -1
- package/src/player/components/timelineGroupEditing.ts +7 -2
- package/src/player/components/timelineMultiDragPreview.test.ts +10 -8
- package/src/player/components/timelineMultiDragPreview.ts +8 -5
- package/src/player/components/timelineProviderStateBuilders.test.ts +1 -0
- package/src/player/components/timelineProviderStateBuilders.ts +7 -2
- package/src/player/components/timelineZMirror.test.ts +2 -2
- package/src/player/components/timelineZMirror.ts +1 -1
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +8 -5
- package/src/player/components/useTimelineClipDrag.ts +3 -2
- package/src/player/components/useTimelinePlayhead.test.tsx +215 -26
- package/src/player/components/useTimelinePlayhead.ts +56 -27
- package/src/player/components/useTimelineProviderState.tsx +1 -0
- package/src/player/components/useTimelineZoom.ts +9 -1
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +44 -0
- package/src/player/hooks/useTimelinePlayer.ts +7 -4
- package/src/player/lib/timelineDOM.test.ts +79 -0
- package/src/player/lib/timelineDOM.ts +16 -7
- package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
- package/src/player/lib/timelineIframeHelpers.ts +12 -3
- package/src/player/store/liveTime.ts +6 -0
- package/src/player/store/playerStore.ts +2 -0
- package/src/player/store/timelineElement.ts +28 -3
- package/src/utils/razorSplitTransaction.test.ts +1 -1
- package/src/utils/razorSplitTransaction.ts +3 -3
- package/dist/chunk-KU4F7QZ3.js.map +0 -1
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
4
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
5
|
+
import { tryGsapDragIntercept } from "./gsapRuntimeBridge";
|
|
6
|
+
|
|
7
|
+
const WORDS = ["How", "we", "build", "videos", "at", "scale", "every", "day"];
|
|
8
|
+
|
|
9
|
+
function mountPhrase(): HTMLElement[] {
|
|
10
|
+
document.body.innerHTML = `<h1>${WORDS.map(
|
|
11
|
+
(word, i) => `<span class="w" data-hf-id="hf-w${i}">${word}</span>`,
|
|
12
|
+
).join("")}</h1>`;
|
|
13
|
+
return [...document.querySelectorAll<HTMLElement>(".w")];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function wordSelection(element: HTMLElement, index: number): DomEditSelection {
|
|
17
|
+
return {
|
|
18
|
+
selector: ".w",
|
|
19
|
+
selectorIndex: index,
|
|
20
|
+
hfId: element.getAttribute("data-hf-id") ?? undefined,
|
|
21
|
+
element,
|
|
22
|
+
} as unknown as DomEditSelection;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// One staggered entrance on the shared class, the way generated titles are written.
|
|
26
|
+
const staggerIn = {
|
|
27
|
+
id: ".w-from-200",
|
|
28
|
+
targetSelector: ".w",
|
|
29
|
+
method: "from",
|
|
30
|
+
properties: { y: 60, opacity: 0 },
|
|
31
|
+
position: 0.2,
|
|
32
|
+
resolvedStart: 0.2,
|
|
33
|
+
duration: 0.6,
|
|
34
|
+
stagger: 0.08,
|
|
35
|
+
} as unknown as GsapAnimation;
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
document.body.innerHTML = "";
|
|
39
|
+
vi.restoreAllMocks();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("tryGsapDragIntercept: one word of a staggered phrase", () => {
|
|
43
|
+
it("leaves the shared tween alone and hands the move to that word's own offset", async () => {
|
|
44
|
+
const words = mountPhrase();
|
|
45
|
+
const commitMutation = vi.fn();
|
|
46
|
+
const outcome = await tryGsapDragIntercept(
|
|
47
|
+
wordSelection(words[0]!, 0),
|
|
48
|
+
{ x: -40, y: 0 },
|
|
49
|
+
[staggerIn],
|
|
50
|
+
null,
|
|
51
|
+
commitMutation,
|
|
52
|
+
async () => [staggerIn],
|
|
53
|
+
);
|
|
54
|
+
expect(outcome).toEqual({ status: "element-offset" });
|
|
55
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("still edits a tween that targets only the dragged word", async () => {
|
|
59
|
+
const words = mountPhrase();
|
|
60
|
+
const own = { ...staggerIn, id: "own", targetSelector: '[data-hf-id="hf-w0"]' };
|
|
61
|
+
const commitMutation = vi.fn();
|
|
62
|
+
const outcome = await tryGsapDragIntercept(
|
|
63
|
+
wordSelection(words[0]!, 0),
|
|
64
|
+
{ x: -40, y: 0 },
|
|
65
|
+
[own as GsapAnimation],
|
|
66
|
+
null,
|
|
67
|
+
commitMutation,
|
|
68
|
+
async () => [own as GsapAnimation],
|
|
69
|
+
);
|
|
70
|
+
expect(outcome.status).toBe("persisted");
|
|
71
|
+
expect(commitMutation).toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("tryGsapDragIntercept: which tweens a word's drag may use", () => {
|
|
76
|
+
it("edits the word's own position tween, not the shared one, when it has both", async () => {
|
|
77
|
+
const words = mountPhrase();
|
|
78
|
+
const own = {
|
|
79
|
+
...staggerIn,
|
|
80
|
+
id: "own",
|
|
81
|
+
method: "set",
|
|
82
|
+
targetSelector: '[data-hf-id="hf-w0"]',
|
|
83
|
+
properties: { x: 10, y: 0 },
|
|
84
|
+
duration: 0,
|
|
85
|
+
propertyGroup: "position",
|
|
86
|
+
} as unknown as GsapAnimation;
|
|
87
|
+
const commitMutation = vi.fn();
|
|
88
|
+
const outcome = await tryGsapDragIntercept(
|
|
89
|
+
wordSelection(words[0]!, 0),
|
|
90
|
+
{ x: -40, y: 0 },
|
|
91
|
+
[staggerIn, own],
|
|
92
|
+
null,
|
|
93
|
+
commitMutation,
|
|
94
|
+
async () => [staggerIn, own],
|
|
95
|
+
);
|
|
96
|
+
expect(outcome.status).toBe("persisted");
|
|
97
|
+
const touched = commitMutation.mock.calls.map((call) => JSON.stringify(call[1]));
|
|
98
|
+
expect(touched.length).toBeGreaterThan(0);
|
|
99
|
+
expect(touched.every((m) => !m.includes(staggerIn.id))).toBe(true);
|
|
100
|
+
expect(touched.some((m) => m.includes('"own"'))).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("ignores a tween whose target never reaches the word", async () => {
|
|
104
|
+
document.body.innerHTML = `<div id="title"><span class="w">A</span><span class="w">B</span></div>
|
|
105
|
+
<div id="subtitle"><span class="w" data-hf-id="hf-s0">C</span></div>`;
|
|
106
|
+
const word = document.querySelector<HTMLElement>('[data-hf-id="hf-s0"]')!;
|
|
107
|
+
const titleOnly = { ...staggerIn, id: "title-only", targetSelector: "#title .w" };
|
|
108
|
+
const commitMutation = vi.fn();
|
|
109
|
+
const outcome = await tryGsapDragIntercept(
|
|
110
|
+
wordSelection(word, 2),
|
|
111
|
+
{ x: -40, y: 0 },
|
|
112
|
+
[titleOnly as GsapAnimation],
|
|
113
|
+
null,
|
|
114
|
+
commitMutation,
|
|
115
|
+
async () => [titleOnly as GsapAnimation],
|
|
116
|
+
);
|
|
117
|
+
expect(outcome.status).toBe("persisted");
|
|
118
|
+
const touched = commitMutation.mock.calls.map((call) => JSON.stringify(call[1]));
|
|
119
|
+
expect(touched.every((m) => !m.includes("title-only"))).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("tryGsapDragIntercept: one drag is one undo step", () => {
|
|
124
|
+
it("records the split and the move under one gesture key", async () => {
|
|
125
|
+
document.body.innerHTML = `<div id="hero"></div>`;
|
|
126
|
+
const hero = document.getElementById("hero")!;
|
|
127
|
+
const mixed = {
|
|
128
|
+
id: "#hero-fromTo-0",
|
|
129
|
+
targetSelector: "#hero",
|
|
130
|
+
method: "fromTo",
|
|
131
|
+
fromProperties: { x: 0, opacity: 0 },
|
|
132
|
+
properties: { x: 100, opacity: 1 },
|
|
133
|
+
position: 0,
|
|
134
|
+
resolvedStart: 0,
|
|
135
|
+
duration: 1,
|
|
136
|
+
} as unknown as GsapAnimation;
|
|
137
|
+
const split = [
|
|
138
|
+
{ ...mixed, id: "#hero-fromTo-0-position", propertyGroup: "position" },
|
|
139
|
+
{ ...mixed, id: "#hero-fromTo-0-opacity", propertyGroup: "opacity" },
|
|
140
|
+
] as GsapAnimation[];
|
|
141
|
+
let fetches = 0;
|
|
142
|
+
const commitMutation = vi.fn();
|
|
143
|
+
await tryGsapDragIntercept(
|
|
144
|
+
{ id: "hero", selector: "#hero", element: hero } as unknown as DomEditSelection,
|
|
145
|
+
{ x: 20, y: 0 },
|
|
146
|
+
[mixed],
|
|
147
|
+
null,
|
|
148
|
+
commitMutation,
|
|
149
|
+
async () => (fetches++ === 0 ? [mixed] : split),
|
|
150
|
+
);
|
|
151
|
+
const options = commitMutation.mock.calls.map((call) => call[2]);
|
|
152
|
+
expect(options.length).toBeGreaterThan(1);
|
|
153
|
+
expect(new Set(options.map((o) => o.coalesceKey)).size).toBe(1);
|
|
154
|
+
expect(options[0].coalesceKey).toMatch(/^gsap:drag:/);
|
|
155
|
+
expect(options.every((o) => o.coalesceMs === Number.POSITIVE_INFINITY)).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
materializeIfDynamic,
|
|
25
25
|
} from "./gsapDragCommit";
|
|
26
26
|
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
|
27
|
+
import { isGestureTransactionCommit } from "./gestureTransaction";
|
|
28
|
+
import { tweenReach, tweensForThisElement } from "./gsapTweenReach";
|
|
27
29
|
import { resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
28
30
|
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
|
|
29
31
|
import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
|
|
@@ -128,12 +130,18 @@ export async function resolveGroupTween(
|
|
|
128
130
|
|
|
129
131
|
export type { GsapDragCommitCallbacks };
|
|
130
132
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
133
|
+
const writesPosition = (a: GsapAnimation) => animationWritesAnyProperty(a, POSITION_CHANNEL_SET);
|
|
134
|
+
|
|
135
|
+
/** Only a tween shared with siblings positions this element (a stagger on `.w`). */
|
|
136
|
+
function positionedOnlyBySharedTween(selection: DomEditSelection, animations: GsapAnimation[]) {
|
|
137
|
+
const positioning = animations.filter(writesPosition);
|
|
138
|
+
return (
|
|
139
|
+
positioning.some((a) => tweenReach(a, selection.element) === "shared") &&
|
|
140
|
+
tweensForThisElement(selection, positioning).length === 0
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// fallow-ignore-next-line complexity
|
|
137
145
|
async function preflightGsapDragIntercept(
|
|
138
146
|
selection: DomEditSelection,
|
|
139
147
|
animations: GsapAnimation[],
|
|
@@ -161,9 +169,18 @@ export function dragEditOutcome(
|
|
|
161
169
|
// set as well: ignoring it would let a transient fetch failure bypass helper /
|
|
162
170
|
// runtime-source ownership and reach a destructive split or property write.
|
|
163
171
|
const allKnownAnimations = [...animations, ...fetchedAnimations];
|
|
164
|
-
const
|
|
172
|
+
const reaching = allKnownAnimations.filter(
|
|
173
|
+
(a) => tweenReach(a, selection.element) !== "elsewhere",
|
|
174
|
+
);
|
|
175
|
+
const editability = directEditOutcomeForProperties(reaching, POSITION_CHANNEL_SET);
|
|
165
176
|
if (editability.status === "blocked") return editability;
|
|
166
|
-
|
|
177
|
+
if (positionedOnlyBySharedTween(selection, allKnownAnimations)) {
|
|
178
|
+
return { status: "element-offset" };
|
|
179
|
+
}
|
|
180
|
+
const sourceAnimations = tweensForThisElement(
|
|
181
|
+
selection,
|
|
182
|
+
fetchedAnimations.length > 0 ? fetchedAnimations : animations,
|
|
183
|
+
);
|
|
167
184
|
// In a group only a tween naming this member by id or by its own selector counts,
|
|
168
185
|
// so a class tween shared with id-selected members cannot take one member's write.
|
|
169
186
|
const positionSources = group
|
|
@@ -187,13 +204,28 @@ export function dragEditOutcome(
|
|
|
187
204
|
return { status: "persisted" };
|
|
188
205
|
}
|
|
189
206
|
|
|
207
|
+
let dragGestureCounter = 0;
|
|
208
|
+
|
|
209
|
+
/** Every write one drag makes (a split, then the move) records as ONE undo step. A
|
|
210
|
+
* transaction or group commit already owns its key. */
|
|
211
|
+
function oneUndoStep(
|
|
212
|
+
commit: GsapDragCommitCallbacks["commitMutation"],
|
|
213
|
+
): GsapDragCommitCallbacks["commitMutation"] {
|
|
214
|
+
if (isGestureTransactionCommit(commit)) return commit;
|
|
215
|
+
const coalesceKey = `gsap:drag:${++dragGestureCounter}`;
|
|
216
|
+
return (selection, mutation, options) =>
|
|
217
|
+
commit(selection, mutation, { ...options, coalesceKey, coalesceMs: Number.POSITIVE_INFINITY });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Commits a drag through the GSAP script. Callers reject `blocked` (the gesture layer
|
|
221
|
+
* restores its drafts) and save `element-offset` on the element itself. */
|
|
190
222
|
export async function tryGsapDragIntercept(
|
|
191
223
|
selection: DomEditSelection,
|
|
192
224
|
offset: { x: number; y: number },
|
|
193
|
-
|
|
225
|
+
allAnimations: GsapAnimation[],
|
|
194
226
|
iframe: HTMLIFrameElement | null,
|
|
195
|
-
|
|
196
|
-
|
|
227
|
+
gestureCommit: GsapDragCommitCallbacks["commitMutation"],
|
|
228
|
+
fetchAllAnimations?: () => Promise<GsapAnimation[]>,
|
|
197
229
|
options?: {
|
|
198
230
|
altKey?: boolean;
|
|
199
231
|
preflightOnly?: boolean;
|
|
@@ -204,16 +236,20 @@ export async function tryGsapDragIntercept(
|
|
|
204
236
|
if (!options?.preflightPassed) {
|
|
205
237
|
const preflight = await preflightGsapDragIntercept(
|
|
206
238
|
selection,
|
|
207
|
-
|
|
239
|
+
allAnimations,
|
|
208
240
|
iframe,
|
|
209
|
-
|
|
241
|
+
fetchAllAnimations,
|
|
210
242
|
options?.group,
|
|
211
243
|
);
|
|
212
|
-
if (preflight.status
|
|
244
|
+
if (preflight.status !== "persisted" || options?.preflightOnly) return preflight;
|
|
213
245
|
}
|
|
246
|
+
const animations = tweensForThisElement(selection, allAnimations);
|
|
247
|
+
const fetchFallbackAnimations =
|
|
248
|
+
fetchAllAnimations && (async () => tweensForThisElement(selection, await fetchAllAnimations()));
|
|
214
249
|
const selector = selectorFromSelection(selection);
|
|
215
250
|
// The preflight above proves this; retain a defensive result for DOM churn.
|
|
216
251
|
if (!selector) return { status: "blocked", reason: "no-selector" };
|
|
252
|
+
const commitMutation = oneUndoStep(gestureCommit);
|
|
217
253
|
|
|
218
254
|
// Self-heal: enforce a single position write BEFORE committing. A corrupted
|
|
219
255
|
// file can carry 2+ conflicting position writes for one selector (e.g. a
|
|
@@ -195,7 +195,7 @@ describe("resolveClipTimingBasis", () => {
|
|
|
195
195
|
it("rebases an expanded sub-comp child by its host mount", () => {
|
|
196
196
|
// Expanded children carry host-ABSOLUTE display starts; the tweens they own are
|
|
197
197
|
// still composition-local, so the basis is the child's local start.
|
|
198
|
-
const pill = { id: "pill", domId: "pill", start: 8, duration: 4,
|
|
198
|
+
const pill = { id: "pill", domId: "pill", start: 8, duration: 4, parentCompositionStart: 6 };
|
|
199
199
|
expect(resolveClipTimingBasis("pill", "scene.html", [pill], [])).toEqual({
|
|
200
200
|
elStart: 2,
|
|
201
201
|
elDuration: 4,
|
package/src/hooks/gsapShared.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
resolveTweenStart,
|
|
11
11
|
resolveTweenDuration,
|
|
12
12
|
} from "../utils/globalTimeCompiler";
|
|
13
|
+
import { toCompositionTime } from "../player/store/timelineElement";
|
|
13
14
|
|
|
14
15
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
15
16
|
|
|
@@ -165,14 +166,16 @@ export function resolveSelectorElementIds(
|
|
|
165
166
|
}
|
|
166
167
|
|
|
167
168
|
/**
|
|
168
|
-
* The clip start in the frame the element's OWN tweens are measured in.
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
* every keyframe rebases to a percentage far outside the clip.
|
|
169
|
+
* The clip start in the frame the element's OWN tweens are measured in. A nested
|
|
170
|
+
* row sits on the master timeline at a master `start`, but its tweens are local
|
|
171
|
+
* to its composition, so the two must be brought into one frame before any clip-%
|
|
172
|
+
* math — or every keyframe rebases to a percentage far outside the clip.
|
|
173
173
|
*/
|
|
174
|
-
export function clipTimingStart(element: {
|
|
175
|
-
|
|
174
|
+
export function clipTimingStart(element: {
|
|
175
|
+
start: number;
|
|
176
|
+
parentCompositionStart?: number;
|
|
177
|
+
}): number {
|
|
178
|
+
return toCompositionTime(element, element.start);
|
|
176
179
|
}
|
|
177
180
|
|
|
178
181
|
export function selectorFromSelection(selection: DomEditSelection): string | null {
|
|
@@ -476,8 +479,8 @@ export function toAbsoluteTime(tweenPos: number, tweenDur: number, percentage: n
|
|
|
476
479
|
* timeline element's start is main-timeline absolute, so passing the raw element
|
|
477
480
|
* start subtracted two different frames from each other: a host mounted at 1.5s
|
|
478
481
|
* cached its 0s tween at -12%, and a clip-relative percentage can never be
|
|
479
|
-
* negative. The composition's mount is `
|
|
480
|
-
*
|
|
482
|
+
* negative. The composition's mount is the row's `parentCompositionStart` when
|
|
483
|
+
* set, the parent composition clip's start otherwise, and 0 for a
|
|
481
484
|
* root-composition element, whose start already IS the tween frame.
|
|
482
485
|
*/
|
|
483
486
|
export function resolveClipTimingBasis(
|
|
@@ -489,7 +492,7 @@ export function resolveClipTimingBasis(
|
|
|
489
492
|
id: string;
|
|
490
493
|
start: number;
|
|
491
494
|
duration: number;
|
|
492
|
-
|
|
495
|
+
parentCompositionStart?: number;
|
|
493
496
|
parentCompositionId?: string | null;
|
|
494
497
|
}>,
|
|
495
498
|
domClipChildren: ReadonlyArray<{ id: string; hostId: string }>,
|
|
@@ -502,8 +505,9 @@ export function resolveClipTimingBasis(
|
|
|
502
505
|
const parent = parentId
|
|
503
506
|
? elements.find((el) => el.domId === parentId || el.id === parentId)
|
|
504
507
|
: undefined;
|
|
505
|
-
|
|
506
|
-
|
|
508
|
+
if (direct.parentCompositionStart !== undefined)
|
|
509
|
+
return { elStart: clipTimingStart(direct), elDuration: direct.duration };
|
|
510
|
+
if (parent) return { elStart: direct.start - parent.start, elDuration: direct.duration };
|
|
507
511
|
// No parent composition named, so this IS a main-timeline clip and its own
|
|
508
512
|
// start is already the basis.
|
|
509
513
|
if (!parentId) return { elStart: direct.start, elDuration: direct.duration };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
3
|
+
|
|
4
|
+
type TweenReach = "own" | "shared" | "elsewhere" | "unknown";
|
|
5
|
+
|
|
6
|
+
/** Which elements a tween's target resolves to in the live preview, relative to `el`. */
|
|
7
|
+
export function tweenReach(animation: GsapAnimation, el: Element | null | undefined): TweenReach {
|
|
8
|
+
const doc = el?.ownerDocument;
|
|
9
|
+
if (!el?.isConnected || !doc || animation.hasUnresolvedSelector) return "unknown";
|
|
10
|
+
let hits: Element[];
|
|
11
|
+
try {
|
|
12
|
+
hits = [...doc.querySelectorAll(animation.targetSelector)];
|
|
13
|
+
} catch {
|
|
14
|
+
return "unknown";
|
|
15
|
+
}
|
|
16
|
+
if (!hits.includes(el)) return "elsewhere";
|
|
17
|
+
return hits.length === 1 ? "own" : "shared";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The tweens a one-element edit may rewrite: not one shared with siblings, nor one
|
|
21
|
+
* that never reaches the element. */
|
|
22
|
+
export function tweensForThisElement(
|
|
23
|
+
selection: DomEditSelection,
|
|
24
|
+
animations: GsapAnimation[],
|
|
25
|
+
): GsapAnimation[] {
|
|
26
|
+
return animations.filter((a) => {
|
|
27
|
+
const reach = tweenReach(a, selection.element);
|
|
28
|
+
return reach === "own" || reach === "unknown";
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
|
|
2
|
+
import { toAuthoredStart } from "../player/store/timelineElement";
|
|
2
3
|
import {
|
|
3
4
|
applyPatchByTarget,
|
|
4
5
|
findTagByTarget,
|
|
@@ -274,7 +275,7 @@ export function buildTimelineResizeTimingPatch(
|
|
|
274
275
|
let patched = applyPatchByTarget(original, target, {
|
|
275
276
|
type: "attribute",
|
|
276
277
|
property: "start",
|
|
277
|
-
value: formatTimelineAttributeNumber(updates.start),
|
|
278
|
+
value: formatTimelineAttributeNumber(toAuthoredStart(element, updates.start)),
|
|
278
279
|
});
|
|
279
280
|
patched = applyPatchByTarget(patched, target, {
|
|
280
281
|
type: "attribute",
|
|
@@ -336,6 +336,64 @@ describe("copy order", () => {
|
|
|
336
336
|
});
|
|
337
337
|
});
|
|
338
338
|
|
|
339
|
+
describe("duplicate of a clip inside a sub-composition", () => {
|
|
340
|
+
it("writes the copy at local time right after the original", async () => {
|
|
341
|
+
// Host at 2 s, so the clip's local 1-3 s shows as a 3-5 s master row.
|
|
342
|
+
const sub: TimelineElement = {
|
|
343
|
+
id: SUB_HF_ID,
|
|
344
|
+
hfId: SUB_HF_ID,
|
|
345
|
+
tag: "h2",
|
|
346
|
+
start: 3,
|
|
347
|
+
duration: 2,
|
|
348
|
+
track: 0,
|
|
349
|
+
authoredTrack: 0,
|
|
350
|
+
sourceFile: "compositions/sub.html",
|
|
351
|
+
parentCompositionStart: 2,
|
|
352
|
+
};
|
|
353
|
+
usePlayerStore.setState({
|
|
354
|
+
elements: [TITLE, sub],
|
|
355
|
+
selectedElementId: sub.id,
|
|
356
|
+
selectedElementIds: new Set([sub.id]),
|
|
357
|
+
});
|
|
358
|
+
const { clipboard, writes } = mountClipboard(null, SUB, (host) =>
|
|
359
|
+
host.setAttribute("data-start", "2"),
|
|
360
|
+
);
|
|
361
|
+
await clipboard().handleDuplicate();
|
|
362
|
+
const starts = [...(writes[0] ?? "").matchAll(/<h2[^>]*data-start="([^"]+)"/g)].map(
|
|
363
|
+
(m) => m[1],
|
|
364
|
+
);
|
|
365
|
+
expect(starts.sort()).toEqual(["1", "3"]);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("moves the copy off a lane its own file already fills right after the original", async () => {
|
|
369
|
+
// Host at 10 s: the clip is local 1-3 (master 11-13); its neighbour is local 3-5,
|
|
370
|
+
// exactly where the copy lands, so the lane check must compare in local time.
|
|
371
|
+
const sub: TimelineElement = {
|
|
372
|
+
id: SUB_HF_ID,
|
|
373
|
+
hfId: SUB_HF_ID,
|
|
374
|
+
tag: "h2",
|
|
375
|
+
start: 11,
|
|
376
|
+
duration: 2,
|
|
377
|
+
track: 0,
|
|
378
|
+
authoredTrack: 0,
|
|
379
|
+
sourceFile: "compositions/sub.html",
|
|
380
|
+
parentCompositionStart: 10,
|
|
381
|
+
};
|
|
382
|
+
const neighbour: TimelineElement = { ...sub, id: "next", hfId: "next", start: 13 };
|
|
383
|
+
usePlayerStore.setState({
|
|
384
|
+
elements: [TITLE, sub, neighbour],
|
|
385
|
+
selectedElementId: sub.id,
|
|
386
|
+
selectedElementIds: new Set([sub.id]),
|
|
387
|
+
});
|
|
388
|
+
const { clipboard, writes } = mountClipboard(null, SUB, (host) =>
|
|
389
|
+
host.setAttribute("data-start", "10"),
|
|
390
|
+
);
|
|
391
|
+
await clipboard().handleDuplicate();
|
|
392
|
+
const copy = /<h2[^>]*data-start="3"[^>]*>/.exec(writes[0] ?? "")?.[0] ?? "";
|
|
393
|
+
expect(copy).toContain('data-track-index="1"');
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
339
397
|
describe("a copy that fails", () => {
|
|
340
398
|
it("leaves the previous copy on the clipboard", async () => {
|
|
341
399
|
selectTitle();
|
|
@@ -2,6 +2,7 @@ import { useCallback, useRef } from "react";
|
|
|
2
2
|
import { EXCLUDED_TAGS, mintHfId, walkCompositionDescendants } from "@hyperframes/parsers/hf-ids";
|
|
3
3
|
import type { TimelineElement } from "../player";
|
|
4
4
|
import { usePlayerStore } from "../player";
|
|
5
|
+
import { toAuthoredStart } from "../player/store/timelineElement";
|
|
5
6
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
6
7
|
import {
|
|
7
8
|
type ClipboardPayload,
|
|
@@ -416,14 +417,22 @@ export function useClipboard({
|
|
|
416
417
|
if (!pid) return false;
|
|
417
418
|
|
|
418
419
|
const { elements } = targets;
|
|
419
|
-
const
|
|
420
|
-
const
|
|
420
|
+
const pathOf = (el: TimelineElement) => el.sourceFile || activeCompPath || "index.html";
|
|
421
|
+
const targetPath = pathOf(elements[0]!);
|
|
422
|
+
// The copy lands in targetPath's own clock, so anchor and lane check are local to it.
|
|
423
|
+
const anchorTime = toAuthoredStart(
|
|
424
|
+
elements[0]!,
|
|
425
|
+
Math.max(...elements.map((el) => el.start + el.duration)),
|
|
426
|
+
);
|
|
421
427
|
|
|
422
428
|
try {
|
|
423
429
|
const clips = await readClips(targets);
|
|
424
430
|
let ids: string[] = [];
|
|
425
431
|
const duplicate = (originalContent: string) => {
|
|
426
|
-
const liveElements = usePlayerStore
|
|
432
|
+
const liveElements = usePlayerStore
|
|
433
|
+
.getState()
|
|
434
|
+
.elements.filter((el) => pathOf(el) === targetPath)
|
|
435
|
+
.map((el) => ({ ...el, start: toAuthoredStart(el, el.start) }));
|
|
427
436
|
const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements);
|
|
428
437
|
ids = pasted.ids;
|
|
429
438
|
return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
|
|
@@ -51,8 +51,8 @@ const MANUAL_FLAGS = [
|
|
|
51
51
|
/** Null when the commit would go through; "" while the check is still running. */
|
|
52
52
|
function refusal(preflight: CommitPreflight | null, check: keyof CommitPreflight): string | null {
|
|
53
53
|
const outcome = preflight?.[check];
|
|
54
|
-
if (outcome
|
|
55
|
-
return outcome ? GSAP_EDIT_BLOCK_COPY[outcome.reason] :
|
|
54
|
+
if (!outcome) return "";
|
|
55
|
+
return outcome.status === "blocked" ? GSAP_EDIT_BLOCK_COPY[outcome.reason] : null;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/** Closes each manual flag whose commit Studio would refuse, and says why. */
|
|
@@ -269,6 +269,7 @@ export function useDomEditCommits({
|
|
|
269
269
|
// ── Geometry commits (path offset, box size, rotation) ──
|
|
270
270
|
|
|
271
271
|
const {
|
|
272
|
+
stageElementPositionOffset,
|
|
272
273
|
handleDomPathOffsetCommit,
|
|
273
274
|
handleDomBoxSizeCommit,
|
|
274
275
|
handleDomRotationCommit,
|
|
@@ -312,6 +313,7 @@ export function useDomEditCommits({
|
|
|
312
313
|
handleDomTextFieldStyleCommit,
|
|
313
314
|
handleDomAddTextField,
|
|
314
315
|
handleDomRemoveTextField,
|
|
316
|
+
stageElementPositionOffset,
|
|
315
317
|
handleDomPathOffsetCommit,
|
|
316
318
|
handleDomBoxSizeCommit,
|
|
317
319
|
handleDomRotationCommit,
|
|
@@ -4,6 +4,7 @@ import type { PatchOperation } from "../utils/sourcePatcher";
|
|
|
4
4
|
import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
|
|
5
5
|
import { DomEditSaveQueueOpenError } from "../utils/domEditSaveQueue";
|
|
6
6
|
import type { PersistDomEditOperations } from "./domEditCommitTypes";
|
|
7
|
+
import { wasAlreadyToasted } from "./domEditPersistFailure";
|
|
7
8
|
|
|
8
9
|
interface UseDomEditPositionPatchCommitParams {
|
|
9
10
|
activeCompPath: string | null;
|
|
@@ -39,7 +40,9 @@ export function useDomEditPositionPatchCommit({
|
|
|
39
40
|
// resolved the commit, which skipped the caller's revert, so the element
|
|
40
41
|
// stayed where the drag put it while nothing reached the file.
|
|
41
42
|
if (error instanceof DomEditSaveQueueOpenError) throw error;
|
|
42
|
-
|
|
43
|
+
if (!wasAlreadyToasted(error)) {
|
|
44
|
+
showToast(error instanceof Error ? error.message : "Failed to save position");
|
|
45
|
+
}
|
|
43
46
|
trackStudioSaveFailure({
|
|
44
47
|
source: "dom_edit",
|
|
45
48
|
error,
|
|
@@ -229,6 +229,7 @@ export function useDomEditSession({
|
|
|
229
229
|
handleDomTextFieldStyleCommit,
|
|
230
230
|
handleDomAddTextField,
|
|
231
231
|
handleDomRemoveTextField,
|
|
232
|
+
stageElementPositionOffset,
|
|
232
233
|
handleDomBoxSizeCommit,
|
|
233
234
|
handleDomManualEditsReset,
|
|
234
235
|
handleDomEditElementsDelete,
|
|
@@ -487,6 +488,7 @@ export function useDomEditSession({
|
|
|
487
488
|
bumpGsapCache,
|
|
488
489
|
makeFetchFallback,
|
|
489
490
|
trackGsapInteractionFailure,
|
|
491
|
+
stageElementPositionOffset,
|
|
490
492
|
handleDomBoxSizeCommit,
|
|
491
493
|
addGsapAnimation,
|
|
492
494
|
convertToKeyframes,
|
|
@@ -224,3 +224,105 @@ describe("useDomGeometryCommit, from the package entry", () => {
|
|
|
224
224
|
unmount();
|
|
225
225
|
});
|
|
226
226
|
});
|
|
227
|
+
|
|
228
|
+
describe("useDomGeometryCommit, one word of a staggered phrase", () => {
|
|
229
|
+
const WORDS = ["How", "we", "build", "videos", "at", "scale", "every", "day"];
|
|
230
|
+
const PHRASE = WORDS.map((w, i) => `<span class="w" data-hf-id="hf-w${i}">${w}</span>`).join("");
|
|
231
|
+
|
|
232
|
+
function stubPhraseServer(patchStatuses: number[] = []) {
|
|
233
|
+
const calls = { patches: [] as unknown[], gsapMutations: [] as unknown[] };
|
|
234
|
+
vi.stubGlobal(
|
|
235
|
+
"fetch",
|
|
236
|
+
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
237
|
+
const url = String(input instanceof Request ? input.url : input);
|
|
238
|
+
const json = (body: unknown) =>
|
|
239
|
+
new Response(JSON.stringify(body), { headers: { "content-type": "application/json" } });
|
|
240
|
+
if (url.includes("/api/projects/p1/gsap-animations/")) {
|
|
241
|
+
return json({
|
|
242
|
+
animations: [
|
|
243
|
+
{
|
|
244
|
+
id: ".w-from-200",
|
|
245
|
+
targetSelector: ".w",
|
|
246
|
+
method: "from",
|
|
247
|
+
position: 0.2,
|
|
248
|
+
duration: 0.6,
|
|
249
|
+
properties: { y: 60, opacity: 0 },
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
if (url.includes("/api/projects/p1/files/")) return json({ content: PHRASE });
|
|
255
|
+
if (url.includes("/api/projects/p1/file-mutations/patch-element/")) {
|
|
256
|
+
calls.patches.push(JSON.parse(String(init?.body)));
|
|
257
|
+
const status = patchStatuses.shift();
|
|
258
|
+
if (status) {
|
|
259
|
+
return new Response(JSON.stringify({ error: "the file changed on disk" }), {
|
|
260
|
+
status,
|
|
261
|
+
headers: { "content-type": "application/json" },
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return json({ ok: true, changed: true, matched: true, content: "AFTER", version: "v2" });
|
|
265
|
+
}
|
|
266
|
+
if (url.includes("/api/projects/p1/gsap-mutations/")) {
|
|
267
|
+
calls.gsapMutations.push(JSON.parse(String(init?.body)));
|
|
268
|
+
return json({ ok: true, changed: true, before: "B", after: "A" });
|
|
269
|
+
}
|
|
270
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
271
|
+
}),
|
|
272
|
+
);
|
|
273
|
+
return calls;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function mountWord(element: HTMLElement) {
|
|
277
|
+
element.ownerDocument.body.innerHTML = PHRASE;
|
|
278
|
+
const word = element.ownerDocument.querySelector<HTMLElement>('[data-hf-id="hf-w0"]')!;
|
|
279
|
+
Object.defineProperties(word, {
|
|
280
|
+
offsetLeft: { get: () => 100 + (Number.parseFloat(word.style.left) || 0) },
|
|
281
|
+
offsetTop: { get: () => 200 + (Number.parseFloat(word.style.top) || 0) },
|
|
282
|
+
});
|
|
283
|
+
return { ...makeSelection("How", word), id: undefined, selector: ".w", hfId: "hf-w0" };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
it("saves left/top on the dragged word only and never rewrites the shared tween", async () => {
|
|
287
|
+
const calls = stubPhraseServer();
|
|
288
|
+
const { element, recordEdit, hook, unmount } = renderHost();
|
|
289
|
+
const selection = mountWord(element);
|
|
290
|
+
|
|
291
|
+
await expect(hook().commitPathOffset(selection, { x: 40, y: 20 })).resolves.toEqual({
|
|
292
|
+
ok: true,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
expect(calls.gsapMutations).toHaveLength(0);
|
|
296
|
+
expect(calls.patches).toEqual([
|
|
297
|
+
expect.objectContaining({
|
|
298
|
+
target: expect.objectContaining({ hfId: "hf-w0" }),
|
|
299
|
+
operations: [
|
|
300
|
+
{ type: "inline-style", property: "position", value: "relative" },
|
|
301
|
+
{ type: "inline-style", property: "left", value: "40px" },
|
|
302
|
+
{ type: "inline-style", property: "top", value: "20px" },
|
|
303
|
+
],
|
|
304
|
+
}),
|
|
305
|
+
]);
|
|
306
|
+
expect(recordEdit).toHaveBeenCalledTimes(1);
|
|
307
|
+
unmount();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("saves a later move after a conflict refused the first, and says what happened", async () => {
|
|
311
|
+
const calls = stubPhraseServer([409]);
|
|
312
|
+
const showToast = vi.fn();
|
|
313
|
+
const { element, recordEdit, hook, unmount } = renderHost({ showToast });
|
|
314
|
+
const selection = mountWord(element);
|
|
315
|
+
|
|
316
|
+
await expect(hook().commitPathOffset(selection, { x: 40, y: 20 })).rejects.toThrow();
|
|
317
|
+
expect(showToast.mock.calls.map((call) => call[0])).toEqual([
|
|
318
|
+
"Couldn't save edit: the file changed on disk",
|
|
319
|
+
]);
|
|
320
|
+
await expect(hook().commitPathOffset(selection, { x: 40, y: 20 })).resolves.toEqual({
|
|
321
|
+
ok: true,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
expect(calls.patches).toHaveLength(2);
|
|
325
|
+
expect(recordEdit).toHaveBeenCalledTimes(1);
|
|
326
|
+
unmount();
|
|
327
|
+
});
|
|
328
|
+
});
|