@hyperframes/studio 0.8.114 → 0.8.116
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-aXNJKiIg.js → hyperframes-player-mGYvPVpw.js} +1 -1
- package/dist/assets/{index-Bn2RJNTJ.js → index-BWHt0-ir.js} +1 -1
- package/dist/assets/{index-Dz7doz9l.js → index-DYNaXPVf.js} +203 -203
- package/dist/assets/{index-C9N-DGjC.js → index-Dr3hNpvS.js} +1 -1
- package/dist/assets/{index-BYnsJnJV.css → index-t1LH6H5H.css} +1 -1
- package/dist/{chunk-BIUXP3PG.js → chunk-3F47EFCT.js} +142 -277
- package/dist/chunk-3F47EFCT.js.map +1 -0
- package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
- package/dist/chunk-5EKWJZLM.js.map +1 -0
- package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
- package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-OL7GLSS4.js} +3 -5
- package/dist/index.d.ts +146 -40
- package/dist/index.html +2 -2
- package/dist/index.js +4021 -3591
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +9 -8
- package/src/App.tsx +2 -2
- package/src/captions/hooks/useCaptionSync.ts +10 -6
- package/src/components/LinkedSelectionToggle.tsx +3 -2
- package/src/components/ProjectUnreachableBanner.tsx +3 -3
- package/src/components/dock/Dock.tsx +5 -7
- package/src/components/editor/CanvasContextMenu.tsx +3 -2
- package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
- package/src/components/editor/DomEditCropHandles.tsx +7 -4
- package/src/components/editor/DomEditOverlay.tsx +5 -1
- package/src/components/editor/FileTreeNodes.tsx +13 -10
- package/src/components/editor/MotionPathOverlay.tsx +7 -2
- package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
- package/src/components/editor/SnapToolbar.test.tsx +100 -1
- package/src/components/editor/SnapToolbar.tsx +43 -5
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
- package/src/components/editor/audioFxTelemetry.ts +2 -3
- package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
- package/src/components/editor/cropResize.ts +4 -3
- package/src/components/editor/domEditingDom.ts +4 -0
- package/src/components/editor/domEditingLayers.ts +1 -5
- package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
- package/src/components/editor/domGroupMoveBy.ts +95 -0
- package/src/components/editor/dragUndoPaint.test.ts +5 -5
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
- package/src/components/editor/manualEditingAvailability.ts +8 -0
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsParsing.ts +7 -0
- package/src/components/editor/manualOffsetDrag.ts +5 -6
- package/src/components/editor/marqueeCommit.test.tsx +106 -37
- package/src/components/editor/marqueeCommit.ts +24 -8
- package/src/components/editor/motionPathCommit.test.ts +29 -1
- package/src/components/editor/motionPathCommit.ts +21 -9
- package/src/components/editor/motionPathSelection.test.ts +6 -31
- package/src/components/editor/probeSourceElement.test.ts +45 -0
- package/src/components/editor/probeSourceElement.ts +41 -16
- package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
- package/src/components/editor/propertyPanelFont.tsx +3 -2
- package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
- package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
- package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
- package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
- package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
- package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
- package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
- package/src/components/editor/propertyPanelFxSection.tsx +30 -28
- package/src/components/editor/propertyPanelTypes.ts +6 -3
- package/src/components/editor/useColorGradingController.ts +2 -1
- package/src/components/editor/useDomEditNudge.test.tsx +37 -0
- package/src/components/editor/useDomEditNudge.ts +2 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
- package/src/components/editor/useDomEditZOrder.ts +12 -2
- package/src/components/editor/useFxCarveNodes.ts +2 -1
- package/src/components/editor/useFxLevelling.ts +2 -1
- package/src/components/feedback/projectProvenance.ts +2 -1
- package/src/components/nle/NLEContext.test.ts +1 -0
- package/src/components/nle/NLEContext.tsx +2 -1
- package/src/components/nle/NLEPreview.tsx +12 -10
- package/src/components/nle/PreviewPoster.tsx +2 -1
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
- package/src/components/renders/useFfmpegStatus.ts +2 -1
- package/src/components/renders/useRenderQueue.ts +5 -4
- package/src/components/sidebar/AssetContextMenu.tsx +4 -4
- package/src/components/sidebar/AssetsTab.tsx +2 -1
- package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
- package/src/components/ui/menuStyle.test.ts +61 -0
- package/src/components/ui/menuStyle.ts +10 -0
- package/src/contexts/DomEditContext.tsx +5 -1
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/domEditExports.test.tsx +9 -1
- package/src/hooks/domEditCommitRunner.ts +1 -1
- package/src/hooks/domSelectionTestHarness.ts +23 -0
- package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
- package/src/hooks/domStyleCommit.ts +8 -1
- package/src/hooks/draggedGsapPosition.ts +10 -0
- package/src/hooks/elementOffsetStager.ts +5 -4
- package/src/hooks/gsapDragCommit.test.ts +75 -164
- package/src/hooks/gsapDragCommit.ts +4 -125
- package/src/hooks/gsapDragPositionCommit.ts +36 -384
- package/src/hooks/gsapEditOutcome.ts +22 -2
- package/src/hooks/gsapGestureOutcome.test.ts +161 -0
- package/src/hooks/gsapGestureOutcome.ts +64 -0
- package/src/hooks/gsapGroupPreflight.ts +34 -0
- package/src/hooks/gsapKeyframeCommit.ts +3 -12
- package/src/hooks/gsapMutationClient.ts +4 -3
- package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
- package/src/hooks/gsapParsedTween.test.ts +62 -0
- package/src/hooks/gsapParsedTween.ts +243 -0
- package/src/hooks/gsapPositionDetection.test.ts +78 -0
- package/src/hooks/gsapPositionDetection.ts +52 -33
- package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
- package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
- package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
- package/src/hooks/gsapResizeIntercept.test.ts +96 -11
- package/src/hooks/gsapResizeIntercept.ts +79 -168
- package/src/hooks/gsapResizeSweep.test.ts +16 -33
- package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
- package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
- package/src/hooks/gsapRuntimeBridge.ts +105 -131
- package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
- package/src/hooks/gsapScriptCommitTypes.ts +13 -0
- package/src/hooks/gsapShared.ts +7 -0
- package/src/hooks/gsapShared.writeTarget.test.ts +45 -57
- package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
- package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
- package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
- package/src/hooks/gsapValueAtPlayhead.ts +292 -0
- package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
- package/src/hooks/keyframeCacheAstLoad.ts +8 -6
- package/src/hooks/newTweenTarget.test.ts +41 -76
- package/src/hooks/plainRotation.ts +3 -2
- package/src/hooks/previewPromotionSelection.test.tsx +1 -1
- package/src/hooks/refreshTimelineRowText.test.ts +65 -0
- package/src/hooks/refreshTimelineRowText.ts +29 -0
- package/src/hooks/timelineTimingSync.test.ts +1 -0
- package/src/hooks/timelineTimingSync.ts +2 -1
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
- package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
- package/src/hooks/useAskAgentModal.ts +2 -1
- package/src/hooks/useBlockCatalog.ts +2 -1
- package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
- package/src/hooks/useCaptionDetection.ts +2 -1
- package/src/hooks/useCompositionContentLoader.ts +2 -1
- package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
- package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
- package/src/hooks/useDomEditSession.test.tsx +30 -1
- package/src/hooks/useDomEditSession.ts +10 -0
- package/src/hooks/useDomEditTextCommits.test.tsx +41 -0
- package/src/hooks/useDomEditTextCommits.ts +13 -3
- package/src/hooks/useDomEditWiring.ts +4 -2
- package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
- package/src/hooks/useDomGeometryCommit.ts +8 -4
- package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
- package/src/hooks/useDomGeometryCommits.ts +3 -2
- package/src/hooks/useDomSelection.test.ts +26 -0
- package/src/hooks/useDomSelection.ts +10 -3
- package/src/hooks/useDomSelectionTypes.ts +6 -1
- package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
- package/src/hooks/useEditHistoryActions.test.tsx +15 -0
- package/src/hooks/useEditHistoryActions.ts +16 -5
- package/src/hooks/useElementLifecycleOps.ts +2 -1
- package/src/hooks/useEnableKeyframes.test.ts +34 -3
- package/src/hooks/useEnableKeyframes.ts +7 -4
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +121 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +35 -8
- package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
- package/src/hooks/useFileManager.ts +14 -10
- package/src/hooks/useFileTree.ts +3 -2
- package/src/hooks/useFrameCapture.ts +5 -1
- package/src/hooks/useFreezeFrame.ts +3 -2
- package/src/hooks/useGestureCommit.test.tsx +225 -55
- package/src/hooks/useGestureCommit.ts +86 -55
- package/src/hooks/useGroupCommits.ts +2 -1
- package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
- package/src/hooks/useGsapAwareEditing.test.tsx +28 -35
- package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
- package/src/hooks/useGsapAwareEditing.ts +101 -195
- package/src/hooks/useGsapAwareGroupMove.ts +179 -0
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
- package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
- package/src/hooks/useGsapKeyframeOps.ts +36 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +60 -1
- package/src/hooks/useGsapScriptCommits.ts +15 -10
- package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
- package/src/hooks/useGsapSelectionHandlers.ts +24 -18
- package/src/hooks/useGsapTweenCache.ts +18 -12
- package/src/hooks/useLintModal.ts +2 -1
- package/src/hooks/usePersistentEditHistory.test.ts +277 -1
- package/src/hooks/usePersistentEditHistory.ts +47 -8
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
- package/src/hooks/usePreviewInteraction.test.ts +21 -0
- package/src/hooks/usePreviewInteraction.ts +2 -0
- package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
- package/src/hooks/usePreviewPersistence.ts +13 -11
- package/src/hooks/useProjectFileWriter.ts +20 -19
- package/src/hooks/useRemoveBackground.ts +2 -1
- package/src/hooks/useRenderClipContent.test.ts +18 -0
- package/src/hooks/useRenderClipContent.ts +4 -5
- package/src/hooks/useSdkSession.ts +2 -1
- package/src/hooks/useServerConnection.ts +4 -3
- package/src/hooks/useStudioSelectionPublisher.ts +2 -1
- package/src/hooks/useThumbnailStill.test.ts +100 -0
- package/src/hooks/useThumbnailStill.ts +37 -0
- package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
- package/src/hooks/useTimelineDeleteOps.ts +22 -27
- package/src/hooks/useTimelineEditing.test.tsx +1 -1
- package/src/index.ts +4 -0
- package/src/player/components/AudioWaveform.tsx +3 -2
- package/src/player/components/AutomationSelectionMenu.tsx +3 -2
- package/src/player/components/ClipContextMenu.test.tsx +73 -0
- package/src/player/components/ClipContextMenu.tsx +33 -31
- package/src/player/components/CompositionThumbnail.tsx +2 -1
- package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
- package/src/player/components/OutOfSyncBadge.tsx +5 -5
- package/src/player/components/TextClipContent.test.ts +19 -0
- package/src/player/components/TextClipContent.tsx +27 -0
- package/src/player/components/TrackGapContextMenu.tsx +3 -6
- package/src/player/components/clipMenuNormalize.ts +2 -1
- package/src/player/components/clipMenuToolItems.test.tsx +23 -0
- package/src/player/components/clipMenuToolItems.tsx +13 -9
- package/src/player/components/clipPeakMap.ts +2 -1
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
- package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
- package/src/player/components/timelineClipGestureHandlers.ts +20 -2
- package/src/player/components/timelineEditing.ts +3 -1
- package/src/player/components/timelineLinkSelection.ts +35 -1
- package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
- package/src/player/components/useTimelineClipDrag.ts +6 -7
- package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
- package/src/player/hooks/timelinePlayerSync.ts +6 -1
- package/src/player/hooks/useTimelinePlayer.ts +5 -1
- package/src/player/lib/thumbnailScheduler.test.ts +16 -0
- package/src/player/lib/thumbnailScheduler.ts +10 -2
- package/src/player/lib/timelineElementHelpers.test.ts +87 -1
- package/src/player/lib/timelineElementHelpers.ts +6 -15
- package/src/player/lib/timelineText.ts +40 -0
- package/src/player/sceneSwap.ts +2 -1
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/theme.css +3 -0
- package/src/telemetry/client.ts +2 -1
- package/src/utils/blockInstaller.ts +2 -1
- package/src/utils/elementGsap.test.ts +9 -0
- package/src/utils/elementGsap.ts +7 -0
- package/src/utils/gsapSoftReload.ts +2 -3
- package/src/utils/gsapUndoRestore.browser.test.ts +51 -45
- package/src/utils/gsapUndoRestore.ts +3 -6
- package/src/utils/keyframeUsage.ts +79 -0
- package/src/utils/previewFeatureUsage.test.ts +32 -0
- package/src/utils/previewFeatureUsage.ts +46 -0
- package/src/utils/previewPrefetch.ts +2 -1
- package/src/utils/razorSplitTransaction.ts +12 -8
- package/src/utils/softReloadTargets.ts +1 -3
- package/src/utils/studioApiFetch.test.ts +75 -0
- package/src/utils/studioApiFetch.ts +13 -0
- package/src/utils/studioFileHistory.ts +4 -1
- package/src/utils/studioHelpers.ts +7 -1
- package/src/utils/studioPendingEdits.test.ts +23 -6
- package/src/utils/studioPendingEdits.ts +32 -8
- package/src/utils/studioTelemetry.test.ts +107 -0
- package/src/utils/studioTelemetry.ts +51 -5
- package/src/utils/timelineCompositionInsert.ts +3 -2
- package/src/webmcp/StudioAgentTools.tsx +4 -3
- package/dist/chunk-BIUXP3PG.js.map +0 -1
- package/dist/chunk-K47E2ZJA.js.map +0 -1
- /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
- /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-OL7GLSS4.js.map} +0 -0
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
* easing, or seek position.
|
|
10
10
|
*/
|
|
11
11
|
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
|
|
12
|
-
import { isXYPositionWrite } from "@hyperframes/parsers/gsap-constants";
|
|
12
|
+
import { isXYPositionWrite, PROPERTY_GROUPS } from "@hyperframes/parsers/gsap-constants";
|
|
13
13
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
14
14
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
15
15
|
|
|
16
16
|
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
|
|
17
|
-
import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
|
|
17
|
+
import { commitGsapPositionFromDrag, gsapPositionFromDragOutcome } from "./gsapDragPositionCommit";
|
|
18
|
+
import { commitValueAtPlayhead } from "./gsapValueAtPlayhead";
|
|
18
19
|
import {
|
|
19
20
|
commitStaticGsapPosition,
|
|
20
21
|
commitStaticGsapRotation,
|
|
@@ -22,7 +23,6 @@ import {
|
|
|
22
23
|
computeCurrentPercentage,
|
|
23
24
|
findExistingPositionWrite,
|
|
24
25
|
findRotationSetAnimation,
|
|
25
|
-
materializeIfDynamic,
|
|
26
26
|
} from "./gsapDragCommit";
|
|
27
27
|
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
|
28
28
|
import { isGestureTransactionCommit } from "./gestureTransaction";
|
|
@@ -54,64 +54,29 @@ const ROTATION_CHANNEL_SET = new Set<string>(ROTATION_CHANNELS);
|
|
|
54
54
|
// ── Property-group tween resolution ───────────────────────────────────────
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* Resolution order:
|
|
61
|
-
* 1. Tween already tagged with `propertyGroup === group`
|
|
62
|
-
* 2. Legacy mixed tween (`!propertyGroup`) → split via server mutation,
|
|
63
|
-
* re-fetch, then return the group tween
|
|
64
|
-
* 3. null — caller must handle the missing-tween case
|
|
57
|
+
* The tween to edit for a property group: a tween tagged with it, else a legacy tween that mixes
|
|
58
|
+
* it with other groups. A mixed tween is edited in place, never split first: inside a gesture the
|
|
59
|
+
* split is only buffered, so ids read after it are stale and the edit lands beside the old tween.
|
|
65
60
|
*/
|
|
66
61
|
export async function resolveGroupTween(
|
|
67
62
|
group: PropertyGroupName,
|
|
68
63
|
animations: GsapAnimation[],
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
_selection: DomEditSelection,
|
|
65
|
+
_commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
71
66
|
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
72
67
|
): Promise<{ anim: GsapAnimation; animations: GsapAnimation[] } | null> {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
{ label: "Split mixed tween into property groups", skipReload: true },
|
|
86
|
-
);
|
|
87
|
-
if (fetchFallbackAnimations) {
|
|
88
|
-
const fresh = await fetchFallbackAnimations();
|
|
89
|
-
const freshGroupAnim = fresh.find((a) => a.propertyGroup === group);
|
|
90
|
-
if (freshGroupAnim) return { anim: freshGroupAnim, animations: fresh };
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// 3. Try fallback fetch (no split needed, just wasn't in the initial list)
|
|
95
|
-
if (!legacyMixed && fetchFallbackAnimations) {
|
|
96
|
-
const fresh = await fetchFallbackAnimations();
|
|
97
|
-
const freshGroupAnim = fresh.find((a) => a.propertyGroup === group);
|
|
98
|
-
if (freshGroupAnim) return { anim: freshGroupAnim, animations: fresh };
|
|
99
|
-
|
|
100
|
-
// Fallback: legacy mixed in the fresh list
|
|
101
|
-
const freshLegacy = fresh.find((a) => !a.propertyGroup);
|
|
102
|
-
if (freshLegacy) {
|
|
103
|
-
await commitMutation(
|
|
104
|
-
selection,
|
|
105
|
-
{ type: "split-into-property-groups", animationId: freshLegacy.id },
|
|
106
|
-
{ label: "Split mixed tween into property groups", skipReload: true },
|
|
107
|
-
);
|
|
108
|
-
const reFetched = await fetchFallbackAnimations();
|
|
109
|
-
const reFetchedGroup = reFetched.find((a) => a.propertyGroup === group);
|
|
110
|
-
if (reFetchedGroup) return { anim: reFetchedGroup, animations: reFetched };
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return null;
|
|
68
|
+
const inGroup = (list: GsapAnimation[]) => {
|
|
69
|
+
const tagged = list.filter((a) => a.propertyGroup === group);
|
|
70
|
+
const props = new Set(PROPERTY_GROUPS[group]);
|
|
71
|
+
const mixed = list.filter((a) => !a.propertyGroup && animationWritesAnyProperty(a, props));
|
|
72
|
+
return pickClosestToPlayhead(tagged.length > 0 ? tagged : mixed);
|
|
73
|
+
};
|
|
74
|
+
const anim = inGroup(animations);
|
|
75
|
+
if (anim) return { anim, animations };
|
|
76
|
+
if (!fetchFallbackAnimations) return null;
|
|
77
|
+
const fresh = await fetchFallbackAnimations();
|
|
78
|
+
const freshAnim = inGroup(fresh);
|
|
79
|
+
return freshAnim ? { anim: freshAnim, animations: fresh } : null;
|
|
115
80
|
}
|
|
116
81
|
|
|
117
82
|
// ── High-level intercept ───────────────────────────────────────────────────
|
|
@@ -205,6 +170,59 @@ function oneUndoStep(
|
|
|
205
170
|
commit(selection, mutation, { ...options, coalesceKey, coalesceMs: Number.POSITIVE_INFINITY });
|
|
206
171
|
}
|
|
207
172
|
|
|
173
|
+
const isPositionWriteOf = (selector: string) => (a: GsapAnimation) =>
|
|
174
|
+
a.targetSelector === selector && isXYPositionWrite(a);
|
|
175
|
+
|
|
176
|
+
/** The one position write a self-heal keeps when holds fight over `selector`, else null. */
|
|
177
|
+
function positionWriteKeeper(animations: GsapAnimation[], selector: string): GsapAnimation | null {
|
|
178
|
+
const dupes = animations.filter(isPositionWriteOf(selector));
|
|
179
|
+
// Real tweens one after another are a motion, not a conflict: only holds can fight.
|
|
180
|
+
if (dupes.length < 2 || dupes.filter((a) => !isInstantHold(a)).length > 1) return null;
|
|
181
|
+
return dupes.find((a) => a.keyframes) ?? dupes.find((a) => (a.duration ?? 0) > 0) ?? dupes[0]!;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Where a drag writes. With no live motion and no keyframed tween (a hold, or a
|
|
185
|
+
* zero-length keyframed tween) the position belongs in a `tl.set`, never keyframes. */
|
|
186
|
+
function dragRoute(
|
|
187
|
+
posAnim: GsapAnimation | null,
|
|
188
|
+
iframe: HTMLIFrameElement | null,
|
|
189
|
+
selector: string,
|
|
190
|
+
altKey?: boolean,
|
|
191
|
+
): "static" | "whole-path" | "at-playhead" {
|
|
192
|
+
const hasNonHold = hasNonHoldTweenForElement(iframe, selector, undefined, POSITION_CHANNELS);
|
|
193
|
+
const hasKeyframedPosTween = !!posAnim?.keyframes && resolveTweenDuration(posAnim) > 0;
|
|
194
|
+
if (!hasNonHold && !hasKeyframedPosTween) return "static";
|
|
195
|
+
// Alt-drag shifts the whole path; with auto-keyframe off (#1808) that is the default.
|
|
196
|
+
return altKey || !usePlayerStore.getState().autoKeyframeEnabled ? "whole-path" : "at-playhead";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The commit's route and keyframe plan, writing nothing, so a group refuses whole. */
|
|
200
|
+
async function planDrag(
|
|
201
|
+
selection: DomEditSelection,
|
|
202
|
+
offset: { x: number; y: number },
|
|
203
|
+
allAnimations: GsapAnimation[],
|
|
204
|
+
iframe: HTMLIFrameElement | null,
|
|
205
|
+
options: { altKey?: boolean },
|
|
206
|
+
): Promise<GsapEditOutcome> {
|
|
207
|
+
const selector = selectorFromSelection(selection);
|
|
208
|
+
if (!selector) return { status: "blocked", reason: "no-selector" };
|
|
209
|
+
const own = tweensForThisElement(selection, allAnimations);
|
|
210
|
+
const keeper = positionWriteKeeper(own, selector);
|
|
211
|
+
const animations = keeper
|
|
212
|
+
? own.filter((a) => a === keeper || !isPositionWriteOf(selector)(a))
|
|
213
|
+
: own;
|
|
214
|
+
const resolved = await resolveGroupTween("position", animations, selection, async () => {});
|
|
215
|
+
const posAnim = resolved?.anim ?? findGsapPositionAnimation(animations, selector);
|
|
216
|
+
const route = dragRoute(posAnim, iframe, selector, options.altKey);
|
|
217
|
+
if (route === "static") return { status: "persisted" };
|
|
218
|
+
if (!posAnim) {
|
|
219
|
+
return { status: "blocked", reason: "source-uneditable", detail: "no-position-tween" };
|
|
220
|
+
}
|
|
221
|
+
if (route === "whole-path") return { status: "persisted" };
|
|
222
|
+
const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
|
|
223
|
+
return gsapPositionFromDragOutcome(selection, posAnim, offset, gsapPos, iframe);
|
|
224
|
+
}
|
|
225
|
+
|
|
208
226
|
/** Commits a drag through the GSAP script. Callers reject `blocked` (the gesture layer
|
|
209
227
|
* restores its drafts) and save `element-offset` on the element itself. */
|
|
210
228
|
export async function tryGsapDragIntercept(
|
|
@@ -229,7 +247,12 @@ export async function tryGsapDragIntercept(
|
|
|
229
247
|
fetchAllAnimations,
|
|
230
248
|
options?.group,
|
|
231
249
|
);
|
|
232
|
-
if (preflight.status !== "persisted"
|
|
250
|
+
if (preflight.status !== "persisted") return preflight;
|
|
251
|
+
if (options?.preflightOnly) {
|
|
252
|
+
return options.group
|
|
253
|
+
? planDrag(selection, offset, allAnimations, iframe, options)
|
|
254
|
+
: preflight;
|
|
255
|
+
}
|
|
233
256
|
}
|
|
234
257
|
const animations = tweensForThisElement(selection, allAnimations);
|
|
235
258
|
const fetchFallbackAnimations =
|
|
@@ -246,13 +269,10 @@ export async function tryGsapDragIntercept(
|
|
|
246
269
|
// the live keyframed/real tween if present (else any), strip the rest, so the
|
|
247
270
|
// commit below updates ONE write instead of fighting duplicates.
|
|
248
271
|
let workingAnimations = animations;
|
|
249
|
-
|
|
250
|
-
if (animations.filter(isPosWrite).length > 1 && fetchFallbackAnimations) {
|
|
272
|
+
if (animations.filter(isPositionWriteOf(selector)).length > 1 && fetchFallbackAnimations) {
|
|
251
273
|
const fresh = await fetchFallbackAnimations();
|
|
252
|
-
const
|
|
253
|
-
if (
|
|
254
|
-
const keeper =
|
|
255
|
-
dupes.find((a) => a.keyframes) ?? dupes.find((a) => (a.duration ?? 0) > 0) ?? dupes[0]!;
|
|
274
|
+
const keeper = positionWriteKeeper(fresh, selector);
|
|
275
|
+
if (keeper) {
|
|
256
276
|
await commitMutation(
|
|
257
277
|
selection,
|
|
258
278
|
{
|
|
@@ -288,26 +308,8 @@ export async function tryGsapDragIntercept(
|
|
|
288
308
|
}
|
|
289
309
|
|
|
290
310
|
const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
// keyframed/tweened position motion. Use the strict non-hold check — a leftover
|
|
294
|
-
// position-hold `set` (after a delete-all, or a stale parse that lags it) must
|
|
295
|
-
// NOT count as live motion. Either way the position belongs in a
|
|
296
|
-
// `tl.set("#el",{x,y})`, not a keyframe conversion: re-nudge an existing set in
|
|
297
|
-
// place (idempotent), else add a new one. This also covers the stale-cache
|
|
298
|
-
// phantom — committing a set is correct because the element genuinely has no live motion.
|
|
299
|
-
const hasNonHold = hasNonHoldTweenForElement(iframe, selector, undefined, POSITION_CHANNELS);
|
|
300
|
-
// A KEYFRAMED position tween — even one that's currently a flat constant ("hold",
|
|
301
|
-
// e.g. 0% and 100% identical) — is still an animation the user is building, so a
|
|
302
|
-
// drag must add/update a keyframe, NOT fall back to a static `set`. Without this,
|
|
303
|
-
// dragging an element whose position tween is constant writes a `gsap.set` that
|
|
304
|
-
// fights the tween (the "drag didn't create a keyframe / didn't persist" bug). The
|
|
305
|
-
// static path is only for elements with NO keyframed position tween (truly static,
|
|
306
|
-
// or just a leftover position-hold `set`).
|
|
307
|
-
// A zero-duration keyframed tween is a static HOLD, not a live animation —
|
|
308
|
-
// treat it as static so the drag heals it instead of feeding it more keyframes.
|
|
309
|
-
const hasKeyframedPosTween = !!posAnim?.keyframes && resolveTweenDuration(posAnim) > 0;
|
|
310
|
-
if (!hasNonHold && !hasKeyframedPosTween) {
|
|
311
|
+
const route = dragRoute(posAnim, iframe, selector, options?.altKey);
|
|
312
|
+
if (route === "static") {
|
|
311
313
|
const existingSet =
|
|
312
314
|
posAnim && isInstantHold(posAnim) && posAnim.targetSelector === selector
|
|
313
315
|
? posAnim
|
|
@@ -329,27 +331,26 @@ export async function tryGsapDragIntercept(
|
|
|
329
331
|
// current ID and avoid a stale-ID remove that creates duplicate tweens.
|
|
330
332
|
if (fetchFallbackAnimations) {
|
|
331
333
|
const fresh = await fetchFallbackAnimations();
|
|
332
|
-
const freshMatch =
|
|
333
|
-
(a) =>
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
334
|
+
const freshMatch =
|
|
335
|
+
fresh.find((a) => a.id === posAnim!.id) ??
|
|
336
|
+
pickClosestToPlayhead(
|
|
337
|
+
fresh.filter(
|
|
338
|
+
(a) =>
|
|
339
|
+
a.targetSelector === posAnim!.targetSelector &&
|
|
340
|
+
a.propertyGroup === posAnim!.propertyGroup &&
|
|
341
|
+
isXYPositionWrite(a) === isXYPositionWrite(posAnim!),
|
|
342
|
+
),
|
|
343
|
+
);
|
|
338
344
|
if (freshMatch && freshMatch.id !== posAnim.id) {
|
|
339
345
|
posAnim = freshMatch;
|
|
340
346
|
}
|
|
341
347
|
}
|
|
342
348
|
|
|
343
349
|
const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations };
|
|
344
|
-
|
|
345
|
-
// toggle (#1808) just makes that the default while it's off, so a manual
|
|
346
|
-
// edit on an already-animated element nudges the animation instead of
|
|
347
|
-
// inserting/updating a keyframe at the playhead.
|
|
348
|
-
const autoKeyframeEnabled = usePlayerStore.getState().autoKeyframeEnabled;
|
|
349
|
-
if (options?.altKey || !autoKeyframeEnabled) {
|
|
350
|
+
if (route === "whole-path") {
|
|
350
351
|
await commitWholePathOffset(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
|
|
351
352
|
} else {
|
|
352
|
-
|
|
353
|
+
return commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, cbs);
|
|
353
354
|
}
|
|
354
355
|
return { status: "persisted" };
|
|
355
356
|
}
|
|
@@ -428,8 +429,9 @@ export async function tryGsapRotationIntercept(
|
|
|
428
429
|
? resolved.anim
|
|
429
430
|
: null;
|
|
430
431
|
if (!anim) {
|
|
431
|
-
anim =
|
|
432
|
-
workingAnimations.
|
|
432
|
+
anim = pickClosestToPlayhead(
|
|
433
|
+
workingAnimations.filter((a) => animationWritesAnyProperty(a, ROTATION_CHANNEL_SET)),
|
|
434
|
+
);
|
|
433
435
|
}
|
|
434
436
|
|
|
435
437
|
// `angle` is the ABSOLUTE target rotation resolved by the gesture (gsap base +
|
|
@@ -469,42 +471,14 @@ export async function tryGsapRotationIntercept(
|
|
|
469
471
|
return { status: "persisted" };
|
|
470
472
|
}
|
|
471
473
|
|
|
472
|
-
|
|
473
|
-
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
|
|
474
|
-
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
|
|
475
|
-
if (newId) anim = { ...anim, id: newId };
|
|
476
|
-
} else if (!anim.keyframes) {
|
|
477
|
-
const resolvedFromValues = selector
|
|
478
|
-
? readAllAnimatedProperties(iframe, selector, anim, "rotation")
|
|
479
|
-
: undefined;
|
|
480
|
-
await commitMutation(
|
|
481
|
-
selection,
|
|
482
|
-
{ type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues },
|
|
483
|
-
{ label: "Convert to keyframes for rotation", skipReload: true },
|
|
484
|
-
);
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim, "rotation");
|
|
488
|
-
|
|
489
|
-
const backfillDefaults: Record<string, number> = { ...runtimeProps };
|
|
490
|
-
if (!("rotation" in runtimeProps)) {
|
|
491
|
-
backfillDefaults.rotation = readGsapProperty(iframe, selector, "rotation") ?? 0;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
const properties = { ...runtimeProps, rotation: newRotation };
|
|
495
|
-
|
|
496
|
-
await commitMutation(
|
|
474
|
+
return commitValueAtPlayhead(
|
|
497
475
|
selection,
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
backfillDefaults,
|
|
504
|
-
},
|
|
505
|
-
{ label: `Rotate (keyframe ${pct}%)`, softReload: true },
|
|
476
|
+
anim,
|
|
477
|
+
{ rotation: newRotation },
|
|
478
|
+
iframe,
|
|
479
|
+
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
|
|
480
|
+
{ label: "Rotate", backfill: { rotation: newRotation }, holdFromStart: true },
|
|
506
481
|
);
|
|
507
|
-
return { status: "persisted" };
|
|
508
482
|
}
|
|
509
483
|
|
|
510
484
|
export { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
|
|
@@ -4,6 +4,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
|
4
4
|
export { PROPERTY_DEFAULTS } from "./gsapShared";
|
|
5
5
|
import { idSelector, matchesExactlyOne } from "./gsapShared";
|
|
6
6
|
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
7
|
+
import { studioApiFetch } from "../utils/studioApiFetch";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* The selector to author a NEW tween against, minting an id on the element when
|
|
@@ -116,7 +117,7 @@ export async function assignGsapTargetAutoIdIfNeeded({
|
|
|
116
117
|
showToast?.("Couldn't assign element id because the patch contains invalid values", "error");
|
|
117
118
|
return false;
|
|
118
119
|
}
|
|
119
|
-
const res = await
|
|
120
|
+
const res = await studioApiFetch(
|
|
120
121
|
`/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
|
|
121
122
|
{
|
|
122
123
|
method: "POST",
|
|
@@ -3,10 +3,20 @@ import type { Composition } from "@hyperframes/sdk";
|
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
4
|
import type { PublishSdkSession } from "../utils/sdkCutover";
|
|
5
5
|
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
|
|
6
|
+
import type { KeyframeUsageAction } from "../utils/keyframeUsage";
|
|
7
|
+
|
|
8
|
+
/** The file a selection's GSAP write goes to. */
|
|
9
|
+
export function gsapWriteFile(
|
|
10
|
+
selection: Pick<DomEditSelection, "sourceFile">,
|
|
11
|
+
activeCompPath: string | null | undefined,
|
|
12
|
+
): string {
|
|
13
|
+
return selection.sourceFile || activeCompPath || "index.html";
|
|
14
|
+
}
|
|
6
15
|
|
|
7
16
|
export interface MutationResult {
|
|
8
17
|
ok: boolean;
|
|
9
18
|
changed?: boolean;
|
|
19
|
+
mutationChanges?: boolean[];
|
|
10
20
|
parsed?: ParsedGsap;
|
|
11
21
|
before?: string;
|
|
12
22
|
after?: string;
|
|
@@ -15,6 +25,9 @@ export interface MutationResult {
|
|
|
15
25
|
|
|
16
26
|
export interface CommitMutationOptions {
|
|
17
27
|
label: string;
|
|
28
|
+
keyframeTelemetry?: boolean;
|
|
29
|
+
keyframeAction?: KeyframeUsageAction;
|
|
30
|
+
keyframeProperty?: string;
|
|
18
31
|
/** Observe the durable writer result without duplicating the request path. */
|
|
19
32
|
onResult?: (result: MutationResult) => void;
|
|
20
33
|
coalesceKey?: string;
|
package/src/hooks/gsapShared.ts
CHANGED
|
@@ -34,6 +34,13 @@ export const PROPERTY_DEFAULTS: Record<string, number> = {
|
|
|
34
34
|
height: 100,
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
/** A tween's eases for re-writing it as keyframes: a flat tween's ease must become easeEach,
|
|
38
|
+
* since GSAP eases each percentage segment power1.inOut whatever the tween-level ease says. */
|
|
39
|
+
export function keyframeEases(anim: GsapAnimation): { ease?: string; easeEach?: string } {
|
|
40
|
+
if (!anim.keyframes) return { easeEach: anim.ease };
|
|
41
|
+
return { ease: anim.ease, easeEach: anim.keyframes.easeEach };
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
/**
|
|
38
45
|
* A timeline write that applies an instantaneous value and then holds it.
|
|
39
46
|
* `set()` is always a hold; authored `to()` / `fromTo()` tweens are holds only
|
|
@@ -3,47 +3,19 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
3
3
|
import { parseGsapScript } from "@hyperframes/core/gsap-parser";
|
|
4
4
|
import { addAnimationWithKeyframesToScript } from "@hyperframes/parsers/gsap-writer-acorn";
|
|
5
5
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
6
|
-
import {
|
|
6
|
+
import { mountGroupSiblings, stableSelectionFor } from "./domSelectionTestHarness";
|
|
7
7
|
import { resolveSelectorElementIds, tweenTargetsElement, writeTargetSelector } from "./gsapShared";
|
|
8
8
|
import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
|
|
9
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
10
|
+
import { trackKeyframeCommit } from "../utils/keyframeUsage";
|
|
11
|
+
import type { CommitMutation } from "./gsapScriptCommitTypes";
|
|
12
|
+
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
9
13
|
import { promoteSetToKeyframes } from "./useEnableKeyframes";
|
|
10
14
|
|
|
11
15
|
afterEach(() => {
|
|
12
16
|
document.body.innerHTML = "";
|
|
13
17
|
});
|
|
14
18
|
|
|
15
|
-
/**
|
|
16
|
-
* A selection built the way production builds it (getDomLayerPatchTarget), so
|
|
17
|
-
* the class-only case under test is the real one: buildStableSelector hands back
|
|
18
|
-
* a BARE class for an element with no id / hf-id / composition id.
|
|
19
|
-
*/
|
|
20
|
-
function selectionFor(el: HTMLElement): DomEditSelection {
|
|
21
|
-
const selector = buildStableSelector(el);
|
|
22
|
-
return {
|
|
23
|
-
element: el,
|
|
24
|
-
id: el.id || undefined,
|
|
25
|
-
hfId: el.getAttribute("data-hf-id") || undefined,
|
|
26
|
-
selector,
|
|
27
|
-
selectorIndex: getSelectorIndex(document, el, selector, "index.html", null),
|
|
28
|
-
sourceFile: "index.html",
|
|
29
|
-
dataAttributes: { start: "0", duration: "2" },
|
|
30
|
-
} as unknown as DomEditSelection;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Five class-only siblings — the shape that made one add collapse the timeline. */
|
|
34
|
-
function mountGroupSiblings(): HTMLElement[] {
|
|
35
|
-
document.body.innerHTML = `
|
|
36
|
-
<div id="scene" class="clip" data-start="0" data-duration="2">
|
|
37
|
-
<div class="group"></div>
|
|
38
|
-
<div class="group"></div>
|
|
39
|
-
<div class="group"></div>
|
|
40
|
-
<div class="group"></div>
|
|
41
|
-
<div class="group"></div>
|
|
42
|
-
</div>
|
|
43
|
-
`;
|
|
44
|
-
return Array.from(document.querySelectorAll<HTMLElement>(".group"));
|
|
45
|
-
}
|
|
46
|
-
|
|
47
19
|
const BASE_SCRIPT = `
|
|
48
20
|
const tl = gsap.timeline({ paused: true });
|
|
49
21
|
tl.to("#scene", { opacity: 1, duration: 0.5 }, 0);
|
|
@@ -64,8 +36,8 @@ function roundTripTargets(selector: string): { script: string; targets: string[]
|
|
|
64
36
|
|
|
65
37
|
describe("writeTargetSelector", () => {
|
|
66
38
|
it("addresses exactly one element when the only identity is a shared class", () => {
|
|
67
|
-
const groups = mountGroupSiblings();
|
|
68
|
-
const selection =
|
|
39
|
+
const groups = mountGroupSiblings(5);
|
|
40
|
+
const selection = stableSelectionFor(groups[2]);
|
|
69
41
|
|
|
70
42
|
// Precondition: this is the defect's input — a bare class hitting all five.
|
|
71
43
|
expect(selection.selector).toBe(".group");
|
|
@@ -82,7 +54,7 @@ describe("writeTargetSelector", () => {
|
|
|
82
54
|
document.body.innerHTML = `<div id="box" class="card"></div>`;
|
|
83
55
|
const el = document.querySelector<HTMLElement>("#box")!;
|
|
84
56
|
|
|
85
|
-
expect(writeTargetSelector(
|
|
57
|
+
expect(writeTargetSelector(stableSelectionFor(el))).toBe("#box");
|
|
86
58
|
});
|
|
87
59
|
|
|
88
60
|
it("prefers data-hf-id over a generated structural selector", () => {
|
|
@@ -91,7 +63,7 @@ describe("writeTargetSelector", () => {
|
|
|
91
63
|
`;
|
|
92
64
|
const el = document.querySelectorAll<HTMLElement>(".group")[1]!;
|
|
93
65
|
|
|
94
|
-
const written = writeTargetSelector(
|
|
66
|
+
const written = writeTargetSelector(stableSelectionFor(el));
|
|
95
67
|
|
|
96
68
|
expect(written).toBe('[data-hf-id="hf-42"]');
|
|
97
69
|
expect(document.querySelector(written!)).toBe(el);
|
|
@@ -101,7 +73,7 @@ describe("writeTargetSelector", () => {
|
|
|
101
73
|
document.body.innerHTML = `<div id="scene"><div class="header"></div></div>`;
|
|
102
74
|
const el = document.querySelector<HTMLElement>(".header")!;
|
|
103
75
|
|
|
104
|
-
expect(writeTargetSelector(
|
|
76
|
+
expect(writeTargetSelector(stableSelectionFor(el))).toBe(".header");
|
|
105
77
|
});
|
|
106
78
|
|
|
107
79
|
it("hands identity back to a data-hf-id ancestor part way up a deep chain", () => {
|
|
@@ -121,7 +93,7 @@ describe("writeTargetSelector", () => {
|
|
|
121
93
|
`;
|
|
122
94
|
const el = document.querySelectorAll<HTMLElement>(".cell")[2]!;
|
|
123
95
|
|
|
124
|
-
const written = writeTargetSelector(
|
|
96
|
+
const written = writeTargetSelector(stableSelectionFor(el));
|
|
125
97
|
|
|
126
98
|
expect(written).toBe('[data-hf-id="mid"] > div:nth-child(3)');
|
|
127
99
|
expect(document.querySelectorAll(written!)).toHaveLength(1);
|
|
@@ -133,7 +105,7 @@ describe("writeTargetSelector", () => {
|
|
|
133
105
|
const el = document.querySelectorAll<HTMLElement>("#scene > div")[1]!;
|
|
134
106
|
// querySelectorAll throws on this, so the "does it match exactly one?" rung
|
|
135
107
|
// must read as a miss rather than crashing the add.
|
|
136
|
-
const selection = { ...
|
|
108
|
+
const selection = { ...stableSelectionFor(el), selector: "div[unclosed" } as DomEditSelection;
|
|
137
109
|
|
|
138
110
|
expect(writeTargetSelector(selection)).toBe("#scene > div:nth-child(2)");
|
|
139
111
|
});
|
|
@@ -141,7 +113,7 @@ describe("writeTargetSelector", () => {
|
|
|
141
113
|
it("returns null when a live DOM is present and no rung addresses one element", () => {
|
|
142
114
|
document.body.innerHTML = `<div id="scene"><div class="group"></div><div class="group"></div></div>`;
|
|
143
115
|
const el = document.querySelectorAll<HTMLElement>(".group")[1]!;
|
|
144
|
-
const selection =
|
|
116
|
+
const selection = stableSelectionFor(el);
|
|
145
117
|
expect(selection.selector).toBe(".group");
|
|
146
118
|
// Detached between selecting and committing: ownerDocument still resolves,
|
|
147
119
|
// but there is no parent to count a :nth-child position in. Returning
|
|
@@ -163,11 +135,11 @@ describe("writeTargetSelector", () => {
|
|
|
163
135
|
|
|
164
136
|
describe("writeTargetSelector — write/read round trip", () => {
|
|
165
137
|
it("re-parses and attributes the new tween to the one element it targeted", () => {
|
|
166
|
-
const groups = mountGroupSiblings();
|
|
138
|
+
const groups = mountGroupSiblings(5);
|
|
167
139
|
// Ids let resolveSelectorElementIds name the attributed elements; the
|
|
168
140
|
// SELECTION still has none, so the write path still faces the bare class.
|
|
169
141
|
groups.forEach((el, i) => el.setAttribute("id", `group-${i}`));
|
|
170
|
-
const selection = { ...
|
|
142
|
+
const selection = { ...stableSelectionFor(groups[2]), id: undefined } as DomEditSelection;
|
|
171
143
|
|
|
172
144
|
const written = writeTargetSelector(selection);
|
|
173
145
|
const { targets } = roundTripTargets(written!);
|
|
@@ -176,9 +148,9 @@ describe("writeTargetSelector — write/read round trip", () => {
|
|
|
176
148
|
});
|
|
177
149
|
|
|
178
150
|
it("does not widen the add to the element's four class siblings", () => {
|
|
179
|
-
const groups = mountGroupSiblings();
|
|
151
|
+
const groups = mountGroupSiblings(5);
|
|
180
152
|
groups.forEach((el, i) => el.setAttribute("id", `group-${i}`));
|
|
181
|
-
const selection = { ...
|
|
153
|
+
const selection = { ...stableSelectionFor(groups[0]), id: undefined } as DomEditSelection;
|
|
182
154
|
|
|
183
155
|
// The old bare-class write attributed the one add to every sibling — the
|
|
184
156
|
// attribution blow-up behind "one add collapsed the timeline to a single row".
|
|
@@ -190,8 +162,8 @@ describe("writeTargetSelector — write/read round trip", () => {
|
|
|
190
162
|
|
|
191
163
|
describe("existingTweenTargetSelector", () => {
|
|
192
164
|
it("keeps the narrowed target when a replace re-authors the tween", async () => {
|
|
193
|
-
const groups = mountGroupSiblings();
|
|
194
|
-
const selection =
|
|
165
|
+
const groups = mountGroupSiblings(5);
|
|
166
|
+
const selection = stableSelectionFor(groups[2]);
|
|
195
167
|
const commitMutation = vi.fn(async () => undefined);
|
|
196
168
|
// A tween a previous add already narrowed to one sibling. Re-deriving the
|
|
197
169
|
// target from the selection would widen it back to all five.
|
|
@@ -211,8 +183,8 @@ describe("existingTweenTargetSelector", () => {
|
|
|
211
183
|
});
|
|
212
184
|
|
|
213
185
|
it("falls back to the selection when the tween's own target did not resolve", async () => {
|
|
214
|
-
const groups = mountGroupSiblings();
|
|
215
|
-
const selection =
|
|
186
|
+
const groups = mountGroupSiblings(5);
|
|
187
|
+
const selection = stableSelectionFor(groups[2]);
|
|
216
188
|
const commitMutation = vi.fn(async () => undefined);
|
|
217
189
|
const setAnim = {
|
|
218
190
|
id: "a1",
|
|
@@ -232,8 +204,8 @@ describe("existingTweenTargetSelector", () => {
|
|
|
232
204
|
|
|
233
205
|
describe("commitKeyframeAtTimeImpl — new-tween target", () => {
|
|
234
206
|
it("authors a one-element selector when no tween exists at the playhead", async () => {
|
|
235
|
-
const groups = mountGroupSiblings();
|
|
236
|
-
const selection =
|
|
207
|
+
const groups = mountGroupSiblings(5);
|
|
208
|
+
const selection = stableSelectionFor(groups[3]);
|
|
237
209
|
const commitMutation = vi.fn(async () => undefined);
|
|
238
210
|
|
|
239
211
|
await commitKeyframeAtTimeImpl(selection, 1, [], { x: 12 }, commitMutation);
|
|
@@ -247,7 +219,7 @@ describe("commitKeyframeAtTimeImpl — new-tween target", () => {
|
|
|
247
219
|
|
|
248
220
|
describe("tweenTargetsElement", () => {
|
|
249
221
|
it("matches a tween narrowed to this element by a selector the selection does not use", () => {
|
|
250
|
-
const groups = mountGroupSiblings();
|
|
222
|
+
const groups = mountGroupSiblings(5);
|
|
251
223
|
groups[2]!.id = "narrowed";
|
|
252
224
|
|
|
253
225
|
// The write half authored "#narrowed"; the read half still has ".group".
|
|
@@ -255,7 +227,7 @@ describe("tweenTargetsElement", () => {
|
|
|
255
227
|
});
|
|
256
228
|
|
|
257
229
|
it("does not hand an individual element the group tween it merely inherits", () => {
|
|
258
|
-
const groups = mountGroupSiblings();
|
|
230
|
+
const groups = mountGroupSiblings(5);
|
|
259
231
|
|
|
260
232
|
// Selecting one sibling by its own address must not let an edit mutate the
|
|
261
233
|
// ".group" tween: that write moves all five, not the one being nudged.
|
|
@@ -263,13 +235,13 @@ describe("tweenTargetsElement", () => {
|
|
|
263
235
|
});
|
|
264
236
|
|
|
265
237
|
it("still edits a group tween when the group itself is the selection", () => {
|
|
266
|
-
const groups = mountGroupSiblings();
|
|
238
|
+
const groups = mountGroupSiblings(5);
|
|
267
239
|
|
|
268
240
|
expect(tweenTargetsElement(".group", ".group", groups[0])).toBe(true);
|
|
269
241
|
});
|
|
270
242
|
|
|
271
243
|
it("does not match a target that is not a selector matches() understands", () => {
|
|
272
|
-
const groups = mountGroupSiblings();
|
|
244
|
+
const groups = mountGroupSiblings(5);
|
|
273
245
|
|
|
274
246
|
expect(tweenTargetsElement("div[unclosed", ".group", groups[0])).toBe(false);
|
|
275
247
|
});
|
|
@@ -277,8 +249,8 @@ describe("tweenTargetsElement", () => {
|
|
|
277
249
|
|
|
278
250
|
describe("commitKeyframeAtTimeImpl — no one-element target", () => {
|
|
279
251
|
it("drops the keyframe rather than authoring the bare class", async () => {
|
|
280
|
-
const groups = mountGroupSiblings();
|
|
281
|
-
const selection =
|
|
252
|
+
const groups = mountGroupSiblings(5);
|
|
253
|
+
const selection = stableSelectionFor(groups[3]!);
|
|
282
254
|
groups[3]!.remove();
|
|
283
255
|
const commitMutation = vi.fn(async () => undefined);
|
|
284
256
|
|
|
@@ -287,3 +259,19 @@ describe("commitKeyframeAtTimeImpl — no one-element target", () => {
|
|
|
287
259
|
expect(commitMutation).not.toHaveBeenCalled();
|
|
288
260
|
});
|
|
289
261
|
});
|
|
262
|
+
|
|
263
|
+
it("counts a keyframe-at-time conversion and insertion as one add gesture", async () => {
|
|
264
|
+
vi.clearAllMocks();
|
|
265
|
+
const el = document.createElement("div");
|
|
266
|
+
el.id = "target";
|
|
267
|
+
document.body.append(el);
|
|
268
|
+
const selection = stableSelectionFor(el);
|
|
269
|
+
const animations = parseGsapScript(
|
|
270
|
+
'const tl = gsap.timeline(); tl.to("#target", {x:100,duration:4},0);',
|
|
271
|
+
).animations;
|
|
272
|
+
const commit: CommitMutation = async (_selection, mutation, options) => {
|
|
273
|
+
trackKeyframeCommit([mutation], { ok: true, changed: true }, options);
|
|
274
|
+
};
|
|
275
|
+
await commitKeyframeAtTimeImpl(selection, 1, animations, { x: 25 }, commit);
|
|
276
|
+
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("keyframe", { action: "add" });
|
|
277
|
+
});
|