@hyperframes/studio 0.7.39 → 0.7.40
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/{index-312a3Ceu.js → index-89kPtC4s.js} +1 -1
- package/dist/assets/{index-B9YvRJz1.js → index-B2Utv-2b.js} +195 -195
- package/dist/assets/index-BpM6cnfP.css +1 -0
- package/dist/assets/{index-CQHiecE7.js → index-CjX8Ljc8.js} +1 -1
- package/dist/{chunk-RCLGSZ7C.js → chunk-BA66NM4L.js} +1 -1
- package/dist/{chunk-RCLGSZ7C.js.map → chunk-BA66NM4L.js.map} +1 -1
- package/dist/{domEditingLayers-S6YOLVRG.js → domEditingLayers-H7LDFIJ7.js} +2 -2
- package/dist/index.d.ts +34 -29
- package/dist/index.html +2 -2
- package/dist/index.js +2749 -2075
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +2 -1
- package/src/components/StudioPreviewArea.tsx +6 -0
- package/src/components/StudioRightPanel.tsx +3 -0
- package/src/components/editor/DomEditOverlay.tsx +33 -47
- package/src/components/editor/PropertyPanel.test.ts +25 -0
- package/src/components/editor/PropertyPanel.tsx +33 -7
- package/src/components/editor/domEditingElement.ts +8 -1
- package/src/components/editor/manualEditsDom.ts +1 -18
- package/src/components/editor/manualEditsStyleHelpers.ts +18 -0
- package/src/components/editor/offCanvasIndicatorGeometry.ts +74 -0
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +154 -0
- package/src/components/editor/offCanvasIndicatorRefresh.ts +99 -0
- package/src/components/editor/propertyPanelHelpers.ts +11 -0
- package/src/components/editor/propertyPanelTypes.ts +1 -0
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/hooks/gsapDragCommit.test.ts +56 -0
- package/src/hooks/gsapDragCommit.ts +41 -109
- package/src/hooks/gsapDragPositionCommit.ts +3 -0
- package/src/hooks/gsapDragStaticSetHelpers.ts +109 -0
- package/src/hooks/gsapRuntimeBridge.ts +23 -2
- package/src/hooks/gsapRuntimeKeyframes.test.ts +34 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +13 -1
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +1 -0
- package/src/hooks/timelineEditingHelpers.ts +17 -5
- package/src/hooks/timelineTrackVisibility.test.ts +202 -0
- package/src/hooks/timelineTrackVisibility.ts +371 -0
- package/src/hooks/useRenderClipContent.test.ts +68 -2
- package/src/hooks/useRenderClipContent.ts +5 -6
- package/src/hooks/useTimelineEditing.ts +35 -30
- package/src/hooks/useTimelineEditingTypes.ts +30 -0
- package/src/icons/SystemIcons.tsx +12 -4
- package/src/player/components/AudioWaveform.tsx +11 -8
- package/src/player/components/CompositionThumbnail.tsx +14 -11
- package/src/player/components/PlayheadIndicator.tsx +21 -11
- package/src/player/components/Timeline.test.ts +105 -5
- package/src/player/components/Timeline.tsx +37 -47
- package/src/player/components/TimelineCanvas.tsx +275 -256
- package/src/player/components/TimelineClip.test.tsx +105 -0
- package/src/player/components/TimelineClip.tsx +44 -57
- package/src/player/components/TimelineRuler.tsx +17 -23
- package/src/player/components/TimelineShortcutHint.tsx +26 -0
- package/src/player/components/TimelineTypes.ts +18 -0
- package/src/player/components/VideoThumbnail.tsx +16 -13
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineIcons.tsx +2 -44
- package/src/player/components/timelineLayout.ts +18 -22
- package/src/player/components/timelineMotionStyles.test.ts +101 -0
- package/src/player/components/timelineTheme.test.ts +21 -4
- package/src/player/components/timelineTheme.ts +15 -15
- package/src/player/components/useTimelineActiveClips.test.ts +93 -0
- package/src/player/components/useTimelineActiveClips.ts +125 -0
- package/src/player/components/useTimelinePlayhead.ts +6 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +110 -3
- package/src/player/hooks/useExpandedTimelineElements.ts +106 -19
- package/src/player/lib/time.test.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +48 -1
- package/src/player/lib/timelineDOM.ts +7 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +5 -1
- package/src/styles/studio.css +108 -0
- package/src/utils/sourcePatcher.test.ts +11 -0
- package/src/utils/sourcePatcher.ts +6 -4
- package/dist/assets/index-0P10SwC_.css +0 -1
- /package/dist/{domEditingLayers-S6YOLVRG.js.map → domEditingLayers-H7LDFIJ7.js.map} +0 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import type { OffCanvasRect } from "./OffCanvasIndicators";
|
|
3
|
+
import { recomputeOffCanvasIndicators } from "./offCanvasIndicatorGeometry";
|
|
4
|
+
|
|
5
|
+
interface OffCanvasIndicatorRefreshOptions {
|
|
6
|
+
iframeRef: React.RefObject<HTMLIFrameElement | null>;
|
|
7
|
+
overlayRef: React.RefObject<HTMLDivElement | null>;
|
|
8
|
+
compRectRef: React.MutableRefObject<{ left: number; top: number; width: number; height: number }>;
|
|
9
|
+
activeCompositionPathRef: React.MutableRefObject<string | null>;
|
|
10
|
+
dirtyRef: React.MutableRefObject<boolean>;
|
|
11
|
+
sigRef: React.MutableRefObject<string>;
|
|
12
|
+
observerRef: React.MutableRefObject<MutationObserver | null>;
|
|
13
|
+
observedDocRef: React.MutableRefObject<Document | null>;
|
|
14
|
+
elementsRef: React.MutableRefObject<Map<string, HTMLElement>>;
|
|
15
|
+
setRects: (rects: OffCanvasRect[]) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function compSignature(comp: { left: number; top: number; width: number; height: number }): string {
|
|
19
|
+
return `${Math.round(comp.left)}:${Math.round(comp.top)}:${Math.round(comp.width)}:${Math.round(comp.height)}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function clearIndicators(options: OffCanvasIndicatorRefreshOptions): void {
|
|
23
|
+
options.dirtyRef.current = false;
|
|
24
|
+
options.sigRef.current = "";
|
|
25
|
+
options.elementsRef.current = new Map();
|
|
26
|
+
options.setRects([]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function observeDoc(doc: Document, markDirty: () => void): MutationObserver | null {
|
|
30
|
+
const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver;
|
|
31
|
+
if (!Observer) return null;
|
|
32
|
+
const observer = new Observer(markDirty);
|
|
33
|
+
observer.observe(doc.documentElement, {
|
|
34
|
+
attributes: true,
|
|
35
|
+
// data-hidden is included explicitly: hiding an element writes data-hidden, and
|
|
36
|
+
// although the runtime honoring also writes display:"none" (a style mutation we'd
|
|
37
|
+
// catch anyway), keying on the attribute directly makes the coupling robust to any
|
|
38
|
+
// future throttling of that runtime sync.
|
|
39
|
+
attributeFilter: ["style", "class", "transform", "width", "height", "data-hidden"],
|
|
40
|
+
childList: true,
|
|
41
|
+
subtree: true,
|
|
42
|
+
});
|
|
43
|
+
return observer;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function startOffCanvasIndicatorRefresh(
|
|
47
|
+
options: OffCanvasIndicatorRefreshOptions,
|
|
48
|
+
): () => void {
|
|
49
|
+
let frame = 0;
|
|
50
|
+
let lastCompSig = "";
|
|
51
|
+
const markDirty = () => {
|
|
52
|
+
options.dirtyRef.current = true;
|
|
53
|
+
};
|
|
54
|
+
const attachObserver = (doc: Document | null) => {
|
|
55
|
+
options.observerRef.current?.disconnect();
|
|
56
|
+
options.observerRef.current = doc?.documentElement ? observeDoc(doc, markDirty) : null;
|
|
57
|
+
options.observedDocRef.current = doc;
|
|
58
|
+
options.sigRef.current = "";
|
|
59
|
+
};
|
|
60
|
+
const update = () => {
|
|
61
|
+
frame = requestAnimationFrame(update);
|
|
62
|
+
const iframe = options.iframeRef.current;
|
|
63
|
+
const overlayEl = options.overlayRef.current;
|
|
64
|
+
const doc = iframe?.contentDocument ?? null;
|
|
65
|
+
if (doc !== options.observedDocRef.current) {
|
|
66
|
+
attachObserver(doc);
|
|
67
|
+
markDirty();
|
|
68
|
+
}
|
|
69
|
+
const comp = options.compRectRef.current;
|
|
70
|
+
const nextCompSig = compSignature(comp);
|
|
71
|
+
if (nextCompSig !== lastCompSig) {
|
|
72
|
+
lastCompSig = nextCompSig;
|
|
73
|
+
markDirty();
|
|
74
|
+
}
|
|
75
|
+
if (!iframe || !overlayEl) {
|
|
76
|
+
if (options.dirtyRef.current) clearIndicators(options);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (!options.dirtyRef.current) return;
|
|
80
|
+
options.dirtyRef.current = false;
|
|
81
|
+
recomputeOffCanvasIndicators(
|
|
82
|
+
iframe,
|
|
83
|
+
overlayEl,
|
|
84
|
+
doc,
|
|
85
|
+
comp,
|
|
86
|
+
options.activeCompositionPathRef.current,
|
|
87
|
+
options.sigRef,
|
|
88
|
+
options.elementsRef,
|
|
89
|
+
options.setRects,
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
frame = requestAnimationFrame(update);
|
|
93
|
+
return () => {
|
|
94
|
+
cancelAnimationFrame(frame);
|
|
95
|
+
options.observerRef.current?.disconnect();
|
|
96
|
+
options.observerRef.current = null;
|
|
97
|
+
options.observedDocRef.current = null;
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -2,6 +2,7 @@ import { parseCssColor, type ParsedColor } from "./colorValue";
|
|
|
2
2
|
import { COMMON_LOCAL_FONT_FAMILIES } from "./fontCatalog";
|
|
3
3
|
import type { DomEditSelection } from "./domEditing";
|
|
4
4
|
import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
|
|
5
|
+
import type { TimelineElement } from "../../player";
|
|
5
6
|
import { roundToCenti } from "../../utils/rounding";
|
|
6
7
|
|
|
7
8
|
export type {
|
|
@@ -18,6 +19,16 @@ export function stripQueryAndHash(value: string): string {
|
|
|
18
19
|
return value.slice(0, Math.min(queryIndex, hashIndex));
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
export function isSelectedElementHidden(
|
|
23
|
+
elements: readonly TimelineElement[],
|
|
24
|
+
selectedElementId: string | null,
|
|
25
|
+
): boolean {
|
|
26
|
+
if (!selectedElementId) return false;
|
|
27
|
+
return (
|
|
28
|
+
elements.find((element) => (element.key ?? element.id) === selectedElementId)?.hidden === true
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
21
32
|
/* ------------------------------------------------------------------ */
|
|
22
33
|
/* Font types & constants (shared by font and section modules) */
|
|
23
34
|
/* ------------------------------------------------------------------ */
|
|
@@ -52,6 +52,7 @@ export interface PropertyPanelProps {
|
|
|
52
52
|
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
|
|
53
53
|
onRemoveTextField: (fieldKey: string) => void;
|
|
54
54
|
onAskAgent: () => void;
|
|
55
|
+
onToggleElementHidden?: (elementKey: string, hidden: boolean) => void | Promise<void>;
|
|
55
56
|
onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
|
|
56
57
|
fontAssets?: ImportedFontAsset[];
|
|
57
58
|
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
|
|
@@ -5,6 +5,7 @@ import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
|
|
|
5
5
|
import {
|
|
6
6
|
commitStaticGsapPosition,
|
|
7
7
|
commitStaticGsapRotation,
|
|
8
|
+
findExistingPositionWrite,
|
|
8
9
|
parkPlayheadOnKeyframe,
|
|
9
10
|
type GsapDragCommitCallbacks,
|
|
10
11
|
} from "./gsapDragCommit";
|
|
@@ -75,6 +76,7 @@ describe("commitGsapPositionFromDrag — flat tween", () => {
|
|
|
75
76
|
});
|
|
76
77
|
|
|
77
78
|
it("extends the existing tween (never spawns a parallel one) when dragged OUTSIDE its range", async () => {
|
|
79
|
+
// fallow-ignore-next-line code-duplication
|
|
78
80
|
usePlayerStore.setState({ currentTime: 6 }); // outside [1.2, 3.4]
|
|
79
81
|
const { types, callbacks } = recordingCallbacks();
|
|
80
82
|
|
|
@@ -94,7 +96,9 @@ describe("commitGsapPositionFromDrag — flat tween", () => {
|
|
|
94
96
|
});
|
|
95
97
|
|
|
96
98
|
it("adds a keyframe at the playhead when dragged INSIDE its range", async () => {
|
|
99
|
+
// fallow-ignore-next-line code-duplication
|
|
97
100
|
usePlayerStore.setState({ currentTime: 2 }); // inside [1.2, 3.4]
|
|
101
|
+
// fallow-ignore-next-line code-duplication
|
|
98
102
|
const { types, callbacks } = recordingCallbacks();
|
|
99
103
|
|
|
100
104
|
await commitGsapPositionFromDrag(
|
|
@@ -115,6 +119,7 @@ describe("commitGsapPositionFromDrag — flat tween", () => {
|
|
|
115
119
|
// User clicked the 100% diamond (activeKeyframePct=100), playhead drifted past
|
|
116
120
|
// the end. Expect: convert + add-keyframe AT 100% — not replace-with-keyframes.
|
|
117
121
|
usePlayerStore.setState({ currentTime: 6, activeKeyframePct: 100 }); // outside [1.2, 3.4]
|
|
122
|
+
// fallow-ignore-next-line code-duplication
|
|
118
123
|
const { types, mutations, callbacks } = recordingCallbacks();
|
|
119
124
|
|
|
120
125
|
await commitGsapPositionFromDrag(
|
|
@@ -338,6 +343,7 @@ describe("commitStaticGsapPosition — instantPatch (value-only set)", () => {
|
|
|
338
343
|
await commitStaticGsapPosition(
|
|
339
344
|
selection(),
|
|
340
345
|
{ x: -50, y: 30 },
|
|
346
|
+
// fallow-ignore-next-line code-duplication
|
|
341
347
|
{ x: 0, y: 0 },
|
|
342
348
|
"#puck-a",
|
|
343
349
|
null, // no existing set → `add` a new base gsap.set
|
|
@@ -352,6 +358,55 @@ describe("commitStaticGsapPosition — instantPatch (value-only set)", () => {
|
|
|
352
358
|
});
|
|
353
359
|
});
|
|
354
360
|
|
|
361
|
+
// A degenerate `tl.to("#el",{keyframes:{...},duration:0})` — what a pre-fix drag
|
|
362
|
+
// left behind when it routed a STATIC position hold (sitting beside a keyframed
|
|
363
|
+
// rotation) into the keyframe branch. A duration-0 keyframed tween renders its
|
|
364
|
+
// final keyframe at every playhead, so the element froze and "couldn't move".
|
|
365
|
+
const keyframedZeroDurationHold = (): GsapAnimation =>
|
|
366
|
+
({
|
|
367
|
+
id: "#puck-a-frozen",
|
|
368
|
+
targetSelector: "#puck-a",
|
|
369
|
+
method: "to",
|
|
370
|
+
propertyGroup: "position",
|
|
371
|
+
duration: 0,
|
|
372
|
+
keyframes: {
|
|
373
|
+
keyframes: [
|
|
374
|
+
{ percentage: 0, properties: { x: 100, y: 50 } },
|
|
375
|
+
{ percentage: 100, properties: { x: -260, y: -70 } },
|
|
376
|
+
],
|
|
377
|
+
},
|
|
378
|
+
properties: {},
|
|
379
|
+
}) as unknown as GsapAnimation;
|
|
380
|
+
|
|
381
|
+
describe("static position hold recognition + heal (frozen duration-0 keyframed tween)", () => {
|
|
382
|
+
beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }));
|
|
383
|
+
|
|
384
|
+
it("findExistingPositionWrite recognizes a keyframed zero-duration position hold", () => {
|
|
385
|
+
const found = findExistingPositionWrite([keyframedZeroDurationHold()], "#puck-a");
|
|
386
|
+
expect(found?.id).toBe("#puck-a-frozen");
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("commitStaticGsapPosition heals a keyframed hold by delete + clean add-set (never update-property)", async () => {
|
|
390
|
+
const { commits, callbacks } = optionRecordingCallbacks();
|
|
391
|
+
|
|
392
|
+
await commitStaticGsapPosition(
|
|
393
|
+
selection(),
|
|
394
|
+
{ x: -50, y: 30 },
|
|
395
|
+
{ x: 0, y: 0 },
|
|
396
|
+
"#puck-a",
|
|
397
|
+
keyframedZeroDurationHold(),
|
|
398
|
+
callbacks,
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
const types = commits.map((c) => c.mutation.type);
|
|
402
|
+
// Can't update-property into keyframes — must delete the frozen tween and
|
|
403
|
+
// write a clean static set, so the element becomes freely movable.
|
|
404
|
+
expect(types).toEqual(["delete", "add"]);
|
|
405
|
+
expect(types).not.toContain("update-property");
|
|
406
|
+
expect((commits[1].mutation as { method?: string }).method).toBe("set");
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
355
410
|
describe("commitStaticGsapRotation — instantPatch (value-only set)", () => {
|
|
356
411
|
beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }));
|
|
357
412
|
|
|
@@ -377,6 +432,7 @@ describe("commitStaticGsapRotation — instantPatch (value-only set)", () => {
|
|
|
377
432
|
it("ADDS a global gsap.set with a global-set instantPatch (off-timeline, no flash)", async () => {
|
|
378
433
|
const { commits, callbacks } = optionRecordingCallbacks();
|
|
379
434
|
|
|
435
|
+
// fallow-ignore-next-line code-duplication
|
|
380
436
|
await commitStaticGsapRotation(selection(), 42, "#puck-a", null, callbacks);
|
|
381
437
|
|
|
382
438
|
expect(commits).toHaveLength(1);
|
|
@@ -14,7 +14,16 @@ import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeComp
|
|
|
14
14
|
import { roundTo3 } from "../utils/rounding";
|
|
15
15
|
import { computeElementPercentage } from "./gsapShared";
|
|
16
16
|
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
|
17
|
-
import type { RuntimeTweenChange
|
|
17
|
+
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
|
|
18
|
+
import {
|
|
19
|
+
setPatchFromUpdateProperties,
|
|
20
|
+
setPatchFromUpdateProperty,
|
|
21
|
+
} from "./gsapDragStaticSetHelpers";
|
|
22
|
+
export {
|
|
23
|
+
findExistingPositionWrite,
|
|
24
|
+
findRotationSetAnimation,
|
|
25
|
+
findSizeSetAnimation,
|
|
26
|
+
} from "./gsapDragStaticSetHelpers";
|
|
18
27
|
export interface GsapDragCommitCallbacks {
|
|
19
28
|
commitMutation: (
|
|
20
29
|
selection: DomEditSelection,
|
|
@@ -103,86 +112,6 @@ export async function materializeIfDynamic(
|
|
|
103
112
|
|
|
104
113
|
// ── Drag → GSAP position math ──────────────────────────────────────────────
|
|
105
114
|
|
|
106
|
-
/** The shape of an `update-property` mutation a static-set nudge POSTs. */
|
|
107
|
-
interface UpdatePropertyMutation {
|
|
108
|
-
type: "update-property";
|
|
109
|
-
animationId: string;
|
|
110
|
-
property: string;
|
|
111
|
-
value: number;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Build the `instantPatch` for a value-only `tl.set` from the SAME
|
|
116
|
-
* `update-property` mutation(s) that are POSTed — so the patch can never carry a
|
|
117
|
-
* value the source write didn't (one source of truth). Each mutation contributes
|
|
118
|
-
* its `{property: value}` channel to the patch's props.
|
|
119
|
-
*/
|
|
120
|
-
function setPatchFromUpdateProperties(
|
|
121
|
-
selector: string,
|
|
122
|
-
mutations: UpdatePropertyMutation[],
|
|
123
|
-
global = false,
|
|
124
|
-
): { selector: string; change: RuntimeTweenChange } {
|
|
125
|
-
const props: SetPatchProps = {};
|
|
126
|
-
for (const m of mutations) props[m.property as keyof SetPatchProps] = m.value;
|
|
127
|
-
// An off-timeline `gsap.set` has no runtime tween to patch — apply it to the
|
|
128
|
-
// element directly. An on-timeline `tl.set` mutates its tween (so a re-seek keeps it).
|
|
129
|
-
return { selector, change: { kind: global ? "global-set" : "set", props } };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** Single-mutation convenience over {@link setPatchFromUpdateProperties}. */
|
|
133
|
-
function setPatchFromUpdateProperty(
|
|
134
|
-
selector: string,
|
|
135
|
-
mutation: UpdatePropertyMutation,
|
|
136
|
-
global = false,
|
|
137
|
-
): { selector: string; change: RuntimeTweenChange } {
|
|
138
|
-
return setPatchFromUpdateProperties(selector, [mutation], global);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Find the studio position-hold `set` for a selector — a `tl.set("#el",{x,y})`
|
|
143
|
-
* with no duration. This is what a static-element nudge writes/updates.
|
|
144
|
-
*/
|
|
145
|
-
function findPositionSetAnimation(
|
|
146
|
-
animations: GsapAnimation[],
|
|
147
|
-
selector: string,
|
|
148
|
-
): GsapAnimation | null {
|
|
149
|
-
return (
|
|
150
|
-
animations.find(
|
|
151
|
-
(a) =>
|
|
152
|
-
a.method === "set" &&
|
|
153
|
-
a.targetSelector === selector &&
|
|
154
|
-
("x" in a.properties || "y" in a.properties),
|
|
155
|
-
) ?? null
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Find the EXISTING static position HOLD to update for a static-hold drag. Not
|
|
161
|
-
* just a `set`: a degenerate `tl.to("#el",{duration:0,x,y})` (what
|
|
162
|
-
* remove-all-keyframes leaves behind) is a held position too, and the next drag
|
|
163
|
-
* must UPDATE it in place rather than append a second `gsap.set` that fights it
|
|
164
|
-
* (the duplicate-position-write bug). Only zero-duration holds qualify — a
|
|
165
|
-
* live-duration `to`/`from` is NOT a static hold (and in the static path it's a
|
|
166
|
-
* stale/phantom parse: re-committing it would resurrect a just-deleted tween).
|
|
167
|
-
* Prefers a `set` (the canonical static channel) when both forms exist.
|
|
168
|
-
*/
|
|
169
|
-
function findExistingPositionWrite(
|
|
170
|
-
animations: GsapAnimation[],
|
|
171
|
-
selector: string,
|
|
172
|
-
): GsapAnimation | null {
|
|
173
|
-
const set = findPositionSetAnimation(animations, selector);
|
|
174
|
-
if (set) return set;
|
|
175
|
-
return (
|
|
176
|
-
animations.find(
|
|
177
|
-
(a) =>
|
|
178
|
-
a.targetSelector === selector &&
|
|
179
|
-
a.propertyGroup === "position" &&
|
|
180
|
-
!a.keyframes &&
|
|
181
|
-
(a.duration ?? 0) === 0,
|
|
182
|
-
) ?? null
|
|
183
|
-
);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
115
|
/**
|
|
187
116
|
* Commit a STATIC element drag as a `tl.set("#el",{x,y})` — the single-source
|
|
188
117
|
* position channel for elements with no position animation. Idempotent: a
|
|
@@ -201,6 +130,34 @@ export async function commitStaticGsapPosition(
|
|
|
201
130
|
): Promise<void> {
|
|
202
131
|
const { newX, newY } = computeDraggedGsapPosition(selection.element, studioOffset, gsapPos);
|
|
203
132
|
if (existingSet) {
|
|
133
|
+
if (existingSet.keyframes) {
|
|
134
|
+
// Keyframed zero-duration hold (drag-path corruption): can't update-property
|
|
135
|
+
// into keyframes — delete it and write a clean static set instead.
|
|
136
|
+
const coalesceKey = `gsap:heal-static:${existingSet.id}`;
|
|
137
|
+
await callbacks.commitMutation(
|
|
138
|
+
selection,
|
|
139
|
+
{ type: "delete", animationId: existingSet.id },
|
|
140
|
+
{ label: "Move layer", skipReload: true, coalesceKey },
|
|
141
|
+
);
|
|
142
|
+
await callbacks.commitMutation(
|
|
143
|
+
selection,
|
|
144
|
+
{
|
|
145
|
+
type: "add",
|
|
146
|
+
targetSelector: selector,
|
|
147
|
+
method: "set",
|
|
148
|
+
position: 0,
|
|
149
|
+
properties: { x: newX, y: newY },
|
|
150
|
+
global: true,
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
label: "Move layer",
|
|
154
|
+
softReload: true,
|
|
155
|
+
coalesceKey,
|
|
156
|
+
instantPatch: { selector, change: { kind: "global-set", props: { x: newX, y: newY } } },
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
204
161
|
// Update in place — two single-property mutations (the API updates one prop
|
|
205
162
|
// per call). Coalesce them and reload only after the second lands.
|
|
206
163
|
const coalesceKey = `gsap:set-nudge:${existingSet.id}`;
|
|
@@ -262,19 +219,6 @@ export async function commitStaticGsapPosition(
|
|
|
262
219
|
);
|
|
263
220
|
}
|
|
264
221
|
|
|
265
|
-
export { findExistingPositionWrite };
|
|
266
|
-
|
|
267
|
-
function findRotationSetAnimation(
|
|
268
|
-
animations: GsapAnimation[],
|
|
269
|
-
selector: string,
|
|
270
|
-
): GsapAnimation | null {
|
|
271
|
-
return (
|
|
272
|
-
animations.find(
|
|
273
|
-
(a) => a.method === "set" && a.targetSelector === selector && "rotation" in a.properties,
|
|
274
|
-
) ?? null
|
|
275
|
-
);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
222
|
/**
|
|
279
223
|
* Commit a STATIC element rotation as a `tl.set("#el",{rotation})` — the single-
|
|
280
224
|
* source rotation channel for elements with no rotation animation (mirrors
|
|
@@ -328,19 +272,6 @@ export async function commitStaticGsapRotation(
|
|
|
328
272
|
);
|
|
329
273
|
}
|
|
330
274
|
|
|
331
|
-
export { findRotationSetAnimation };
|
|
332
|
-
|
|
333
|
-
function findSizeSetAnimation(animations: GsapAnimation[], selector: string): GsapAnimation | null {
|
|
334
|
-
return (
|
|
335
|
-
animations.find(
|
|
336
|
-
(a) =>
|
|
337
|
-
a.method === "set" &&
|
|
338
|
-
a.targetSelector === selector &&
|
|
339
|
-
("width" in a.properties || "height" in a.properties),
|
|
340
|
-
) ?? null
|
|
341
|
-
);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
275
|
/**
|
|
345
276
|
* Commit a STATIC element resize as a `tl.set("#el",{width,height})` — the
|
|
346
277
|
* single-source size channel for elements with no size animation (mirrors
|
|
@@ -489,8 +420,6 @@ export async function commitKeyframedSizeFromResize(
|
|
|
489
420
|
return true;
|
|
490
421
|
}
|
|
491
422
|
|
|
492
|
-
export { findSizeSetAnimation };
|
|
493
|
-
|
|
494
423
|
// ── Whole-path offset (plain drag on animated element) ──────────────────
|
|
495
424
|
|
|
496
425
|
/**
|
|
@@ -498,6 +427,7 @@ export { findSizeSetAnimation };
|
|
|
498
427
|
* shifts together so the animation shape is preserved and the element can't
|
|
499
428
|
* dart off-screen. For flat tweens (no keyframes), convert first then shift.
|
|
500
429
|
*/
|
|
430
|
+
// fallow-ignore-next-line code-duplication
|
|
501
431
|
// fallow-ignore-next-line complexity
|
|
502
432
|
export async function commitWholePathOffset(
|
|
503
433
|
selection: DomEditSelection,
|
|
@@ -515,6 +445,7 @@ export async function commitWholePathOffset(
|
|
|
515
445
|
gsapPos,
|
|
516
446
|
);
|
|
517
447
|
const deltaX = newX - baseGsapX;
|
|
448
|
+
// fallow-ignore-next-line code-duplication
|
|
518
449
|
const deltaY = newY - baseGsapY;
|
|
519
450
|
const origX = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0;
|
|
520
451
|
const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0;
|
|
@@ -525,6 +456,7 @@ export async function commitWholePathOffset(
|
|
|
525
456
|
el.removeAttribute("data-hf-drag-initial-offset-y");
|
|
526
457
|
};
|
|
527
458
|
|
|
459
|
+
// fallow-ignore-next-line code-duplication
|
|
528
460
|
let effectiveAnim = anim;
|
|
529
461
|
if (anim.keyframes) {
|
|
530
462
|
const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
|
|
@@ -202,6 +202,7 @@ async function commitFlatViaKeyframes(
|
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
const coalesceKey = `gsap:convert-drag:${anim.id}`;
|
|
205
|
+
// fallow-ignore-next-line code-duplication
|
|
205
206
|
await callbacks.commitMutation(
|
|
206
207
|
selection,
|
|
207
208
|
{
|
|
@@ -229,6 +230,7 @@ async function commitFlatViaKeyframes(
|
|
|
229
230
|
if (editedSelected) parkPlayheadOnKeyframe(anim, pct);
|
|
230
231
|
}
|
|
231
232
|
|
|
233
|
+
// fallow-ignore-next-line code-duplication
|
|
232
234
|
// fallow-ignore-next-line complexity
|
|
233
235
|
export async function commitGsapPositionFromDrag(
|
|
234
236
|
selection: DomEditSelection,
|
|
@@ -240,6 +242,7 @@ export async function commitGsapPositionFromDrag(
|
|
|
240
242
|
callbacks: GsapDragCommitCallbacks,
|
|
241
243
|
): Promise<void> {
|
|
242
244
|
const el = selection.element;
|
|
245
|
+
// fallow-ignore-next-line code-duplication
|
|
243
246
|
const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition(
|
|
244
247
|
el,
|
|
245
248
|
studioOffset,
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
|
+
import { resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
3
|
+
import type { RuntimeTweenChange, SetPatchProps } from "./gsapRuntimePatch";
|
|
4
|
+
|
|
5
|
+
/** The shape of an `update-property` mutation a static-set nudge POSTs. */
|
|
6
|
+
interface UpdatePropertyMutation {
|
|
7
|
+
type: "update-property";
|
|
8
|
+
animationId: string;
|
|
9
|
+
property: string;
|
|
10
|
+
value: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build the `instantPatch` for a value-only `tl.set` from the SAME
|
|
15
|
+
* `update-property` mutation(s) that are POSTed — so the patch can never carry a
|
|
16
|
+
* value the source write didn't (one source of truth). Each mutation contributes
|
|
17
|
+
* its `{property: value}` channel to the patch's props.
|
|
18
|
+
*/
|
|
19
|
+
export function setPatchFromUpdateProperties(
|
|
20
|
+
selector: string,
|
|
21
|
+
mutations: UpdatePropertyMutation[],
|
|
22
|
+
global = false,
|
|
23
|
+
): { selector: string; change: RuntimeTweenChange } {
|
|
24
|
+
const props: SetPatchProps = {};
|
|
25
|
+
for (const m of mutations) props[m.property as keyof SetPatchProps] = m.value;
|
|
26
|
+
// An off-timeline `gsap.set` has no runtime tween to patch — apply it to the
|
|
27
|
+
// element directly. An on-timeline `tl.set` mutates its tween (so a re-seek keeps it).
|
|
28
|
+
return { selector, change: { kind: global ? "global-set" : "set", props } };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Single-mutation convenience over {@link setPatchFromUpdateProperties}. */
|
|
32
|
+
export function setPatchFromUpdateProperty(
|
|
33
|
+
selector: string,
|
|
34
|
+
mutation: UpdatePropertyMutation,
|
|
35
|
+
global = false,
|
|
36
|
+
): { selector: string; change: RuntimeTweenChange } {
|
|
37
|
+
return setPatchFromUpdateProperties(selector, [mutation], global);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Find the studio position-hold `set` for a selector — a `tl.set("#el",{x,y})`
|
|
42
|
+
* with no duration. This is what a static-element nudge writes/updates.
|
|
43
|
+
*/
|
|
44
|
+
function findPositionSetAnimation(
|
|
45
|
+
animations: GsapAnimation[],
|
|
46
|
+
selector: string,
|
|
47
|
+
): GsapAnimation | null {
|
|
48
|
+
return (
|
|
49
|
+
animations.find(
|
|
50
|
+
(a) =>
|
|
51
|
+
a.method === "set" &&
|
|
52
|
+
a.targetSelector === selector &&
|
|
53
|
+
("x" in a.properties || "y" in a.properties),
|
|
54
|
+
) ?? null
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Find the EXISTING static position HOLD to update for a static-hold drag. Not
|
|
60
|
+
* just a `set`: a degenerate `tl.to("#el",{duration:0,x,y})` (what
|
|
61
|
+
* remove-all-keyframes leaves behind) is a held position too, and the next drag
|
|
62
|
+
* must UPDATE it in place rather than append a second `gsap.set` that fights it
|
|
63
|
+
* (the duplicate-position-write bug). Only zero-duration holds qualify — a
|
|
64
|
+
* live-duration `to`/`from` is NOT a static hold (and in the static path it's a
|
|
65
|
+
* stale/phantom parse: re-committing it would resurrect a just-deleted tween).
|
|
66
|
+
* A keyframed zero-duration `to` is ALSO a static hold (a drag-path corruption
|
|
67
|
+
* artifact) and must be recognized so the static commit normalizes it.
|
|
68
|
+
* Prefers a `set` (the canonical static channel) when both forms exist.
|
|
69
|
+
*/
|
|
70
|
+
export function findExistingPositionWrite(
|
|
71
|
+
animations: GsapAnimation[],
|
|
72
|
+
selector: string,
|
|
73
|
+
): GsapAnimation | null {
|
|
74
|
+
const set = findPositionSetAnimation(animations, selector);
|
|
75
|
+
if (set) return set;
|
|
76
|
+
return (
|
|
77
|
+
animations.find(
|
|
78
|
+
(a) =>
|
|
79
|
+
a.targetSelector === selector &&
|
|
80
|
+
a.propertyGroup === "position" &&
|
|
81
|
+
resolveTweenDuration(a) === 0,
|
|
82
|
+
) ?? null
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function findRotationSetAnimation(
|
|
87
|
+
animations: GsapAnimation[],
|
|
88
|
+
selector: string,
|
|
89
|
+
): GsapAnimation | null {
|
|
90
|
+
return (
|
|
91
|
+
animations.find(
|
|
92
|
+
(a) => a.method === "set" && a.targetSelector === selector && "rotation" in a.properties,
|
|
93
|
+
) ?? null
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function findSizeSetAnimation(
|
|
98
|
+
animations: GsapAnimation[],
|
|
99
|
+
selector: string,
|
|
100
|
+
): GsapAnimation | null {
|
|
101
|
+
return (
|
|
102
|
+
animations.find(
|
|
103
|
+
(a) =>
|
|
104
|
+
a.method === "set" &&
|
|
105
|
+
a.targetSelector === selector &&
|
|
106
|
+
("width" in a.properties || "height" in a.properties),
|
|
107
|
+
) ?? null
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -38,6 +38,23 @@ import {
|
|
|
38
38
|
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
|
|
39
39
|
import { roundTo3 } from "../utils/rounding";
|
|
40
40
|
|
|
41
|
+
// Position channels — used to scope the "has a live position tween?" check so a
|
|
42
|
+
// sibling rotation/scale animation never forces a static position hold into the
|
|
43
|
+
// keyframe branch (which corrupts it into a frozen duration-0 keyframed tween).
|
|
44
|
+
const POSITION_CHANNELS = [
|
|
45
|
+
"x",
|
|
46
|
+
"y",
|
|
47
|
+
"xPercent",
|
|
48
|
+
"yPercent",
|
|
49
|
+
"left",
|
|
50
|
+
"top",
|
|
51
|
+
// GSAP normalizes translateX/Y to x/y at play time, but readTween reads the
|
|
52
|
+
// AUTHORED shape — include them so a hand-authored translateX/Y position tween
|
|
53
|
+
// still counts as a live position tween.
|
|
54
|
+
"translateX",
|
|
55
|
+
"translateY",
|
|
56
|
+
];
|
|
57
|
+
|
|
41
58
|
// ── Property-group tween resolution ───────────────────────────────────────
|
|
42
59
|
|
|
43
60
|
/**
|
|
@@ -185,7 +202,7 @@ export async function tryGsapDragIntercept(
|
|
|
185
202
|
// `tl.set("#el",{x,y})`, not a keyframe conversion: re-nudge an existing set in
|
|
186
203
|
// place (idempotent), else add a new one. This also covers the stale-cache
|
|
187
204
|
// phantom — committing a set is correct because the element genuinely has no live motion.
|
|
188
|
-
const hasNonHold = hasNonHoldTweenForElement(iframe, selector);
|
|
205
|
+
const hasNonHold = hasNonHoldTweenForElement(iframe, selector, undefined, POSITION_CHANNELS);
|
|
189
206
|
// A KEYFRAMED position tween — even one that's currently a flat constant ("hold",
|
|
190
207
|
// e.g. 0% and 100% identical) — is still an animation the user is building, so a
|
|
191
208
|
// drag must add/update a keyframe, NOT fall back to a static `set`. Without this,
|
|
@@ -193,7 +210,9 @@ export async function tryGsapDragIntercept(
|
|
|
193
210
|
// fights the tween (the "drag didn't create a keyframe / didn't persist" bug). The
|
|
194
211
|
// static path is only for elements with NO keyframed position tween (truly static,
|
|
195
212
|
// or just a leftover position-hold `set`).
|
|
196
|
-
|
|
213
|
+
// A zero-duration keyframed tween is a static HOLD, not a live animation —
|
|
214
|
+
// treat it as static so the drag heals it instead of feeding it more keyframes.
|
|
215
|
+
const hasKeyframedPosTween = !!posAnim?.keyframes && resolveTweenDuration(posAnim) > 0;
|
|
197
216
|
if (!hasNonHold && !hasKeyframedPosTween) {
|
|
198
217
|
const existingSet =
|
|
199
218
|
posAnim && posAnim.method === "set" && posAnim.targetSelector === selector
|
|
@@ -362,6 +381,7 @@ export async function tryGsapResizeIntercept(
|
|
|
362
381
|
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); // Convert flat tweens to keyframes only for in-range resizes.
|
|
363
382
|
// Outside-range uses the extend path which handles everything atomically.
|
|
364
383
|
if (!outsideRange) {
|
|
384
|
+
// fallow-ignore-next-line code-duplication
|
|
365
385
|
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
|
|
366
386
|
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
|
|
367
387
|
if (newId) anim = { ...anim, id: newId };
|
|
@@ -530,6 +550,7 @@ export async function tryGsapRotationIntercept(
|
|
|
530
550
|
return true;
|
|
531
551
|
}
|
|
532
552
|
|
|
553
|
+
// fallow-ignore-next-line code-duplication
|
|
533
554
|
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
|
|
534
555
|
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
|
|
535
556
|
if (newId) anim = { ...anim, id: newId };
|