@hyperframes/studio 0.6.106 → 0.6.108

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 (49) hide show
  1. package/dist/assets/{hyperframes-player-DgsMQSvV.js → hyperframes-player-67pq7USK.js} +2 -2
  2. package/dist/assets/index-BVqybwMG.css +1 -0
  3. package/dist/assets/index-BgbVhDYd.js +296 -0
  4. package/dist/assets/{index-CBS_7p5W.js → index-Btg-jqxS.js} +1 -1
  5. package/dist/assets/{index-CRCE5Dz_.js → index-tn9M43-U.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/package.json +5 -5
  8. package/src/App.tsx +1 -0
  9. package/src/components/StudioRightPanel.tsx +161 -75
  10. package/src/components/editor/PropertyPanel.tsx +26 -1
  11. package/src/components/editor/domEditOverlayGeometry.ts +2 -11
  12. package/src/components/editor/domEditingDom.ts +21 -0
  13. package/src/components/editor/domEditingElement.ts +2 -11
  14. package/src/components/editor/manualEditingAvailability.test.ts +12 -0
  15. package/src/components/editor/manualEditingAvailability.ts +6 -0
  16. package/src/components/editor/propertyPanelColorGradingControls.tsx +475 -0
  17. package/src/components/editor/propertyPanelColorGradingSection.tsx +355 -0
  18. package/src/components/editor/propertyPanelHelpers.ts +2 -1
  19. package/src/components/editor/propertyPanelPrimitives.tsx +32 -37
  20. package/src/contexts/DomEditContext.tsx +4 -0
  21. package/src/contexts/PanelLayoutContext.tsx +6 -0
  22. package/src/hooks/gsapScriptCommitTypes.ts +11 -0
  23. package/src/hooks/serializeByKey.test.ts +83 -0
  24. package/src/hooks/serializeByKey.ts +33 -0
  25. package/src/hooks/useDomEditCommits.ts +7 -0
  26. package/src/hooks/useDomEditSession.ts +2 -2
  27. package/src/hooks/useDomEditTextCommits.ts +82 -26
  28. package/src/hooks/useDomEditWiring.ts +1 -0
  29. package/src/hooks/useDomSelection.ts +2 -10
  30. package/src/hooks/useGsapAnimationOps.ts +5 -1
  31. package/src/hooks/useGsapKeyframeOps.ts +21 -1
  32. package/src/hooks/useGsapScriptCommits.ts +33 -5
  33. package/src/hooks/usePanelLayout.ts +26 -1
  34. package/src/hooks/useStudioContextValue.ts +8 -3
  35. package/src/icons/SystemIcons.tsx +4 -0
  36. package/src/player/lib/timelineDOM.test.ts +36 -0
  37. package/src/player/lib/timelineDOM.ts +2 -0
  38. package/src/player/lib/timelineElementHelpers.ts +18 -1
  39. package/src/styles/studio.css +81 -0
  40. package/src/utils/mediaTypes.ts +1 -0
  41. package/src/utils/sdkShadow.test.ts +187 -1
  42. package/src/utils/sdkShadow.ts +67 -10
  43. package/src/utils/sdkShadowGsapFidelity.ts +46 -18
  44. package/src/utils/sdkShadowGsapKeyframe.test.ts +265 -0
  45. package/src/utils/sdkShadowGsapKeyframe.ts +257 -0
  46. package/src/utils/sdkShadowNumeric.ts +11 -0
  47. package/src/utils/studioHelpers.ts +6 -0
  48. package/dist/assets/index-BrhJl2JY.css +0 -1
  49. package/dist/assets/index-koqvg-_0.js +0 -296
@@ -0,0 +1,355 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type PointerEvent as ReactPointerEvent,
8
+ type RefObject,
9
+ } from "react";
10
+ import {
11
+ HF_COLOR_GRADING_ATTR,
12
+ HF_COLOR_GRADING_COLOR_SPACE,
13
+ isHfColorGradingActive,
14
+ normalizeHfColorGrading,
15
+ serializeHfColorGrading,
16
+ type HfColorGradingAdjustKey,
17
+ type HfColorGradingTarget,
18
+ type NormalizedHfColorGrading,
19
+ } from "@hyperframes/core/color-grading";
20
+ import { Compare, Palette, RotateCcw } from "../../icons/SystemIcons";
21
+ import type { DomEditSelection } from "./domEditing";
22
+ import { ColorGradingControls } from "./propertyPanelColorGradingControls";
23
+ import { Section } from "./propertyPanelPrimitives";
24
+
25
+ const DEFAULT_ADJUST: Record<HfColorGradingAdjustKey, number> = {
26
+ exposure: 0,
27
+ contrast: 0,
28
+ highlights: 0,
29
+ shadows: 0,
30
+ whites: 0,
31
+ blacks: 0,
32
+ temperature: 0,
33
+ tint: 0,
34
+ saturation: 0,
35
+ };
36
+
37
+ const DEFAULT_COLOR_GRADING: NormalizedHfColorGrading = {
38
+ enabled: true,
39
+ preset: "neutral",
40
+ intensity: 1,
41
+ adjust: DEFAULT_ADJUST,
42
+ lut: null,
43
+ colorSpace: HF_COLOR_GRADING_COLOR_SPACE,
44
+ };
45
+
46
+ const COLOR_GRADING_DATA_KEY = HF_COLOR_GRADING_ATTR.replace(/^data-/, "");
47
+
48
+ interface RuntimeColorGradingStatus {
49
+ state: "missing" | "inactive" | "pending" | "active" | "unavailable";
50
+ message: string;
51
+ }
52
+
53
+ export function isColorGradingCapableElement(element: DomEditSelection): boolean {
54
+ return element.tagName === "video" || element.tagName === "img";
55
+ }
56
+
57
+ function readColorGradingFromElement(element: DomEditSelection): NormalizedHfColorGrading {
58
+ const grading =
59
+ normalizeHfColorGrading(element.dataAttributes[COLOR_GRADING_DATA_KEY]) ??
60
+ DEFAULT_COLOR_GRADING;
61
+ return { ...grading, intensity: 1 };
62
+ }
63
+
64
+ function toBridgeColorGrading(grading: NormalizedHfColorGrading): unknown {
65
+ if (!isHfColorGradingActive(grading)) return null;
66
+ const { enabled: _enabled, ...bridgeGrading } = grading;
67
+ return bridgeGrading;
68
+ }
69
+
70
+ function readRuntimeColorGradingStatus(
71
+ iframe: HTMLIFrameElement | null | undefined,
72
+ target: HfColorGradingTarget,
73
+ ): RuntimeColorGradingStatus {
74
+ try {
75
+ const win = iframe?.contentWindow as
76
+ | (Window & {
77
+ __hf?: {
78
+ colorGrading?: {
79
+ getStatus?: (
80
+ target: HfColorGradingTarget | string | null | undefined,
81
+ ) => RuntimeColorGradingStatus;
82
+ };
83
+ };
84
+ })
85
+ | null
86
+ | undefined;
87
+ const status = win?.__hf?.colorGrading?.getStatus?.(target);
88
+ return status ?? { state: "pending", message: "Waiting for runtime" };
89
+ } catch {
90
+ return { state: "unavailable", message: "Preview unavailable" };
91
+ }
92
+ }
93
+
94
+ function StatusPill({ status }: { status: RuntimeColorGradingStatus }) {
95
+ const dotClass =
96
+ status.state === "active"
97
+ ? "bg-emerald-400"
98
+ : status.state === "pending"
99
+ ? "bg-amber-300"
100
+ : status.state === "unavailable"
101
+ ? "bg-red-400"
102
+ : "bg-panel-text-5";
103
+ return (
104
+ <div className="flex min-w-0 items-center gap-1.5 rounded bg-panel-input px-2 py-1 text-[10px] font-medium text-panel-text-3">
105
+ <span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${dotClass}`} />
106
+ <span className="truncate">{status.message}</span>
107
+ </div>
108
+ );
109
+ }
110
+
111
+ function HoldBeforeButton({
112
+ active,
113
+ disabled,
114
+ onHoldChange,
115
+ }: {
116
+ active: boolean;
117
+ disabled: boolean;
118
+ onHoldChange: (holding: boolean) => void;
119
+ }) {
120
+ const startHold = (event: ReactPointerEvent<HTMLButtonElement>) => {
121
+ if (disabled) return;
122
+ event.preventDefault();
123
+ event.stopPropagation();
124
+ onHoldChange(true);
125
+ const release = () => {
126
+ onHoldChange(false);
127
+ window.removeEventListener("pointerup", release);
128
+ window.removeEventListener("pointercancel", release);
129
+ window.removeEventListener("blur", release);
130
+ };
131
+ window.addEventListener("pointerup", release);
132
+ window.addEventListener("pointercancel", release);
133
+ window.addEventListener("blur", release);
134
+ };
135
+ const stopHold = (event: ReactPointerEvent<HTMLButtonElement>) => {
136
+ if (disabled) return;
137
+ event.preventDefault();
138
+ event.stopPropagation();
139
+ onHoldChange(false);
140
+ };
141
+
142
+ return (
143
+ <button
144
+ type="button"
145
+ disabled={disabled}
146
+ aria-pressed={active}
147
+ aria-label="Hold to show original"
148
+ onPointerDown={startHold}
149
+ onPointerUp={stopHold}
150
+ onPointerCancel={stopHold}
151
+ onBlur={() => {
152
+ if (active) onHoldChange(false);
153
+ }}
154
+ onKeyDown={(event) => {
155
+ if (disabled || (event.key !== " " && event.key !== "Enter")) return;
156
+ event.preventDefault();
157
+ if (!active) onHoldChange(true);
158
+ }}
159
+ onKeyUp={(event) => {
160
+ if (disabled || (event.key !== " " && event.key !== "Enter")) return;
161
+ event.preventDefault();
162
+ onHoldChange(false);
163
+ }}
164
+ className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded transition-colors ${
165
+ active
166
+ ? "bg-studio-accent text-black"
167
+ : "text-panel-text-4 hover:bg-panel-hover hover:text-panel-text-1"
168
+ } disabled:cursor-not-allowed disabled:opacity-40`}
169
+ title="Hold to show original"
170
+ >
171
+ <Compare size={13} />
172
+ </button>
173
+ );
174
+ }
175
+
176
+ export function ColorGradingSection({
177
+ element,
178
+ assets,
179
+ previewIframeRef,
180
+ onImportAssets,
181
+ onSetAttributeLive,
182
+ }: {
183
+ element: DomEditSelection;
184
+ assets: string[];
185
+ previewIframeRef?: RefObject<HTMLIFrameElement | null>;
186
+ onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
187
+ onSetAttributeLive: (attr: string, value: string | null) => void | Promise<void>;
188
+ }) {
189
+ const [grading, setGrading] = useState(() => readColorGradingFromElement(element));
190
+ const [compareEnabled, setCompareEnabled] = useState(false);
191
+ const [runtimeStatus, setRuntimeStatus] = useState<RuntimeColorGradingStatus>(() => ({
192
+ state: "pending",
193
+ message: "Waiting for runtime",
194
+ }));
195
+ const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
196
+ const pendingPersistValueRef = useRef<string | null | undefined>(undefined);
197
+ const onSetAttributeLiveRef = useRef(onSetAttributeLive);
198
+ const compareEnabledRef = useRef(compareEnabled);
199
+ onSetAttributeLiveRef.current = onSetAttributeLive;
200
+ compareEnabledRef.current = compareEnabled;
201
+ const target = useMemo(
202
+ (): HfColorGradingTarget => ({
203
+ id: element.id ?? null,
204
+ hfId: element.hfId ?? null,
205
+ selector: element.selector ?? null,
206
+ selectorIndex: element.selectorIndex ?? null,
207
+ }),
208
+ [element.hfId, element.id, element.selector, element.selectorIndex],
209
+ );
210
+
211
+ const refreshRuntimeStatus = useCallback(() => {
212
+ setRuntimeStatus(readRuntimeColorGradingStatus(previewIframeRef?.current, target));
213
+ }, [previewIframeRef, target]);
214
+
215
+ useEffect(() => {
216
+ refreshRuntimeStatus();
217
+ }, [refreshRuntimeStatus]);
218
+
219
+ useEffect(() => {
220
+ const iframe = previewIframeRef?.current;
221
+ if (!iframe) return;
222
+ const refresh = () => {
223
+ window.setTimeout(refreshRuntimeStatus, 50);
224
+ };
225
+ iframe.addEventListener("load", refresh);
226
+ const timer = window.setTimeout(refreshRuntimeStatus, 80);
227
+ return () => {
228
+ iframe.removeEventListener("load", refresh);
229
+ window.clearTimeout(timer);
230
+ };
231
+ }, [previewIframeRef, refreshRuntimeStatus]);
232
+
233
+ useEffect(() => {
234
+ return () => {
235
+ if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
236
+ if (pendingPersistValueRef.current !== undefined) {
237
+ void onSetAttributeLiveRef.current(COLOR_GRADING_DATA_KEY, pendingPersistValueRef.current);
238
+ pendingPersistValueRef.current = undefined;
239
+ }
240
+ };
241
+ }, []);
242
+
243
+ const postColorGrading = useCallback(
244
+ (nextGrading: NormalizedHfColorGrading) => {
245
+ previewIframeRef?.current?.contentWindow?.postMessage(
246
+ {
247
+ source: "hf-parent",
248
+ type: "control",
249
+ action: "set-color-grading",
250
+ target,
251
+ grading: toBridgeColorGrading(nextGrading),
252
+ },
253
+ "*",
254
+ );
255
+ },
256
+ [previewIframeRef, target],
257
+ );
258
+
259
+ const postCompare = useCallback(
260
+ (enabled: boolean) => {
261
+ previewIframeRef?.current?.contentWindow?.postMessage(
262
+ {
263
+ source: "hf-parent",
264
+ type: "control",
265
+ action: "set-color-grading-compare",
266
+ target,
267
+ compare: {
268
+ enabled,
269
+ position: 1,
270
+ lineWidth: 0,
271
+ },
272
+ },
273
+ "*",
274
+ );
275
+ },
276
+ [previewIframeRef, target],
277
+ );
278
+
279
+ useEffect(
280
+ () => () => {
281
+ postCompare(false);
282
+ },
283
+ [postCompare],
284
+ );
285
+
286
+ const commitColorGrading = useCallback(
287
+ (nextGrading: NormalizedHfColorGrading) => {
288
+ setGrading(nextGrading);
289
+ setRuntimeStatus({ state: "pending", message: "Updating shader" });
290
+ postColorGrading(nextGrading);
291
+ const active = isHfColorGradingActive(nextGrading);
292
+ if (compareEnabledRef.current) {
293
+ postCompare(active);
294
+ if (!active) setCompareEnabled(false);
295
+ }
296
+ window.setTimeout(refreshRuntimeStatus, 50);
297
+ if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
298
+ pendingPersistValueRef.current = isHfColorGradingActive(nextGrading)
299
+ ? serializeHfColorGrading(nextGrading)
300
+ : null;
301
+ persistTimerRef.current = setTimeout(() => {
302
+ const value = pendingPersistValueRef.current;
303
+ pendingPersistValueRef.current = undefined;
304
+ void onSetAttributeLive(COLOR_GRADING_DATA_KEY, value ?? null);
305
+ }, 350);
306
+ },
307
+ [onSetAttributeLive, postColorGrading, postCompare, refreshRuntimeStatus],
308
+ );
309
+
310
+ const commitCompare = useCallback(
311
+ (enabled: boolean) => {
312
+ const nextEnabled = enabled && isHfColorGradingActive(grading);
313
+ setCompareEnabled(nextEnabled);
314
+ if (nextEnabled) postColorGrading(grading);
315
+ postCompare(nextEnabled);
316
+ window.setTimeout(refreshRuntimeStatus, 50);
317
+ },
318
+ [grading, postColorGrading, postCompare, refreshRuntimeStatus],
319
+ );
320
+
321
+ return (
322
+ <Section
323
+ title="Color Grading"
324
+ icon={<Palette size={15} />}
325
+ accessory={
326
+ <div className="flex min-w-0 items-center gap-1.5">
327
+ <HoldBeforeButton
328
+ active={compareEnabled}
329
+ disabled={!isHfColorGradingActive(grading)}
330
+ onHoldChange={commitCompare}
331
+ />
332
+ <StatusPill status={runtimeStatus} />
333
+ <button
334
+ type="button"
335
+ onClick={(event) => {
336
+ event.stopPropagation();
337
+ commitColorGrading(DEFAULT_COLOR_GRADING);
338
+ }}
339
+ className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1"
340
+ title="Reset grading"
341
+ >
342
+ <RotateCcw size={12} />
343
+ </button>
344
+ </div>
345
+ }
346
+ >
347
+ <ColorGradingControls
348
+ grading={grading}
349
+ assets={assets}
350
+ onImportAssets={onImportAssets}
351
+ onCommitColorGrading={commitColorGrading}
352
+ />
353
+ </Section>
354
+ );
355
+ }
@@ -15,6 +15,7 @@ export interface PropertyPanelProps {
15
15
  onClearSelection: () => void;
16
16
  onSetStyle: (prop: string, value: string) => void | Promise<void>;
17
17
  onSetAttribute: (attr: string, value: string) => void | Promise<void>;
18
+ onSetAttributeLive: (attr: string, value: string | null) => void | Promise<void>;
18
19
  onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
19
20
  onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
20
21
  onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
@@ -24,7 +25,7 @@ export interface PropertyPanelProps {
24
25
  onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
25
26
  onRemoveTextField: (fieldKey: string) => void;
26
27
  onAskAgent: () => void;
27
- onImportAssets?: (files: FileList) => Promise<string[]>;
28
+ onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
28
29
  fontAssets?: ImportedFontAsset[];
29
30
  onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
30
31
  previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
@@ -348,46 +348,41 @@ export function Section({
348
348
  defaultCollapsed?: boolean;
349
349
  }) {
350
350
  const [collapsed, setCollapsed] = useState(defaultCollapsed);
351
+ const collapseIcon = collapsed ? (
352
+ <svg
353
+ width="12"
354
+ height="12"
355
+ viewBox="0 0 12 12"
356
+ fill="none"
357
+ className="flex-shrink-0 text-panel-text-5"
358
+ >
359
+ <path d="M6 2.5v7M2.5 6h7" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
360
+ </svg>
361
+ ) : (
362
+ <svg
363
+ width="10"
364
+ height="10"
365
+ viewBox="0 0 10 10"
366
+ fill="currentColor"
367
+ className="flex-shrink-0 text-panel-text-5"
368
+ >
369
+ <path d="M2 3l3 4 3-4z" />
370
+ </svg>
371
+ );
351
372
 
352
373
  return (
353
374
  <section className="min-w-0 border-t border-panel-border">
354
- <button
355
- type="button"
356
- onClick={() => setCollapsed((v) => !v)}
357
- className="flex w-full items-center justify-between gap-2 px-4 py-2.5"
358
- >
359
- <h3 className="text-[12px] font-semibold text-panel-text-1">{title}</h3>
360
- <div className="flex items-center gap-2">
361
- {accessory}
362
- {collapsed && (
363
- <svg
364
- width="12"
365
- height="12"
366
- viewBox="0 0 12 12"
367
- fill="none"
368
- className="flex-shrink-0 text-panel-text-5"
369
- >
370
- <path
371
- d="M6 2.5v7M2.5 6h7"
372
- stroke="currentColor"
373
- strokeWidth="1.2"
374
- strokeLinecap="round"
375
- />
376
- </svg>
377
- )}
378
- {!collapsed && (
379
- <svg
380
- width="10"
381
- height="10"
382
- viewBox="0 0 10 10"
383
- fill="currentColor"
384
- className="flex-shrink-0 text-panel-text-5"
385
- >
386
- <path d="M2 3l3 4 3-4z" />
387
- </svg>
388
- )}
389
- </div>
390
- </button>
375
+ <div className="flex w-full items-center gap-2 px-4 py-2.5">
376
+ <button
377
+ type="button"
378
+ onClick={() => setCollapsed((v) => !v)}
379
+ className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left"
380
+ >
381
+ <h3 className="text-[12px] font-semibold text-panel-text-1">{title}</h3>
382
+ {collapseIcon}
383
+ </button>
384
+ {accessory && <div className="flex flex-shrink-0 items-center">{accessory}</div>}
385
+ </div>
391
386
  {!collapsed && <div className="px-4 pb-3">{children}</div>}
392
387
  </section>
393
388
  );
@@ -14,6 +14,7 @@ export interface DomEditActionsValue extends Pick<
14
14
  | "clearDomSelection"
15
15
  | "handleDomStyleCommit"
16
16
  | "handleDomAttributeCommit"
17
+ | "handleDomAttributeLiveCommit"
17
18
  | "handleDomHtmlAttributeCommit"
18
19
  | "handleDomPathOffsetCommit"
19
20
  | "handleDomGroupPathOffsetCommit"
@@ -115,6 +116,7 @@ export function DomEditProvider({
115
116
  clearDomSelection,
116
117
  handleDomStyleCommit,
117
118
  handleDomAttributeCommit,
119
+ handleDomAttributeLiveCommit,
118
120
  handleDomHtmlAttributeCommit,
119
121
  handleDomPathOffsetCommit,
120
122
  handleDomGroupPathOffsetCommit,
@@ -189,6 +191,7 @@ export function DomEditProvider({
189
191
  clearDomSelection,
190
192
  handleDomStyleCommit,
191
193
  handleDomAttributeCommit,
194
+ handleDomAttributeLiveCommit,
192
195
  handleDomHtmlAttributeCommit,
193
196
  handleDomPathOffsetCommit,
194
197
  handleDomGroupPathOffsetCommit,
@@ -245,6 +248,7 @@ export function DomEditProvider({
245
248
  clearDomSelection,
246
249
  handleDomStyleCommit,
247
250
  handleDomAttributeCommit,
251
+ handleDomAttributeLiveCommit,
248
252
  handleDomHtmlAttributeCommit,
249
253
  handleDomPathOffsetCommit,
250
254
  handleDomGroupPathOffsetCommit,
@@ -22,6 +22,8 @@ export function PanelLayoutProvider({
22
22
  setRightCollapsed,
23
23
  rightPanelTab,
24
24
  setRightPanelTab,
25
+ rightInspectorPanes,
26
+ toggleRightInspectorPane,
25
27
  toggleLeftSidebar,
26
28
  handlePanelResizeStart,
27
29
  handlePanelResizeMove,
@@ -43,6 +45,8 @@ export function PanelLayoutProvider({
43
45
  setRightCollapsed,
44
46
  rightPanelTab,
45
47
  setRightPanelTab,
48
+ rightInspectorPanes,
49
+ toggleRightInspectorPane,
46
50
  toggleLeftSidebar,
47
51
  handlePanelResizeStart,
48
52
  handlePanelResizeMove,
@@ -58,6 +62,8 @@ export function PanelLayoutProvider({
58
62
  setRightCollapsed,
59
63
  rightPanelTab,
60
64
  setRightPanelTab,
65
+ rightInspectorPanes,
66
+ toggleRightInspectorPane,
61
67
  toggleLeftSidebar,
62
68
  handlePanelResizeStart,
63
69
  handlePanelResizeMove,
@@ -3,6 +3,7 @@ import type { Composition } from "@hyperframes/sdk";
3
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
4
  import type { EditHistoryKind } from "../utils/editHistory";
5
5
  import type { ShadowGsapOp } from "../utils/sdkShadow";
6
+ import type { ShadowKeyframeOp } from "../utils/sdkShadowGsapKeyframe";
6
7
 
7
8
  export interface MutationResult {
8
9
  ok: boolean;
@@ -19,8 +20,18 @@ export interface CommitMutationOptions {
19
20
  softReload?: boolean;
20
21
  skipReload?: boolean;
21
22
  beforeReload?: () => void;
23
+ /**
24
+ * Serialize this commit against others sharing the same key. Used to chain
25
+ * per-animationId GSAP meta updates so overlapping read-modify-write POSTs to
26
+ * one file can't interleave — which would pair the shadow fidelity diff with a
27
+ * stale server result and report false ease mismatches. Commits without a key
28
+ * (and under distinct keys) run concurrently as before.
29
+ */
30
+ serializeKey?: string;
22
31
  /** Stage 7 Step 3b: typed SDK equivalent of this mutation for value-fidelity shadow. */
23
32
  shadowGsapOp?: ShadowGsapOp;
33
+ /** Typed SDK equivalent of a keyframe mutation for keyframe value-fidelity shadow (gsap_keyframe). */
34
+ shadowKeyframeOp?: ShadowKeyframeOp;
24
35
  }
25
36
 
26
37
  export type CommitMutation = (
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createKeyedSerializer } from "./serializeByKey";
3
+
4
+ function deferred<T>() {
5
+ let resolve!: (value: T) => void;
6
+ let reject!: (reason?: unknown) => void;
7
+ const promise = new Promise<T>((res, rej) => {
8
+ resolve = res;
9
+ reject = rej;
10
+ });
11
+ return { promise, resolve, reject };
12
+ }
13
+
14
+ describe("createKeyedSerializer", () => {
15
+ it("runs same-key tasks strictly in order (second awaits the first)", async () => {
16
+ const run = createKeyedSerializer();
17
+ const order: string[] = [];
18
+ const first = deferred<void>();
19
+
20
+ const p1 = run("k", async () => {
21
+ order.push("1-start");
22
+ await first.promise;
23
+ order.push("1-end");
24
+ });
25
+ const p2 = run("k", async () => {
26
+ order.push("2-start");
27
+ });
28
+
29
+ // Second task must not start until the first finishes.
30
+ await Promise.resolve();
31
+ expect(order).toEqual(["1-start"]);
32
+
33
+ first.resolve();
34
+ await Promise.all([p1, p2]);
35
+ expect(order).toEqual(["1-start", "1-end", "2-start"]);
36
+ });
37
+
38
+ it("does not block tasks under different keys", async () => {
39
+ const run = createKeyedSerializer();
40
+ const order: string[] = [];
41
+ const blockA = deferred<void>();
42
+
43
+ const pa = run("a", async () => {
44
+ order.push("a-start");
45
+ await blockA.promise;
46
+ order.push("a-end");
47
+ });
48
+ const pb = run("b", async () => {
49
+ order.push("b-start");
50
+ order.push("b-end");
51
+ });
52
+
53
+ // Different key runs to completion while "a" is still blocked.
54
+ await pb;
55
+ expect(order).toEqual(["a-start", "b-start", "b-end"]);
56
+
57
+ blockA.resolve();
58
+ await pa;
59
+ expect(order).toEqual(["a-start", "b-start", "b-end", "a-end"]);
60
+ });
61
+
62
+ it("does not wedge a key when a prior task rejects", async () => {
63
+ const run = createKeyedSerializer();
64
+ const order: string[] = [];
65
+
66
+ const p1 = run("k", async () => {
67
+ order.push("1");
68
+ throw new Error("boom");
69
+ });
70
+ await expect(p1).rejects.toThrow("boom");
71
+
72
+ const p2 = run("k", async () => {
73
+ order.push("2");
74
+ });
75
+ await p2;
76
+ expect(order).toEqual(["1", "2"]);
77
+ });
78
+
79
+ it("propagates the task's resolved value to its caller", async () => {
80
+ const run = createKeyedSerializer();
81
+ await expect(run("k", async () => 42)).resolves.toBe(42);
82
+ });
83
+ });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Per-key task serializer. Tasks sharing a key run strictly in order: a new
3
+ * task for a key awaits the prior task for that key before starting, so their
4
+ * effects (e.g. overlapping read-modify-write POSTs to one file) can't
5
+ * interleave. Tasks under different keys are independent and never block each
6
+ * other.
7
+ *
8
+ * Used to serialize GSAP meta-update commits per animationId so the shadow
9
+ * fidelity diff always pairs an op with the server result that includes it —
10
+ * without globally serializing unrelated commits.
11
+ */
12
+ export function createKeyedSerializer() {
13
+ const inFlight = new Map<string, Promise<unknown>>();
14
+
15
+ return function run<T>(key: string, task: () => Promise<T>): Promise<T> {
16
+ const prior = inFlight.get(key) ?? Promise.resolve();
17
+ // Chain onto the prior task regardless of how it settled; a rejected prior
18
+ // commit must not wedge the key forever.
19
+ const next = prior.then(task, task);
20
+ inFlight.set(key, next);
21
+ // Once this task settles, drop it from the map if nothing newer replaced it,
22
+ // so completed keys don't leak.
23
+ void next.then(
24
+ () => {
25
+ if (inFlight.get(key) === next) inFlight.delete(key);
26
+ },
27
+ () => {
28
+ if (inFlight.get(key) === next) inFlight.delete(key);
29
+ },
30
+ );
31
+ return next;
32
+ };
33
+ }
@@ -16,6 +16,9 @@ import { useDomGeometryCommits } from "./useDomGeometryCommits";
16
16
  import { useElementLifecycleOps } from "./useElementLifecycleOps";
17
17
  import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
18
18
 
19
+ // Re-export so existing consumers keep their import path
20
+ export { GSAP_CSS_FALLBACK_BLOCKED_MESSAGE } from "./useDomGeometryCommits";
21
+
19
22
  // ── Helpers ──
20
23
 
21
24
  function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
@@ -42,6 +45,8 @@ interface RecordEditInput {
42
45
  files: Record<string, { before: string; after: string }>;
43
46
  }
44
47
 
48
+ export type { PersistDomEditOperations } from "./domEditCommitTypes";
49
+
45
50
  export interface UseDomEditCommitsParams {
46
51
  activeCompPath: string | null;
47
52
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
@@ -238,6 +243,7 @@ export function useDomEditCommits({
238
243
  const {
239
244
  handleDomStyleCommit,
240
245
  handleDomAttributeCommit,
246
+ handleDomAttributeLiveCommit,
241
247
  handleDomHtmlAttributeCommit,
242
248
  handleDomTextCommit,
243
249
  commitDomTextFields,
@@ -297,6 +303,7 @@ export function useDomEditCommits({
297
303
  resolveImportedFontAsset,
298
304
  handleDomStyleCommit,
299
305
  handleDomAttributeCommit,
306
+ handleDomAttributeLiveCommit,
300
307
  handleDomHtmlAttributeCommit,
301
308
  handleDomTextCommit,
302
309
  commitDomTextFields,