@hyperframes/studio 0.8.86 → 0.8.87
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-CtNVImMi.js +473 -0
- package/dist/assets/{index-Cz3E_ZSj.js → index-B_MF86RE.js} +1 -1
- package/dist/assets/{index-8Fgryj7q.js → index-DJ-Ol7YS.js} +1 -1
- package/dist/assets/{index-d5DKK15Q.js → index-DZKcDAtf.js} +203 -203
- package/dist/assets/{index-mzZCha_N.css → index-Dx79V1Se.css} +1 -1
- package/dist/{chunk-3IOCVWB6.js → chunk-PQFEGZWM.js} +3 -1
- package/dist/chunk-PQFEGZWM.js.map +1 -0
- package/dist/{domEditingLayers-ZOOMN7CQ.js → domEditingLayers-3BAP6NNO.js} +2 -2
- package/dist/index.d.ts +203 -160
- package/dist/index.html +2 -2
- package/dist/index.js +1958 -1611
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/editor/DomEditOverlay.tsx +1 -2
- package/src/components/editor/DomEditOverlayHover.test.tsx +5 -0
- package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -3
- package/src/components/editor/DomEditSelectionChrome.tsx +3 -13
- package/src/components/editor/LayersPanel.test.ts +14 -0
- package/src/components/editor/LayersPanel.tsx +2 -2
- package/src/components/editor/TopologyLens.tsx +2 -2
- package/src/components/editor/commitPreflightHandles.test.tsx +466 -0
- package/src/components/editor/domEditOverlayGestures.ts +0 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +10 -1
- package/src/components/editor/domEditing.test.ts +29 -0
- package/src/components/editor/domEditingDom.ts +1 -0
- package/src/components/editor/domEditingTypes.ts +1 -0
- package/src/components/editor/marqueeCommit.test.tsx +84 -0
- package/src/components/editor/marqueeCommit.ts +13 -1
- package/src/components/editor/useColorGradingController.test.ts +24 -0
- package/src/components/editor/useColorGradingController.ts +3 -2
- package/src/components/editor/useDomEditOverlayGestures.ts +20 -5
- package/src/components/nle/useCompositionStack.test.tsx +53 -0
- package/src/components/nle/useCompositionStack.ts +4 -8
- package/src/hooks/domStyleCommit.ts +129 -0
- package/src/hooks/gsapEditOutcome.ts +2 -2
- package/src/hooks/gsapElementMatch.ts +38 -0
- package/src/hooks/gsapResizeIntercept.ts +10 -31
- package/src/hooks/gsapResizePreflight.ts +56 -0
- package/src/hooks/gsapRuntimeBridge.ts +66 -26
- package/src/hooks/keyframeCacheAstLoad.ts +4 -1
- package/src/hooks/useCommitPreflightCapabilities.ts +147 -0
- package/src/hooks/useDomEditCommits.ts +17 -182
- package/src/hooks/useDomEditPersist.ts +225 -0
- package/src/hooks/useDomEditPreviewSync.test.ts +58 -0
- package/src/hooks/useDomEditPreviewSync.ts +2 -4
- package/src/hooks/useDomEditSession.test.tsx +51 -1
- package/src/hooks/useDomEditSession.ts +11 -2
- package/src/hooks/useDomEditTextCommits.ts +15 -99
- package/src/hooks/useDomStyleCommit.test.tsx +155 -0
- package/src/hooks/useDomStyleCommit.ts +74 -0
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +12 -0
- package/src/hooks/useGsapAnimationFetchFallback.ts +8 -2
- package/src/hooks/useGsapAwareEditing.test.tsx +4 -0
- package/src/hooks/useGsapAwareEditing.ts +1 -1
- package/src/hooks/useGsapTweenCache.ts +2 -48
- package/src/index.ts +10 -1
- package/src/player/components/Timeline.audioEffects.test.tsx +80 -0
- package/src/player/components/Timeline.bandEscape.test.tsx +75 -0
- package/src/player/components/Timeline.playheadRemount.test.tsx +3 -27
- package/src/player/components/TimelineGroupHeader.tsx +21 -10
- package/src/player/components/TimelineGroupLaneLabels.tsx +7 -2
- package/src/player/components/TimelineGroupRow.test.tsx +24 -33
- package/src/player/components/TimelineGroupRow.tsx +4 -1
- package/src/player/components/TimelineLanes.tsx +5 -5
- package/src/player/components/TimelineTrackHeader.test.tsx +27 -0
- package/src/player/components/TimelineTrackHeader.tsx +6 -3
- package/src/player/components/TimelineTypes.ts +1 -0
- package/src/player/components/timelineLaneProps.ts +1 -0
- package/src/player/components/timelineMountTestEnv.ts +29 -0
- package/src/player/components/useTimelineLabelColumn.ts +18 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +6 -1
- package/src/player/components/useTimelineProviderState.tsx +6 -12
- package/src/player/components/useTimelineRangeSelection.ts +13 -12
- package/src/player/hooks/useShadowPreviewReload.ts +2 -0
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +191 -0
- package/src/player/hooks/useTimelinePlayer.ts +29 -7
- package/src/player/lib/runtimeProtocol.test.ts +1 -0
- package/src/player/sceneSwap.test.ts +95 -0
- package/src/player/sceneSwap.ts +53 -0
- package/dist/assets/hyperframes-player-Dx-2Gpsu.js +0 -473
- package/dist/chunk-3IOCVWB6.js.map +0 -1
- /package/dist/{domEditingLayers-ZOOMN7CQ.js.map → domEditingLayers-3BAP6NNO.js.map} +0 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
|
|
2
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
3
|
+
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
|
|
4
|
+
import { selectorFromSelection } from "./gsapShared";
|
|
5
|
+
import {
|
|
6
|
+
animationWritesAnyProperty,
|
|
7
|
+
directEditOutcomeForProperties,
|
|
8
|
+
type GsapEditOutcome,
|
|
9
|
+
} from "./gsapEditOutcome";
|
|
10
|
+
|
|
11
|
+
export function resizeRoute(animations: GsapAnimation[], fetchedAnimations: GsapAnimation[]) {
|
|
12
|
+
const allKnownAnimations = [...animations, ...fetchedAnimations];
|
|
13
|
+
// If the element already has a scale-group tween, resize should modify scale
|
|
14
|
+
// (the user is resizing something whose visual size is driven by scale).
|
|
15
|
+
// Otherwise, use the size group (width/height).
|
|
16
|
+
const hasScaleGroup = allKnownAnimations.some((a) => a.propertyGroup === "scale");
|
|
17
|
+
const resizeGroup: PropertyGroupName = hasScaleGroup ? "scale" : "size";
|
|
18
|
+
const resizeProperties =
|
|
19
|
+
resizeGroup === "scale" ? new Set(["scale", "scaleX", "scaleY"]) : new Set(["width", "height"]);
|
|
20
|
+
const workingAnimations = animations.length > 0 ? animations : fetchedAnimations;
|
|
21
|
+
return { allKnownAnimations, resizeGroup, resizeProperties, workingAnimations };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The resize commit's refusal rule, also run ahead of time to hide the resize handles. */
|
|
25
|
+
export function preflightGsapResizeIntercept(
|
|
26
|
+
selection: DomEditSelection,
|
|
27
|
+
animations: GsapAnimation[],
|
|
28
|
+
iframe: HTMLIFrameElement | null,
|
|
29
|
+
fetchedAnimations: GsapAnimation[] = [],
|
|
30
|
+
): GsapEditOutcome {
|
|
31
|
+
const { allKnownAnimations, resizeGroup, resizeProperties, workingAnimations } = resizeRoute(
|
|
32
|
+
animations,
|
|
33
|
+
fetchedAnimations,
|
|
34
|
+
);
|
|
35
|
+
const editability = directEditOutcomeForProperties(allKnownAnimations, resizeProperties);
|
|
36
|
+
if (editability.status === "blocked") return editability;
|
|
37
|
+
const hasSourceTween = workingAnimations.some(
|
|
38
|
+
(a) =>
|
|
39
|
+
(a.propertyGroup === resizeGroup || !a.propertyGroup) &&
|
|
40
|
+
animationWritesAnyProperty(a, resizeProperties),
|
|
41
|
+
);
|
|
42
|
+
const liveSelector = selectorFromSelection(selection);
|
|
43
|
+
if (
|
|
44
|
+
!hasSourceTween &&
|
|
45
|
+
liveSelector &&
|
|
46
|
+
hasNonHoldTweenForElement(iframe, liveSelector, undefined, [...resizeProperties])
|
|
47
|
+
) {
|
|
48
|
+
// Third twin of the position/rotation cases: a live tween with no source match.
|
|
49
|
+
return {
|
|
50
|
+
status: "blocked",
|
|
51
|
+
reason: "source-uneditable",
|
|
52
|
+
detail: "live-resize-no-source-tween",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return { status: "persisted" };
|
|
56
|
+
}
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
readGsapPositionFromIframe,
|
|
34
34
|
} from "./gsapPositionDetection";
|
|
35
35
|
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
|
|
36
|
+
import { getAnimationsForElement } from "./gsapElementMatch";
|
|
36
37
|
import {
|
|
37
38
|
animationWritesAnyProperty,
|
|
38
39
|
directEditOutcomeForProperties,
|
|
@@ -133,17 +134,28 @@ export type { GsapDragCommitCallbacks };
|
|
|
133
134
|
* Returns an explicit persisted/blocked outcome. Callers must reject blocked
|
|
134
135
|
* outcomes so the gesture layer restores its runtime and overlay drafts.
|
|
135
136
|
*/
|
|
136
|
-
// fallow-ignore-next-line complexity
|
|
137
137
|
async function preflightGsapDragIntercept(
|
|
138
138
|
selection: DomEditSelection,
|
|
139
139
|
animations: GsapAnimation[],
|
|
140
140
|
iframe: HTMLIFrameElement | null,
|
|
141
141
|
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
142
|
+
group?: boolean,
|
|
142
143
|
): Promise<GsapEditOutcome> {
|
|
144
|
+
const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
|
|
145
|
+
return dragEditOutcome(selection, animations, iframe, fetchedAnimations, group);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The move commit's refusal rule, also run ahead of time to hide the move handles. */
|
|
149
|
+
// fallow-ignore-next-line complexity
|
|
150
|
+
export function dragEditOutcome(
|
|
151
|
+
selection: DomEditSelection,
|
|
152
|
+
animations: GsapAnimation[],
|
|
153
|
+
iframe: HTMLIFrameElement | null,
|
|
154
|
+
fetchedAnimations: GsapAnimation[] = [],
|
|
155
|
+
group = false,
|
|
156
|
+
): GsapEditOutcome {
|
|
143
157
|
const selector = selectorFromSelection(selection);
|
|
144
158
|
if (!selector) return { status: "blocked", reason: "no-selector" };
|
|
145
|
-
|
|
146
|
-
const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
|
|
147
159
|
// The fallback API currently represents both a definitive empty parse and an
|
|
148
160
|
// exhausted fetch failure as `[]`. Keep the selected cache in the preflight
|
|
149
161
|
// set as well: ignoring it would let a transient fetch failure bypass helper /
|
|
@@ -152,7 +164,12 @@ async function preflightGsapDragIntercept(
|
|
|
152
164
|
const editability = directEditOutcomeForProperties(allKnownAnimations, POSITION_CHANNEL_SET);
|
|
153
165
|
if (editability.status === "blocked") return editability;
|
|
154
166
|
const sourceAnimations = fetchedAnimations.length > 0 ? fetchedAnimations : animations;
|
|
155
|
-
|
|
167
|
+
// In a group only a tween naming this member by id or by its own selector counts,
|
|
168
|
+
// so a class tween shared with id-selected members cannot take one member's write.
|
|
169
|
+
const positionSources = group
|
|
170
|
+
? getAnimationsForElement(sourceAnimations, { id: selection.id ?? null, selector })
|
|
171
|
+
: sourceAnimations;
|
|
172
|
+
const posAnim = findGsapPositionAnimation(positionSources, selector);
|
|
156
173
|
const hasLivePosition = hasNonHoldTweenForElement(iframe, selector, undefined, POSITION_CHANNELS);
|
|
157
174
|
|
|
158
175
|
if (hasLivePosition && !posAnim) {
|
|
@@ -177,7 +194,12 @@ export async function tryGsapDragIntercept(
|
|
|
177
194
|
iframe: HTMLIFrameElement | null,
|
|
178
195
|
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
179
196
|
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
180
|
-
options?: {
|
|
197
|
+
options?: {
|
|
198
|
+
altKey?: boolean;
|
|
199
|
+
preflightOnly?: boolean;
|
|
200
|
+
preflightPassed?: boolean;
|
|
201
|
+
group?: boolean;
|
|
202
|
+
},
|
|
181
203
|
): Promise<GsapEditOutcome> {
|
|
182
204
|
if (!options?.preflightPassed) {
|
|
183
205
|
const preflight = await preflightGsapDragIntercept(
|
|
@@ -185,6 +207,7 @@ export async function tryGsapDragIntercept(
|
|
|
185
207
|
animations,
|
|
186
208
|
iframe,
|
|
187
209
|
fetchFallbackAnimations,
|
|
210
|
+
options?.group,
|
|
188
211
|
);
|
|
189
212
|
if (preflight.status === "blocked" || options?.preflightOnly) return preflight;
|
|
190
213
|
}
|
|
@@ -312,6 +335,41 @@ export { readGsapProperty, readAllAnimatedProperties };
|
|
|
312
335
|
|
|
313
336
|
// ── Identity-prop synthesis ───────────────────────────────────────────────
|
|
314
337
|
|
|
338
|
+
/** The rotation commit's refusal rule, also run ahead of time to hide the rotate handle. */
|
|
339
|
+
export function preflightGsapRotationIntercept(
|
|
340
|
+
selection: DomEditSelection,
|
|
341
|
+
animations: GsapAnimation[],
|
|
342
|
+
iframe: HTMLIFrameElement | null,
|
|
343
|
+
fetchedAnimations: GsapAnimation[] = [],
|
|
344
|
+
): GsapEditOutcome {
|
|
345
|
+
const liveSelector = selectorFromSelection(selection);
|
|
346
|
+
if (!(liveSelector ?? writeTargetSelector(selection))) {
|
|
347
|
+
return { status: "blocked", reason: "no-selector" };
|
|
348
|
+
}
|
|
349
|
+
const editability = directEditOutcomeForProperties(
|
|
350
|
+
[...animations, ...fetchedAnimations],
|
|
351
|
+
ROTATION_CHANNEL_SET,
|
|
352
|
+
);
|
|
353
|
+
if (editability.status === "blocked") return editability;
|
|
354
|
+
const workingAnimations = animations.length > 0 ? animations : fetchedAnimations;
|
|
355
|
+
const hasSourceTween = workingAnimations.some((a) =>
|
|
356
|
+
animationWritesAnyProperty(a, ROTATION_CHANNEL_SET),
|
|
357
|
+
);
|
|
358
|
+
if (
|
|
359
|
+
!hasSourceTween &&
|
|
360
|
+
liveSelector &&
|
|
361
|
+
hasNonHoldTweenForElement(iframe, liveSelector, undefined, ROTATION_CHANNELS)
|
|
362
|
+
) {
|
|
363
|
+
// Rotation twin of the position case above: live tween, no source match.
|
|
364
|
+
return {
|
|
365
|
+
status: "blocked",
|
|
366
|
+
reason: "source-uneditable",
|
|
367
|
+
detail: "live-rotation-no-source-tween",
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
return { status: "persisted" };
|
|
371
|
+
}
|
|
372
|
+
|
|
315
373
|
export async function tryGsapRotationIntercept(
|
|
316
374
|
selection: DomEditSelection,
|
|
317
375
|
angle: number,
|
|
@@ -320,16 +378,11 @@ export async function tryGsapRotationIntercept(
|
|
|
320
378
|
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
321
379
|
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
322
380
|
): Promise<GsapEditOutcome> {
|
|
323
|
-
const selector = selectorFromSelection(selection) ?? writeTargetSelector(selection);
|
|
324
|
-
if (!selector) return { status: "blocked", reason: "no-selector" };
|
|
325
|
-
|
|
326
381
|
const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
|
|
382
|
+
const outcome = preflightGsapRotationIntercept(selection, animations, iframe, fetchedAnimations);
|
|
383
|
+
if (outcome.status === "blocked") return outcome;
|
|
384
|
+
const selector = (selectorFromSelection(selection) ?? writeTargetSelector(selection))!;
|
|
327
385
|
const workingAnimations = animations.length > 0 ? animations : fetchedAnimations;
|
|
328
|
-
const editability = directEditOutcomeForProperties(
|
|
329
|
-
[...animations, ...fetchedAnimations],
|
|
330
|
-
ROTATION_CHANNEL_SET,
|
|
331
|
-
);
|
|
332
|
-
if (editability.status === "blocked") return editability;
|
|
333
386
|
const postSplitFetch = workingAnimations.some((animation) => !animation.propertyGroup)
|
|
334
387
|
? fetchFallbackAnimations
|
|
335
388
|
: undefined;
|
|
@@ -354,19 +407,6 @@ export async function tryGsapRotationIntercept(
|
|
|
354
407
|
workingAnimations.find((a) => animationWritesAnyProperty(a, ROTATION_CHANNEL_SET)) ?? null;
|
|
355
408
|
}
|
|
356
409
|
|
|
357
|
-
const liveSelector = selectorFromSelection(selection);
|
|
358
|
-
const hasLiveRotationTween = liveSelector
|
|
359
|
-
? hasNonHoldTweenForElement(iframe, liveSelector, undefined, ROTATION_CHANNELS)
|
|
360
|
-
: false;
|
|
361
|
-
if (!anim && hasLiveRotationTween) {
|
|
362
|
-
// Rotation twin of the position case above: live tween, no source match.
|
|
363
|
-
return {
|
|
364
|
-
status: "blocked",
|
|
365
|
-
reason: "source-uneditable",
|
|
366
|
-
detail: "live-rotation-no-source-tween",
|
|
367
|
-
};
|
|
368
|
-
}
|
|
369
|
-
|
|
370
410
|
// `angle` is the ABSOLUTE target rotation resolved by the gesture (gsap base +
|
|
371
411
|
// pointer sweep) or the inspector — so it IS the new rotation. No base re-add: the
|
|
372
412
|
// gesture's live preview already gsap.set this value (single source of truth).
|
|
@@ -53,12 +53,15 @@ function hasAnimations(value: unknown): value is ParsedGsapAnimations {
|
|
|
53
53
|
*/
|
|
54
54
|
const inFlightParses = new Map<string, Promise<ParsedGsapAnimations | null>>();
|
|
55
55
|
|
|
56
|
+
export const parseCacheKey = (projectId: string, sourceFile: string) =>
|
|
57
|
+
`${projectId}|${sourceFile}`;
|
|
58
|
+
|
|
56
59
|
export function fetchParsedAnimations(
|
|
57
60
|
projectId: string,
|
|
58
61
|
sourceFile: string,
|
|
59
62
|
options: { fresh?: boolean } = {},
|
|
60
63
|
): Promise<ParsedGsapAnimations | null> {
|
|
61
|
-
const key =
|
|
64
|
+
const key = parseCacheKey(projectId, sourceFile);
|
|
62
65
|
if (options.fresh) inFlightParses.delete(key);
|
|
63
66
|
const inFlight = inFlightParses.get(key);
|
|
64
67
|
if (inFlight) return inFlight;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
|
+
import { dragEditOutcome, preflightGsapRotationIntercept } from "./gsapRuntimeBridge";
|
|
5
|
+
import { preflightGsapResizeIntercept } from "./gsapResizePreflight";
|
|
6
|
+
import { GSAP_EDIT_BLOCK_COPY, type GsapEditOutcome } from "./gsapEditOutcome";
|
|
7
|
+
import { fetchParsedAnimations, parseCacheKey } from "./keyframeCacheAstLoad";
|
|
8
|
+
import {
|
|
9
|
+
gsapSourceFileForSelection,
|
|
10
|
+
selectElementAnimationsOrRetry,
|
|
11
|
+
} from "./useGsapAnimationFetchFallback";
|
|
12
|
+
|
|
13
|
+
interface CommitPreflight {
|
|
14
|
+
offset: GsapEditOutcome;
|
|
15
|
+
size: GsapEditOutcome;
|
|
16
|
+
rotation: GsapEditOutcome;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function runCommitPreflights(
|
|
20
|
+
selection: DomEditSelection,
|
|
21
|
+
fileAnimations: GsapAnimation[],
|
|
22
|
+
iframe: HTMLIFrameElement | null,
|
|
23
|
+
group: boolean,
|
|
24
|
+
): CommitPreflight {
|
|
25
|
+
const target = { id: selection.id ?? null, selector: selection.selector ?? null };
|
|
26
|
+
const matched = selectElementAnimationsOrRetry(
|
|
27
|
+
{ animations: fileAnimations },
|
|
28
|
+
target,
|
|
29
|
+
selection.element,
|
|
30
|
+
);
|
|
31
|
+
// A file the server parsed with no tweens at all is a definitive answer here.
|
|
32
|
+
const animations = matched.kind === "resolved" ? matched.animations : [];
|
|
33
|
+
return {
|
|
34
|
+
offset: dragEditOutcome(selection, animations, iframe, [], group),
|
|
35
|
+
size: preflightGsapResizeIntercept(selection, animations, iframe),
|
|
36
|
+
rotation: preflightGsapRotationIntercept(selection, animations, iframe),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Studio can hand a narrowed copy back as a new selection; narrowing starts from the resolved one.
|
|
41
|
+
const resolvedSelections = new WeakMap<DomEditSelection, DomEditSelection>();
|
|
42
|
+
|
|
43
|
+
const resolvedOf = (selection: DomEditSelection) => resolvedSelections.get(selection) ?? selection;
|
|
44
|
+
|
|
45
|
+
const MANUAL_FLAGS = [
|
|
46
|
+
["canApplyManualOffset", "offset"],
|
|
47
|
+
["canApplyManualSize", "size"],
|
|
48
|
+
["canApplyManualRotation", "rotation"],
|
|
49
|
+
] as const;
|
|
50
|
+
|
|
51
|
+
/** Null when the commit would go through; "" while the check is still running. */
|
|
52
|
+
function refusal(preflight: CommitPreflight | null, check: keyof CommitPreflight): string | null {
|
|
53
|
+
const outcome = preflight?.[check];
|
|
54
|
+
if (outcome?.status === "persisted") return null;
|
|
55
|
+
return outcome ? GSAP_EDIT_BLOCK_COPY[outcome.reason] : "";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Closes each manual flag whose commit Studio would refuse, and says why. */
|
|
59
|
+
function narrowCapabilities(
|
|
60
|
+
selection: DomEditSelection,
|
|
61
|
+
preflight: CommitPreflight | null,
|
|
62
|
+
): DomEditSelection {
|
|
63
|
+
const resolved = resolvedOf(selection);
|
|
64
|
+
const next = { ...resolved.capabilities };
|
|
65
|
+
const reasons: string[] = [];
|
|
66
|
+
for (const [flag, check] of MANUAL_FLAGS) {
|
|
67
|
+
const reason = refusal(preflight, check);
|
|
68
|
+
if (!next[flag] || reason === null) continue;
|
|
69
|
+
next[flag] = false;
|
|
70
|
+
reasons.push(reason);
|
|
71
|
+
}
|
|
72
|
+
if (reasons.length === 0) return resolved;
|
|
73
|
+
next.reasonIfDisabled = reasons.find(Boolean) || next.reasonIfDisabled;
|
|
74
|
+
if (!preflight) next.commitCheckPending = true;
|
|
75
|
+
const narrowed = { ...resolved, capabilities: next };
|
|
76
|
+
resolvedSelections.set(narrowed, resolved);
|
|
77
|
+
return narrowed;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface FileParse {
|
|
81
|
+
version: number;
|
|
82
|
+
animations: GsapAnimation[] | null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The one place selection capabilities learn what the GSAP commit would refuse,
|
|
87
|
+
* so chrome, nudge and group gates never offer an edit that snaps back.
|
|
88
|
+
*/
|
|
89
|
+
export function useCommitPreflightCapabilities({
|
|
90
|
+
projectId,
|
|
91
|
+
enabled,
|
|
92
|
+
selection,
|
|
93
|
+
groupSelections,
|
|
94
|
+
previewIframeRef,
|
|
95
|
+
version,
|
|
96
|
+
}: {
|
|
97
|
+
projectId: string | null;
|
|
98
|
+
enabled: boolean;
|
|
99
|
+
selection: DomEditSelection | null;
|
|
100
|
+
groupSelections: DomEditSelection[];
|
|
101
|
+
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
|
|
102
|
+
version: number;
|
|
103
|
+
}) {
|
|
104
|
+
// One parse per project file and version; the last good parse answers while a newer one loads.
|
|
105
|
+
const parsesRef = useRef(new Map<string, FileParse>());
|
|
106
|
+
const [parseTick, setParseTick] = useState(0);
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!enabled || !projectId) return;
|
|
110
|
+
const targets = selection ? [selection, ...groupSelections] : groupSelections;
|
|
111
|
+
for (const file of new Set(targets.map(gsapSourceFileForSelection))) {
|
|
112
|
+
const key = parseCacheKey(projectId, file);
|
|
113
|
+
const known = parsesRef.current.get(key);
|
|
114
|
+
if (known?.version === version) continue;
|
|
115
|
+
parsesRef.current.set(key, { version, animations: known?.animations ?? null });
|
|
116
|
+
void fetchParsedAnimations(projectId, file).then((parsed) => {
|
|
117
|
+
if (parsesRef.current.get(key)?.version !== version) return;
|
|
118
|
+
if (parsed) {
|
|
119
|
+
parsesRef.current.set(key, { version, animations: parsed.animations });
|
|
120
|
+
setParseTick((tick) => tick + 1);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// A failed read is not an answer: keep the last one, and the next selection asks again.
|
|
124
|
+
if (known?.animations) parsesRef.current.set(key, known);
|
|
125
|
+
else parsesRef.current.delete(key);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}, [enabled, projectId, selection, groupSelections, version]);
|
|
129
|
+
|
|
130
|
+
return useMemo(() => {
|
|
131
|
+
void parseTick;
|
|
132
|
+
if (!enabled || !projectId) return { selection, groupSelections };
|
|
133
|
+
const group = groupSelections.length > 1;
|
|
134
|
+
const narrow = (target: DomEditSelection) => {
|
|
135
|
+
const file = gsapSourceFileForSelection(target);
|
|
136
|
+
const animations = parsesRef.current.get(parseCacheKey(projectId, file))?.animations;
|
|
137
|
+
const preflight = animations
|
|
138
|
+
? runCommitPreflights(resolvedOf(target), animations, previewIframeRef.current, group)
|
|
139
|
+
: null;
|
|
140
|
+
return narrowCapabilities(target, preflight);
|
|
141
|
+
};
|
|
142
|
+
return {
|
|
143
|
+
selection: selection && narrow(selection),
|
|
144
|
+
groupSelections: groupSelections.map(narrow),
|
|
145
|
+
};
|
|
146
|
+
}, [enabled, projectId, selection, groupSelections, parseTick, previewIframeRef]);
|
|
147
|
+
}
|
|
@@ -1,20 +1,15 @@
|
|
|
1
1
|
import { buildProjectApiPath } from "../utils/projectRouting";
|
|
2
|
-
import { useCallback
|
|
2
|
+
import { useCallback } from "react";
|
|
3
3
|
import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mutation";
|
|
4
4
|
import { FONT_EXT } from "../utils/mediaTypes";
|
|
5
|
-
|
|
6
|
-
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
7
5
|
import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
|
|
8
6
|
import { StudioSaveHttpError, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
|
|
9
|
-
import {
|
|
7
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
10
8
|
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
|
11
|
-
import type { CommitDomEditPatchBatches
|
|
9
|
+
import type { CommitDomEditPatchBatches } from "./domEditCommitTypes";
|
|
12
10
|
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
DomEditPersistUnresolvableError,
|
|
16
|
-
warnDomEditPersistNoOp,
|
|
17
|
-
} from "./domEditPersistFailure";
|
|
11
|
+
import { DomEditPersistUnsafeValueError } from "./domEditPersistFailure";
|
|
12
|
+
import { useDomEditPersist, type RecordEditInput } from "./useDomEditPersist";
|
|
18
13
|
import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
|
|
19
14
|
import { useDomEditTextCommits } from "./useDomEditTextCommits";
|
|
20
15
|
import { useDomGeometryCommits } from "./useDomGeometryCommits";
|
|
@@ -24,19 +19,9 @@ import {
|
|
|
24
19
|
batchesAreInlineStyleOnly,
|
|
25
20
|
formatUnsafeFieldList,
|
|
26
21
|
patchElementBatches,
|
|
27
|
-
postPatchElement,
|
|
28
|
-
writePreparedContent,
|
|
29
22
|
} from "./useDomEditCommitsHelpers";
|
|
30
23
|
import type { CutoverResult } from "../utils/sdkCutover";
|
|
31
|
-
import { reseekPreviewRuntime } from "./timelineTrackVisibility";
|
|
32
24
|
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
|
33
|
-
import { readProjectFileContent } from "../utils/studioFileHistory";
|
|
34
|
-
interface RecordEditInput {
|
|
35
|
-
label: string;
|
|
36
|
-
coalesceKey?: string;
|
|
37
|
-
coalesceMs?: number;
|
|
38
|
-
files: Record<string, { before: string; after: string }>;
|
|
39
|
-
}
|
|
40
25
|
|
|
41
26
|
export interface UseDomEditCommitsParams {
|
|
42
27
|
activeCompPath: string | null;
|
|
@@ -132,168 +117,18 @@ export function useDomEditCommits({
|
|
|
132
117
|
[fileTree, projectId, importedFontAssetsRef],
|
|
133
118
|
);
|
|
134
119
|
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
throw new Error("Active project changed before the edit could be saved");
|
|
148
|
-
}
|
|
149
|
-
const pid = expectedProjectId;
|
|
150
|
-
if (options?.shouldSave && !options.shouldSave()) return;
|
|
151
|
-
|
|
152
|
-
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
153
|
-
const completePersistence = <T>(result: T, changed: boolean): T => {
|
|
154
|
-
if (options?.skipRefresh && changed) reseekPreviewRuntime(previewIframeRef.current);
|
|
155
|
-
return result;
|
|
156
|
-
};
|
|
157
|
-
|
|
158
|
-
const readTarget = async (): Promise<string | null> => {
|
|
159
|
-
const content = await readProjectFileContent(pid, targetPath);
|
|
160
|
-
if (projectIdRef.current !== expectedProjectId) {
|
|
161
|
-
throw new Error("Active project changed before the edit could be saved");
|
|
162
|
-
}
|
|
163
|
-
return options?.shouldSave && !options.shouldSave() ? null : content;
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
// Validate layout values BEFORE any persist path runs. The SDK cutover
|
|
167
|
-
// path (onTrySdkPersist) returns early on success, so leaving this check
|
|
168
|
-
// after it let invalid numeric values bypass the guard whenever the
|
|
169
|
-
// cutover flag was on.
|
|
170
|
-
const patchTarget = buildDomEditPatchTarget(selection);
|
|
171
|
-
const patchBody = { target: patchTarget, operations };
|
|
172
|
-
const unsafeFields = findUnsafeDomPatchValues(patchBody);
|
|
173
|
-
if (unsafeFields.length > 0) {
|
|
174
|
-
const fields = formatUnsafeFieldList(unsafeFields);
|
|
175
|
-
showToast("Couldn't save edit because it contains invalid layout values", "error");
|
|
176
|
-
throw new DomEditPersistUnsafeValueError(`DOM patch contains unsafe values: ${fields}`, {
|
|
177
|
-
alreadyToasted: true,
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Skip the SDK path when prepareContent is set (e.g. @font-face injection
|
|
182
|
-
// for a custom font): sdkCutoverPersist serializes only the patched DOM
|
|
183
|
-
// and would drop the injected content. Let the server path run prepareContent.
|
|
184
|
-
// The SDK joins the file queue itself and re-reads there; this read is only its fallback.
|
|
185
|
-
if (onTrySdkPersist && !options?.prepareContent) {
|
|
186
|
-
const originalContent = await readTarget();
|
|
187
|
-
if (originalContent === null) return;
|
|
188
|
-
const cutover = await onTrySdkPersist(selection, operations, originalContent, targetPath, {
|
|
189
|
-
label: options?.label,
|
|
190
|
-
coalesceKey: options?.coalesceKey,
|
|
191
|
-
skipRefresh: options?.skipRefresh,
|
|
192
|
-
});
|
|
193
|
-
if (cutover.status === "failed") throw cutover.error;
|
|
194
|
-
if (cutover.status === "committed") {
|
|
195
|
-
// SDK handled it — its in-memory doc is already current, so do NOT
|
|
196
|
-
// forceReload (that would echo-reload the session we just wrote).
|
|
197
|
-
return completePersistence(
|
|
198
|
-
{ sourceFile: targetPath, version: cutover.version, changed: true },
|
|
199
|
-
true,
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
const history = {
|
|
205
|
-
label: options?.label ?? "Edit layer",
|
|
206
|
-
coalesceKey: options?.coalesceKey,
|
|
207
|
-
coalesceMs: options?.coalesceMs,
|
|
208
|
-
};
|
|
209
|
-
const prepare = options?.prepareContent;
|
|
210
|
-
// Read, server patch, follow-up write and history hold the file's queue, so no save lands between them.
|
|
211
|
-
const saved = await serializeStudioFileMutations(writeProjectFile, [targetPath], async () => {
|
|
212
|
-
const originalContent = await readTarget();
|
|
213
|
-
if (originalContent === null) return null;
|
|
214
|
-
const patchData = await postPatchElement(pid, targetPath, patchBody, showToast);
|
|
215
|
-
if (!patchData.changed) return { patchData, patchedContent: null, finalContent: null };
|
|
216
|
-
|
|
217
|
-
const patchedContent =
|
|
218
|
-
typeof patchData.content === "string" ? patchData.content : originalContent;
|
|
219
|
-
const finalContent = prepare
|
|
220
|
-
? await writePreparedContent(
|
|
221
|
-
targetPath,
|
|
222
|
-
patchedContent,
|
|
223
|
-
prepare,
|
|
224
|
-
writeProjectFile,
|
|
225
|
-
showToast,
|
|
226
|
-
)
|
|
227
|
-
: patchedContent;
|
|
228
|
-
|
|
229
|
-
await editHistory.recordEdit({
|
|
230
|
-
...history,
|
|
231
|
-
files: { [targetPath]: { before: originalContent, after: finalContent } },
|
|
232
|
-
});
|
|
233
|
-
return { patchData, patchedContent, finalContent };
|
|
234
|
-
});
|
|
235
|
-
if (saved === null) return;
|
|
236
|
-
const { patchData, patchedContent, finalContent } = saved;
|
|
237
|
-
|
|
238
|
-
if (finalContent === null) {
|
|
239
|
-
if (patchData.matched === false) {
|
|
240
|
-
const targetKey = selection.selector ?? selection.id ?? "selection";
|
|
241
|
-
if (!reportedUnresolvableRef.current.has(targetKey)) {
|
|
242
|
-
reportedUnresolvableRef.current.add(targetKey);
|
|
243
|
-
trackStudioEvent("save_skipped_unresolvable", {
|
|
244
|
-
target_id: selection.id ?? undefined,
|
|
245
|
-
target_selector: selection.selector ?? undefined,
|
|
246
|
-
target_source_file: selection.sourceFile ?? undefined,
|
|
247
|
-
composition: activeCompPath ?? undefined,
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
throw new DomEditPersistUnresolvableError(targetPath);
|
|
251
|
-
}
|
|
252
|
-
warnDomEditPersistNoOp(selection, operations);
|
|
253
|
-
return completePersistence(
|
|
254
|
-
typeof patchData.path === "string" && typeof patchData.version === "string"
|
|
255
|
-
? { sourceFile: patchData.path, version: patchData.version, changed: false }
|
|
256
|
-
: undefined,
|
|
257
|
-
false,
|
|
258
|
-
);
|
|
259
|
-
}
|
|
260
|
-
forceReloadSdkSession?.();
|
|
261
|
-
|
|
262
|
-
if (!options?.skipRefresh) {
|
|
263
|
-
reloadPreview();
|
|
264
|
-
}
|
|
265
|
-
return completePersistence(
|
|
266
|
-
finalContent === patchedContent &&
|
|
267
|
-
typeof patchData.path === "string" &&
|
|
268
|
-
typeof patchData.version === "string"
|
|
269
|
-
? { sourceFile: patchData.path, version: patchData.version, changed: true }
|
|
270
|
-
: undefined,
|
|
271
|
-
true,
|
|
272
|
-
);
|
|
273
|
-
},
|
|
274
|
-
[
|
|
275
|
-
activeCompPath,
|
|
276
|
-
editHistory,
|
|
277
|
-
writeProjectFile,
|
|
278
|
-
projectIdRef,
|
|
279
|
-
reloadPreview,
|
|
280
|
-
showToast,
|
|
281
|
-
forceReloadSdkSession,
|
|
282
|
-
onTrySdkPersist,
|
|
283
|
-
previewIframeRef,
|
|
284
|
-
],
|
|
285
|
-
);
|
|
286
|
-
|
|
287
|
-
const persistDomEditOperations: PersistDomEditOperations = useCallback(
|
|
288
|
-
(selection, operations, options) => {
|
|
289
|
-
const expectedProjectId = projectIdRef.current;
|
|
290
|
-
if (!expectedProjectId) return Promise.reject(new Error("No active project"));
|
|
291
|
-
return queueDomEditSave(() =>
|
|
292
|
-
performPersistDomEditOperations(selection, operations, options, expectedProjectId),
|
|
293
|
-
);
|
|
294
|
-
},
|
|
295
|
-
[performPersistDomEditOperations, projectIdRef, queueDomEditSave],
|
|
296
|
-
);
|
|
120
|
+
const persistDomEditOperations = useDomEditPersist({
|
|
121
|
+
activeCompPath,
|
|
122
|
+
previewIframeRef,
|
|
123
|
+
showToast,
|
|
124
|
+
queueDomEditSave,
|
|
125
|
+
writeProjectFile,
|
|
126
|
+
editHistory,
|
|
127
|
+
projectIdRef,
|
|
128
|
+
reloadPreview,
|
|
129
|
+
forceReloadSdkSession,
|
|
130
|
+
onTrySdkPersist,
|
|
131
|
+
});
|
|
297
132
|
|
|
298
133
|
const commitDomEditPatchBatches: CommitDomEditPatchBatches = useCallback(
|
|
299
134
|
(batches, options) => {
|