@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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { trackPreviewFeatureUsed } from "../../utils/previewFeatureUsage";
|
|
1
2
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
2
3
|
import { MagnetStraight, GridFour, Path, Ruler, FrameCorners } from "@phosphor-icons/react";
|
|
3
4
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
@@ -16,6 +17,16 @@ export const SnapToolbar = memo(function SnapToolbar() {
|
|
|
16
17
|
const setMotionPathArmed = usePlayerStore((s) => s.setMotionPathArmed);
|
|
17
18
|
const popoverRef = useRef<HTMLDivElement>(null);
|
|
18
19
|
const gridButtonRef = useRef<HTMLButtonElement>(null);
|
|
20
|
+
const gridSpacingAtFocus = useRef<number | null>(null);
|
|
21
|
+
const currentGridSpacing = useRef(prefs.gridSpacing);
|
|
22
|
+
currentGridSpacing.current = prefs.gridSpacing;
|
|
23
|
+
const settleGridSpacing = useCallback(() => {
|
|
24
|
+
const previous = gridSpacingAtFocus.current;
|
|
25
|
+
gridSpacingAtFocus.current = null;
|
|
26
|
+
if (previous !== null && previous !== currentGridSpacing.current)
|
|
27
|
+
trackPreviewFeatureUsed("grid_spacing", "field");
|
|
28
|
+
}, []);
|
|
29
|
+
useEffect(() => settleGridSpacing, [settleGridSpacing]);
|
|
19
30
|
|
|
20
31
|
const updatePrefs = useCallback(
|
|
21
32
|
(patch: Partial<typeof prefs>) => {
|
|
@@ -26,10 +37,12 @@ export const SnapToolbar = memo(function SnapToolbar() {
|
|
|
26
37
|
|
|
27
38
|
const toggleSnap = useCallback(() => {
|
|
28
39
|
updatePrefs({ snapEnabled: !prefs.snapEnabled });
|
|
40
|
+
trackPreviewFeatureUsed("snapping", "button");
|
|
29
41
|
}, [prefs.snapEnabled, updatePrefs]);
|
|
30
42
|
|
|
31
43
|
const toggleGrid = useCallback(() => {
|
|
32
44
|
updatePrefs({ gridVisible: !prefs.gridVisible });
|
|
45
|
+
trackPreviewFeatureUsed("grid", "button");
|
|
33
46
|
}, [prefs.gridVisible, updatePrefs]);
|
|
34
47
|
|
|
35
48
|
useEffect(() => {
|
|
@@ -41,10 +54,12 @@ export const SnapToolbar = memo(function SnapToolbar() {
|
|
|
41
54
|
if (e.key === "s" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
42
55
|
e.preventDefault();
|
|
43
56
|
updatePrefs({ snapEnabled: !prefs.snapEnabled });
|
|
57
|
+
if (!e.repeat) trackPreviewFeatureUsed("snapping", "keyboard");
|
|
44
58
|
}
|
|
45
59
|
if (e.key === "g" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
46
60
|
e.preventDefault();
|
|
47
61
|
updatePrefs({ gridVisible: !prefs.gridVisible });
|
|
62
|
+
if (!e.repeat) trackPreviewFeatureUsed("grid", "keyboard");
|
|
48
63
|
}
|
|
49
64
|
};
|
|
50
65
|
document.addEventListener("keydown", handleKeyDown);
|
|
@@ -56,11 +71,12 @@ export const SnapToolbar = memo(function SnapToolbar() {
|
|
|
56
71
|
const handleClickOutside = (e: MouseEvent) => {
|
|
57
72
|
const target = e.target as Node;
|
|
58
73
|
if (popoverRef.current?.contains(target) || gridButtonRef.current?.contains(target)) return;
|
|
74
|
+
settleGridSpacing();
|
|
59
75
|
setGridPopoverOpen(false);
|
|
60
76
|
};
|
|
61
77
|
document.addEventListener("mousedown", handleClickOutside);
|
|
62
78
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
63
|
-
}, [gridPopoverOpen]);
|
|
79
|
+
}, [gridPopoverOpen, settleGridSpacing]);
|
|
64
80
|
|
|
65
81
|
return (
|
|
66
82
|
<div
|
|
@@ -101,7 +117,10 @@ export const SnapToolbar = memo(function SnapToolbar() {
|
|
|
101
117
|
? "bg-studio-accent/20 text-accent-ink"
|
|
102
118
|
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
|
|
103
119
|
}`}
|
|
104
|
-
onClick={
|
|
120
|
+
onClick={() => {
|
|
121
|
+
toggle();
|
|
122
|
+
trackPreviewFeatureUsed(key === "rulerVisible" ? "ruler" : "safe_margins", "button");
|
|
123
|
+
}}
|
|
105
124
|
title={`${label} ${visible ? "on" : "off"}`}
|
|
106
125
|
aria-label={`Toggle ${label.toLowerCase()}`}
|
|
107
126
|
aria-pressed={visible}
|
|
@@ -136,6 +155,7 @@ export const SnapToolbar = memo(function SnapToolbar() {
|
|
|
136
155
|
onClick={toggleGrid}
|
|
137
156
|
onContextMenu={(e) => {
|
|
138
157
|
e.preventDefault();
|
|
158
|
+
if (gridPopoverOpen) settleGridSpacing();
|
|
139
159
|
setGridPopoverOpen((v) => !v);
|
|
140
160
|
}}
|
|
141
161
|
title={
|
|
@@ -150,7 +170,10 @@ export const SnapToolbar = memo(function SnapToolbar() {
|
|
|
150
170
|
<button
|
|
151
171
|
type="button"
|
|
152
172
|
className="absolute -right-0.5 -bottom-0.5 rounded-sm p-0.5 text-white/50 hover:text-white/90 bg-black/50"
|
|
153
|
-
onClick={() =>
|
|
173
|
+
onClick={() => {
|
|
174
|
+
if (gridPopoverOpen) settleGridSpacing();
|
|
175
|
+
setGridPopoverOpen((v) => !v);
|
|
176
|
+
}}
|
|
154
177
|
title="Grid options"
|
|
155
178
|
aria-label="Grid options"
|
|
156
179
|
aria-expanded={gridPopoverOpen}
|
|
@@ -173,9 +196,21 @@ export const SnapToolbar = memo(function SnapToolbar() {
|
|
|
173
196
|
max={500}
|
|
174
197
|
step={10}
|
|
175
198
|
value={prefs.gridSpacing}
|
|
199
|
+
onFocus={() => {
|
|
200
|
+
gridSpacingAtFocus.current = prefs.gridSpacing;
|
|
201
|
+
}}
|
|
202
|
+
onBlur={settleGridSpacing}
|
|
203
|
+
onKeyDown={(e) => {
|
|
204
|
+
if (e.key === "Enter") e.currentTarget.blur();
|
|
205
|
+
}}
|
|
176
206
|
onChange={(e) => {
|
|
177
207
|
const val = Number.parseInt(e.target.value, 10);
|
|
178
|
-
if (
|
|
208
|
+
if (
|
|
209
|
+
Number.isFinite(val) &&
|
|
210
|
+
val >= 10 &&
|
|
211
|
+
val <= 500 &&
|
|
212
|
+
val !== prefs.gridSpacing
|
|
213
|
+
) {
|
|
179
214
|
updatePrefs({ gridSpacing: val });
|
|
180
215
|
}
|
|
181
216
|
}}
|
|
@@ -186,7 +221,10 @@ export const SnapToolbar = memo(function SnapToolbar() {
|
|
|
186
221
|
<input
|
|
187
222
|
type="checkbox"
|
|
188
223
|
checked={prefs.snapToGrid}
|
|
189
|
-
onChange={() =>
|
|
224
|
+
onChange={() => {
|
|
225
|
+
updatePrefs({ snapToGrid: !prefs.snapToGrid });
|
|
226
|
+
trackPreviewFeatureUsed("snap_to_grid", "button");
|
|
227
|
+
}}
|
|
190
228
|
className="accent-studio-accent"
|
|
191
229
|
/>
|
|
192
230
|
<span>Snap to grid</span>
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
3
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
5
|
+
vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
4
6
|
import { DomEditSaveQueueOpenError } from "../../utils/domEditSaveQueue";
|
|
5
7
|
import type { DomEditSelection } from "./domEditing";
|
|
6
8
|
import type { GestureState, UseDomEditOverlayGesturesOptions } from "./domEditOverlayGestures";
|
|
@@ -178,7 +180,7 @@ function buildHarness(
|
|
|
178
180
|
};
|
|
179
181
|
|
|
180
182
|
const handlers = createDomEditOverlayGestureHandlers(opts);
|
|
181
|
-
return { handlers, commits, selection };
|
|
183
|
+
return { handlers, commits, selection, opts };
|
|
182
184
|
}
|
|
183
185
|
|
|
184
186
|
type OverlayRectLike = {
|
|
@@ -287,3 +289,48 @@ describe("anchored corner resize — the release commit feeds the center-pin off
|
|
|
287
289
|
expect(consoleError).toHaveBeenCalledWith("resize commit failed", failure);
|
|
288
290
|
});
|
|
289
291
|
});
|
|
292
|
+
|
|
293
|
+
describe("resize usage at pointer release", () => {
|
|
294
|
+
it("counts one completed resize despite multiple pointer moves", async () => {
|
|
295
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
296
|
+
const h = buildHarness(async () => ({ ok: true, changed: true }));
|
|
297
|
+
await finishResize(h.handlers);
|
|
298
|
+
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
|
|
299
|
+
feature: "resize",
|
|
300
|
+
surface: "preview",
|
|
301
|
+
method: "drag",
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
it.each([undefined, { ok: true as const, changed: false }])(
|
|
305
|
+
"does not count an unchanged or unconfirmed resize",
|
|
306
|
+
async (result) => {
|
|
307
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
308
|
+
const h = buildHarness(async () => result);
|
|
309
|
+
await finishResize(h.handlers);
|
|
310
|
+
expect(trackStudioEvent).not.toHaveBeenCalled();
|
|
311
|
+
},
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe("move and rotation usage", () => {
|
|
316
|
+
it.each(["drag", "rotate"] as const)("counts %s once after pointer release", async (kind) => {
|
|
317
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
318
|
+
const h = buildHarness();
|
|
319
|
+
h.selection.capabilities.canApplyManualRotation = true;
|
|
320
|
+
const saved = vi.fn(async () => ({ ok: true as const, changed: true }));
|
|
321
|
+
h.opts.onPathOffsetCommitRef.current = saved;
|
|
322
|
+
h.opts.onRotationCommitRef.current = saved;
|
|
323
|
+
h.handlers.startGesture(kind, evt(200, 50));
|
|
324
|
+
h.handlers.onPointerMove(evt(150, 120));
|
|
325
|
+
h.handlers.onPointerMove(evt(100, 150));
|
|
326
|
+
expect(trackStudioEvent).not.toHaveBeenCalled();
|
|
327
|
+
h.handlers.onPointerUp(evt(100, 150));
|
|
328
|
+
await Promise.resolve();
|
|
329
|
+
expect(saved).toHaveBeenCalledTimes(1);
|
|
330
|
+
expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
|
|
331
|
+
feature: kind === "drag" ? "move" : "rotate",
|
|
332
|
+
surface: "preview",
|
|
333
|
+
method: "drag",
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
});
|
|
@@ -10,9 +10,8 @@
|
|
|
10
10
|
* filenames, not composition paths, not chain JSON. Every property below is
|
|
11
11
|
* either a fixed identifier from our own catalogue (a preset id, an effect
|
|
12
12
|
* type, a parameter key), a number, or a boolean. `studioTelemetry.ts` already
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* the same line.
|
|
13
|
+
* sends only a route kind in `url_hash` for the same reason. The rack sees
|
|
14
|
+
* the same class of data and must hold the same line.
|
|
16
15
|
*
|
|
17
16
|
* The second rule is about volume: **commit, not preview.** Every control in
|
|
18
17
|
* the rack has a preview path that fires continuously while a slider moves and
|
|
@@ -287,7 +287,7 @@ describe("handles follow what Studio would commit", () => {
|
|
|
287
287
|
{ x: 0, y: 0 },
|
|
288
288
|
commitList,
|
|
289
289
|
preview,
|
|
290
|
-
vi.fn(),
|
|
290
|
+
vi.fn(async () => undefined),
|
|
291
291
|
undefined,
|
|
292
292
|
preflightOnly,
|
|
293
293
|
);
|
|
@@ -401,7 +401,7 @@ describe("handles follow what Studio would commit", () => {
|
|
|
401
401
|
{ x: 0, y: 0 },
|
|
402
402
|
[stagger],
|
|
403
403
|
preview,
|
|
404
|
-
vi.fn(),
|
|
404
|
+
vi.fn(async () => undefined),
|
|
405
405
|
undefined,
|
|
406
406
|
groupPreflight,
|
|
407
407
|
);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DomEditPersistOutcome } from "../../hooks/domEditCommitTypes";
|
|
1
2
|
import type { PatchOperation } from "../../utils/sourcePatcher";
|
|
2
3
|
import type { CommitMutation } from "../../hooks/gsapScriptCommitTypes";
|
|
3
4
|
import type { DomEditSelection } from "./domEditingTypes";
|
|
@@ -94,7 +95,7 @@ type PatchCommit = (
|
|
|
94
95
|
selection: DomEditSelection,
|
|
95
96
|
patches: PatchOperation[],
|
|
96
97
|
options: { label: string; coalesceKey: string; coalesceMs?: number },
|
|
97
|
-
) => Promise<
|
|
98
|
+
) => Promise<DomEditPersistOutcome | undefined>;
|
|
98
99
|
|
|
99
100
|
/** Stage the crop and save it under the resize's undo key, taking it back off the element if that fails. */
|
|
100
101
|
export async function saveCropResize(
|
|
@@ -102,11 +103,11 @@ export async function saveCropResize(
|
|
|
102
103
|
selection: DomEditSelection,
|
|
103
104
|
commit: PatchCommit,
|
|
104
105
|
coalesceKey: string,
|
|
105
|
-
): Promise<
|
|
106
|
+
): Promise<DomEditPersistOutcome | undefined> {
|
|
106
107
|
const crop = stage();
|
|
107
108
|
if (!crop) return;
|
|
108
109
|
try {
|
|
109
|
-
await commit(selection, [crop.patch], {
|
|
110
|
+
return await commit(selection, [crop.patch], {
|
|
110
111
|
label: "Resize layer",
|
|
111
112
|
coalesceKey,
|
|
112
113
|
coalesceMs: Number.POSITIVE_INFINITY,
|
|
@@ -30,6 +30,10 @@ export function isTextBearingTag(tagName: string): boolean {
|
|
|
30
30
|
return ["div", "span", "p", "strong", "h1", "h2", "h3", "h4", "h5", "h6"].includes(tagName);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export function isEditableTextLeaf(el: HTMLElement): boolean {
|
|
34
|
+
return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
33
37
|
/** Does this node render AT ALL, ignoring what it inherits? Sole owner of the
|
|
34
38
|
* rule; the walk below only decides which nodes to ask it about. */
|
|
35
39
|
function elementRendersItself(win: Window, el: HTMLElement): boolean {
|
|
@@ -23,8 +23,8 @@ import {
|
|
|
23
23
|
getInlineStyles,
|
|
24
24
|
getSelectorIndex,
|
|
25
25
|
getSourceFileForElement,
|
|
26
|
+
isEditableTextLeaf,
|
|
26
27
|
isHtmlElement,
|
|
27
|
-
isTextBearingTag,
|
|
28
28
|
} from "./domEditingDom";
|
|
29
29
|
import {
|
|
30
30
|
findElementForSelection,
|
|
@@ -35,10 +35,6 @@ import { isCompositionRootLayer } from "./domEditingRootLayer";
|
|
|
35
35
|
import { withSelectorIndexPass } from "../../utils/sourceScopedSelectorIndex";
|
|
36
36
|
import { type DomEditLayerWalkCache, readDomEditLayerWalkEntry } from "./domEditLayerWalkCache";
|
|
37
37
|
|
|
38
|
-
export function isEditableTextLeaf(el: HTMLElement): boolean {
|
|
39
|
-
return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
38
|
function sameTagChildIndex(el: HTMLElement): number {
|
|
43
39
|
let index = 0;
|
|
44
40
|
let sibling = el.previousElementSibling;
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
5
|
+
import type { DomEditGroupPathOffsetCommit } from "./domEditOverlayGestures";
|
|
6
|
+
import { STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP } from "./manualEdits";
|
|
7
|
+
import { isStudioManualEditGestureLiveIn } from "./manualEditsDom";
|
|
8
|
+
import { readTranslatePx } from "./plainTranslate";
|
|
9
|
+
import { moveDomGroupBy } from "./domGroupMoveBy";
|
|
10
|
+
import { GsapEditBlockedError } from "../../hooks/gsapEditOutcome";
|
|
11
|
+
|
|
12
|
+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
13
|
+
|
|
14
|
+
const mocks = vi.hoisted(() => ({ drag: vi.fn() }));
|
|
15
|
+
vi.mock("../../hooks/gsapRuntimeBridge", () => ({
|
|
16
|
+
tryGsapDragIntercept: mocks.drag,
|
|
17
|
+
tryGsapRotationIntercept: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
vi.mock("../../hooks/useAnimatedPropertyCommit", () => ({
|
|
20
|
+
useAnimatedPropertyCommit: () => ({
|
|
21
|
+
commitAnimatedProperty: vi.fn(),
|
|
22
|
+
commitAnimatedProperties: vi.fn(),
|
|
23
|
+
}),
|
|
24
|
+
}));
|
|
25
|
+
vi.mock("../../hooks/useSafeGsapCommitMutation", () => ({
|
|
26
|
+
useGsapSaveFailureTelemetry: () => vi.fn(),
|
|
27
|
+
useSafeGsapCommitMutation: (commit: unknown) => commit,
|
|
28
|
+
}));
|
|
29
|
+
vi.mock("../../utils/studioSaveDiagnostics", async (importOriginal) => ({
|
|
30
|
+
...(await importOriginal<typeof import("../../utils/studioSaveDiagnostics")>()),
|
|
31
|
+
trackStudioEditBlocked: vi.fn(),
|
|
32
|
+
trackStudioSaveFailure: vi.fn(),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
import { mountGsapAwareEditing } from "../../hooks/useGsapAwareEditing.testHelpers";
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
vi.clearAllMocks();
|
|
39
|
+
document.body.innerHTML = "";
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** A preview iframe drawn at half size: 200x100 composition px in a 100x50 box. */
|
|
43
|
+
function preview(): Document {
|
|
44
|
+
const iframe = document.createElement("iframe");
|
|
45
|
+
document.body.append(iframe);
|
|
46
|
+
const win = iframe.contentWindow!;
|
|
47
|
+
Object.defineProperty(win, "frameElement", { configurable: true, value: iframe });
|
|
48
|
+
Object.defineProperty(win, "innerWidth", { configurable: true, value: 200 });
|
|
49
|
+
Object.defineProperty(win, "innerHeight", { configurable: true, value: 100 });
|
|
50
|
+
iframe.getBoundingClientRect = () => new DOMRect(50, 40, 100, 50);
|
|
51
|
+
return iframe.contentDocument!;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const turned = (deg: number, scale: number) => {
|
|
55
|
+
const r = (deg * Math.PI) / 180;
|
|
56
|
+
return [scale * Math.cos(r), scale * Math.sin(r), -scale * Math.sin(r), scale * Math.cos(r)];
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** An element whose box centre is `at` plus its own move carried through its ancestors' linear map. */
|
|
60
|
+
function layer(doc: Document, id: string, parent = [1, 0, 0, 1], gsap?: object) {
|
|
61
|
+
const element = Object.assign(doc.createElement("div"), gsap ? { _gsap: gsap } : {});
|
|
62
|
+
doc.body.append(element);
|
|
63
|
+
const own = () => {
|
|
64
|
+
if (!gsap) return readTranslatePx(element);
|
|
65
|
+
const read = (prop: string) => Number.parseFloat(element.style.getPropertyValue(prop)) || 0;
|
|
66
|
+
return { x: read(STUDIO_OFFSET_X_PROP), y: read(STUDIO_OFFSET_Y_PROP) };
|
|
67
|
+
};
|
|
68
|
+
element.getBoundingClientRect = () => {
|
|
69
|
+
const { x, y } = own();
|
|
70
|
+
const [a, b, c, d] = parent as [number, number, number, number];
|
|
71
|
+
return new DOMRect(60 + a * x + c * y - 20, 40 + b * x + d * y - 10, 40, 20);
|
|
72
|
+
};
|
|
73
|
+
const selection = {
|
|
74
|
+
element,
|
|
75
|
+
id,
|
|
76
|
+
selector: `#${id}`,
|
|
77
|
+
label: id,
|
|
78
|
+
capabilities: { canApplyManualOffset: true },
|
|
79
|
+
} as unknown as DomEditSelection;
|
|
80
|
+
const centre = () => {
|
|
81
|
+
const r = element.getBoundingClientRect();
|
|
82
|
+
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
|
83
|
+
};
|
|
84
|
+
return { element, selection, centre };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function mountGroupCommit() {
|
|
88
|
+
const showToast = vi.fn();
|
|
89
|
+
const save = vi.fn().mockResolvedValue(undefined);
|
|
90
|
+
const stageElementPositionOffset = vi.fn(() => ({ save, rollback: vi.fn() }));
|
|
91
|
+
const { editing, root } = mountGsapAwareEditing({ showToast, stageElementPositionOffset });
|
|
92
|
+
const commit = (u: DomEditGroupPathOffsetCommit[], o?: { refusalToast?: boolean }) =>
|
|
93
|
+
editing().handleGsapAwareGroupPathOffsetCommit(u, o);
|
|
94
|
+
const moveBy = (moves: Parameters<typeof moveDomGroupBy>[0]) =>
|
|
95
|
+
act(() => moveDomGroupBy(moves, (u) => commit(u, { refusalToast: false })));
|
|
96
|
+
return {
|
|
97
|
+
commit: (u: DomEditGroupPathOffsetCommit[]) => commit(u),
|
|
98
|
+
moveBy,
|
|
99
|
+
showToast,
|
|
100
|
+
stageElementPositionOffset,
|
|
101
|
+
root,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const moved = (from: { x: number; y: number }, to: { x: number; y: number }) => ({
|
|
106
|
+
x: to.x - from.x,
|
|
107
|
+
y: to.y - from.y,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("handleDomGroupMoveBy", () => {
|
|
111
|
+
it("moves each box by its delta in composition px, saved under one undo entry", async () => {
|
|
112
|
+
const doc = preview();
|
|
113
|
+
const offset = layer(doc, "offset");
|
|
114
|
+
offset.element.style.setProperty("translate", "30px 0px");
|
|
115
|
+
const halved = layer(doc, "halved", [0.5, 0, 0, 0.5]);
|
|
116
|
+
const before = [offset.centre(), halved.centre()];
|
|
117
|
+
const { moveBy, stageElementPositionOffset, showToast, root } = mountGroupCommit();
|
|
118
|
+
|
|
119
|
+
await moveBy([
|
|
120
|
+
{ selection: offset.selection, delta: { x: 10, y: 0 } },
|
|
121
|
+
{ selection: halved.selection, delta: { x: 0, y: -5 } },
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
expect(moved(before[0]!, offset.centre())).toEqual({ x: 10, y: 0 });
|
|
125
|
+
expect(moved(before[1]!, halved.centre())).toEqual({ x: 0, y: -5 });
|
|
126
|
+
const calls = stageElementPositionOffset.mock.calls as unknown as unknown[][];
|
|
127
|
+
expect(calls.map((call) => call[1])).toEqual([
|
|
128
|
+
{ x: 40, y: 0 },
|
|
129
|
+
{ x: 0, y: -10 },
|
|
130
|
+
]);
|
|
131
|
+
expect(calls[0]![3]).toMatch(/^group-drag:\d+$/);
|
|
132
|
+
expect(calls[1]![3]).toBe(calls[0]![3]);
|
|
133
|
+
expect(showToast).not.toHaveBeenCalled();
|
|
134
|
+
expect(isStudioManualEditGestureLiveIn(doc)).toBe(false);
|
|
135
|
+
act(() => root.unmount());
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("lands exactly under a half-scale parent with a rotated ancestor", async () => {
|
|
139
|
+
const doc = preview();
|
|
140
|
+
const tilted = layer(doc, "tilted", turned(30, 0.5));
|
|
141
|
+
const before = tilted.centre();
|
|
142
|
+
const { moveBy, root } = mountGroupCommit();
|
|
143
|
+
|
|
144
|
+
await moveBy([{ selection: tilted.selection, delta: { x: 10, y: 0 } }]);
|
|
145
|
+
|
|
146
|
+
const delta = moved(before, tilted.centre());
|
|
147
|
+
// Exact to the 0.001 px a translate is written with.
|
|
148
|
+
expect(delta.x).toBeCloseTo(10, 3);
|
|
149
|
+
expect(delta.y).toBeCloseTo(0, 3);
|
|
150
|
+
act(() => root.unmount());
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("moves a member GSAP positions through the GSAP write, planned and written at one delta", async () => {
|
|
154
|
+
mocks.drag.mockResolvedValue({ status: "persisted" });
|
|
155
|
+
const doc = preview();
|
|
156
|
+
const animated = layer(doc, "animated", [1, 0, 0, 1], { renderTransform: () => {} });
|
|
157
|
+
const before = animated.centre();
|
|
158
|
+
const { moveBy, stageElementPositionOffset, root } = mountGroupCommit();
|
|
159
|
+
|
|
160
|
+
await moveBy([{ selection: animated.selection, delta: { x: 10, y: 0 } }]);
|
|
161
|
+
|
|
162
|
+
const write = mocks.drag.mock.calls.find((call) => !call[6]?.preflightOnly);
|
|
163
|
+
expect(write?.[0]).toBe(animated.selection);
|
|
164
|
+
expect(write?.[1]).toEqual({ x: 10, y: 0 });
|
|
165
|
+
const plan = mocks.drag.mock.calls.find((call) => call[6]?.preflightOnly);
|
|
166
|
+
expect(plan?.[1]).toEqual(write?.[1]);
|
|
167
|
+
expect(moved(before, animated.centre())).toEqual({ x: 10, y: 0 });
|
|
168
|
+
expect(stageElementPositionOffset).not.toHaveBeenCalled();
|
|
169
|
+
act(() => root.unmount());
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("skips a zero delta, and writes nothing when every delta is zero", async () => {
|
|
173
|
+
const doc = preview();
|
|
174
|
+
const still = layer(doc, "still");
|
|
175
|
+
const { moveBy, stageElementPositionOffset, root } = mountGroupCommit();
|
|
176
|
+
|
|
177
|
+
await moveBy([{ selection: still.selection, delta: { x: 0, y: 0 } }]);
|
|
178
|
+
|
|
179
|
+
expect(stageElementPositionOffset).not.toHaveBeenCalled();
|
|
180
|
+
act(() => root.unmount());
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it.each([
|
|
184
|
+
[
|
|
185
|
+
"a member that can't take a manual move",
|
|
186
|
+
(doc: Document) => {
|
|
187
|
+
const locked = layer(doc, "locked");
|
|
188
|
+
Object.assign(locked.selection.capabilities, {
|
|
189
|
+
canApplyManualOffset: false,
|
|
190
|
+
reasonIfDisabled: "Locked by a helper loop.",
|
|
191
|
+
});
|
|
192
|
+
return locked;
|
|
193
|
+
},
|
|
194
|
+
/helper loop/,
|
|
195
|
+
],
|
|
196
|
+
[
|
|
197
|
+
"a member an animation took over",
|
|
198
|
+
(doc: Document) => layer(doc, "folded", [1, 0, 0, 1], { x: "94px" }),
|
|
199
|
+
/animation took over/,
|
|
200
|
+
],
|
|
201
|
+
[
|
|
202
|
+
"a member the GSAP write refuses",
|
|
203
|
+
(doc: Document) => {
|
|
204
|
+
mocks.drag.mockResolvedValue({ status: "blocked", reason: "no-selector" });
|
|
205
|
+
return layer(doc, "blocked", [1, 0, 0, 1], { renderTransform: () => {} });
|
|
206
|
+
},
|
|
207
|
+
new GsapEditBlockedError("no-selector").message,
|
|
208
|
+
],
|
|
209
|
+
])("refuses %s before any write, without a Studio toast", async (_, make, reason) => {
|
|
210
|
+
const doc = preview();
|
|
211
|
+
const fine = layer(doc, "fine");
|
|
212
|
+
const refused = make(doc);
|
|
213
|
+
const before = [fine.centre(), refused.centre()];
|
|
214
|
+
const { moveBy, stageElementPositionOffset, showToast, root } = mountGroupCommit();
|
|
215
|
+
|
|
216
|
+
await expect(
|
|
217
|
+
moveBy([
|
|
218
|
+
{ selection: fine.selection, delta: { x: 10, y: 0 } },
|
|
219
|
+
{ selection: refused.selection, delta: { x: 10, y: 0 } },
|
|
220
|
+
]),
|
|
221
|
+
).rejects.toThrow(reason);
|
|
222
|
+
|
|
223
|
+
expect(stageElementPositionOffset).not.toHaveBeenCalled();
|
|
224
|
+
expect(mocks.drag.mock.calls.some((call) => !call[6]?.preflightOnly)).toBe(false);
|
|
225
|
+
expect(showToast).not.toHaveBeenCalled();
|
|
226
|
+
expect([fine.centre(), refused.centre()]).toEqual(before);
|
|
227
|
+
expect(isStudioManualEditGestureLiveIn(doc)).toBe(false);
|
|
228
|
+
act(() => root.unmount());
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it.each([
|
|
232
|
+
["an animation took over", () => layer(preview(), "folded", [1, 0, 0, 1], { x: "94px" }), true],
|
|
233
|
+
[
|
|
234
|
+
"the GSAP write refuses",
|
|
235
|
+
() => {
|
|
236
|
+
mocks.drag.mockResolvedValue({ status: "blocked", reason: "no-selector" });
|
|
237
|
+
return layer(preview(), "blocked", [1, 0, 0, 1], { renderTransform: () => {} });
|
|
238
|
+
},
|
|
239
|
+
false,
|
|
240
|
+
],
|
|
241
|
+
])("still toasts on a canvas group drag when %s", async (_, make, plainTranslate) => {
|
|
242
|
+
const { selection } = make();
|
|
243
|
+
const { commit, showToast, root } = mountGroupCommit();
|
|
244
|
+
|
|
245
|
+
await expect(
|
|
246
|
+
act(() => commit([{ selection, next: { x: 1, y: 2 }, plainTranslate }])),
|
|
247
|
+
).rejects.toThrow();
|
|
248
|
+
|
|
249
|
+
expect(showToast).toHaveBeenCalledWith(expect.any(String), "error");
|
|
250
|
+
act(() => root.unmount());
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { DomEditSelection } from "./domEditing";
|
|
2
|
+
import type { DomEditGroupPathOffsetCommit } from "./domEditOverlayGestures";
|
|
3
|
+
import { canCanvasNudgeTargets } from "./domEditNudge";
|
|
4
|
+
import { isStudioManualEditGestureCurrent, restoreStudioPathOffset } from "./manualEdits";
|
|
5
|
+
import { countStudioManualEditSave } from "./manualEditsDom";
|
|
6
|
+
import {
|
|
7
|
+
applyManualOffsetDragCommit,
|
|
8
|
+
createManualOffsetDragMember,
|
|
9
|
+
endManualOffsetDragMembers,
|
|
10
|
+
restoreManualOffsetDragMembers,
|
|
11
|
+
toTopScreenPoint,
|
|
12
|
+
type ManualOffsetDragMember,
|
|
13
|
+
} from "./manualOffsetDrag";
|
|
14
|
+
|
|
15
|
+
export interface DomGroupMove {
|
|
16
|
+
selection: DomEditSelection;
|
|
17
|
+
/** Composition px: the element's box moves by exactly this in the film. */
|
|
18
|
+
delta: { x: number; y: number };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Composition px to the top page's px; the preview frames only scale and shift. */
|
|
22
|
+
function toScreen(doc: Document, delta: { x: number; y: number }) {
|
|
23
|
+
const origin = toTopScreenPoint(doc, { x: 0, y: 0 });
|
|
24
|
+
const moved = toTopScreenPoint(doc, delta);
|
|
25
|
+
if (!origin || !moved) throw new Error("The preview has no measurable size.");
|
|
26
|
+
return { x: moved.x - origin.x, y: moved.y - origin.y };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function memberFor(
|
|
30
|
+
selection: DomEditSelection,
|
|
31
|
+
scale: { x: number; y: number },
|
|
32
|
+
): ManualOffsetDragMember {
|
|
33
|
+
const { element } = selection;
|
|
34
|
+
const box = element.getBoundingClientRect();
|
|
35
|
+
const result = createManualOffsetDragMember({
|
|
36
|
+
key: selection.id ?? selection.selector ?? selection.label,
|
|
37
|
+
selection,
|
|
38
|
+
element,
|
|
39
|
+
rect: {
|
|
40
|
+
left: box.left,
|
|
41
|
+
top: box.top,
|
|
42
|
+
width: box.width,
|
|
43
|
+
height: box.height,
|
|
44
|
+
editScaleX: scale.x,
|
|
45
|
+
editScaleY: scale.y,
|
|
46
|
+
},
|
|
47
|
+
gesture: "drag",
|
|
48
|
+
});
|
|
49
|
+
if (!result.ok) throw new Error(result.reason);
|
|
50
|
+
return result.member;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Moves each element by its delta as one group drag would drop it: one save, one undo entry.
|
|
55
|
+
* Any member that can't move refuses the whole call before anything is drawn or written.
|
|
56
|
+
*/
|
|
57
|
+
export async function moveDomGroupBy(
|
|
58
|
+
moves: DomGroupMove[],
|
|
59
|
+
commitGroup: (updates: DomEditGroupPathOffsetCommit[]) => Promise<unknown>,
|
|
60
|
+
): Promise<void> {
|
|
61
|
+
const moving = moves.filter(({ delta }) => delta.x !== 0 || delta.y !== 0);
|
|
62
|
+
if (moving.length === 0) return;
|
|
63
|
+
const selections = moving.map(({ selection }) => selection);
|
|
64
|
+
if (!canCanvasNudgeTargets(selections)) {
|
|
65
|
+
const blocked = selections.find((s) => !s.capabilities.canApplyManualOffset);
|
|
66
|
+
throw new Error(blocked?.capabilities.reasonIfDisabled ?? `${blocked?.label} can't be moved.`);
|
|
67
|
+
}
|
|
68
|
+
const screens = moving.map(({ selection, delta }) =>
|
|
69
|
+
toScreen(selection.element.ownerDocument, delta),
|
|
70
|
+
);
|
|
71
|
+
const members: ManualOffsetDragMember[] = [];
|
|
72
|
+
try {
|
|
73
|
+
for (const selection of selections)
|
|
74
|
+
members.push(memberFor(selection, toScreen(selection.element.ownerDocument, { x: 1, y: 1 })));
|
|
75
|
+
} catch (error) {
|
|
76
|
+
restoreManualOffsetDragMembers(members);
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
const updates = members.map((member, index) => ({
|
|
80
|
+
selection: member.selection,
|
|
81
|
+
next: applyManualOffsetDragCommit(member, screens[index]!.x, screens[index]!.y),
|
|
82
|
+
plainTranslate: member.plainTranslate,
|
|
83
|
+
}));
|
|
84
|
+
try {
|
|
85
|
+
await countStudioManualEditSave(members[0]!.element, () => commitGroup(updates));
|
|
86
|
+
} catch (error) {
|
|
87
|
+
for (const member of members) {
|
|
88
|
+
if (isStudioManualEditGestureCurrent(member.element, member.gestureToken))
|
|
89
|
+
restoreStudioPathOffset(member.element, member.initialPathOffset);
|
|
90
|
+
}
|
|
91
|
+
throw error;
|
|
92
|
+
} finally {
|
|
93
|
+
endManualOffsetDragMembers(members);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -5,7 +5,7 @@ import type { GestureState } from "./domEditOverlayGestures";
|
|
|
5
5
|
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
|
|
6
6
|
import {
|
|
7
7
|
hasStudioPendingEdits,
|
|
8
|
-
|
|
8
|
+
paintBackNewestStudioPendingEdit,
|
|
9
9
|
} from "../../utils/studioPendingEdits";
|
|
10
10
|
|
|
11
11
|
afterEach(() => {
|
|
@@ -59,13 +59,13 @@ it("a drag whose save is still running can be painted back at once, and shown ag
|
|
|
59
59
|
const moved = element.style.getPropertyValue("translate");
|
|
60
60
|
expect(moved).not.toBe("40px 30px");
|
|
61
61
|
|
|
62
|
-
const
|
|
62
|
+
const shown = paintBackNewestStudioPendingEdit();
|
|
63
63
|
expect(element.style.getPropertyValue("translate")).toBe("40px 30px");
|
|
64
|
-
expect(
|
|
65
|
-
showAgain
|
|
64
|
+
expect(paintBackNewestStudioPendingEdit()).toBeNull();
|
|
65
|
+
shown!.showAgain();
|
|
66
66
|
expect(element.style.getPropertyValue("translate")).toBe(moved);
|
|
67
67
|
|
|
68
68
|
saved();
|
|
69
69
|
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
|
|
70
|
-
expect(
|
|
70
|
+
expect(paintBackNewestStudioPendingEdit()).toBeNull();
|
|
71
71
|
});
|