@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
|
@@ -203,6 +203,7 @@ export function useDomEditSession({
|
|
|
203
203
|
resolveImportedFontAsset,
|
|
204
204
|
handleDomStyleCommit,
|
|
205
205
|
handleDomAttributeCommit,
|
|
206
|
+
handleDomAttributeLiveCommit,
|
|
206
207
|
handleDomHtmlAttributeCommit,
|
|
207
208
|
handleDomTextCommit,
|
|
208
209
|
handleDomTextFieldStyleCommit,
|
|
@@ -265,8 +266,6 @@ export function useDomEditSession({
|
|
|
265
266
|
handleGsapRemoveAllKeyframes,
|
|
266
267
|
handleResetSelectedElementKeyframes,
|
|
267
268
|
} = useDomEditWiring({
|
|
268
|
-
// Pre-existing prop-drilling clone (same param set forwarded to
|
|
269
|
-
// useDomEditWiring); surfaced by this PR's adjacent edits, not introduced.
|
|
270
269
|
// fallow-ignore-next-line code-duplication
|
|
271
270
|
projectId,
|
|
272
271
|
activeCompPath,
|
|
@@ -374,6 +373,7 @@ export function useDomEditSession({
|
|
|
374
373
|
clearDomSelection,
|
|
375
374
|
handleDomStyleCommit,
|
|
376
375
|
handleDomAttributeCommit,
|
|
376
|
+
handleDomAttributeLiveCommit,
|
|
377
377
|
handleDomHtmlAttributeCommit,
|
|
378
378
|
handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit,
|
|
379
379
|
handleDomGroupPathOffsetCommit,
|
|
@@ -43,6 +43,33 @@ export interface UseDomEditTextCommitsParams {
|
|
|
43
43
|
resolveImportedFontAsset: (fontFamilyValue: string) => ImportedFontAsset | null;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
function applyPreviewAttribute(
|
|
47
|
+
doc: Document | null | undefined,
|
|
48
|
+
selection: DomEditSelection,
|
|
49
|
+
activeCompPath: string | null,
|
|
50
|
+
attr: string,
|
|
51
|
+
value: string | null,
|
|
52
|
+
options: { prefixData?: boolean; removeFalse?: boolean } = {},
|
|
53
|
+
): void {
|
|
54
|
+
if (!doc) return;
|
|
55
|
+
const el = findElementForSelection(doc, selection, activeCompPath);
|
|
56
|
+
if (!el) return;
|
|
57
|
+
const fullAttr = options.prefixData && !attr.startsWith("data-") ? `data-${attr}` : attr;
|
|
58
|
+
if (value === null || value === "" || (options.removeFalse && value === "false")) {
|
|
59
|
+
el.removeAttribute(fullAttr);
|
|
60
|
+
} else {
|
|
61
|
+
el.setAttribute(fullAttr, value);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface DataAttributeCommitOptions {
|
|
66
|
+
label: string;
|
|
67
|
+
coalescePrefix: string;
|
|
68
|
+
skipRefresh: boolean;
|
|
69
|
+
warningMessage: string;
|
|
70
|
+
refreshAfter?: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
46
73
|
// ── Hook ──
|
|
47
74
|
|
|
48
75
|
export function useDomEditTextCommits({
|
|
@@ -114,29 +141,33 @@ export function useDomEditTextCommits({
|
|
|
114
141
|
],
|
|
115
142
|
);
|
|
116
143
|
|
|
117
|
-
const
|
|
118
|
-
async (attr: string, value: string) => {
|
|
144
|
+
const commitDataAttribute = useCallback(
|
|
145
|
+
async (attr: string, value: string | null, options: DataAttributeCommitOptions) => {
|
|
119
146
|
if (!domEditSelection) return;
|
|
120
147
|
const iframe = previewIframeRef.current;
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
148
|
+
applyPreviewAttribute(
|
|
149
|
+
iframe?.contentDocument,
|
|
150
|
+
domEditSelection,
|
|
151
|
+
activeCompPath,
|
|
152
|
+
attr,
|
|
153
|
+
value,
|
|
154
|
+
{
|
|
155
|
+
prefixData: true,
|
|
156
|
+
},
|
|
157
|
+
);
|
|
126
158
|
const op: PatchOperation = { type: "attribute", property: attr, value };
|
|
127
159
|
try {
|
|
128
160
|
await persistDomEditOperations(domEditSelection, [op], {
|
|
129
|
-
label:
|
|
130
|
-
coalesceKey:
|
|
131
|
-
skipRefresh:
|
|
161
|
+
label: options.label,
|
|
162
|
+
coalesceKey: `${options.coalescePrefix}:${attr}:${getDomEditTargetKey(domEditSelection)}`,
|
|
163
|
+
skipRefresh: options.skipRefresh,
|
|
132
164
|
});
|
|
133
165
|
} catch (err) {
|
|
134
|
-
console.warn(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
);
|
|
166
|
+
console.warn(options.warningMessage, err instanceof Error ? err.message : err);
|
|
167
|
+
}
|
|
168
|
+
if (options.refreshAfter) {
|
|
169
|
+
refreshDomEditSelectionFromPreview(domEditSelection);
|
|
138
170
|
}
|
|
139
|
-
refreshDomEditSelectionFromPreview(domEditSelection);
|
|
140
171
|
},
|
|
141
172
|
[
|
|
142
173
|
activeCompPath,
|
|
@@ -147,21 +178,45 @@ export function useDomEditTextCommits({
|
|
|
147
178
|
],
|
|
148
179
|
);
|
|
149
180
|
|
|
181
|
+
const handleDomAttributeCommit = useCallback(
|
|
182
|
+
async (attr: string, value: string) => {
|
|
183
|
+
await commitDataAttribute(attr, value, {
|
|
184
|
+
label: `Edit ${attr.replace(/-/g, " ")}`,
|
|
185
|
+
coalescePrefix: "attr",
|
|
186
|
+
skipRefresh: false,
|
|
187
|
+
warningMessage: "[Studio] Attribute persist failed:",
|
|
188
|
+
refreshAfter: true,
|
|
189
|
+
});
|
|
190
|
+
},
|
|
191
|
+
[commitDataAttribute],
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const handleDomAttributeLiveCommit = useCallback(
|
|
195
|
+
async (attr: string, value: string | null) => {
|
|
196
|
+
await commitDataAttribute(attr, value, {
|
|
197
|
+
label: `Edit ${attr.replace(/^(data-)?/, "").replace(/-/g, " ")}`,
|
|
198
|
+
coalescePrefix: "attr-live",
|
|
199
|
+
skipRefresh: true,
|
|
200
|
+
warningMessage: "[Studio] Live attribute persist failed:",
|
|
201
|
+
});
|
|
202
|
+
},
|
|
203
|
+
[commitDataAttribute],
|
|
204
|
+
);
|
|
205
|
+
|
|
150
206
|
const handleDomHtmlAttributeCommit = useCallback(
|
|
151
207
|
async (attr: string, value: string | null) => {
|
|
152
208
|
if (!domEditSelection) return;
|
|
153
209
|
const iframe = previewIframeRef.current;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
210
|
+
applyPreviewAttribute(
|
|
211
|
+
iframe?.contentDocument,
|
|
212
|
+
domEditSelection,
|
|
213
|
+
activeCompPath,
|
|
214
|
+
attr,
|
|
215
|
+
value,
|
|
216
|
+
{
|
|
217
|
+
removeFalse: true,
|
|
218
|
+
},
|
|
219
|
+
);
|
|
165
220
|
const op: PatchOperation = { type: "html-attribute", property: attr, value };
|
|
166
221
|
try {
|
|
167
222
|
await persistDomEditOperations(domEditSelection, [op], {
|
|
@@ -395,6 +450,7 @@ export function useDomEditTextCommits({
|
|
|
395
450
|
return {
|
|
396
451
|
handleDomStyleCommit,
|
|
397
452
|
handleDomAttributeCommit,
|
|
453
|
+
handleDomAttributeLiveCommit,
|
|
398
454
|
handleDomHtmlAttributeCommit,
|
|
399
455
|
handleDomTextCommit,
|
|
400
456
|
commitDomTextFields,
|
|
@@ -176,9 +176,7 @@ export function useDomSelection({
|
|
|
176
176
|
if (nextSelection) {
|
|
177
177
|
if (options?.revealPanel !== false) {
|
|
178
178
|
setRightCollapsed(false);
|
|
179
|
-
|
|
180
|
-
setRightPanelTab("design");
|
|
181
|
-
}
|
|
179
|
+
setRightPanelTab("design");
|
|
182
180
|
}
|
|
183
181
|
const nextSelectedTimelineId = findMatchingTimelineElementId(
|
|
184
182
|
nextSelection,
|
|
@@ -190,13 +188,7 @@ export function useDomSelection({
|
|
|
190
188
|
|
|
191
189
|
setSelectedTimelineElementId(null);
|
|
192
190
|
},
|
|
193
|
-
[
|
|
194
|
-
setSelectedTimelineElementId,
|
|
195
|
-
timelineElements,
|
|
196
|
-
setRightCollapsed,
|
|
197
|
-
setRightPanelTab,
|
|
198
|
-
rightPanelTab,
|
|
199
|
-
],
|
|
191
|
+
[setSelectedTimelineElementId, timelineElements, setRightCollapsed, setRightPanelTab],
|
|
200
192
|
);
|
|
201
193
|
|
|
202
194
|
const clearDomSelection = useCallback(() => {
|
|
@@ -40,10 +40,14 @@ export function useGsapAnimationOps({
|
|
|
40
40
|
animationId,
|
|
41
41
|
properties: { duration: updates.duration, ease: updates.ease, position: updates.position },
|
|
42
42
|
};
|
|
43
|
+
// coalesceKey groups rapid meta edits into one history entry. Request
|
|
44
|
+
// serialization is now handled per-file at the commitMutation chokepoint
|
|
45
|
+
// (useGsapScriptCommits), so no per-op serializeKey is needed here.
|
|
46
|
+
const metaKey = `gsap:${animationId}:meta`;
|
|
43
47
|
commitMutationSafely(
|
|
44
48
|
selection,
|
|
45
49
|
{ type: "update-meta", animationId, updates },
|
|
46
|
-
{ label: "Edit GSAP animation", coalesceKey:
|
|
50
|
+
{ label: "Edit GSAP animation", coalesceKey: metaKey, shadowGsapOp },
|
|
47
51
|
);
|
|
48
52
|
if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp);
|
|
49
53
|
},
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import type { ShadowKeyframeOp } from "../utils/sdkShadowGsapKeyframe";
|
|
3
4
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
5
|
import { executeOptimistic } from "../utils/optimisticUpdate";
|
|
5
6
|
import type { KeyframeCacheEntry } from "../player/store/playerStore";
|
|
@@ -58,6 +59,13 @@ export function useGsapKeyframeOps({
|
|
|
58
59
|
percentage,
|
|
59
60
|
properties: { [property]: value },
|
|
60
61
|
};
|
|
62
|
+
// Shadow op (gsap_keyframe): SDK equivalent diffed via the commit chokepoint.
|
|
63
|
+
const shadowKeyframeOp: ShadowKeyframeOp = {
|
|
64
|
+
kind: "add",
|
|
65
|
+
animationId,
|
|
66
|
+
percentage,
|
|
67
|
+
properties: { [property]: value },
|
|
68
|
+
};
|
|
61
69
|
void executeOptimisticKeyframeCacheUpdate({
|
|
62
70
|
sourceFile,
|
|
63
71
|
elementId: selection.id,
|
|
@@ -71,6 +79,7 @@ export function useGsapKeyframeOps({
|
|
|
71
79
|
commitMutation(selection, mutation, {
|
|
72
80
|
label: `Add keyframe at ${percentage}%`,
|
|
73
81
|
softReload: true,
|
|
82
|
+
shadowKeyframeOp,
|
|
74
83
|
}),
|
|
75
84
|
}).catch((error) => {
|
|
76
85
|
trackGsapSaveFailure(error, selection, mutation, `Add keyframe at ${percentage}%`);
|
|
@@ -86,10 +95,16 @@ export function useGsapKeyframeOps({
|
|
|
86
95
|
percentage: number,
|
|
87
96
|
properties: Record<string, number | string>,
|
|
88
97
|
) => {
|
|
98
|
+
const shadowKeyframeOp: ShadowKeyframeOp = {
|
|
99
|
+
kind: "add",
|
|
100
|
+
animationId,
|
|
101
|
+
percentage,
|
|
102
|
+
properties,
|
|
103
|
+
};
|
|
89
104
|
return commitMutation(
|
|
90
105
|
selection,
|
|
91
106
|
{ type: "add-keyframe", animationId, percentage, properties },
|
|
92
|
-
{ label: `Add keyframe at ${percentage}%`, softReload: true },
|
|
107
|
+
{ label: `Add keyframe at ${percentage}%`, softReload: true, shadowKeyframeOp },
|
|
93
108
|
);
|
|
94
109
|
},
|
|
95
110
|
[commitMutation],
|
|
@@ -99,6 +114,10 @@ export function useGsapKeyframeOps({
|
|
|
99
114
|
(selection: DomEditSelection, animationId: string, percentage: number) => {
|
|
100
115
|
const sourceFile = selection.sourceFile || activeCompPath || "index.html";
|
|
101
116
|
const mutation = { type: "remove-keyframe", animationId, percentage };
|
|
117
|
+
// Shadow op (gsap_keyframe): SDK has no %-based removeGsapKeyframe on main,
|
|
118
|
+
// so the runner resolves percentage → keyframeIndex against the pre-op
|
|
119
|
+
// script and no-ops on ambiguity (duplicate-percentage keyframes).
|
|
120
|
+
const shadowKeyframeOp: ShadowKeyframeOp = { kind: "remove", animationId, percentage };
|
|
102
121
|
void executeOptimisticKeyframeCacheUpdate({
|
|
103
122
|
sourceFile,
|
|
104
123
|
elementId: selection.id,
|
|
@@ -112,6 +131,7 @@ export function useGsapKeyframeOps({
|
|
|
112
131
|
commitMutation(selection, mutation, {
|
|
113
132
|
label: `Remove keyframe at ${percentage}%`,
|
|
114
133
|
softReload: true,
|
|
134
|
+
shadowKeyframeOp,
|
|
115
135
|
}),
|
|
116
136
|
}).catch((error) => {
|
|
117
137
|
trackGsapSaveFailure(error, selection, mutation, `Remove keyframe at ${percentage}%`);
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { useCallback } from "react";
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
2
2
|
import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation";
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
4
|
import { applySoftReload } from "../utils/gsapSoftReload";
|
|
5
5
|
import { resolveGsapFidelityArgs, runShadowGsapFidelity } from "../utils/sdkShadowGsapFidelity";
|
|
6
|
+
import { runShadowGsapKeyframeFidelity } from "../utils/sdkShadowGsapKeyframe";
|
|
6
7
|
import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers";
|
|
8
|
+
import { createKeyedSerializer } from "./serializeByKey";
|
|
7
9
|
import {
|
|
8
10
|
GsapMutationHttpError,
|
|
9
11
|
formatGsapMutationRejectionToast,
|
|
@@ -45,10 +47,16 @@ async function mutateGsapScript(
|
|
|
45
47
|
// oxfmt-ignore
|
|
46
48
|
// fallow-ignore-next-line complexity
|
|
47
49
|
export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession }: GsapScriptCommitsParams) {
|
|
50
|
+
// Serializer for per-key commits (options.serializeKey). Keyed by
|
|
51
|
+
// `gsap:${animationId}:meta`, it chains a meta commit onto the prior one for
|
|
52
|
+
// the same animationId so their POSTs can't interleave — which is what made
|
|
53
|
+
// the shadow fidelity diff pair an op with a stale server result and report
|
|
54
|
+
// false ease mismatches. Held in a ref so the chain survives re-renders.
|
|
55
|
+
const serializerRef = useRef(createKeyedSerializer());
|
|
48
56
|
// Pre-existing complexity (server mutate + history + reload branches); this PR
|
|
49
57
|
// adds only a guarded shadow-fidelity dispatch.
|
|
50
58
|
// fallow-ignore-next-line complexity
|
|
51
|
-
const
|
|
59
|
+
const runCommit = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
|
|
52
60
|
const pid = projectIdRef.current;
|
|
53
61
|
if (!pid) return;
|
|
54
62
|
const unsafeFields = findUnsafeMutationValues(mutation);
|
|
@@ -70,9 +78,10 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
70
78
|
domEditSaveTimestampRef.current = Date.now();
|
|
71
79
|
// Shadow value fidelity: diff the SDK's GSAP writer output against the
|
|
72
80
|
// server's, from the same pre-op file. Fire-and-forget; server authoritative.
|
|
73
|
-
//
|
|
74
|
-
// useGsapAnimationOps)
|
|
75
|
-
// useGsapKeyframeOps
|
|
81
|
+
// Meta-level ops carry shadowGsapOp (add / update-meta / delete via
|
|
82
|
+
// useGsapAnimationOps); keyframe ops carry shadowKeyframeOp (add/remove via
|
|
83
|
+
// useGsapKeyframeOps, handled by the gsap_keyframe block below). Per-property
|
|
84
|
+
// handlers (useGsapPropertyDebounce) don't synthesize one yet — deferred follow-up.
|
|
76
85
|
// scriptText is null when the composition has no GSAP script; nothing to diff.
|
|
77
86
|
const fidelityArgs = resolveGsapFidelityArgs(
|
|
78
87
|
sdkSession,
|
|
@@ -83,6 +92,12 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
83
92
|
if (fidelityArgs) {
|
|
84
93
|
void runShadowGsapFidelity(fidelityArgs.before, fidelityArgs.op, fidelityArgs.serverScript);
|
|
85
94
|
}
|
|
95
|
+
// Keyframe value fidelity (gsap_keyframe): same serialize-diff approach, but
|
|
96
|
+
// the SDK has no keyframe reader so there is no live-existence path — the diff
|
|
97
|
+
// is the only signal. Guarded on a live session + both scripts to diff.
|
|
98
|
+
if (sdkSession && options.shadowKeyframeOp && result.before != null && result.scriptText != null) {
|
|
99
|
+
void runShadowGsapKeyframeFidelity(result.before, options.shadowKeyframeOp, result.scriptText);
|
|
100
|
+
}
|
|
86
101
|
if (result.before != null && result.after != null) {
|
|
87
102
|
await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } });
|
|
88
103
|
}
|
|
@@ -97,6 +112,19 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
97
112
|
}
|
|
98
113
|
onCacheInvalidate();
|
|
99
114
|
}, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession]);
|
|
115
|
+
// Every GSAP-script commit is a read-modify-write of one file. Overlapping
|
|
116
|
+
// commits to the SAME file (any op type, any animation) interleave server-side
|
|
117
|
+
// and make the shadow fidelity diff pair an op with a stale server result —
|
|
118
|
+
// the false ease/value mismatches this serializer exists to prevent. So
|
|
119
|
+
// serialize per target file by default; an explicit serializeKey overrides.
|
|
120
|
+
const commitMutation = useCallback(
|
|
121
|
+
(selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
|
|
122
|
+
const file = selection.sourceFile || activeCompPath || "index.html";
|
|
123
|
+
const key = options.serializeKey ?? `gsap-file:${file}`;
|
|
124
|
+
return serializerRef.current(key, () => runCommit(selection, mutation, options));
|
|
125
|
+
},
|
|
126
|
+
[runCommit, activeCompPath],
|
|
127
|
+
);
|
|
100
128
|
const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
|
|
101
129
|
const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast);
|
|
102
130
|
const propertyOps = useGsapPropertyDebounce(commitMutationSafely);
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { useState, useCallback, useRef } from "react";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
RightInspectorPane,
|
|
4
|
+
RightInspectorPanes,
|
|
5
|
+
RightPanelTab,
|
|
6
|
+
} from "../utils/studioHelpers";
|
|
3
7
|
import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences";
|
|
4
8
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
5
9
|
|
|
@@ -8,6 +12,11 @@ export interface InitialPanelLayoutState {
|
|
|
8
12
|
rightPanelTab?: RightPanelTab | null;
|
|
9
13
|
}
|
|
10
14
|
|
|
15
|
+
function getInitialRightInspectorPanes(tab?: RightPanelTab | null): RightInspectorPanes {
|
|
16
|
+
if (tab === "layers") return { layers: true, design: false };
|
|
17
|
+
return { layers: false, design: true };
|
|
18
|
+
}
|
|
19
|
+
|
|
11
20
|
export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
12
21
|
const [leftWidth, setLeftWidth] = useState(240);
|
|
13
22
|
const [rightWidth, setRightWidth] = useState(400);
|
|
@@ -18,6 +27,9 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
|
18
27
|
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>(
|
|
19
28
|
initialState?.rightPanelTab ?? "renders",
|
|
20
29
|
);
|
|
30
|
+
const [rightInspectorPanes, setRightInspectorPanes] = useState<RightInspectorPanes>(() =>
|
|
31
|
+
getInitialRightInspectorPanes(initialState?.rightPanelTab),
|
|
32
|
+
);
|
|
21
33
|
const panelDragRef = useRef<{
|
|
22
34
|
side: "left" | "right";
|
|
23
35
|
startX: number;
|
|
@@ -67,12 +79,23 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
|
67
79
|
|
|
68
80
|
const trackedSetRightPanelTab = useCallback(
|
|
69
81
|
(tab: RightPanelTab) => {
|
|
82
|
+
if (tab === "design" || tab === "layers") {
|
|
83
|
+
setRightInspectorPanes((panes) => ({ ...panes, [tab]: true }));
|
|
84
|
+
}
|
|
70
85
|
setRightPanelTab(tab);
|
|
71
86
|
trackStudioEvent("tab_switch", { panel: "right_panel", tab });
|
|
72
87
|
},
|
|
73
88
|
[setRightPanelTab],
|
|
74
89
|
);
|
|
75
90
|
|
|
91
|
+
const toggleRightInspectorPane = useCallback((pane: RightInspectorPane) => {
|
|
92
|
+
setRightInspectorPanes((panes) => {
|
|
93
|
+
const next = { ...panes, [pane]: !panes[pane] };
|
|
94
|
+
if (!next.design && !next.layers) return panes;
|
|
95
|
+
return next;
|
|
96
|
+
});
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
76
99
|
return {
|
|
77
100
|
leftWidth,
|
|
78
101
|
setLeftWidth,
|
|
@@ -83,6 +106,8 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
|
83
106
|
setRightCollapsed,
|
|
84
107
|
rightPanelTab,
|
|
85
108
|
setRightPanelTab: trackedSetRightPanelTab,
|
|
109
|
+
rightInspectorPanes,
|
|
110
|
+
toggleRightInspectorPane,
|
|
86
111
|
toggleLeftSidebar,
|
|
87
112
|
handlePanelResizeStart,
|
|
88
113
|
handlePanelResizeMove,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useMemo, useRef, useState, type DragEvent } from "react";
|
|
2
2
|
import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
3
3
|
import type { StudioContextValue } from "../contexts/StudioContext";
|
|
4
|
+
import type { RightInspectorPanes } from "../utils/studioHelpers";
|
|
4
5
|
|
|
5
6
|
interface StudioContextInput {
|
|
6
7
|
projectId: string;
|
|
@@ -70,14 +71,18 @@ export interface InspectorState {
|
|
|
70
71
|
|
|
71
72
|
export function useInspectorState(
|
|
72
73
|
rightPanelTab: string,
|
|
74
|
+
rightInspectorPanes: RightInspectorPanes,
|
|
73
75
|
rightCollapsed: boolean,
|
|
74
76
|
isPlaying: boolean,
|
|
75
77
|
isGestureRecording?: boolean,
|
|
76
78
|
): InspectorState {
|
|
77
79
|
// fallow-ignore-next-line complexity
|
|
78
80
|
return useMemo(() => {
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
+
const inspectorTabActive = rightPanelTab === "design" || rightPanelTab === "layers";
|
|
82
|
+
const layersPanelActive =
|
|
83
|
+
STUDIO_INSPECTOR_PANELS_ENABLED && inspectorTabActive && rightInspectorPanes.layers;
|
|
84
|
+
const designPanelActive =
|
|
85
|
+
STUDIO_INSPECTOR_PANELS_ENABLED && inspectorTabActive && rightInspectorPanes.design;
|
|
81
86
|
const inspectorPanelActive = layersPanelActive || designPanelActive;
|
|
82
87
|
return {
|
|
83
88
|
layersPanelActive,
|
|
@@ -88,7 +93,7 @@ export function useInspectorState(
|
|
|
88
93
|
shouldShowSelectedDomBounds:
|
|
89
94
|
inspectorPanelActive && !rightCollapsed && !isPlaying && !isGestureRecording,
|
|
90
95
|
};
|
|
91
|
-
}, [rightPanelTab, rightCollapsed, isPlaying, isGestureRecording]);
|
|
96
|
+
}, [rightPanelTab, rightInspectorPanes, rightCollapsed, isPlaying, isGestureRecording]);
|
|
92
97
|
}
|
|
93
98
|
|
|
94
99
|
// fallow-ignore-next-line complexity
|
|
@@ -8,8 +8,10 @@ import {
|
|
|
8
8
|
ArrowsOutCardinal,
|
|
9
9
|
MusicNote,
|
|
10
10
|
Palette as PhPalette,
|
|
11
|
+
Minus as PhMinus,
|
|
11
12
|
Plus as PhPlus,
|
|
12
13
|
Square as PhSquare,
|
|
14
|
+
SquareSplitVertical as PhSquareSplitVertical,
|
|
13
15
|
TextT,
|
|
14
16
|
X as PhX,
|
|
15
17
|
Lightning,
|
|
@@ -43,8 +45,10 @@ export const MessageSquare = makeIcon(ChatCenteredText);
|
|
|
43
45
|
export const Move = makeIcon(ArrowsOutCardinal);
|
|
44
46
|
export const Music = makeIcon(MusicNote);
|
|
45
47
|
export const Palette = makeIcon(PhPalette);
|
|
48
|
+
export const Minus = makeIcon(PhMinus);
|
|
46
49
|
export const Plus = makeIcon(PhPlus);
|
|
47
50
|
export const Square = makeIcon(PhSquare);
|
|
51
|
+
export const Compare = makeIcon(PhSquareSplitVertical);
|
|
48
52
|
export const Type = makeIcon(TextT);
|
|
49
53
|
export const X = makeIcon(PhX);
|
|
50
54
|
export const Zap = makeIcon(Lightning);
|
|
@@ -36,6 +36,25 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
|
|
|
36
36
|
expect(plain).toBeDefined();
|
|
37
37
|
expect(plain?.hfId).toBeUndefined();
|
|
38
38
|
});
|
|
39
|
+
|
|
40
|
+
it("ignores runtime-owned color grading canvases with timing attributes", () => {
|
|
41
|
+
const doc = makeDoc(`
|
|
42
|
+
<div data-composition-id="root">
|
|
43
|
+
<img id="photo" class="clip" data-start="0" data-duration="5" />
|
|
44
|
+
<canvas
|
|
45
|
+
class="__hf_color_grading_canvas__"
|
|
46
|
+
data-hf-color-grading-canvas="true"
|
|
47
|
+
data-hyperframes-ignore
|
|
48
|
+
data-start="0"
|
|
49
|
+
data-duration="5"
|
|
50
|
+
></canvas>
|
|
51
|
+
</div>
|
|
52
|
+
`);
|
|
53
|
+
|
|
54
|
+
const elements = parseTimelineFromDOM(doc, 10);
|
|
55
|
+
|
|
56
|
+
expect(elements.map((el) => el.tag)).toEqual(["img"]);
|
|
57
|
+
});
|
|
39
58
|
});
|
|
40
59
|
|
|
41
60
|
describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => {
|
|
@@ -52,4 +71,21 @@ describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => {
|
|
|
52
71
|
expect(layer).toBeDefined();
|
|
53
72
|
expect(layer?.hfId).toBe("hf-xyz789");
|
|
54
73
|
});
|
|
74
|
+
|
|
75
|
+
it("ignores runtime-owned color grading canvases as implicit layers", () => {
|
|
76
|
+
const doc = makeDoc(`
|
|
77
|
+
<div data-composition-id="root" data-duration="5">
|
|
78
|
+
<img id="photo" class="clip" data-start="0" data-duration="5" />
|
|
79
|
+
<canvas
|
|
80
|
+
class="__hf_color_grading_canvas__"
|
|
81
|
+
data-hf-color-grading-canvas="true"
|
|
82
|
+
data-hyperframes-ignore
|
|
83
|
+
></canvas>
|
|
84
|
+
</div>
|
|
85
|
+
`);
|
|
86
|
+
|
|
87
|
+
const layers = createImplicitTimelineLayersFromDOM(doc, 5);
|
|
88
|
+
|
|
89
|
+
expect(layers).toEqual([]);
|
|
90
|
+
});
|
|
55
91
|
});
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
buildTimelineElementKey,
|
|
23
23
|
buildTimelineElementIdentity,
|
|
24
24
|
getTimelineElementIdentity,
|
|
25
|
+
isTimelineIgnoredElement,
|
|
25
26
|
} from "./timelineElementHelpers";
|
|
26
27
|
|
|
27
28
|
// Re-export helpers that were previously public from this module so that
|
|
@@ -230,6 +231,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
|
|
230
231
|
|
|
231
232
|
nodes.forEach((node) => {
|
|
232
233
|
if (node === rootComp) return;
|
|
234
|
+
if (isTimelineIgnoredElement(node)) return;
|
|
233
235
|
const el = node as HTMLElement;
|
|
234
236
|
const startStr = el.getAttribute("data-start");
|
|
235
237
|
if (startStr == null) return;
|
|
@@ -23,6 +23,19 @@ function readDurationAttribute(el: Element | null | undefined): number {
|
|
|
23
23
|
return isFinitePositive(duration) ? duration : 0;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export function isTimelineIgnoredElement(el: Element): boolean {
|
|
27
|
+
return Boolean(
|
|
28
|
+
el.closest(
|
|
29
|
+
[
|
|
30
|
+
"[data-hyperframes-ignore]",
|
|
31
|
+
"[data-hyperframes-picker-ignore]",
|
|
32
|
+
"[data-hf-ignore]",
|
|
33
|
+
"[data-hf-color-grading-canvas]",
|
|
34
|
+
].join(","),
|
|
35
|
+
),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
export function readTimelineDurationFromDocument(doc: Document | null | undefined): number {
|
|
27
40
|
if (!doc) return 0;
|
|
28
41
|
const rootDuration = readDurationAttribute(doc.querySelector("[data-composition-id]"));
|
|
@@ -30,6 +43,7 @@ export function readTimelineDurationFromDocument(doc: Document | null | undefine
|
|
|
30
43
|
|
|
31
44
|
let maxEnd = 0;
|
|
32
45
|
for (const node of Array.from(doc.querySelectorAll("[data-start]"))) {
|
|
46
|
+
if (isTimelineIgnoredElement(node)) continue;
|
|
33
47
|
const start = Number.parseFloat(node.getAttribute("data-start") ?? "");
|
|
34
48
|
const duration = readDurationAttribute(node);
|
|
35
49
|
if (!Number.isFinite(start) || start < 0 || duration <= 0) continue;
|
|
@@ -241,7 +255,9 @@ export function getTimelineElementIdentity(element: TimelineElement): string {
|
|
|
241
255
|
|
|
242
256
|
function getTimelineDomNodes(doc: Document): Element[] {
|
|
243
257
|
const rootComp = doc.querySelector("[data-composition-id]");
|
|
244
|
-
return Array.from(doc.querySelectorAll("[data-start]")).filter(
|
|
258
|
+
return Array.from(doc.querySelectorAll("[data-start]")).filter(
|
|
259
|
+
(node) => node !== rootComp && !isTimelineIgnoredElement(node),
|
|
260
|
+
);
|
|
245
261
|
}
|
|
246
262
|
|
|
247
263
|
function numbersNearlyEqual(a: number, b: number): boolean {
|
|
@@ -295,6 +311,7 @@ export function findTimelineDomNodeForClip(
|
|
|
295
311
|
|
|
296
312
|
export function isImplicitTimelineLayerCandidate(root: Element, el: Element): el is HTMLElement {
|
|
297
313
|
if (!isHtmlElement(el)) return false;
|
|
314
|
+
if (isTimelineIgnoredElement(el)) return false;
|
|
298
315
|
if (el.parentElement !== root) return false;
|
|
299
316
|
const tagName = el.tagName.toLowerCase();
|
|
300
317
|
if (IMPLICIT_TIMELINE_LAYER_SKIP_TAGS.has(tagName)) return false;
|