@hyperframes/studio 0.8.89 → 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-CVoS7kv8.js → hyperframes-player-zT2Bdx_l.js} +1 -1
- package/dist/assets/{index-D35-L_yn.js → index-DSOLcE8X.js} +1 -1
- package/dist/assets/{index-DBub9qQY.js → index-DV-z19zB.js} +1 -1
- package/dist/assets/{index-BM8PwYby.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 +1349 -1066
- 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/useInlineTextEditing.tsx +2 -4
- 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/sceneSwap.ts +21 -0
- 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/gsapUndoRestore.test.ts +58 -0
- package/src/utils/gsapUndoRestore.ts +6 -1
- package/src/utils/razorSplitTransaction.test.ts +1 -1
- package/src/utils/razorSplitTransaction.ts +3 -3
- package/src/utils/studioPreviewHelpers.test.ts +29 -0
- package/src/utils/studioPreviewHelpers.ts +37 -80
- package/dist/chunk-KU4F7QZ3.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.91",
|
|
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/parsers": "0.8.
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/shader-transitions": "0.8.
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/parsers": "0.8.91",
|
|
64
|
+
"@hyperframes/player": "0.8.91",
|
|
65
|
+
"@hyperframes/shader-transitions": "0.8.91",
|
|
66
|
+
"@hyperframes/sdk": "0.8.91",
|
|
67
|
+
"@hyperframes/studio-server": "0.8.91",
|
|
68
|
+
"@hyperframes/core": "0.8.91"
|
|
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.91"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -226,6 +226,18 @@ describe("TimelineToolbar Fit", () => {
|
|
|
226
226
|
expect(fit()?.getAttribute("aria-pressed")).toBe("false");
|
|
227
227
|
act(() => root.unmount());
|
|
228
228
|
});
|
|
229
|
+
|
|
230
|
+
it("counts a zoom-in click as a person's zoom, so the timeline anchors it on the playhead", () => {
|
|
231
|
+
const { host, root } = renderToolbar();
|
|
232
|
+
const before = usePlayerStore.getState().userZoomCount;
|
|
233
|
+
act(() =>
|
|
234
|
+
host
|
|
235
|
+
.querySelector('button[aria-label="Zoom in"]')
|
|
236
|
+
?.dispatchEvent(new MouseEvent("click", { bubbles: true })),
|
|
237
|
+
);
|
|
238
|
+
expect(usePlayerStore.getState().userZoomCount).toBe(before + 1);
|
|
239
|
+
act(() => root.unmount());
|
|
240
|
+
});
|
|
229
241
|
});
|
|
230
242
|
|
|
231
243
|
describe("TimelineToolbar audio meters", () => {
|
|
@@ -127,7 +127,11 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
|
127
127
|
// just in render, after the early returns) so the park-timer cleanup can key on
|
|
128
128
|
// it: a pending park seek belongs to the OLD animation, so firing it after the
|
|
129
129
|
// active animation changed would jump the playhead onto a stale keyframe.
|
|
130
|
-
const animId = editableAnimationId(
|
|
130
|
+
const animId = editableAnimationId(
|
|
131
|
+
selectedGsapAnimations ?? [],
|
|
132
|
+
geometry?.kind ?? "linear",
|
|
133
|
+
selection,
|
|
134
|
+
);
|
|
131
135
|
// Clear the debounced park timer on unmount AND whenever the active animation id
|
|
132
136
|
// changes — not unmount-only, or a queued seek from the previous selection still
|
|
133
137
|
// fires against the new one.
|
|
@@ -377,45 +377,32 @@ describe("handles follow what Studio would commit", () => {
|
|
|
377
377
|
expect(flags(seen.selection)[0]).toBe(true);
|
|
378
378
|
});
|
|
379
379
|
|
|
380
|
-
|
|
380
|
+
// The per-element left/top channel supersedes the old refusal: each member saves its own move.
|
|
381
|
+
it("lets a group move carried by one shared tween through as each member's own offset", async () => {
|
|
381
382
|
const dots = [element("dot-1", "dot"), element("dot-2", "dot")];
|
|
382
383
|
const preview = livePreview(dots, { y: 40 });
|
|
383
384
|
const stagger = tween({ y: 40 }, { targetSelector: ".dot", method: "from" } as never);
|
|
384
385
|
const [a, b] = dots.map(resolved);
|
|
385
|
-
const { seen, overlay, spies
|
|
386
|
+
const { seen, overlay, spies } = await select([stagger], {
|
|
386
387
|
selection: a!,
|
|
387
388
|
groups: [a!, b!],
|
|
388
389
|
preview,
|
|
389
390
|
});
|
|
390
391
|
|
|
391
|
-
expect(seen.groups.map((s) => flags(s)[0])).toEqual([
|
|
392
|
-
expect(seen.groups[0]?.capabilities.reasonIfDisabled).toBe(
|
|
393
|
-
GSAP_EDIT_BLOCK_COPY["source-uneditable"],
|
|
394
|
-
);
|
|
392
|
+
expect(seen.groups.map((s) => flags(s)[0])).toEqual([true, true]);
|
|
395
393
|
fire(overlay().querySelector(BOX)!, "pointerdown", { clientX: 150, clientY: 150 });
|
|
396
|
-
expect(spies.onBlockedMove).
|
|
397
|
-
const commitList = getAnimationsForElement(
|
|
398
|
-
[stagger],
|
|
399
|
-
{ id: "dot-1", selector: "#dot-1" },
|
|
400
|
-
dots[0],
|
|
401
|
-
);
|
|
394
|
+
expect(spies.onBlockedMove).not.toHaveBeenCalled();
|
|
402
395
|
const groupPreflight = { preflightOnly: true, group: true };
|
|
403
396
|
const commit = tryGsapDragIntercept(
|
|
404
397
|
a!,
|
|
405
398
|
{ x: 0, y: 0 },
|
|
406
|
-
|
|
399
|
+
[stagger],
|
|
407
400
|
preview,
|
|
408
401
|
vi.fn(),
|
|
409
402
|
undefined,
|
|
410
403
|
groupPreflight,
|
|
411
404
|
);
|
|
412
|
-
expect(await commit).
|
|
413
|
-
status: "blocked",
|
|
414
|
-
detail: "live-position-no-source-tween",
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
render({ selection: a!, preview });
|
|
418
|
-
expect(flags(seen.selection)[0]).toBe(true);
|
|
405
|
+
expect(await commit).toEqual({ status: "element-offset" });
|
|
419
406
|
});
|
|
420
407
|
|
|
421
408
|
it("toasts once on the primary press of a blocked element, before any travel", async () => {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { applyElementPositionOffset } from "./elementPositionOffset";
|
|
4
|
+
|
|
5
|
+
// jsdom has no layout: offsets follow left/top unless the box is anchored elsewhere.
|
|
6
|
+
function word(style: string, anchoredRight = false): HTMLElement {
|
|
7
|
+
const el = document.createElement("span");
|
|
8
|
+
el.setAttribute("style", style);
|
|
9
|
+
const px = (v: string) => Number.parseFloat(v) || 0;
|
|
10
|
+
Object.defineProperties(el, {
|
|
11
|
+
offsetLeft: { get: () => (anchoredRight ? 500 : 100 + px(el.style.left)) },
|
|
12
|
+
offsetTop: { get: () => 200 + px(el.style.top) },
|
|
13
|
+
offsetWidth: { get: () => 80 },
|
|
14
|
+
offsetHeight: { get: () => 40 },
|
|
15
|
+
});
|
|
16
|
+
el.setAttribute("data-hf-drag-initial-offset-x", "0");
|
|
17
|
+
el.setAttribute("data-hf-drag-initial-offset-y", "0");
|
|
18
|
+
document.body.append(el);
|
|
19
|
+
return el;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("applyElementPositionOffset", () => {
|
|
23
|
+
it("persists position for a static word the Layers panel lifted to relative", () => {
|
|
24
|
+
const el = word("position: relative");
|
|
25
|
+
el.setAttribute("data-hf-reveal-prior-pos", "static");
|
|
26
|
+
expect(applyElementPositionOffset(el, { x: 40, y: 20 })).toEqual([
|
|
27
|
+
{ type: "inline-style", property: "position", value: "relative" },
|
|
28
|
+
{ type: "inline-style", property: "left", value: "40px" },
|
|
29
|
+
{ type: "inline-style", property: "top", value: "20px" },
|
|
30
|
+
]);
|
|
31
|
+
expect(el.hasAttribute("data-hf-reveal-prior-pos")).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("names percent positioning as the reason it refuses", () => {
|
|
35
|
+
const el = word("position: relative; left: 10%; top: 0px");
|
|
36
|
+
expect(applyElementPositionOffset(el, { x: 40, y: 20 })).toBe("percent");
|
|
37
|
+
expect(el.style.left).toBe("10%");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("moves a static element by relative left/top", () => {
|
|
41
|
+
const el = word("");
|
|
42
|
+
expect(applyElementPositionOffset(el, { x: 40, y: 20 })).toEqual([
|
|
43
|
+
{ type: "inline-style", property: "position", value: "relative" },
|
|
44
|
+
{ type: "inline-style", property: "left", value: "40px" },
|
|
45
|
+
{ type: "inline-style", property: "top", value: "20px" },
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("adds to left/top the element already has, so a second drag accumulates", () => {
|
|
50
|
+
const el = word("position: relative; left: 40px; top: 20px");
|
|
51
|
+
expect(applyElementPositionOffset(el, { x: 40, y: 20 })).toEqual([
|
|
52
|
+
{ type: "inline-style", property: "left", value: "80px" },
|
|
53
|
+
{ type: "inline-style", property: "top", value: "40px" },
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("refuses, leaving the element untouched, when left does not move the box", () => {
|
|
58
|
+
const el = word("position: absolute; right: 30px", true);
|
|
59
|
+
expect(applyElementPositionOffset(el, { x: 40, y: 20 })).toBe("anchored");
|
|
60
|
+
expect([el.style.left, el.style.top, el.style.right]).toEqual(["", "", "30px"]);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { PatchOperation } from "../../utils/sourcePatcher";
|
|
2
|
+
import { LAYER_REVEAL_PRIOR_POSITION_ATTR } from "../../player/lib/timelineElementHelpers";
|
|
3
|
+
|
|
4
|
+
interface LayoutBox {
|
|
5
|
+
left: number;
|
|
6
|
+
top: number;
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const layoutBox = (el: HTMLElement): LayoutBox => ({
|
|
12
|
+
left: el.offsetLeft,
|
|
13
|
+
top: el.offsetTop,
|
|
14
|
+
width: el.offsetWidth,
|
|
15
|
+
height: el.offsetHeight,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function readDragStart(el: HTMLElement, axis: "x" | "y"): number {
|
|
19
|
+
const value = Number.parseFloat(el.getAttribute(`data-hf-drag-initial-offset-${axis}`) ?? "");
|
|
20
|
+
return Number.isFinite(value) ? value : 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ElementOffsetRefusal = "percent" | "anchored";
|
|
24
|
+
|
|
25
|
+
/** Moves one element by adding to its own `left`/`top` (GSAP never parses them), or
|
|
26
|
+
* names why not, leaving the element untouched. */
|
|
27
|
+
export function applyElementPositionOffset(
|
|
28
|
+
el: HTMLElement,
|
|
29
|
+
gestureOffset: { x: number; y: number },
|
|
30
|
+
): PatchOperation[] | ElementOffsetRefusal {
|
|
31
|
+
const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
|
|
32
|
+
if (!cs) return "anchored";
|
|
33
|
+
const dx = Math.round(gestureOffset.x - readDragStart(el, "x"));
|
|
34
|
+
const dy = Math.round(gestureOffset.y - readDragStart(el, "y"));
|
|
35
|
+
// A Layers-panel pick lifts a static element to relative for display only.
|
|
36
|
+
const lifted = el.getAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR) === "static";
|
|
37
|
+
const isStatic = lifted || !/^(relative|absolute|fixed|sticky)$/.test(cs.position);
|
|
38
|
+
if (!isStatic && (cs.left.endsWith("%") || cs.top.endsWith("%"))) return "percent";
|
|
39
|
+
const baseLeft = isStatic ? 0 : Number.parseFloat(cs.left) || 0;
|
|
40
|
+
const baseTop = isStatic ? 0 : Number.parseFloat(cs.top) || 0;
|
|
41
|
+
const previous = { position: el.style.position, left: el.style.left, top: el.style.top };
|
|
42
|
+
const before = layoutBox(el);
|
|
43
|
+
if (isStatic) el.style.position = "relative";
|
|
44
|
+
el.style.left = `${Math.round(baseLeft) + dx}px`;
|
|
45
|
+
el.style.top = `${Math.round(baseTop) + dy}px`;
|
|
46
|
+
const after = layoutBox(el);
|
|
47
|
+
const shiftedExactly =
|
|
48
|
+
Math.abs(after.left - before.left - dx) <= 1 &&
|
|
49
|
+
Math.abs(after.top - before.top - dy) <= 1 &&
|
|
50
|
+
Math.abs(after.width - before.width) <= 1 &&
|
|
51
|
+
Math.abs(after.height - before.height) <= 1;
|
|
52
|
+
if (!shiftedExactly) {
|
|
53
|
+
Object.assign(el.style, previous);
|
|
54
|
+
return "anchored";
|
|
55
|
+
}
|
|
56
|
+
if (lifted) el.removeAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR);
|
|
57
|
+
return [
|
|
58
|
+
...(isStatic
|
|
59
|
+
? [{ type: "inline-style", property: "position", value: "relative" } as const]
|
|
60
|
+
: []),
|
|
61
|
+
{ type: "inline-style", property: "left", value: el.style.left },
|
|
62
|
+
{ type: "inline-style", property: "top", value: el.style.top },
|
|
63
|
+
];
|
|
64
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
1
2
|
import { describe, it, expect, vi } from "vitest";
|
|
2
3
|
import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
|
|
3
4
|
import { editableAnimationId } from "./motionPathSelection";
|
|
@@ -19,10 +20,13 @@ const anim = (over: Partial<GsapAnimation>): GsapAnimation =>
|
|
|
19
20
|
...over,
|
|
20
21
|
}) as GsapAnimation;
|
|
21
22
|
|
|
23
|
+
// The element the default `#el` tweens target.
|
|
24
|
+
const elSel = { id: "el" } as never;
|
|
25
|
+
|
|
22
26
|
describe("editableAnimationId", () => {
|
|
23
27
|
it("picks the arc animation for an arc path", () => {
|
|
24
28
|
const arc = anim({ id: "arc1", arcPath: { enabled: true, autoRotate: false, segments: [] } });
|
|
25
|
-
expect(editableAnimationId([anim({ id: "other" }), arc], "arc")).toBe("arc1");
|
|
29
|
+
expect(editableAnimationId([anim({ id: "other" }), arc], "arc", elSel)).toBe("arc1");
|
|
26
30
|
});
|
|
27
31
|
|
|
28
32
|
it("picks a position-keyframe animation for a linear path", () => {
|
|
@@ -34,7 +38,7 @@ describe("editableAnimationId", () => {
|
|
|
34
38
|
keyframes: [{ percentage: 0, properties: { x: 0, y: 0 } }],
|
|
35
39
|
} as never,
|
|
36
40
|
});
|
|
37
|
-
expect(editableAnimationId([kf], "linear")).toBe("kf1");
|
|
41
|
+
expect(editableAnimationId([kf], "linear", elSel)).toBe("kf1");
|
|
38
42
|
});
|
|
39
43
|
|
|
40
44
|
it("returns null for dynamic (unresolved) tweens — read-only", () => {
|
|
@@ -43,7 +47,7 @@ describe("editableAnimationId", () => {
|
|
|
43
47
|
arcPath: { enabled: true, autoRotate: false, segments: [] },
|
|
44
48
|
hasUnresolvedKeyframes: true,
|
|
45
49
|
});
|
|
46
|
-
expect(editableAnimationId([dyn], "arc")).toBeNull();
|
|
50
|
+
expect(editableAnimationId([dyn], "arc", elSel)).toBeNull();
|
|
47
51
|
});
|
|
48
52
|
|
|
49
53
|
it("returns null for non-literal (helper) provenance — read-only", () => {
|
|
@@ -52,11 +56,27 @@ describe("editableAnimationId", () => {
|
|
|
52
56
|
arcPath: { enabled: true, autoRotate: false, segments: [] },
|
|
53
57
|
provenance: { kind: "helper" } as never,
|
|
54
58
|
});
|
|
55
|
-
expect(editableAnimationId([helper], "arc")).toBeNull();
|
|
59
|
+
expect(editableAnimationId([helper], "arc", elSel)).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("leaves a tween that also moves the element's siblings read-only", () => {
|
|
63
|
+
document.body.innerHTML = `<span class="w" data-hf-id="w0">How</span><span class="w" data-hf-id="w1">we</span>`;
|
|
64
|
+
const word = document.querySelector<HTMLElement>('[data-hf-id="w0"]')!;
|
|
65
|
+
const stagger = anim({
|
|
66
|
+
id: "stagger",
|
|
67
|
+
targetSelector: ".w",
|
|
68
|
+
propertyGroup: "position",
|
|
69
|
+
keyframes: {
|
|
70
|
+
format: "percentage",
|
|
71
|
+
keyframes: [{ percentage: 0, properties: { x: 0, y: 0 } }],
|
|
72
|
+
} as never,
|
|
73
|
+
});
|
|
74
|
+
const wordSel = { selector: ".w", selectorIndex: 0, hfId: "w0", element: word } as never;
|
|
75
|
+
expect(editableAnimationId([stagger], "linear", wordSel)).toBeNull();
|
|
56
76
|
});
|
|
57
77
|
|
|
58
78
|
it("returns null when nothing matches", () => {
|
|
59
|
-
expect(editableAnimationId([anim({ id: "x" })], "linear")).toBeNull();
|
|
79
|
+
expect(editableAnimationId([anim({ id: "x" })], "linear", elSel)).toBeNull();
|
|
60
80
|
});
|
|
61
81
|
});
|
|
62
82
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
|
|
7
7
|
import type { DomEditSelection } from "./domEditing";
|
|
8
|
-
import { writeTargetSelector } from "../../hooks/gsapShared";
|
|
8
|
+
import { tweenTargetsElement, writeTargetSelector } from "../../hooks/gsapShared";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* The selector the overlay both MEASURES the element by and authors a new
|
|
@@ -24,13 +24,20 @@ export function selectorFor(sel: DomEditSelection | null): string | null {
|
|
|
24
24
|
|
|
25
25
|
/** The animation whose path is editable on-canvas: literal, statically resolved,
|
|
26
26
|
* and matching the rendered geometry kind. Returns null when the path can only
|
|
27
|
-
* be displayed (dynamic/helper tweens
|
|
27
|
+
* be displayed (dynamic/helper tweens, or a tween that also moves other
|
|
28
|
+
* elements) — those nodes stay read-only and a press drags the element itself. */
|
|
28
29
|
export function editableAnimationId(
|
|
29
30
|
animations: GsapAnimation[],
|
|
30
31
|
kind: "linear" | "arc",
|
|
32
|
+
sel: DomEditSelection | null,
|
|
31
33
|
): string | null {
|
|
34
|
+
const own = selectorFor(sel);
|
|
32
35
|
const ok = (a: GsapAnimation) =>
|
|
33
|
-
!a.hasUnresolvedKeyframes &&
|
|
36
|
+
!a.hasUnresolvedKeyframes &&
|
|
37
|
+
!a.hasUnresolvedSelector &&
|
|
38
|
+
!a.provenance &&
|
|
39
|
+
!!own &&
|
|
40
|
+
tweenTargetsElement(a.targetSelector, own, sel?.element);
|
|
34
41
|
if (kind === "arc") return animations.find((a) => a.arcPath?.enabled && ok(a))?.id ?? null;
|
|
35
42
|
const a = animations.find(
|
|
36
43
|
(anim) =>
|
|
@@ -61,10 +61,8 @@ export function useInlineTextEditing(
|
|
|
61
61
|
const elementUnderPress = (event: { clientX: number; clientY: number }) => {
|
|
62
62
|
const iframe = actions?.previewIframeRef?.current;
|
|
63
63
|
if (!iframe) return null;
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
// finds wrappers. This helper lifts that for the length of the hit test,
|
|
67
|
-
// and is the same one the canvas uses to decide what was clicked.
|
|
64
|
+
// The same hit test the canvas uses to decide what was clicked; a plain
|
|
65
|
+
// elementFromPoint only finds wrappers, since Studio suppresses pointer events.
|
|
68
66
|
return getPreviewTargetFromPointer(
|
|
69
67
|
iframe,
|
|
70
68
|
event.clientX,
|
|
@@ -56,6 +56,14 @@ describe("liftElementToTop / restoreLiftedElement", () => {
|
|
|
56
56
|
expect(el.style.zIndex).toBe("");
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
it("keeps a position a move authored while the element was lifted", () => {
|
|
60
|
+
const el = makeEl();
|
|
61
|
+
const lift = liftElementToTop(el)!;
|
|
62
|
+
el.removeAttribute("data-hf-reveal-prior-pos");
|
|
63
|
+
restoreLiftedElement(el, lift);
|
|
64
|
+
expect(el.style.position).toBe("relative");
|
|
65
|
+
});
|
|
66
|
+
|
|
59
67
|
it("a z-reorder commit consumes the lift: restore becomes a no-op", () => {
|
|
60
68
|
const el = makeEl("3", "absolute");
|
|
61
69
|
const lift = liftElementToTop(el)!;
|
|
@@ -271,12 +271,13 @@ export function restoreLiftedElement(element: HTMLElement, lift: RevealLift): vo
|
|
|
271
271
|
}
|
|
272
272
|
return;
|
|
273
273
|
}
|
|
274
|
+
const positionStillLifted = element.hasAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR);
|
|
274
275
|
element.removeAttribute(LAYER_REVEAL_PRIOR_Z_ATTR);
|
|
275
276
|
element.removeAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR);
|
|
276
277
|
if (element.style.zIndex === LAYER_REVEAL_LIFT_Z) {
|
|
277
278
|
restoreInline(element, "z-index", lift.priors.zIndex);
|
|
278
279
|
}
|
|
279
|
-
if (lift.positionLifted && element.style.position === "relative") {
|
|
280
|
+
if (lift.positionLifted && positionStillLifted && element.style.position === "relative") {
|
|
280
281
|
restoreInline(element, "position", lift.priors.position);
|
|
281
282
|
}
|
|
282
283
|
}
|
|
@@ -21,7 +21,7 @@ export interface NLEContextValue {
|
|
|
21
21
|
play: () => void;
|
|
22
22
|
pause: () => void;
|
|
23
23
|
togglePlay: () => void;
|
|
24
|
-
seek: (time: number, options?: { keepPlaying?: boolean }) => boolean;
|
|
24
|
+
seek: (time: number, options?: { keepPlaying?: boolean; follow?: boolean }) => boolean;
|
|
25
25
|
refreshPlayer: () => void;
|
|
26
26
|
onIframeLoad: () => void;
|
|
27
27
|
// The hidden reload iframe NLEPreview renders next to the live one during a full reload.
|
|
@@ -149,6 +149,59 @@ function arrangeClickedCircle(): {
|
|
|
149
149
|
return { circle, selection: selectionForElement(circle) };
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
// main 0 > intro 2 > logo 3 > badge 1: rows are master time, tweens are local.
|
|
153
|
+
function nestedKeyframedRow(id: string, start: number, duration: number, host: number) {
|
|
154
|
+
const row: TimelineElement = {
|
|
155
|
+
id,
|
|
156
|
+
key: `compositions/${id}-parent.html#${id}`,
|
|
157
|
+
domId: id,
|
|
158
|
+
tag: "div",
|
|
159
|
+
start,
|
|
160
|
+
duration,
|
|
161
|
+
track: 0,
|
|
162
|
+
sourceFile: `compositions/${id}-parent.html`,
|
|
163
|
+
parentCompositionStart: host,
|
|
164
|
+
};
|
|
165
|
+
const anim: GsapAnimation = {
|
|
166
|
+
...authoredInteriorAnimation(),
|
|
167
|
+
id: `${id}-to-position`,
|
|
168
|
+
targetSelector: `#${id}`,
|
|
169
|
+
position: start - host,
|
|
170
|
+
resolvedStart: start - host,
|
|
171
|
+
duration,
|
|
172
|
+
};
|
|
173
|
+
usePlayerStore.setState({
|
|
174
|
+
elements: [row],
|
|
175
|
+
gsapAnimations: new Map([[row.key!, [anim]]]),
|
|
176
|
+
});
|
|
177
|
+
return { row, anim };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
describe("keyframe drags on nested rows", () => {
|
|
181
|
+
it.each([
|
|
182
|
+
["logo", 5, 5, 2, 60],
|
|
183
|
+
["badge", 6, 2, 5, 75],
|
|
184
|
+
])("re-keys %s's middle keyframe inside its local tween", async (id, start, dur, host, pct) => {
|
|
185
|
+
const { row, anim } = nestedKeyframedRow(id, start, dur, host);
|
|
186
|
+
const view = renderCallbacks();
|
|
187
|
+
await act(async () => {
|
|
188
|
+
await view.callbacks.onMoveKeyframe?.(
|
|
189
|
+
row.key!,
|
|
190
|
+
{ percentage: 50, propertyGroup: "position", tweenPercentage: 50, animationId: anim.id },
|
|
191
|
+
pct,
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
|
|
195
|
+
anim.id,
|
|
196
|
+
50,
|
|
197
|
+
pct,
|
|
198
|
+
selectionForElement(row),
|
|
199
|
+
);
|
|
200
|
+
expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
|
|
201
|
+
view.unmount();
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
152
205
|
beforeEach(() => {
|
|
153
206
|
vi.clearAllMocks();
|
|
154
207
|
mocks.animations = [flatAnimation];
|
|
@@ -46,6 +46,13 @@ function getSelectionLabel(selection: DomEditPersistFailureSelection): string {
|
|
|
46
46
|
return selection.label || selection.selector || selection.id || "this element";
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
export function wasAlreadyToasted(error: unknown): boolean {
|
|
50
|
+
return (
|
|
51
|
+
(error instanceof DomEditPersistUnsafeValueError || error instanceof StudioSaveHttpError) &&
|
|
52
|
+
error.alreadyToasted
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
49
56
|
export function reportDomEditPersistFailure(
|
|
50
57
|
selection: DomEditPersistFailureSelection,
|
|
51
58
|
operations: PatchOperation[],
|
|
@@ -59,12 +66,7 @@ export function reportDomEditPersistFailure(
|
|
|
59
66
|
error: detail,
|
|
60
67
|
});
|
|
61
68
|
|
|
62
|
-
|
|
63
|
-
(error instanceof DomEditPersistUnsafeValueError || error instanceof StudioSaveHttpError) &&
|
|
64
|
-
error.alreadyToasted;
|
|
65
|
-
if (wasAlreadyToasted) {
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
69
|
+
if (wasAlreadyToasted(error)) return;
|
|
68
70
|
|
|
69
71
|
showToast(`Couldn't save "${getSelectionLabel(selection)}": ${detail}`, "error");
|
|
70
72
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing";
|
|
2
|
+
import {
|
|
3
|
+
applyElementPositionOffset,
|
|
4
|
+
type ElementOffsetRefusal,
|
|
5
|
+
} from "../components/editor/elementPositionOffset";
|
|
6
|
+
import { LAYER_REVEAL_PRIOR_POSITION_ATTR } from "../player/lib/timelineElementHelpers";
|
|
7
|
+
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
8
|
+
|
|
9
|
+
const ELEMENT_OFFSET_REFUSED: Record<ElementOffsetRefusal, string> = {
|
|
10
|
+
anchored: "This layer is anchored from its right or bottom edge. Move it in the Code tab.",
|
|
11
|
+
percent: "This layer's position is set in percent. Move it in the Code tab.",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export interface ElementOffsetStagerDeps {
|
|
15
|
+
commitPositionPatchToHtml: (
|
|
16
|
+
selection: DomEditSelection,
|
|
17
|
+
patches: PatchOperation[],
|
|
18
|
+
options: { label: string; coalesceKey: string; coalesceMs?: number },
|
|
19
|
+
) => Promise<void>;
|
|
20
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
21
|
+
readOnlyPreview?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The drag draft moved GSAP's x/y; left/top carries the move now, so put them back. */
|
|
25
|
+
function settleGsapDraftAtGestureStart(el: HTMLElement): void {
|
|
26
|
+
const gsap = (el.ownerDocument.defaultView as { gsap?: { set: (t: Element, v: object) => void } })
|
|
27
|
+
?.gsap;
|
|
28
|
+
const x = Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-x") ?? "");
|
|
29
|
+
const y = Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-y") ?? "");
|
|
30
|
+
if (gsap && Number.isFinite(x) && Number.isFinite(y)) gsap.set(el, { x, y });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Applies a shared-tween element's move live now; `save` persists it as left/top on that
|
|
34
|
+
* element, `rollback` takes the live move back. Throws, after a toast, when it cannot. */
|
|
35
|
+
export function stageElementOffset(
|
|
36
|
+
{ commitPositionPatchToHtml, showToast, readOnlyPreview }: ElementOffsetStagerDeps,
|
|
37
|
+
selection: DomEditSelection,
|
|
38
|
+
next: { x: number; y: number },
|
|
39
|
+
coalesceKey?: string,
|
|
40
|
+
): { save: () => Promise<void>; rollback: () => void } {
|
|
41
|
+
const el = selection.element;
|
|
42
|
+
if (readOnlyPreview) return { save: () => Promise.resolve(), rollback: () => undefined };
|
|
43
|
+
const previous = { position: el.style.position, left: el.style.left, top: el.style.top };
|
|
44
|
+
const liftMarker = el.getAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR);
|
|
45
|
+
const result = applyElementPositionOffset(el, next);
|
|
46
|
+
if (!Array.isArray(result)) {
|
|
47
|
+
showToast(ELEMENT_OFFSET_REFUSED[result], "error");
|
|
48
|
+
throw new Error(ELEMENT_OFFSET_REFUSED[result]);
|
|
49
|
+
}
|
|
50
|
+
settleGsapDraftAtGestureStart(el);
|
|
51
|
+
const rollback = () => {
|
|
52
|
+
Object.assign(el.style, previous);
|
|
53
|
+
if (liftMarker !== null) el.setAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR, liftMarker);
|
|
54
|
+
};
|
|
55
|
+
const save = () =>
|
|
56
|
+
commitPositionPatchToHtml(selection, result, {
|
|
57
|
+
label: "Move layer",
|
|
58
|
+
coalesceKey: coalesceKey ?? `element-offset:${getDomEditTargetKey(selection)}`,
|
|
59
|
+
...(coalesceKey && { coalesceMs: Number.POSITIVE_INFINITY }),
|
|
60
|
+
}).catch((error) => {
|
|
61
|
+
rollback();
|
|
62
|
+
throw error;
|
|
63
|
+
});
|
|
64
|
+
return { save, rollback };
|
|
65
|
+
}
|
|
@@ -14,7 +14,8 @@ export interface GestureTransaction {
|
|
|
14
14
|
element: HTMLElement;
|
|
15
15
|
label: string;
|
|
16
16
|
settle(): void;
|
|
17
|
-
persist(commit: TxCommit): Promise<void>;
|
|
17
|
+
persist(commit: TxCommit, coalesceKey: string): Promise<void>;
|
|
18
|
+
afterBufferedCommitsSaved?(): Promise<void>;
|
|
18
19
|
restore(): void;
|
|
19
20
|
skipPixelAssert?: boolean;
|
|
20
21
|
}
|
|
@@ -142,9 +143,10 @@ export function runGestureTransaction(tx: GestureTransaction): Promise<void> {
|
|
|
142
143
|
};
|
|
143
144
|
|
|
144
145
|
return tx
|
|
145
|
-
.persist(commit)
|
|
146
|
+
.persist(commit, coalesceKey)
|
|
146
147
|
.then(async () => {
|
|
147
148
|
reloadCount = await dispatchBufferedCommits(bufferedCommits);
|
|
149
|
+
await tx.afterBufferedCommitsSaved?.();
|
|
148
150
|
const durationMs = Math.round(performance.now() - startedAt);
|
|
149
151
|
logCommit("persisted", { label: tx.label, coalesceKey });
|
|
150
152
|
if (before) {
|
|
@@ -43,7 +43,8 @@ export type GsapEditOutcome =
|
|
|
43
43
|
*/
|
|
44
44
|
ownsDragOffset?: boolean;
|
|
45
45
|
}
|
|
46
|
-
| { status: "blocked"; reason: GsapEditBlockReason; detail?: GsapEditBlockDetail }
|
|
46
|
+
| { status: "blocked"; reason: GsapEditBlockReason; detail?: GsapEditBlockDetail }
|
|
47
|
+
| { status: "element-offset" };
|
|
47
48
|
|
|
48
49
|
export const GSAP_EDIT_BLOCK_COPY: Record<GsapEditBlockReason, string> = {
|
|
49
50
|
"no-selector": "This layer needs a stable selector before Studio can save the edit.",
|