@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.
- package/dist/assets/{hyperframes-player-DgsMQSvV.js → hyperframes-player-67pq7USK.js} +2 -2
- package/dist/assets/index-BVqybwMG.css +1 -0
- package/dist/assets/index-BgbVhDYd.js +296 -0
- package/dist/assets/{index-CBS_7p5W.js → index-Btg-jqxS.js} +1 -1
- package/dist/assets/{index-CRCE5Dz_.js → index-tn9M43-U.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +5 -5
- package/src/App.tsx +1 -0
- package/src/components/StudioRightPanel.tsx +161 -75
- package/src/components/editor/PropertyPanel.tsx +26 -1
- package/src/components/editor/domEditOverlayGeometry.ts +2 -11
- package/src/components/editor/domEditingDom.ts +21 -0
- package/src/components/editor/domEditingElement.ts +2 -11
- package/src/components/editor/manualEditingAvailability.test.ts +12 -0
- package/src/components/editor/manualEditingAvailability.ts +6 -0
- package/src/components/editor/propertyPanelColorGradingControls.tsx +475 -0
- package/src/components/editor/propertyPanelColorGradingSection.tsx +355 -0
- package/src/components/editor/propertyPanelHelpers.ts +2 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +32 -37
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/contexts/PanelLayoutContext.tsx +6 -0
- package/src/hooks/gsapScriptCommitTypes.ts +11 -0
- package/src/hooks/serializeByKey.test.ts +83 -0
- package/src/hooks/serializeByKey.ts +33 -0
- package/src/hooks/useDomEditCommits.ts +7 -0
- package/src/hooks/useDomEditSession.ts +2 -2
- package/src/hooks/useDomEditTextCommits.ts +82 -26
- package/src/hooks/useDomEditWiring.ts +1 -0
- package/src/hooks/useDomSelection.ts +2 -10
- package/src/hooks/useGsapAnimationOps.ts +5 -1
- package/src/hooks/useGsapKeyframeOps.ts +21 -1
- package/src/hooks/useGsapScriptCommits.ts +33 -5
- package/src/hooks/usePanelLayout.ts +26 -1
- package/src/hooks/useStudioContextValue.ts +8 -3
- package/src/icons/SystemIcons.tsx +4 -0
- package/src/player/lib/timelineDOM.test.ts +36 -0
- package/src/player/lib/timelineDOM.ts +2 -0
- package/src/player/lib/timelineElementHelpers.ts +18 -1
- package/src/styles/studio.css +81 -0
- package/src/utils/mediaTypes.ts +1 -0
- package/src/utils/sdkShadow.test.ts +187 -1
- package/src/utils/sdkShadow.ts +67 -10
- package/src/utils/sdkShadowGsapFidelity.ts +46 -18
- package/src/utils/sdkShadowGsapKeyframe.test.ts +265 -0
- package/src/utils/sdkShadowGsapKeyframe.ts +257 -0
- package/src/utils/sdkShadowNumeric.ts +11 -0
- package/src/utils/studioHelpers.ts +6 -0
- package/dist/assets/index-BrhJl2JY.css +0 -1
- 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
|
-
<
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
{
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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,
|