@hyperframes/studio 0.7.58 → 0.7.60

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.
Files changed (173) hide show
  1. package/dist/assets/{hyperframes-player-CtTDO63S.js → hyperframes-player-3XTTaVNf.js} +1 -1
  2. package/dist/assets/{index-C47jAC3Q.js → index-D6etaey-.js} +1 -1
  3. package/dist/assets/index-DXbu6IPT.css +1 -0
  4. package/dist/assets/{index-DeQPzqwH.js → index-Dh_WhagG.js} +1 -1
  5. package/dist/assets/index-cH6NfVV_.js +426 -0
  6. package/dist/index.d.ts +12 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +13755 -8627
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.d.ts +5 -0
  11. package/dist/styles/tailwind-preset.js +8 -1
  12. package/dist/styles/tailwind-preset.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +4 -4
  15. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  16. package/src/components/StudioRightPanel.tsx +42 -53
  17. package/src/components/editor/AnimationCard.test.tsx +134 -0
  18. package/src/components/editor/AnimationCard.tsx +19 -4
  19. package/src/components/editor/ArcPathControls.tsx +1 -0
  20. package/src/components/editor/GestureRecordControl.tsx +7 -1
  21. package/src/components/editor/GsapAnimationSection.tsx +109 -14
  22. package/src/components/editor/InspectorHeaderActions.tsx +42 -4
  23. package/src/components/editor/PropertyPanel.test.tsx +948 -0
  24. package/src/components/editor/PropertyPanel.tsx +144 -148
  25. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  26. package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
  27. package/src/components/editor/PropertyPanelFlat.tsx +580 -0
  28. package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
  29. package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
  30. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
  31. package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
  32. package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
  33. package/src/components/editor/gsapLivePreview.ts +34 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +10 -0
  35. package/src/components/editor/manualEditingAvailability.ts +9 -0
  36. package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
  37. package/src/components/editor/propertyPanelColor.test.tsx +28 -0
  38. package/src/components/editor/propertyPanelColor.tsx +31 -1
  39. package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
  40. package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
  41. package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
  42. package/src/components/editor/propertyPanelFill.tsx +22 -5
  43. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
  44. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
  45. package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
  46. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
  47. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
  48. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
  49. package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
  50. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
  51. package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
  52. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
  53. package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
  54. package/src/components/editor/propertyPanelFlatSelectRow.tsx +107 -0
  55. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
  56. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
  57. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
  58. package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
  59. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
  60. package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
  61. package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
  62. package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
  63. package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
  64. package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
  65. package/src/components/editor/propertyPanelFont.test.tsx +30 -0
  66. package/src/components/editor/propertyPanelFont.tsx +136 -95
  67. package/src/components/editor/propertyPanelHelpers.ts +73 -0
  68. package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
  69. package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
  70. package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
  71. package/src/components/editor/propertyPanelSections.test.tsx +161 -0
  72. package/src/components/editor/propertyPanelSections.tsx +154 -90
  73. package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
  74. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  75. package/src/components/editor/propertyPanelTypes.ts +14 -1
  76. package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
  77. package/src/components/editor/propertyPanelValueTier.ts +28 -0
  78. package/src/components/editor/useColorGradingController.test.ts +418 -0
  79. package/src/components/editor/useColorGradingController.ts +596 -0
  80. package/src/components/panels/VariablesPanel.tsx +4 -0
  81. package/src/components/sidebar/CompositionsTab.test.ts +28 -2
  82. package/src/components/sidebar/CompositionsTab.tsx +3 -1
  83. package/src/components/storyboard/FramePoster.tsx +24 -4
  84. package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
  85. package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
  86. package/src/components/storyboard/StoryboardGrid.tsx +23 -1
  87. package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
  88. package/src/components/storyboard/StoryboardView.tsx +9 -2
  89. package/src/components/storyboard/frameComments.test.ts +95 -0
  90. package/src/components/storyboard/frameComments.ts +115 -0
  91. package/src/components/storyboard/useFrameComments.ts +82 -0
  92. package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
  93. package/src/contexts/DesignPanelInputContext.tsx +48 -0
  94. package/src/contexts/DomEditContext.tsx +4 -0
  95. package/src/contexts/FileManagerContext.tsx +3 -0
  96. package/src/contexts/PanelLayoutContext.tsx +3 -0
  97. package/src/contexts/VariablePromoteContext.test.tsx +67 -0
  98. package/src/contexts/VariablePromoteContext.tsx +10 -5
  99. package/src/hooks/domEditCommitRunner.ts +11 -0
  100. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  101. package/src/hooks/timelineEditingHelpers.ts +2 -2
  102. package/src/hooks/timelineTrackVisibility.test.ts +52 -0
  103. package/src/hooks/timelineTrackVisibility.ts +27 -10
  104. package/src/hooks/useAppHotkeys.ts +9 -0
  105. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  106. package/src/hooks/useDomEditCommits.test.tsx +41 -1
  107. package/src/hooks/useDomEditCommits.ts +31 -177
  108. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  109. package/src/hooks/useDomEditSession.ts +18 -6
  110. package/src/hooks/useDomEditTextCommits.ts +14 -9
  111. package/src/hooks/useDomEditWiring.ts +10 -9
  112. package/src/hooks/useEditorSave.ts +1 -1
  113. package/src/hooks/useElementLifecycleOps.ts +8 -3
  114. package/src/hooks/useFileManager.projectOwnership.test.tsx +109 -0
  115. package/src/hooks/useFileManager.ts +151 -65
  116. package/src/hooks/useGsapAnimationOps.ts +7 -6
  117. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  118. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  119. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  120. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  121. package/src/hooks/useGsapScriptCommits.ts +150 -50
  122. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  123. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  124. package/src/hooks/useInspectorSplitResize.ts +51 -0
  125. package/src/hooks/usePanelLayout.test.ts +118 -0
  126. package/src/hooks/usePanelLayout.ts +24 -1
  127. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  128. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  129. package/src/hooks/usePersistentEditHistory.ts +71 -31
  130. package/src/hooks/usePreviewPersistence.ts +4 -1
  131. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  132. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  133. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  134. package/src/hooks/useRazorSplit.history.test.tsx +12 -4
  135. package/src/hooks/useRazorSplit.test.tsx +88 -0
  136. package/src/hooks/useRazorSplit.testHelpers.ts +3 -2
  137. package/src/hooks/useRazorSplit.ts +50 -16
  138. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  139. package/src/hooks/useSdkSession.ts +155 -14
  140. package/src/hooks/useSlideshowPersist.ts +7 -1
  141. package/src/hooks/useStoryboard.ts +23 -4
  142. package/src/hooks/useTimelineEditing.test.tsx +4 -1
  143. package/src/hooks/useTimelineEditing.ts +18 -10
  144. package/src/hooks/useTimelineEditingTypes.ts +5 -1
  145. package/src/hooks/useTimelineGroupEditing.ts +17 -3
  146. package/src/hooks/useVariablesPersist.ts +9 -7
  147. package/src/icons/SystemIcons.tsx +2 -0
  148. package/src/styles/studio.css +22 -0
  149. package/src/styles/tailwind-preset.shared.js +7 -0
  150. package/src/utils/designInputTracking.test.ts +97 -0
  151. package/src/utils/designInputTracking.ts +80 -0
  152. package/src/utils/domEditSaveQueue.test.ts +19 -0
  153. package/src/utils/domEditSaveQueue.ts +2 -1
  154. package/src/utils/sdkCutover.gate.test.ts +7 -7
  155. package/src/utils/sdkCutover.test.ts +511 -62
  156. package/src/utils/sdkCutover.ts +181 -219
  157. package/src/utils/sdkEditTransaction.ts +293 -0
  158. package/src/utils/sdkResolverAttempts.ts +94 -0
  159. package/src/utils/sdkResolverShadow.test.ts +148 -0
  160. package/src/utils/sdkResolverShadow.ts +139 -106
  161. package/src/utils/setSlideshowManifest.test.ts +0 -9
  162. package/src/utils/setSlideshowManifest.ts +20 -27
  163. package/src/utils/studioFileHistory.test.ts +41 -0
  164. package/src/utils/studioFileHistory.ts +34 -29
  165. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  166. package/src/utils/studioFileVersion.test.ts +36 -0
  167. package/src/utils/studioFileVersion.ts +23 -0
  168. package/src/utils/studioHelpers.test.ts +45 -0
  169. package/src/utils/studioHelpers.ts +45 -14
  170. package/src/utils/studioSaveDiagnostics.test.ts +18 -0
  171. package/src/utils/studioSaveDiagnostics.ts +38 -0
  172. package/dist/assets/index-B_UvTX3E.js +0 -423
  173. package/dist/assets/index-uahwWkgw.css +0 -1
