@hyperframes/studio 0.6.52 → 0.6.54
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-CKJCBFsG.js +138 -0
- package/dist/assets/index-ZdgB8MFr.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/components/StudioFeedbackBar.tsx +208 -0
- package/src/components/StudioPreviewArea.tsx +97 -92
- package/src/components/StudioRightPanel.tsx +18 -0
- package/src/components/editor/AnimationCard.tsx +325 -0
- package/src/components/editor/EaseCurveSection.tsx +213 -0
- package/src/components/editor/GsapAnimationSection.tsx +112 -0
- package/src/components/editor/PropertyPanel.tsx +48 -18
- package/src/components/editor/domEditingTypes.ts +2 -0
- package/src/components/editor/gsapAnimationConstants.ts +130 -0
- package/src/components/editor/manualEditingAvailability.ts +6 -0
- package/src/components/editor/manualEdits.test.ts +101 -0
- package/src/components/editor/manualEdits.ts +22 -9
- package/src/components/editor/manualEditsDom.ts +22 -21
- package/src/components/editor/manualOffsetDrag.test.ts +35 -22
- package/src/components/editor/manualOffsetDrag.ts +1 -7
- package/src/components/editor/propertyPanelPrimitives.tsx +6 -1
- package/src/contexts/DomEditContext.tsx +27 -0
- package/src/hooks/useDomEditSession.ts +98 -2
- package/src/hooks/useDomSelection.ts +8 -0
- package/src/hooks/useGsapScriptCommits.ts +303 -0
- package/src/hooks/useGsapTweenCache.ts +80 -0
- package/src/hooks/usePreviewPersistence.ts +1 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +142 -0
- package/src/player/hooks/useTimelinePlayer.ts +2 -1
- package/src/telemetry/events.ts +32 -0
- package/dist/assets/index-Bvy50smZ.js +0 -138
- package/dist/assets/index-SKRp8mGz.css +0 -1
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
import { memo } from "react";
|
|
2
2
|
import { Clock, Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons";
|
|
3
3
|
import { type DomEditSelection } from "./domEditing";
|
|
4
|
-
import {
|
|
5
|
-
readStudioBoxSize,
|
|
6
|
-
readStudioPathOffset,
|
|
7
|
-
readStudioRotation,
|
|
8
|
-
readGsapTranslateFromTransform,
|
|
9
|
-
} from "./manualEdits";
|
|
4
|
+
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
|
|
10
5
|
import type { ImportedFontAsset } from "./fontAssets";
|
|
11
6
|
import {
|
|
12
7
|
EMPTY_STYLES,
|
|
@@ -18,12 +13,13 @@ import {
|
|
|
18
13
|
import { MetricField, Section } from "./propertyPanelPrimitives";
|
|
19
14
|
import { isMediaElement, MediaSection } from "./propertyPanelMediaSection";
|
|
20
15
|
import { TextSection, StyleSections } from "./propertyPanelSections";
|
|
16
|
+
import { GsapAnimationSection } from "./GsapAnimationSection";
|
|
17
|
+
import { STUDIO_GSAP_PANEL_ENABLED } from "./manualEditingAvailability";
|
|
21
18
|
|
|
22
19
|
// Re-export helpers that external consumers import from this module
|
|
23
20
|
export {
|
|
24
21
|
buildStrokeStyleUpdates,
|
|
25
22
|
buildStrokeWidthStyleUpdates,
|
|
26
|
-
clampPanelNumber,
|
|
27
23
|
getCssFilterFunctionPx,
|
|
28
24
|
getClipPathInsetPx,
|
|
29
25
|
inferBoxShadowPreset,
|
|
@@ -54,6 +50,18 @@ interface PropertyPanelProps {
|
|
|
54
50
|
onImportAssets?: (files: FileList) => Promise<string[]>;
|
|
55
51
|
fontAssets?: ImportedFontAsset[];
|
|
56
52
|
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
|
|
53
|
+
gsapAnimations?: import("@hyperframes/core/gsap-parser").GsapAnimation[];
|
|
54
|
+
gsapMultipleTimelines?: boolean;
|
|
55
|
+
gsapUnsupportedTimelinePattern?: boolean;
|
|
56
|
+
onUpdateGsapProperty?: (animId: string, prop: string, value: number | string) => void;
|
|
57
|
+
onUpdateGsapMeta?: (
|
|
58
|
+
animId: string,
|
|
59
|
+
updates: { duration?: number; ease?: string; position?: number },
|
|
60
|
+
) => void;
|
|
61
|
+
onDeleteGsapAnimation?: (animId: string) => void;
|
|
62
|
+
onAddGsapProperty?: (animId: string, prop: string) => void;
|
|
63
|
+
onRemoveGsapProperty?: (animId: string, prop: string) => void;
|
|
64
|
+
onAddGsapAnimation?: (method: "to" | "from" | "set") => void;
|
|
57
65
|
}
|
|
58
66
|
|
|
59
67
|
/* ------------------------------------------------------------------ */
|
|
@@ -146,6 +154,15 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
146
154
|
onImportAssets,
|
|
147
155
|
fontAssets = [],
|
|
148
156
|
onImportFonts,
|
|
157
|
+
gsapAnimations = [],
|
|
158
|
+
gsapMultipleTimelines,
|
|
159
|
+
gsapUnsupportedTimelinePattern,
|
|
160
|
+
onUpdateGsapProperty,
|
|
161
|
+
onUpdateGsapMeta,
|
|
162
|
+
onDeleteGsapAnimation,
|
|
163
|
+
onAddGsapProperty,
|
|
164
|
+
onRemoveGsapProperty,
|
|
165
|
+
onAddGsapAnimation,
|
|
149
166
|
}: PropertyPanelProps) {
|
|
150
167
|
const styles = element?.computedStyles ?? EMPTY_STYLES;
|
|
151
168
|
|
|
@@ -186,11 +203,6 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
186
203
|
const sourceLabel = element.id ? `#${element.id}` : element.selector;
|
|
187
204
|
const showEditableSections = element.capabilities.canEditStyles;
|
|
188
205
|
const manualOffset = readStudioPathOffset(element.element);
|
|
189
|
-
const gsapTranslate = readGsapTranslateFromTransform(element.element);
|
|
190
|
-
const visualOffset = {
|
|
191
|
-
x: manualOffset.x + gsapTranslate.x,
|
|
192
|
-
y: manualOffset.y + gsapTranslate.y,
|
|
193
|
-
};
|
|
194
206
|
const manualSize = readStudioBoxSize(element.element);
|
|
195
207
|
const resolvedWidth =
|
|
196
208
|
manualSize.width > 0
|
|
@@ -204,11 +216,10 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
204
216
|
const commitManualOffset = (axis: "x" | "y", nextValue: string) => {
|
|
205
217
|
const parsed = parsePxMetricValue(nextValue);
|
|
206
218
|
if (parsed == null) return;
|
|
207
|
-
const
|
|
208
|
-
const currentGsap = readGsapTranslateFromTransform(element.element);
|
|
219
|
+
const current = readStudioPathOffset(element.element);
|
|
209
220
|
onSetManualOffset(element, {
|
|
210
|
-
x: axis === "x" ? parsed
|
|
211
|
-
y: axis === "y" ? parsed
|
|
221
|
+
x: axis === "x" ? parsed : current.x,
|
|
222
|
+
y: axis === "y" ? parsed : current.y,
|
|
212
223
|
});
|
|
213
224
|
};
|
|
214
225
|
|
|
@@ -300,14 +311,14 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
300
311
|
<div className={RESPONSIVE_GRID}>
|
|
301
312
|
<MetricField
|
|
302
313
|
label="X"
|
|
303
|
-
value={formatPxMetricValue(
|
|
314
|
+
value={formatPxMetricValue(manualOffset.x)}
|
|
304
315
|
disabled={manualOffsetEditingDisabled}
|
|
305
316
|
scrub
|
|
306
317
|
onCommit={(next) => commitManualOffset("x", next)}
|
|
307
318
|
/>
|
|
308
319
|
<MetricField
|
|
309
320
|
label="Y"
|
|
310
|
-
value={formatPxMetricValue(
|
|
321
|
+
value={formatPxMetricValue(manualOffset.y)}
|
|
311
322
|
disabled={manualOffsetEditingDisabled}
|
|
312
323
|
scrub
|
|
313
324
|
onCommit={(next) => commitManualOffset("y", next)}
|
|
@@ -342,6 +353,25 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
342
353
|
</div>
|
|
343
354
|
</Section>
|
|
344
355
|
|
|
356
|
+
{STUDIO_GSAP_PANEL_ENABLED &&
|
|
357
|
+
onUpdateGsapProperty &&
|
|
358
|
+
onUpdateGsapMeta &&
|
|
359
|
+
onDeleteGsapAnimation &&
|
|
360
|
+
onAddGsapProperty &&
|
|
361
|
+
onAddGsapAnimation && (
|
|
362
|
+
<GsapAnimationSection
|
|
363
|
+
animations={gsapAnimations}
|
|
364
|
+
multipleTimelines={gsapMultipleTimelines}
|
|
365
|
+
unsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
|
|
366
|
+
onUpdateProperty={onUpdateGsapProperty}
|
|
367
|
+
onUpdateMeta={onUpdateGsapMeta}
|
|
368
|
+
onDeleteAnimation={onDeleteGsapAnimation}
|
|
369
|
+
onAddProperty={onAddGsapProperty}
|
|
370
|
+
onRemoveProperty={onRemoveGsapProperty ?? (() => {})}
|
|
371
|
+
onAddAnimation={onAddGsapAnimation}
|
|
372
|
+
/>
|
|
373
|
+
)}
|
|
374
|
+
|
|
345
375
|
{showEditableSections && (
|
|
346
376
|
<StyleSections
|
|
347
377
|
projectId={projectId}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PatchTarget } from "../../utils/sourcePatcher";
|
|
2
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
3
|
|
|
3
4
|
export const CURATED_STYLE_PROPERTIES = [
|
|
4
5
|
"position",
|
|
@@ -86,6 +87,7 @@ export interface DomEditSelection extends PatchTarget {
|
|
|
86
87
|
computedStyles: Record<string, string>;
|
|
87
88
|
textFields: DomEditTextField[];
|
|
88
89
|
capabilities: DomEditCapabilities;
|
|
90
|
+
gsapAnimations?: GsapAnimation[];
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
export interface DomEditLayerItem {
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { controlPointsForGsapEase } from "./studioMotion";
|
|
2
|
+
|
|
3
|
+
export const METHOD_LABELS: Record<string, string> = {
|
|
4
|
+
set: "Set",
|
|
5
|
+
to: "Animate",
|
|
6
|
+
from: "Animate In",
|
|
7
|
+
fromTo: "Animate",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const METHOD_TOOLTIPS: Record<string, string> = {
|
|
11
|
+
set: "Instantly snap to these values — no transition",
|
|
12
|
+
to: "Smoothly animate the element to these target values",
|
|
13
|
+
from: "Element starts at these values and transitions to its normal state",
|
|
14
|
+
fromTo: "Animate from one state to another",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const PROP_LABELS: Record<string, string> = {
|
|
18
|
+
x: "Move X",
|
|
19
|
+
y: "Move Y",
|
|
20
|
+
width: "Width",
|
|
21
|
+
height: "Height",
|
|
22
|
+
rotation: "Rotate",
|
|
23
|
+
opacity: "Opacity",
|
|
24
|
+
scale: "Scale",
|
|
25
|
+
scaleX: "Scale X",
|
|
26
|
+
scaleY: "Scale Y",
|
|
27
|
+
autoAlpha: "Visibility",
|
|
28
|
+
visibility: "Visible",
|
|
29
|
+
scaleX_alias: "Stretch X",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const PROP_UNITS: Record<string, string> = {
|
|
33
|
+
x: "px",
|
|
34
|
+
y: "px",
|
|
35
|
+
width: "px",
|
|
36
|
+
height: "px",
|
|
37
|
+
rotation: "°",
|
|
38
|
+
opacity: "%",
|
|
39
|
+
scale: "×",
|
|
40
|
+
scaleX: "×",
|
|
41
|
+
scaleY: "×",
|
|
42
|
+
autoAlpha: "%",
|
|
43
|
+
visibility: "",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const PROP_TOOLTIPS: Record<string, string> = {
|
|
47
|
+
x: "Move left/right (negative = left, positive = right)",
|
|
48
|
+
y: "Move up/down (negative = up, positive = down)",
|
|
49
|
+
opacity: "How visible (0 = invisible, 1 = fully visible)",
|
|
50
|
+
scale: "Size multiplier (1 = normal, 2 = double, 0.5 = half)",
|
|
51
|
+
scaleX: "Horizontal stretch (1 = normal)",
|
|
52
|
+
scaleY: "Vertical stretch (1 = normal)",
|
|
53
|
+
rotation: "Spin angle (360 = full rotation)",
|
|
54
|
+
width: "Element width",
|
|
55
|
+
height: "Element height",
|
|
56
|
+
autoAlpha: "Like opacity but hides element completely at 0",
|
|
57
|
+
visibility: "Show or hide the element",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const EASE_LABELS: Record<string, string> = {
|
|
61
|
+
none: "Constant speed",
|
|
62
|
+
"power1.out": "Gentle slowdown",
|
|
63
|
+
"power2.out": "Smooth slowdown",
|
|
64
|
+
"power3.out": "Snappy slowdown",
|
|
65
|
+
"power4.out": "Sharp slowdown",
|
|
66
|
+
"power1.in": "Gentle speedup",
|
|
67
|
+
"power2.in": "Smooth speedup",
|
|
68
|
+
"power3.in": "Strong speedup",
|
|
69
|
+
"power4.in": "Sharp speedup",
|
|
70
|
+
"power1.inOut": "Gentle ease",
|
|
71
|
+
"power2.inOut": "Smooth ease",
|
|
72
|
+
"power3.inOut": "Strong ease",
|
|
73
|
+
"power4.inOut": "Sharp ease",
|
|
74
|
+
"back.out": "Overshoot & settle",
|
|
75
|
+
"back.in": "Pull back & go",
|
|
76
|
+
"back.inOut": "Pull & overshoot",
|
|
77
|
+
"elastic.out": "Springy bounce",
|
|
78
|
+
"elastic.in": "Wind up spring",
|
|
79
|
+
"elastic.inOut": "Full spring",
|
|
80
|
+
"bounce.out": "Drop & bounce",
|
|
81
|
+
"bounce.in": "Reverse bounce",
|
|
82
|
+
"bounce.inOut": "Double bounce",
|
|
83
|
+
"expo.out": "Very snappy stop",
|
|
84
|
+
"expo.in": "Very slow start",
|
|
85
|
+
"expo.inOut": "Dramatic ease",
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const EASE_CURVES: Record<string, [number, number, number, number]> = {
|
|
89
|
+
none: [0, 0, 1, 1],
|
|
90
|
+
"power1.out": [0, 0, 0.58, 1],
|
|
91
|
+
"power2.out": [0.16, 1, 0.3, 1],
|
|
92
|
+
"power3.out": [0.08, 0.82, 0.17, 1],
|
|
93
|
+
"power4.out": [0.06, 0.73, 0.09, 1],
|
|
94
|
+
"power1.in": [0.42, 0, 1, 1],
|
|
95
|
+
"power2.in": [0.55, 0.06, 0.68, 0.19],
|
|
96
|
+
"power3.in": [0.6, 0.04, 0.98, 0.34],
|
|
97
|
+
"power4.in": [0.7, 0, 0.84, 0],
|
|
98
|
+
"power1.inOut": [0.42, 0, 0.58, 1],
|
|
99
|
+
"power2.inOut": [0.45, 0.05, 0.55, 0.95],
|
|
100
|
+
"power3.inOut": [0.65, 0.05, 0.35, 1],
|
|
101
|
+
"power4.inOut": [0.76, 0, 0.24, 1],
|
|
102
|
+
"back.out": [0.34, 1.56, 0.64, 1],
|
|
103
|
+
"back.in": [0.36, 0, 0.66, -0.56],
|
|
104
|
+
"back.inOut": [0.68, -0.55, 0.27, 1.55],
|
|
105
|
+
"expo.out": [0.16, 1, 0.3, 1],
|
|
106
|
+
"expo.in": [0.7, 0, 0.84, 0],
|
|
107
|
+
"expo.inOut": [0.87, 0, 0.13, 1],
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export function parseCustomEaseFromString(ease: string): {
|
|
111
|
+
x1: number;
|
|
112
|
+
y1: number;
|
|
113
|
+
x2: number;
|
|
114
|
+
y2: number;
|
|
115
|
+
} {
|
|
116
|
+
const match = ease.match(/^custom\((.+)\)$/);
|
|
117
|
+
if (!match) return controlPointsForGsapEase("power2.out");
|
|
118
|
+
const data = match[1];
|
|
119
|
+
const nums = data.match(/[\d.]+/g)?.map(Number);
|
|
120
|
+
if (!nums || nums.length < 6) return controlPointsForGsapEase("power2.out");
|
|
121
|
+
return { x1: nums[2], y1: nums[3], x2: nums[4], y2: nums[5] };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const ADD_METHODS = ["to", "from", "set"] as const;
|
|
125
|
+
|
|
126
|
+
export const ADD_METHOD_LABELS: Record<string, string> = {
|
|
127
|
+
to: "Animate",
|
|
128
|
+
from: "Animate In",
|
|
129
|
+
set: "Set Instantly",
|
|
130
|
+
};
|
|
@@ -65,6 +65,12 @@ export const STUDIO_BLOCKS_PANEL_ENABLED = resolveStudioBooleanEnvFlag(
|
|
|
65
65
|
true,
|
|
66
66
|
);
|
|
67
67
|
|
|
68
|
+
export const STUDIO_GSAP_PANEL_ENABLED = resolveStudioBooleanEnvFlag(
|
|
69
|
+
env,
|
|
70
|
+
["VITE_STUDIO_ENABLE_GSAP_PANEL", "VITE_STUDIO_GSAP_PANEL_ENABLED"],
|
|
71
|
+
false,
|
|
72
|
+
);
|
|
73
|
+
|
|
68
74
|
export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
|
|
69
75
|
|
|
70
76
|
export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
|
|
@@ -516,3 +516,104 @@ describe("studio manual edits", () => {
|
|
|
516
516
|
expect(frames).toHaveLength(0);
|
|
517
517
|
});
|
|
518
518
|
});
|
|
519
|
+
|
|
520
|
+
describe("applyStudioPathOffset sets correct attribute name", () => {
|
|
521
|
+
it("sets data-hf-studio-path-offset without double data- prefix", () => {
|
|
522
|
+
const window = new Window();
|
|
523
|
+
const el = window.document.createElement("div");
|
|
524
|
+
window.document.body.append(el);
|
|
525
|
+
|
|
526
|
+
applyStudioPathOffset(el, { x: 100, y: 50 });
|
|
527
|
+
|
|
528
|
+
expect(el.getAttribute("data-hf-studio-path-offset")).toBe("true");
|
|
529
|
+
expect(el.getAttribute("data-data-hf-studio-path-offset")).toBeNull();
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it("stores offset in CSS vars alongside the attribute marker", () => {
|
|
533
|
+
const window = new Window();
|
|
534
|
+
const el = window.document.createElement("div");
|
|
535
|
+
window.document.body.append(el);
|
|
536
|
+
|
|
537
|
+
applyStudioPathOffset(el, { x: 50, y: 25 });
|
|
538
|
+
|
|
539
|
+
expect(el.getAttribute("data-hf-studio-path-offset")).toBe("true");
|
|
540
|
+
expect(el.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe("50px");
|
|
541
|
+
expect(el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)).toBe("25px");
|
|
542
|
+
expect(el.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("corrects offset applied on top of legacy double-prefix element", () => {
|
|
546
|
+
const window = new Window();
|
|
547
|
+
const el = window.document.createElement("div");
|
|
548
|
+
el.setAttribute("data-data-hf-studio-path-offset", "true");
|
|
549
|
+
el.style.setProperty(STUDIO_OFFSET_X_PROP, "200px");
|
|
550
|
+
el.style.setProperty(STUDIO_OFFSET_Y_PROP, "-30px");
|
|
551
|
+
window.document.body.append(el);
|
|
552
|
+
|
|
553
|
+
applyStudioPathOffset(el, { x: 200, y: -30 });
|
|
554
|
+
|
|
555
|
+
expect(el.getAttribute("data-hf-studio-path-offset")).toBe("true");
|
|
556
|
+
expect(readStudioPathOffset(el)).toEqual({ x: 200, y: -30 });
|
|
557
|
+
expect(el.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
describe("applyStudioPathOffset strips GSAP double-counted translate", () => {
|
|
562
|
+
it("strips GSAP transform translate when applying offset", () => {
|
|
563
|
+
const window = new Window();
|
|
564
|
+
const element = window.document.createElement("div");
|
|
565
|
+
window.document.body.append(element);
|
|
566
|
+
|
|
567
|
+
// Simulate GSAP having baked translate into the transform matrix
|
|
568
|
+
element.style.setProperty("transform", "matrix(1, 0, 0, 1, 200, 0)");
|
|
569
|
+
|
|
570
|
+
applyStudioPathOffset(element, { x: 200, y: 0 });
|
|
571
|
+
|
|
572
|
+
// The transform translate should be stripped (GSAP's 200px removed)
|
|
573
|
+
const transform = element.style.getPropertyValue("transform");
|
|
574
|
+
if (transform && transform !== "none") {
|
|
575
|
+
const m = new window.DOMMatrix(transform);
|
|
576
|
+
expect(m.m41).toBe(0);
|
|
577
|
+
expect(m.m42).toBe(0);
|
|
578
|
+
}
|
|
579
|
+
// The offset should be stored in CSS vars
|
|
580
|
+
expect(readStudioPathOffset(element).x).toBe(200);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it("subtracts only the studio offset from GSAP transform, preserving animation values", () => {
|
|
584
|
+
const window = new Window();
|
|
585
|
+
const element = window.document.createElement("div");
|
|
586
|
+
window.document.body.append(element);
|
|
587
|
+
|
|
588
|
+
// GSAP has scale + baked translate (offset 50) + animation contribution (-70)
|
|
589
|
+
// Total m42 = 50 + (-70) = -20
|
|
590
|
+
element.style.setProperty("transform", "matrix(0.5, 0, 0, 0.5, 0, -20)");
|
|
591
|
+
|
|
592
|
+
applyStudioPathOffset(element, { x: 0, y: 50 });
|
|
593
|
+
|
|
594
|
+
const transform = element.style.getPropertyValue("transform");
|
|
595
|
+
if (transform && transform !== "none") {
|
|
596
|
+
const m = new window.DOMMatrix(transform);
|
|
597
|
+
expect(m.a).toBeCloseTo(0.5);
|
|
598
|
+
expect(m.d).toBeCloseTo(0.5);
|
|
599
|
+
// Only the studio offset (50) is subtracted, animation contribution (-70) preserved
|
|
600
|
+
expect(m.m41).toBe(0);
|
|
601
|
+
expect(m.m42).toBe(-70);
|
|
602
|
+
}
|
|
603
|
+
expect(readStudioPathOffset(element).y).toBe(50);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it("offset survives repeated applyStudioPathOffset calls without drift", () => {
|
|
607
|
+
const window = new Window();
|
|
608
|
+
const element = window.document.createElement("div");
|
|
609
|
+
window.document.body.append(element);
|
|
610
|
+
|
|
611
|
+
// Apply offset 3 times with same value (simulates reapply hook firing multiple times)
|
|
612
|
+
applyStudioPathOffset(element, { x: 100, y: -20 });
|
|
613
|
+
applyStudioPathOffset(element, { x: 100, y: -20 });
|
|
614
|
+
applyStudioPathOffset(element, { x: 100, y: -20 });
|
|
615
|
+
|
|
616
|
+
expect(readStudioPathOffset(element).x).toBe(100);
|
|
617
|
+
expect(readStudioPathOffset(element).y).toBe(-20);
|
|
618
|
+
});
|
|
619
|
+
});
|
|
@@ -3,9 +3,7 @@ export {
|
|
|
3
3
|
STUDIO_OFFSET_X_PROP,
|
|
4
4
|
STUDIO_OFFSET_Y_PROP,
|
|
5
5
|
STUDIO_WIDTH_PROP,
|
|
6
|
-
STUDIO_HEIGHT_PROP,
|
|
7
6
|
STUDIO_ROTATION_PROP,
|
|
8
|
-
type StudioManualEditSeekWindow,
|
|
9
7
|
type StudioBoxSizeSnapshot,
|
|
10
8
|
type StudioRotationSnapshot,
|
|
11
9
|
type StudioPathOffsetSnapshot,
|
|
@@ -20,7 +18,6 @@ export {
|
|
|
20
18
|
readStudioPathOffset,
|
|
21
19
|
readStudioBoxSize,
|
|
22
20
|
readStudioRotation,
|
|
23
|
-
readGsapTranslateFromTransform,
|
|
24
21
|
applyStudioPathOffset,
|
|
25
22
|
applyStudioPathOffsetDraft,
|
|
26
23
|
applyStudioBoxSize,
|
|
@@ -28,8 +25,6 @@ export {
|
|
|
28
25
|
applyStudioRotation,
|
|
29
26
|
applyStudioRotationDraft,
|
|
30
27
|
reapplyPositionEditsAfterSeek,
|
|
31
|
-
buildMotionPatches,
|
|
32
|
-
buildClearMotionPatches,
|
|
33
28
|
} from "./manualEditsDom";
|
|
34
29
|
|
|
35
30
|
export {
|
|
@@ -51,7 +46,6 @@ import {
|
|
|
51
46
|
STUDIO_MANUAL_EDITS_PLAYBACK_FRAME_PROP,
|
|
52
47
|
} from "./manualEditsTypes";
|
|
53
48
|
import { finiteNumber } from "./manualEditsParsing";
|
|
54
|
-
import { isStudioManualEditGestureActive } from "./manualEditsDom";
|
|
55
49
|
|
|
56
50
|
/* ── Seek/play reapply wrappers ───────────────────────────────────── */
|
|
57
51
|
function markWrapped(fn: (...args: unknown[]) => unknown): void {
|
|
@@ -262,6 +256,28 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi
|
|
|
262
256
|
wrapApplyAfterFunction(studioWin, timeline, "pause") || wrappedNamedTimelinePause;
|
|
263
257
|
}
|
|
264
258
|
|
|
259
|
+
// Auto-wrap timelines registered AFTER this install runs. GSAP compositions
|
|
260
|
+
// register via `window.__timelines[id] = tl` which may happen after the
|
|
261
|
+
// Studio hook runs. The Proxy intercepts new registrations and wraps
|
|
262
|
+
// seek/play/pause immediately, closing the gap that causes translate doubling.
|
|
263
|
+
if (studioWin.__timelines && !(studioWin.__timelines as Record<string, unknown>).__proxied) {
|
|
264
|
+
const original = studioWin.__timelines;
|
|
265
|
+
studioWin.__timelines = new Proxy(original, {
|
|
266
|
+
set(target, prop, value) {
|
|
267
|
+
target[prop as string] = value;
|
|
268
|
+
if (typeof value === "object" && value !== null) {
|
|
269
|
+
const tl = value as Record<string, unknown>;
|
|
270
|
+
wrapSeekReapplyFunction(studioWin, tl, "seek");
|
|
271
|
+
wrapPlayReapplyFunction(studioWin, tl, "play");
|
|
272
|
+
wrapApplyAfterFunction(studioWin, tl, "pause");
|
|
273
|
+
studioWin.__hfStudioManualEditsApply?.();
|
|
274
|
+
}
|
|
275
|
+
return true;
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
(studioWin.__timelines as Record<string, unknown>).__proxied = true;
|
|
279
|
+
}
|
|
280
|
+
|
|
265
281
|
if (isStudioManualEditPlaybackActive(studioWin)) {
|
|
266
282
|
startStudioManualEditPlaybackReapply(studioWin);
|
|
267
283
|
}
|
|
@@ -280,6 +296,3 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi
|
|
|
280
296
|
wrappedNamedTimelinePause
|
|
281
297
|
);
|
|
282
298
|
}
|
|
283
|
-
|
|
284
|
-
// Re-export for internal use (seek hooks need this)
|
|
285
|
-
export { isStudioManualEditGestureActive };
|
|
@@ -48,7 +48,7 @@ export function endStudioManualEditGesture(element: HTMLElement, token?: string)
|
|
|
48
48
|
element.removeAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
function isStudioManualEditGestureActive(element: HTMLElement): boolean {
|
|
52
52
|
return element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR);
|
|
53
53
|
}
|
|
54
54
|
|
|
@@ -213,26 +213,15 @@ function writeStudioPathOffsetVars(
|
|
|
213
213
|
|
|
214
214
|
// GSAP 3.x reads the resolved CSS `translate` individual property at initialization and bakes it
|
|
215
215
|
// into element.style.transform (as a matrix) on every seek. When the studio's reapply hook also
|
|
216
|
-
// writes `translate`, both properties compose additively, doubling the visual offset.
|
|
217
|
-
//
|
|
216
|
+
// writes `translate`, both properties compose additively, doubling the visual offset.
|
|
217
|
+
//
|
|
218
|
+
// This helper subtracts only the baked studio offset from m41/m42, preserving any GSAP animation
|
|
219
|
+
// contribution (e.g. a tween animating y: -20). The studio offset is read from the CSS custom
|
|
220
|
+
// properties which tell us exactly how much was baked from the CSS translate.
|
|
218
221
|
function isIdentityAfterTranslateStrip(m: DOMMatrix): boolean {
|
|
219
222
|
return m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1;
|
|
220
223
|
}
|
|
221
224
|
|
|
222
|
-
export function readGsapTranslateFromTransform(element: HTMLElement): { x: number; y: number } {
|
|
223
|
-
const transform = element.style.getPropertyValue("transform");
|
|
224
|
-
if (!transform || transform === "none") return { x: 0, y: 0 };
|
|
225
|
-
const DOMMatrixCtor = (element.ownerDocument.defaultView as (Window & typeof globalThis) | null)
|
|
226
|
-
?.DOMMatrix;
|
|
227
|
-
if (!DOMMatrixCtor) return { x: 0, y: 0 };
|
|
228
|
-
try {
|
|
229
|
-
const m = new DOMMatrixCtor(transform);
|
|
230
|
-
return { x: m.m41, y: m.m42 };
|
|
231
|
-
} catch {
|
|
232
|
-
return { x: 0, y: 0 };
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
225
|
function stripGsapTranslateFromTransform(element: HTMLElement): void {
|
|
237
226
|
const transform = element.style.getPropertyValue("transform");
|
|
238
227
|
if (!transform || transform === "none") return;
|
|
@@ -242,9 +231,11 @@ function stripGsapTranslateFromTransform(element: HTMLElement): void {
|
|
|
242
231
|
try {
|
|
243
232
|
const m = new DOMMatrixCtor(transform);
|
|
244
233
|
if (m.m41 === 0 && m.m42 === 0) return;
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
234
|
+
const offsetX = readPxCustomProperty(element, STUDIO_OFFSET_X_PROP);
|
|
235
|
+
const offsetY = readPxCustomProperty(element, STUDIO_OFFSET_Y_PROP);
|
|
236
|
+
m.m41 -= offsetX;
|
|
237
|
+
m.m42 -= offsetY;
|
|
238
|
+
if (Math.abs(m.m41) < 0.01 && Math.abs(m.m42) < 0.01 && isIdentityAfterTranslateStrip(m)) {
|
|
248
239
|
element.style.removeProperty("transform");
|
|
249
240
|
} else {
|
|
250
241
|
element.style.setProperty("transform", m.toString());
|
|
@@ -493,9 +484,19 @@ export {
|
|
|
493
484
|
function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
|
|
494
485
|
const ctor = doc.defaultView?.HTMLElement;
|
|
495
486
|
if (!ctor) return [];
|
|
496
|
-
|
|
487
|
+
const elements = Array.from(doc.querySelectorAll(`[${attr}="true"]`)).filter(
|
|
497
488
|
(el): el is HTMLElement => el instanceof ctor,
|
|
498
489
|
);
|
|
490
|
+
// Handle legacy HTML files where attributes were persisted with a double data- prefix
|
|
491
|
+
const legacyAttr = `data-${attr}`;
|
|
492
|
+
for (const el of doc.querySelectorAll(`[${legacyAttr}="true"]`)) {
|
|
493
|
+
if (el instanceof ctor && !el.hasAttribute(attr)) {
|
|
494
|
+
el.setAttribute(attr, "true");
|
|
495
|
+
el.removeAttribute(legacyAttr);
|
|
496
|
+
elements.push(el);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return elements;
|
|
499
500
|
}
|
|
500
501
|
|
|
501
502
|
function reapplyPathOffsets(doc: Document): void {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { Window } from "happy-dom";
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
3
|
import {
|
|
4
|
+
applyManualOffsetDragCommit,
|
|
4
5
|
applyManualOffsetDragMatrix,
|
|
5
6
|
createManualOffsetDragMember,
|
|
7
|
+
endManualOffsetDragMembers,
|
|
6
8
|
invertManualOffsetDragMatrix,
|
|
7
9
|
measureManualOffsetDragScreenToOffsetMatrix,
|
|
8
10
|
resolveManualOffsetForPointerDelta,
|
|
@@ -140,8 +142,8 @@ describe("measureManualOffsetDragScreenToOffsetMatrix", () => {
|
|
|
140
142
|
});
|
|
141
143
|
});
|
|
142
144
|
|
|
143
|
-
describe("createManualOffsetDragMember
|
|
144
|
-
it("
|
|
145
|
+
describe("createManualOffsetDragMember uses raw CSS var offset", () => {
|
|
146
|
+
it("ignores GSAP transform — initialOffset comes from CSS vars only", () => {
|
|
145
147
|
const window = new Window();
|
|
146
148
|
const element = window.document.createElement("div");
|
|
147
149
|
window.document.body.append(element);
|
|
@@ -164,14 +166,18 @@ describe("createManualOffsetDragMember GSAP translate compensation", () => {
|
|
|
164
166
|
expect(result.ok).toBe(true);
|
|
165
167
|
if (!result.ok) return;
|
|
166
168
|
expect(result.member.initialOffset.x).toBe(0);
|
|
167
|
-
expect(result.member.initialOffset.y).toBe(
|
|
169
|
+
expect(result.member.initialOffset.y).toBe(0);
|
|
168
170
|
});
|
|
169
171
|
|
|
170
|
-
it("
|
|
172
|
+
it("reads only the CSS var offset, not GSAP transform", () => {
|
|
171
173
|
const window = new Window();
|
|
172
174
|
const element = window.document.createElement("div");
|
|
173
175
|
window.document.body.append(element);
|
|
174
176
|
|
|
177
|
+
element.style.setProperty(STUDIO_OFFSET_X_PROP, "30px");
|
|
178
|
+
element.style.setProperty(STUDIO_OFFSET_Y_PROP, "10px");
|
|
179
|
+
element.style.setProperty("transform", "translate(50px, -15px)");
|
|
180
|
+
|
|
175
181
|
element.getBoundingClientRect = () => {
|
|
176
182
|
const offsetX = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0;
|
|
177
183
|
const offsetY = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)) || 0;
|
|
@@ -187,35 +193,42 @@ describe("createManualOffsetDragMember GSAP translate compensation", () => {
|
|
|
187
193
|
|
|
188
194
|
expect(result.ok).toBe(true);
|
|
189
195
|
if (!result.ok) return;
|
|
190
|
-
expect(result.member.initialOffset.x).toBe(
|
|
191
|
-
expect(result.member.initialOffset.y).toBe(
|
|
196
|
+
expect(result.member.initialOffset.x).toBe(30);
|
|
197
|
+
expect(result.member.initialOffset.y).toBe(10);
|
|
192
198
|
});
|
|
193
199
|
|
|
194
|
-
it("
|
|
200
|
+
it("does not accumulate drift across multiple drag cycles", () => {
|
|
195
201
|
const window = new Window();
|
|
196
202
|
const element = window.document.createElement("div");
|
|
197
203
|
window.document.body.append(element);
|
|
198
204
|
|
|
199
|
-
element.style.setProperty(STUDIO_OFFSET_X_PROP, "30px");
|
|
200
|
-
element.style.setProperty(STUDIO_OFFSET_Y_PROP, "10px");
|
|
201
|
-
element.style.setProperty("transform", "translate(50px, -15px)");
|
|
202
|
-
|
|
203
205
|
element.getBoundingClientRect = () => {
|
|
204
206
|
const offsetX = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0;
|
|
205
207
|
const offsetY = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)) || 0;
|
|
206
208
|
return new window.DOMRect(10 + offsetX, 20 + offsetY, 100, 50);
|
|
207
209
|
};
|
|
208
210
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
211
|
+
// Simulate GSAP baking a translate into transform each cycle
|
|
212
|
+
for (let cycle = 0; cycle < 3; cycle++) {
|
|
213
|
+
element.style.setProperty("transform", `translate(${50 * (cycle + 1)}px, 0px)`);
|
|
214
|
+
|
|
215
|
+
const result = createManualOffsetDragMember({
|
|
216
|
+
key: "test",
|
|
217
|
+
selection: { element } as never,
|
|
218
|
+
element,
|
|
219
|
+
rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 },
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(result.ok).toBe(true);
|
|
223
|
+
if (!result.ok) return;
|
|
224
|
+
// initialOffset should always be the CSS var value, never inflated by GSAP transform
|
|
225
|
+
const currentRawX =
|
|
226
|
+
Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0;
|
|
227
|
+
expect(result.member.initialOffset.x).toBe(currentRawX);
|
|
228
|
+
|
|
229
|
+
// Simulate drag commit: apply a small offset
|
|
230
|
+
applyManualOffsetDragCommit(result.member, 10, 0);
|
|
231
|
+
endManualOffsetDragMembers([result.member]);
|
|
232
|
+
}
|
|
220
233
|
});
|
|
221
234
|
});
|
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
beginStudioManualEditGesture,
|
|
6
6
|
captureStudioPathOffset,
|
|
7
7
|
endStudioManualEditGesture,
|
|
8
|
-
readGsapTranslateFromTransform,
|
|
9
8
|
readStudioPathOffset,
|
|
10
9
|
restoreStudioPathOffset,
|
|
11
10
|
type StudioPathOffsetSnapshot,
|
|
@@ -232,12 +231,7 @@ export function createManualOffsetDragMember(input: {
|
|
|
232
231
|
element: HTMLElement;
|
|
233
232
|
rect: ManualOffsetDragRect;
|
|
234
233
|
}): ManualOffsetDragMemberResult {
|
|
235
|
-
const
|
|
236
|
-
const gsapTranslate = readGsapTranslateFromTransform(input.element);
|
|
237
|
-
const initialOffset = {
|
|
238
|
-
x: rawOffset.x + gsapTranslate.x,
|
|
239
|
-
y: rawOffset.y + gsapTranslate.y,
|
|
240
|
-
};
|
|
234
|
+
const initialOffset = readStudioPathOffset(input.element);
|
|
241
235
|
const initialPathOffset = captureStudioPathOffset(input.element);
|
|
242
236
|
const gestureToken = beginStudioManualEditGesture(input.element);
|
|
243
237
|
const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset);
|