@@ -0,0 +1,286 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
3
+ import { Check, ClipboardList } from "../../icons/SystemIcons";
4
+ import type { DomEditSelection } from "./domEditing";
5
+ import {
6
+ type BackgroundRemovalProgress,
7
+ type BackgroundRemovalResult,
8
+ formatNumericValue,
9
+ formatTimingValue,
10
+ parseNumericValue,
11
+ stripQueryAndHash,
12
+ } from "./propertyPanelHelpers";
13
+ import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives";
14
+ import { FlatToggle } from "./propertyPanelFlatToggle";
15
+
16
+ // fallow-ignore-next-line complexity
17
+ export function FlatMediaSection({
18
+ projectDir,
19
+ element,
20
+ styles,
21
+ onSetStyle,
22
+ onSetAttribute,
23
+ onSetHtmlAttribute,
24
+ onRemoveBackground,
25
+ }: {
26
+ projectDir: string | null;
27
+ element: DomEditSelection;
28
+ styles: Record<string, string>;
29
+ onSetStyle: (prop: string, value: string) => void | Promise<void>;
30
+ onSetAttribute: (attr: string, value: string) => void | Promise<void>;
31
+ onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
32
+ onRemoveBackground?: (
33
+ inputPath: string,
34
+ options: {
35
+ createBackgroundPlate?: boolean;
36
+ quality?: "fast" | "balanced" | "best";
37
+ onProgress?: (progress: BackgroundRemovalProgress) => void;
38
+ },
39
+ ) => Promise<BackgroundRemovalResult>;
40
+ }) {
41
+ const track = useTrackDesignInput();
42
+ const isVideo = element.tagName === "video";
43
+ const isAudio = element.tagName === "audio";
44
+ const isImage = element.tagName === "img";
45
+ const isVisualMedia = isVideo || isImage;
46
+ const el = element.element;
47
+
48
+ const volume = parseNumericValue(element.dataAttributes.volume ?? "") ?? 1;
49
+ const volumePercent = Math.round(volume * 100);
50
+ const mediaStart =
51
+ Number.parseFloat(
52
+ element.dataAttributes["media-start"] ?? element.dataAttributes["playback-start"] ?? "0",
53
+ ) || 0;
54
+ const playbackRate = Number.parseFloat(element.dataAttributes["playback-rate"] ?? "1") || 1;
55
+ const sourceDuration =
56
+ Number.parseFloat(element.dataAttributes["source-duration"] ?? "") ||
57
+ (el as HTMLMediaElement).duration ||
58
+ 0;
59
+ const mediaStartMax = Math.max(30, Math.ceil(sourceDuration || mediaStart + 10));
60
+ const hasLoop = el.hasAttribute("loop");
61
+ const hasMuted = el.hasAttribute("muted");
62
+ const hasAudio = element.dataAttributes["has-audio"] === "true";
63
+ const objectFit = styles["object-fit"] || "contain";
64
+ const objectPosition = styles["object-position"] || "center";
65
+
66
+ const srcAttr = el.getAttribute("src") ?? "";
67
+ const [copied, setCopied] = useState(false);
68
+ const [removeBusy, setRemoveBusy] = useState(false);
69
+ const [removeProgress, setRemoveProgress] = useState<BackgroundRemovalProgress | null>(null);
70
+ const [createPlate, setCreatePlate] = useState(false);
71
+ const [quality, setQuality] = useState<"fast" | "balanced" | "best">("balanced");
72
+
73
+ const absoluteSrc =
74
+ projectDir && srcAttr && !srcAttr.startsWith("http") ? `${projectDir}/${srcAttr}` : srcAttr;
75
+ const projectSrc =
76
+ srcAttr && !/^(?:https?:|data:|blob:)/i.test(srcAttr)
77
+ ? stripQueryAndHash(srcAttr.startsWith("./") ? srcAttr.slice(2) : srcAttr)
78
+ : "";
79
+ const canRemoveBackground = Boolean(onRemoveBackground && isVisualMedia && projectSrc);
80
+
81
+ useEffect(() => {
82
+ setRemoveProgress(null);
83
+ setCreatePlate(false);
84
+ }, [srcAttr]);
85
+
86
+ const applyCutoutResult = async (result: BackgroundRemovalResult) => {
87
+ await onSetHtmlAttribute("src", result.outputPath);
88
+ if (isVideo) {
89
+ await onSetAttribute("has-audio", "");
90
+ await onSetHtmlAttribute("muted", "true");
91
+ }
92
+ };
93
+
94
+ const runBackgroundRemoval = async () => {
95
+ if (!onRemoveBackground || !projectSrc || removeBusy) return;
96
+ track("button", "Remove background");
97
+ setRemoveBusy(true);
98
+ setRemoveProgress({ status: "processing", progress: 0, stage: "Preparing" });
99
+ try {
100
+ const result = await onRemoveBackground(projectSrc, {
101
+ createBackgroundPlate: isVideo && createPlate,
102
+ quality,
103
+ onProgress: setRemoveProgress,
104
+ });
105
+ await applyCutoutResult(result);
106
+ setRemoveProgress({ status: "complete", progress: 100, stage: "Applied cutout", ...result });
107
+ } catch (error) {
108
+ setRemoveProgress({
109
+ status: "failed",
110
+ progress: 0,
111
+ stage: "Failed",
112
+ error: error instanceof Error ? error.message : String(error),
113
+ });
114
+ } finally {
115
+ setRemoveBusy(false);
116
+ }
117
+ };
118
+
119
+ return (
120
+ <div className="space-y-1.5">
121
+ <div className="flex min-h-8 items-center justify-between gap-2">
122
+ <span className="flex min-w-0 items-center gap-2">
123
+ <span className="h-5 w-8 flex-shrink-0 rounded-[3px] bg-panel-surface" />
124
+ <span className="min-w-0 truncate font-mono text-[11px] text-panel-text-0">
125
+ {srcAttr}
126
+ </span>
127
+ </span>
128
+ <button
129
+ type="button"
130
+ data-flat-media-copy="true"
131
+ onClick={() => {
132
+ track("button", "Copy media path");
133
+ void navigator.clipboard.writeText(absoluteSrc).then(() => {
134
+ setCopied(true);
135
+ setTimeout(() => setCopied(false), 1500);
136
+ });
137
+ }}
138
+ className="flex flex-shrink-0 items-center gap-1 text-[10px] text-panel-text-3 hover:text-panel-text-1"
139
+ >
140
+ {copied ? <Check size={11} /> : <ClipboardList size={11} />}
141
+ {copied ? "Copied" : "Copy"}
142
+ </button>
143
+ </div>
144
+ {isVisualMedia && (
145
+ <div className="ml-[1px] border-l-2 border-panel-border-input py-1 pl-[10px]">
146
+ <div className="flex min-h-6 items-center justify-between">
147
+ <span className="flex items-baseline gap-[7px]">
148
+ <span className="text-[11px] font-semibold text-panel-text-1">Cutout</span>
149
+ <span className="font-mono text-[9px] text-panel-text-4">
150
+ transparent {isVideo ? "WebM" : "PNG"}
151
+ </span>
152
+ </span>
153
+ <button
154
+ type="button"
155
+ data-flat-media-remove-bg="true"
156
+ disabled={!canRemoveBackground || removeBusy}
157
+ onClick={() => void runBackgroundRemoval()}
158
+ className="flex items-center gap-1 text-[10px] font-medium text-panel-accent disabled:cursor-not-allowed disabled:opacity-50"
159
+ title={
160
+ canRemoveBackground
161
+ ? "Remove background and save a transparent asset"
162
+ : "Select a project-local image or video asset"
163
+ }
164
+ >
165
+ {removeBusy ? "Working" : "Remove BG"}
166
+ </button>
167
+ </div>
168
+ <FlatSelectRow
169
+ label="Quality"
170
+ value={quality}
171
+ options={["fast", "balanced", "best"]}
172
+ tier="explicitDefault"
173
+ onChange={(next) => setQuality(next as typeof quality)}
174
+ />
175
+ {isVideo && (
176
+ <FlatToggle label="BG plate" checked={createPlate} onChange={setCreatePlate} />
177
+ )}
178
+ {removeProgress && (
179
+ <div className="mt-1 space-y-1">
180
+ <div className="flex items-center justify-between text-[10px] text-panel-text-4">
181
+ <span className="min-w-0 flex-1 truncate">
182
+ {removeProgress.error ?? removeProgress.stage ?? "Processing"}
183
+ </span>
184
+ <span>{Math.round(removeProgress.progress)}%</span>
185
+ </div>
186
+ <div className="h-1 overflow-hidden rounded-full bg-panel-hover">
187
+ <div
188
+ className={`h-full rounded-full ${
189
+ removeProgress.status === "failed" ? "bg-red-400" : "bg-panel-accent"
190
+ }`}
191
+ style={{ width: `${Math.max(0, Math.min(100, removeProgress.progress))}%` }}
192
+ />
193
+ </div>
194
+ </div>
195
+ )}
196
+ </div>
197
+ )}
198
+ {(isVideo || isAudio) && (
199
+ <>
200
+ <FlatSlider
201
+ label="Volume"
202
+ value={volumePercent}
203
+ min={0}
204
+ max={100}
205
+ tier={volumePercent === 100 ? "default" : "explicitCustom"}
206
+ displayValue={`${volumePercent}%`}
207
+ onCommit={(next) => void onSetAttribute("volume", formatNumericValue(next / 100))}
208
+ />
209
+ <FlatSlider
210
+ label="Rate"
211
+ value={playbackRate * 100}
212
+ min={25}
213
+ max={300}
214
+ tier={playbackRate === 1 ? "default" : "explicitCustom"}
215
+ displayValue={`${formatNumericValue(playbackRate)}x`}
216
+ onCommit={(next) =>
217
+ void onSetAttribute("playback-rate", formatNumericValue(next / 100))
218
+ }
219
+ />
220
+ <FlatSlider
221
+ label="Media start"
222
+ value={Math.round(mediaStart * 100)}
223
+ min={0}
224
+ max={mediaStartMax * 100}
225
+ tier={mediaStart === 0 ? "default" : "explicitCustom"}
226
+ displayValue={formatTimingValue(mediaStart)}
227
+ onCommit={(next) => void onSetAttribute("media-start", (next / 100).toFixed(2))}
228
+ />
229
+ <FlatToggle
230
+ label="Loop"
231
+ checked={hasLoop}
232
+ onChange={(next) => void onSetHtmlAttribute("loop", next ? "true" : null)}
233
+ />
234
+ <FlatToggle
235
+ label="Muted"
236
+ checked={hasMuted}
237
+ onChange={(next) => void onSetHtmlAttribute("muted", next ? "true" : null)}
238
+ />
239
+ {isVideo && (
240
+ <FlatToggle
241
+ label="Has audio track"
242
+ checked={hasAudio}
243
+ onChange={(next) => {
244
+ if (next) {
245
+ void onSetAttribute("has-audio", "true");
246
+ void onSetHtmlAttribute("muted", null);
247
+ } else {
248
+ void onSetAttribute("has-audio", "");
249
+ void onSetHtmlAttribute("muted", "true");
250
+ }
251
+ }}
252
+ />
253
+ )}
254
+ </>
255
+ )}
256
+ {isVisualMedia && (
257
+ <>
258
+ <FlatSelectRow
259
+ label="Fit"
260
+ value={objectFit}
261
+ options={["contain", "cover", "fill", "none", "scale-down"]}
262
+ tier={objectFit === "contain" ? "default" : "explicitCustom"}
263
+ onChange={(next) => void onSetStyle("object-fit", next)}
264
+ />
265
+ <FlatSelectRow
266
+ label="Position"
267
+ value={objectPosition}
268
+ options={[
269
+ "center",
270
+ "top",
271
+ "bottom",
272
+ "left",
273
+ "right",
274
+ "left top",
275
+ "right top",
276
+ "left bottom",
277
+ "right bottom",
278
+ ]}
279
+ tier={objectPosition === "center" ? "default" : "explicitCustom"}
280
+ onChange={(next) => void onSetStyle("object-position", next)}
281
+ />
282
+ </>
283
+ )}
284
+ </div>
285
+ );
286
+ }
@@ -0,0 +1,272 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { FlatMotionSection, FlatTimingRow } from "./propertyPanelFlatMotionSection";
7
+ import type { DomEditSelection } from "./domEditing";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ afterEach(() => {
12
+ document.body.innerHTML = "";
13
+ });
14
+
15
+ function baseElement(overrides: Partial<DomEditSelection> = {}): DomEditSelection {
16
+ return {
17
+ element: document.createElement("div"),
18
+ id: "hero",
19
+ selector: "#hero",
20
+ label: "Hero",
21
+ tagName: "div",
22
+ sourceFile: "index.html",
23
+ compositionPath: "index.html",
24
+ isCompositionHost: false,
25
+ isInsideLockedComposition: false,
26
+ boundingBox: { x: 0, y: 0, width: 100, height: 100 },
27
+ textContent: "",
28
+ dataAttributes: { start: "8", duration: "4" },
29
+ inlineStyles: {},
30
+ computedStyles: {},
31
+ textFields: [],
32
+ capabilities: {
33
+ canSelect: true,
34
+ canEditStyles: true,
35
+ canCrop: true,
36
+ canMove: true,
37
+ canResize: true,
38
+ canApplyManualOffset: true,
39
+ canApplyManualSize: true,
40
+ canApplyManualRotation: true,
41
+ },
42
+ ...overrides,
43
+ } as DomEditSelection;
44
+ }
45
+
46
+ function renderInto(node: React.ReactElement) {
47
+ const host = document.createElement("div");
48
+ document.body.append(host);
49
+ const root = createRoot(host);
50
+ act(() => {
51
+ root.render(node);
52
+ });
53
+ return { host, root };
54
+ }
55
+
56
+ describe("FlatTimingRow", () => {
57
+ it("renders Start, End, and Duration from the element's data attributes", () => {
58
+ const { host, root } = renderInto(
59
+ <FlatTimingRow element={baseElement()} onSetAttribute={vi.fn()} />,
60
+ );
61
+ expect(host.textContent).toContain("Start");
62
+ expect(host.textContent).toContain("End");
63
+ expect(host.textContent).toContain("Duration");
64
+ // Values render inside <input>s (CommitField), not as text nodes, so they
65
+ // don't show up in textContent — assert on the rendered input values,
66
+ // in the same Start/End/Duration order the row is built in.
67
+ const inputs = host.querySelectorAll<HTMLInputElement>("input");
68
+ expect(inputs[0]?.value).toBe("8.00s");
69
+ expect(inputs[1]?.value).toBe("12.00s");
70
+ expect(inputs[2]?.value).toBe("4.00s");
71
+ act(() => root.unmount());
72
+ });
73
+
74
+ it("shows the inferred note when duration is derived from animations, not authored", () => {
75
+ const onSetAttribute = vi.fn();
76
+ const element = baseElement({ dataAttributes: { start: "0", duration: "0" } });
77
+ const { host, root } = renderInto(
78
+ <FlatTimingRow
79
+ element={element}
80
+ animations={[{ position: 2, duration: 3 } as never]}
81
+ onSetAttribute={onSetAttribute}
82
+ />,
83
+ );
84
+ expect(host.textContent).toContain("Inferred");
85
+ act(() => root.unmount());
86
+ });
87
+
88
+ it("commits a Start edit through onSetAttribute", () => {
89
+ const onSetAttribute = vi.fn();
90
+ const { host, root } = renderInto(
91
+ <FlatTimingRow element={baseElement()} onSetAttribute={onSetAttribute} />,
92
+ );
93
+ const startInput = host.querySelectorAll("input")[0];
94
+ if (!startInput) throw new Error("expected a Start input");
95
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
96
+ act(() => {
97
+ setter.call(startInput, "10s");
98
+ startInput.dispatchEvent(new Event("input", { bubbles: true }));
99
+ startInput.dispatchEvent(new Event("focusout", { bubbles: true }));
100
+ });
101
+ expect(onSetAttribute).toHaveBeenCalledWith("start", "10.00");
102
+ act(() => root.unmount());
103
+ });
104
+
105
+ it("pins an inferred range through ONE atomic onSetAttributes call when provided, instead of two sequential onSetAttribute calls", async () => {
106
+ const onSetAttribute = vi.fn();
107
+ const onSetAttributes = vi.fn().mockResolvedValue(undefined);
108
+ const element = baseElement({ dataAttributes: { start: "0", duration: "0" } });
109
+ const { host, root } = renderInto(
110
+ <FlatTimingRow
111
+ element={element}
112
+ animations={[{ position: 2, duration: 3 } as never]}
113
+ onSetAttribute={onSetAttribute}
114
+ onSetAttributes={onSetAttributes}
115
+ />,
116
+ );
117
+ // Range is inferred (start=2, duration=3) — editing Start alone must pin
118
+ // the WHOLE range (both attrs), not just data-start.
119
+ const startInput = host.querySelectorAll("input")[0];
120
+ if (!startInput) throw new Error("expected a Start input");
121
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
122
+ await act(async () => {
123
+ setter.call(startInput, "5s");
124
+ startInput.dispatchEvent(new Event("input", { bubbles: true }));
125
+ startInput.dispatchEvent(new Event("focusout", { bubbles: true }));
126
+ await Promise.resolve();
127
+ });
128
+ expect(onSetAttributes).toHaveBeenCalledTimes(1);
129
+ expect(onSetAttributes).toHaveBeenCalledWith(element, { start: "5.00", duration: "3.00" });
130
+ expect(onSetAttribute).not.toHaveBeenCalled();
131
+ act(() => root.unmount());
132
+ });
133
+
134
+ it("falls back to two sequential onSetAttribute calls to pin an inferred range when onSetAttributes is not provided", async () => {
135
+ const onSetAttribute = vi.fn().mockResolvedValue(undefined);
136
+ const element = baseElement({ dataAttributes: { start: "0", duration: "0" } });
137
+ const { host, root } = renderInto(
138
+ <FlatTimingRow
139
+ element={element}
140
+ animations={[{ position: 2, duration: 3 } as never]}
141
+ onSetAttribute={onSetAttribute}
142
+ />,
143
+ );
144
+ const startInput = host.querySelectorAll("input")[0];
145
+ if (!startInput) throw new Error("expected a Start input");
146
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
147
+ await act(async () => {
148
+ setter.call(startInput, "5s");
149
+ startInput.dispatchEvent(new Event("input", { bubbles: true }));
150
+ startInput.dispatchEvent(new Event("focusout", { bubbles: true }));
151
+ await Promise.resolve();
152
+ });
153
+ expect(onSetAttribute).toHaveBeenNthCalledWith(1, "start", "5.00");
154
+ expect(onSetAttribute).toHaveBeenNthCalledWith(2, "duration", "3.00");
155
+ act(() => root.unmount());
156
+ });
157
+ });
158
+
159
+ describe("FlatMotionSection", () => {
160
+ it("renders Timing when showTiming is true and the effect list when showEffects is true", () => {
161
+ const { host, root } = renderInto(
162
+ <FlatMotionSection
163
+ element={baseElement()}
164
+ animations={[
165
+ {
166
+ id: "a1",
167
+ method: "to",
168
+ position: 0.8,
169
+ duration: 1.2,
170
+ ease: "power2.out",
171
+ properties: { opacity: 1 },
172
+ } as never,
173
+ ]}
174
+ showTiming
175
+ showEffects
176
+ onSetAttribute={vi.fn()}
177
+ onAddAnimation={vi.fn()}
178
+ onUpdateProperty={vi.fn()}
179
+ onUpdateMeta={vi.fn()}
180
+ onDeleteAnimation={vi.fn()}
181
+ onAddProperty={vi.fn()}
182
+ onRemoveProperty={vi.fn()}
183
+ />,
184
+ );
185
+ expect(host.textContent).toContain("Start");
186
+ expect(host.textContent).toContain("power2.out");
187
+ act(() => root.unmount());
188
+ });
189
+
190
+ it("omits Timing entirely when showTiming is false", () => {
191
+ const { host, root } = renderInto(
192
+ <FlatMotionSection
193
+ element={baseElement()}
194
+ animations={[]}
195
+ showTiming={false}
196
+ showEffects
197
+ onSetAttribute={vi.fn()}
198
+ onAddAnimation={vi.fn()}
199
+ onUpdateProperty={vi.fn()}
200
+ onUpdateMeta={vi.fn()}
201
+ onDeleteAnimation={vi.fn()}
202
+ onAddProperty={vi.fn()}
203
+ onRemoveProperty={vi.fn()}
204
+ />,
205
+ );
206
+ expect(host.textContent).not.toContain("Start");
207
+ act(() => root.unmount());
208
+ });
209
+
210
+ it("omits the effect list entirely when showEffects is false", () => {
211
+ const { host, root } = renderInto(
212
+ <FlatMotionSection
213
+ element={baseElement()}
214
+ animations={[
215
+ {
216
+ id: "a1",
217
+ method: "to",
218
+ position: 0.8,
219
+ duration: 1.2,
220
+ ease: "power2.out",
221
+ properties: { opacity: 1 },
222
+ } as never,
223
+ ]}
224
+ showTiming
225
+ showEffects={false}
226
+ onSetAttribute={vi.fn()}
227
+ onAddAnimation={vi.fn()}
228
+ onUpdateProperty={vi.fn()}
229
+ onUpdateMeta={vi.fn()}
230
+ onDeleteAnimation={vi.fn()}
231
+ onAddProperty={vi.fn()}
232
+ onRemoveProperty={vi.fn()}
233
+ />,
234
+ );
235
+ expect(host.textContent).not.toContain("power2.out");
236
+ act(() => root.unmount());
237
+ });
238
+
239
+ it("opens the add-method menu on '+ Add effect' and calls onAddAnimation with the chosen method", () => {
240
+ const onAddAnimation = vi.fn();
241
+ const { host, root } = renderInto(
242
+ <FlatMotionSection
243
+ element={baseElement()}
244
+ animations={[]}
245
+ showTiming
246
+ showEffects
247
+ onSetAttribute={vi.fn()}
248
+ onAddAnimation={onAddAnimation}
249
+ onUpdateProperty={vi.fn()}
250
+ onUpdateMeta={vi.fn()}
251
+ onDeleteAnimation={vi.fn()}
252
+ onAddProperty={vi.fn()}
253
+ onRemoveProperty={vi.fn()}
254
+ />,
255
+ );
256
+ const buttons = () => Array.from(host.querySelectorAll("button"));
257
+ const addTrigger = buttons().find((b) => b.textContent === "+ Add effect");
258
+ if (!addTrigger) throw new Error("expected an '+ Add effect' trigger button");
259
+ act(() => {
260
+ addTrigger.dispatchEvent(new MouseEvent("click", { bubbles: true }));
261
+ });
262
+ const animateButton = buttons().find((b) => b.textContent === "Animate");
263
+ if (!animateButton) throw new Error("expected an 'Animate' method button");
264
+ act(() => {
265
+ animateButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
266
+ });
267
+ expect(onAddAnimation).toHaveBeenCalledWith("to");
268
+ // The menu closes back to the trigger after a selection.
269
+ expect(buttons().some((b) => b.textContent === "+ Add effect")).toBe(true);
270
+ act(() => root.unmount());
271
+ });
272
+ });