@hyperframes/studio 0.7.54 → 0.7.56
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/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
- package/dist/assets/{index-CMHYjEZ5.js → index-C4csZims.js} +1 -1
- package/dist/assets/index-CmVCjZjp.js +423 -0
- package/dist/assets/index-D-GyYi2d.css +1 -0
- package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
- package/dist/chunk-5QSIMBEJ.js.map +1 -0
- package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
- package/dist/index.d.ts +146 -144
- package/dist/index.html +2 -2
- package/dist/index.js +40582 -37803
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +106 -96
- package/src/components/EditorShell.tsx +253 -0
- package/src/components/StudioGlobalDragOverlay.tsx +1 -3
- package/src/components/StudioLeftSidebar.tsx +13 -3
- package/src/components/StudioRightPanel.tsx +8 -3
- package/src/components/TimelineToolbar.test.tsx +2 -2
- package/src/components/TimelineToolbar.tsx +179 -121
- package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
- package/src/components/editor/CanvasContextMenu.tsx +198 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
- package/src/components/editor/DomEditCropHandles.tsx +64 -29
- package/src/components/editor/DomEditOverlay.test.ts +245 -279
- package/src/components/editor/DomEditOverlay.tsx +158 -126
- package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
- package/src/components/editor/DomEditRotateHandle.tsx +38 -18
- package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
- package/src/components/editor/LayersPanel.test.ts +66 -2
- package/src/components/editor/LayersPanel.tsx +43 -1
- package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
- package/src/components/editor/OffCanvasIndicators.tsx +49 -10
- package/src/components/editor/SnapGuideOverlay.tsx +28 -23
- package/src/components/editor/SnapToolbar.test.tsx +0 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +208 -0
- package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
- package/src/components/editor/domEditNudge.test.ts +80 -0
- package/src/components/editor/domEditNudge.ts +44 -0
- package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
- package/src/components/editor/domEditOverlayGeometry.ts +280 -20
- package/src/components/editor/domEditOverlayGestures.ts +44 -41
- package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
- package/src/components/editor/domEditResizeLocal.test.ts +131 -0
- package/src/components/editor/domEditResizeLocal.ts +125 -0
- package/src/components/editor/domEditingDom.ts +1 -1
- package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
- package/src/components/editor/manualOffsetDrag.ts +55 -12
- package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
- package/src/components/editor/resizeDraft.ts +108 -0
- package/src/components/editor/snapEngine.test.ts +12 -78
- package/src/components/editor/snapEngine.ts +13 -53
- package/src/components/editor/useDomEditNudge.test.tsx +228 -0
- package/src/components/editor/useDomEditNudge.ts +254 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
- package/src/components/editor/useDomEditOverlayRects.ts +9 -2
- package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
- package/src/components/nle/NLEContext.test.ts +144 -0
- package/src/components/nle/NLEContext.tsx +328 -0
- package/src/components/nle/NLEPreview.tsx +1 -1
- package/src/components/nle/PreviewOverlays.tsx +240 -0
- package/src/components/nle/PreviewPane.tsx +163 -0
- package/src/components/nle/TimelinePane.test.ts +60 -0
- package/src/components/nle/TimelinePane.tsx +293 -0
- package/src/components/nle/TimelineResizeDivider.tsx +8 -2
- package/src/components/nle/useCompositionStack.test.tsx +44 -0
- package/src/components/nle/useCompositionStack.ts +17 -9
- package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
- package/src/components/sidebar/AssetCard.tsx +326 -0
- package/src/components/sidebar/AssetContextMenu.tsx +15 -30
- package/src/components/sidebar/AssetsTab.test.ts +96 -0
- package/src/components/sidebar/AssetsTab.tsx +71 -206
- package/src/components/sidebar/AudioRow.tsx +39 -2
- package/src/components/sidebar/BlocksTab.tsx +8 -1
- package/src/components/sidebar/LeftSidebar.tsx +4 -1
- package/src/components/sidebar/assetHelpers.ts +29 -0
- package/src/contexts/DomEditContext.tsx +9 -0
- package/src/contexts/StudioContext.tsx +9 -8
- package/src/contexts/TimelineEditContext.tsx +3 -7
- package/src/hooks/deleteSelectedKeyframes.ts +35 -0
- package/src/hooks/domEditCommitTypes.ts +12 -1
- package/src/hooks/domSelectionTestHarness.ts +14 -0
- package/src/hooks/gestureTransaction.test.ts +311 -0
- package/src/hooks/gestureTransaction.ts +199 -0
- package/src/hooks/gsapDragCommit.test.ts +141 -41
- package/src/hooks/gsapDragCommit.ts +64 -74
- package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
- package/src/hooks/gsapResizeIntercept.test.ts +69 -0
- package/src/hooks/gsapResizeIntercept.ts +38 -10
- package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
- package/src/hooks/gsapRuntimeBridge.ts +4 -4
- package/src/hooks/gsapScriptCommitTypes.ts +16 -5
- package/src/hooks/gsapShared.test.ts +19 -1
- package/src/hooks/gsapShared.ts +14 -0
- package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
- package/src/hooks/timelineEditingGsap.ts +14 -0
- package/src/hooks/timelineEditingHelpers.test.ts +34 -1
- package/src/hooks/timelineEditingHelpers.ts +22 -48
- package/src/hooks/timelineMoveAdapter.test.ts +77 -0
- package/src/hooks/timelineMoveAdapter.ts +37 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
- package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
- package/src/hooks/useAppHotkeys.ts +16 -25
- package/src/hooks/useBlockHandlers.ts +55 -27
- package/src/hooks/useContextMenuDismiss.ts +31 -7
- package/src/hooks/useDomEditCommits.test.tsx +154 -1
- package/src/hooks/useDomEditCommits.ts +100 -3
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -2
- package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
- package/src/hooks/useDomEditTextCommits.ts +8 -3
- package/src/hooks/useDomEditWiring.ts +7 -9
- package/src/hooks/useDomGeometryCommits.ts +17 -2
- package/src/hooks/useDomSelection.test.ts +2 -36
- package/src/hooks/useDomSelection.ts +81 -51
- package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
- package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
- package/src/hooks/useElementLifecycleOps.ts +40 -71
- package/src/hooks/useEnableKeyframes.test.ts +142 -1
- package/src/hooks/useEnableKeyframes.ts +73 -29
- package/src/hooks/useGestureCommit.test.tsx +130 -0
- package/src/hooks/useGestureCommit.ts +32 -8
- package/src/hooks/useGroupCommits.ts +7 -28
- package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
- package/src/hooks/useGsapAwareEditing.ts +115 -20
- package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
- package/src/hooks/useGsapKeyframeOps.ts +38 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
- package/src/hooks/useGsapScriptCommits.ts +134 -50
- package/src/hooks/useGsapSelectionHandlers.ts +49 -16
- package/src/hooks/useMusicBeatAnalysis.ts +72 -36
- package/src/hooks/usePersistentEditHistory.test.ts +33 -0
- package/src/hooks/usePersistentEditHistory.ts +92 -58
- package/src/hooks/usePreviewPersistence.ts +25 -7
- package/src/hooks/useRazorSplit.history.test.tsx +303 -0
- package/src/hooks/useRazorSplit.test.ts +269 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
- package/src/hooks/useRazorSplit.ts +107 -36
- package/src/hooks/useRenderClipContent.ts +24 -6
- package/src/hooks/useStudioContextValue.ts +15 -5
- package/src/hooks/useStudioUrlState.ts +2 -4
- package/src/hooks/useTimelineEditing.test.tsx +20 -29
- package/src/hooks/useTimelineEditingTypes.ts +6 -0
- package/src/hooks/useTimelineGroupEditing.ts +18 -4
- package/src/index.ts +1 -1
- package/src/player/components/ImageThumbnail.test.tsx +173 -0
- package/src/player/components/ImageThumbnail.tsx +160 -0
- package/src/player/components/PlayheadIndicator.tsx +50 -5
- package/src/player/components/Timeline.test.ts +102 -19
- package/src/player/components/Timeline.tsx +194 -194
- package/src/player/components/TimelineCanvas.tsx +207 -521
- package/src/player/components/TimelineClip.tsx +4 -1
- package/src/player/components/TimelineClipDiamonds.tsx +9 -40
- package/src/player/components/TimelineEmptyState.tsx +3 -1
- package/src/player/components/TimelineLanes.tsx +487 -0
- package/src/player/components/TimelineOverlays.tsx +122 -0
- package/src/player/components/VideoThumbnail.test.tsx +152 -0
- package/src/player/components/VideoThumbnail.tsx +28 -7
- package/src/player/components/thumbnailUtils.test.ts +127 -0
- package/src/player/components/thumbnailUtils.ts +54 -0
- package/src/player/components/timelineCallbacks.ts +18 -18
- package/src/player/components/timelineClipChildren.tsx +39 -0
- package/src/player/components/timelineClipDragCommit.test.ts +943 -0
- package/src/player/components/timelineClipDragCommit.ts +401 -0
- package/src/player/components/timelineClipDragPreview.test.ts +144 -0
- package/src/player/components/timelineClipDragPreview.ts +323 -38
- package/src/player/components/timelineClipDragTypes.ts +65 -0
- package/src/player/components/timelineCollision.test.ts +477 -0
- package/src/player/components/timelineCollision.ts +263 -0
- package/src/player/components/timelineDragDrop.ts +69 -60
- package/src/player/components/timelineEditCapabilities.ts +52 -0
- package/src/player/components/timelineEditing.test.ts +1 -1
- package/src/player/components/timelineEditing.ts +76 -59
- package/src/player/components/timelineGroupEditing.test.ts +126 -0
- package/src/player/components/timelineGroupEditing.ts +133 -0
- package/src/player/components/timelineGroupResizeCommit.ts +54 -0
- package/src/player/components/timelineLayout.test.ts +95 -23
- package/src/player/components/timelineLayout.ts +120 -61
- package/src/player/components/timelineMarquee.test.ts +197 -0
- package/src/player/components/timelineMarquee.ts +112 -0
- package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
- package/src/player/components/timelineMultiDragPreview.ts +106 -0
- package/src/player/components/timelineOptimisticRevision.ts +42 -0
- package/src/player/components/timelineSnapping.test.ts +134 -0
- package/src/player/components/timelineSnapping.ts +110 -0
- package/src/player/components/timelineStackingSync.test.ts +244 -0
- package/src/player/components/timelineStackingSync.ts +331 -0
- package/src/player/components/timelineTheme.ts +6 -1
- package/src/player/components/timelineZones.test.ts +425 -0
- package/src/player/components/timelineZones.ts +198 -0
- package/src/player/components/timelineZoom.test.ts +67 -0
- package/src/player/components/timelineZoom.ts +51 -0
- package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
- package/src/player/components/useTimelineClipDrag.ts +380 -386
- package/src/player/components/useTimelineEditPinning.ts +121 -0
- package/src/player/components/useTimelineGeometry.ts +129 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -16
- package/src/player/components/useTimelineRangeSelection.ts +341 -50
- package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
- package/src/player/components/useTimelineStackingSync.ts +83 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
- package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
- package/src/player/hooks/useTimelinePlayer.ts +48 -53
- package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
- package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
- package/src/player/index.ts +3 -2
- package/src/player/lib/time.test.ts +19 -19
- package/src/player/lib/time.ts +6 -2
- package/src/player/lib/timelineDOM.test.ts +25 -98
- package/src/player/lib/timelineDOM.ts +12 -81
- package/src/player/lib/timelineElementHelpers.ts +54 -5
- package/src/player/lib/timelineIframeHelpers.ts +2 -0
- package/src/player/store/playerStore.ts +62 -3
- package/src/styles/studio.css +27 -0
- package/src/utils/assetClickBehavior.test.ts +104 -0
- package/src/utils/assetClickBehavior.ts +75 -0
- package/src/utils/assetPreviewStore.ts +33 -0
- package/src/utils/blockInstaller.ts +10 -2
- package/src/utils/canvasNudgeGate.test.ts +31 -0
- package/src/utils/canvasNudgeGate.ts +32 -0
- package/src/utils/editHistory.test.ts +35 -0
- package/src/utils/gsapSoftReload.test.ts +119 -1
- package/src/utils/gsapSoftReload.ts +153 -0
- package/src/utils/mediaTypes.ts +3 -2
- package/src/utils/resizeDebug.ts +55 -0
- package/src/utils/rootDuration.test.ts +90 -1
- package/src/utils/rootDuration.ts +76 -13
- package/src/utils/sdkCutover.ts +4 -0
- package/src/utils/studioFileHistory.ts +37 -7
- package/src/utils/studioHelpers.ts +62 -0
- package/src/utils/studioPreviewHelpers.test.ts +65 -8
- package/src/utils/studioPreviewHelpers.ts +10 -0
- package/src/utils/studioTelemetry.ts +4 -1
- package/src/utils/studioUiPreferences.test.ts +38 -0
- package/src/utils/studioUiPreferences.ts +27 -0
- package/src/utils/studioUrlState.test.ts +0 -1
- package/src/utils/timelineAssetDrop.ts +62 -0
- package/src/utils/timelineDiscovery.ts +0 -17
- package/src/utils/timelineInspector.test.ts +121 -0
- package/src/utils/timelineInspector.ts +32 -1
- package/dist/assets/index-Dq7FEg0K.css +0 -1
- package/dist/assets/index-pRhCpGPz.js +0 -423
- package/dist/chunk-SOTCF4DF.js.map +0 -1
- package/src/components/StudioPreviewArea.tsx +0 -500
- package/src/components/nle/NLELayout.test.ts +0 -12
- package/src/components/nle/NLELayout.tsx +0 -591
- package/src/player/components/TimelineLayerGutter.tsx +0 -61
- package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
- package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
- package/src/player/components/timelineSnapTargets.test.ts +0 -144
- package/src/player/components/timelineSnapTargets.ts +0 -164
- package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
- package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
- package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
- package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
- package/src/utils/timelineDiscovery.test.ts +0 -90
- /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
resolveCenterResizeScale,
|
|
4
|
+
resolveCenterResizeSize,
|
|
5
|
+
resolveRotatedResizeCursor,
|
|
6
|
+
} from "./domEditResizeLocal";
|
|
7
|
+
|
|
8
|
+
const DEG = Math.PI / 180;
|
|
9
|
+
|
|
10
|
+
describe("resolveCenterResizeScale — radial distance from the center", () => {
|
|
11
|
+
it("scale is the ratio of pointer-to-center distances", () => {
|
|
12
|
+
// start 100px from center, now 150px from center → 1.5x.
|
|
13
|
+
expect(
|
|
14
|
+
resolveCenterResizeScale({
|
|
15
|
+
centerStart: { x: 100, y: 100 },
|
|
16
|
+
pointerStart: { x: 200, y: 100 },
|
|
17
|
+
pointer: { x: 250, y: 100 },
|
|
18
|
+
}),
|
|
19
|
+
).toBeCloseTo(1.5, 9);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("shrinks as the pointer moves toward the center", () => {
|
|
23
|
+
expect(
|
|
24
|
+
resolveCenterResizeScale({
|
|
25
|
+
centerStart: { x: 0, y: 0 },
|
|
26
|
+
pointerStart: { x: 0, y: 200 },
|
|
27
|
+
pointer: { x: 0, y: 50 },
|
|
28
|
+
}),
|
|
29
|
+
).toBeCloseTo(0.25, 9);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("bails to 1 when the gesture starts at (or ~at) the center (degenerate)", () => {
|
|
33
|
+
expect(
|
|
34
|
+
resolveCenterResizeScale({
|
|
35
|
+
centerStart: { x: 100, y: 100 },
|
|
36
|
+
pointerStart: { x: 101, y: 100 },
|
|
37
|
+
pointer: { x: 400, y: 400 },
|
|
38
|
+
}),
|
|
39
|
+
).toBe(1);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("resolveCenterResizeSize — rotation is a non-event (radial distance)", () => {
|
|
44
|
+
// The pure function takes no rotation argument: rotating the WHOLE gesture
|
|
45
|
+
// (center, pointer-down, pointer-now) about the center by any angle preserves
|
|
46
|
+
// every radial distance, so the returned size is identical at 0/37/90deg.
|
|
47
|
+
const base = { baseWidth: 240, baseHeight: 120 };
|
|
48
|
+
const centerStart = { x: 320, y: 180 };
|
|
49
|
+
// A pointer-down 100px right of center, dragged to 160px right of center (1.6x).
|
|
50
|
+
const p0 = { x: 100, y: 0 };
|
|
51
|
+
const p1 = { x: 160, y: 0 };
|
|
52
|
+
const rot = (v: { x: number; y: number }, t: number) => ({
|
|
53
|
+
x: centerStart.x + (Math.cos(t) * v.x - Math.sin(t) * v.y),
|
|
54
|
+
y: centerStart.y + (Math.sin(t) * v.x + Math.cos(t) * v.y),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
for (const deg of [0, 37, 90]) {
|
|
58
|
+
it(`@${deg}deg: proportional 1.6x scale, same result as unrotated`, () => {
|
|
59
|
+
const t = deg * DEG;
|
|
60
|
+
const out = resolveCenterResizeSize({
|
|
61
|
+
...base,
|
|
62
|
+
centerStart,
|
|
63
|
+
pointerStart: rot(p0, t),
|
|
64
|
+
pointer: rot(p1, t),
|
|
65
|
+
});
|
|
66
|
+
expect(out.width).toBeCloseTo(240 * 1.6, 6);
|
|
67
|
+
expect(out.height).toBeCloseTo(120 * 1.6, 6);
|
|
68
|
+
expect(out.width / out.height).toBeCloseTo(2, 9);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
it("shrink toward center keeps the aspect ratio", () => {
|
|
73
|
+
const out = resolveCenterResizeSize({
|
|
74
|
+
baseWidth: 300,
|
|
75
|
+
baseHeight: 180,
|
|
76
|
+
centerStart: { x: 0, y: 0 },
|
|
77
|
+
pointerStart: { x: 0, y: 200 },
|
|
78
|
+
pointer: { x: 0, y: 120 },
|
|
79
|
+
});
|
|
80
|
+
expect(out.width).toBeCloseTo(300 * 0.6, 6);
|
|
81
|
+
expect(out.height).toBeCloseTo(180 * 0.6, 6);
|
|
82
|
+
expect(out.width / out.height).toBeCloseTo(300 / 180, 9);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("clamps at the local minimum, never mirroring through zero (drag past center)", () => {
|
|
86
|
+
// Pointer dragged to the exact center → raw scale 0; the smaller edge is
|
|
87
|
+
// clamped to MIN_RESIZE_LOCAL_PX and the aspect ratio holds at the clamp.
|
|
88
|
+
const out = resolveCenterResizeSize({
|
|
89
|
+
baseWidth: 200,
|
|
90
|
+
baseHeight: 100,
|
|
91
|
+
centerStart: { x: 100, y: 100 },
|
|
92
|
+
pointerStart: { x: 200, y: 100 },
|
|
93
|
+
pointer: { x: 100, y: 100 },
|
|
94
|
+
});
|
|
95
|
+
expect(out.width).toBeGreaterThan(0);
|
|
96
|
+
expect(out.height).toBeGreaterThan(0);
|
|
97
|
+
expect(Math.min(out.width, out.height)).toBeCloseTo(1, 9);
|
|
98
|
+
expect(out.width / out.height).toBeCloseTo(2, 9);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("degenerate start-at-center returns the base size unchanged (scale 1)", () => {
|
|
102
|
+
const out = resolveCenterResizeSize({
|
|
103
|
+
baseWidth: 200,
|
|
104
|
+
baseHeight: 100,
|
|
105
|
+
centerStart: { x: 100, y: 100 },
|
|
106
|
+
pointerStart: { x: 100, y: 100 },
|
|
107
|
+
pointer: { x: 400, y: 400 },
|
|
108
|
+
});
|
|
109
|
+
expect(out).toEqual({ width: 200, height: 100 });
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("resolveRotatedResizeCursor", () => {
|
|
114
|
+
it("returns the static diagonal cursors at rotation 0", () => {
|
|
115
|
+
expect(resolveRotatedResizeCursor("nw", 0)).toBe("nwse-resize");
|
|
116
|
+
expect(resolveRotatedResizeCursor("se", 0)).toBe("nwse-resize");
|
|
117
|
+
expect(resolveRotatedResizeCursor("ne", 0)).toBe("nesw-resize");
|
|
118
|
+
expect(resolveRotatedResizeCursor("sw", 0)).toBe("nesw-resize");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("rotates the cursor with the element (90deg swaps the diagonals)", () => {
|
|
122
|
+
// NW base 315° + 90° = 45° → nesw-resize
|
|
123
|
+
expect(resolveRotatedResizeCursor("nw", 90)).toBe("nesw-resize");
|
|
124
|
+
// NW base 315° + 45° = 360°→0° → ns-resize
|
|
125
|
+
expect(resolveRotatedResizeCursor("nw", 45)).toBe("ns-resize");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("wraps negative rotations", () => {
|
|
129
|
+
expect(resolveRotatedResizeCursor("se", -90)).toBe("nesw-resize");
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Center-anchored corner-resize math (the CapCut model): the element scales
|
|
3
|
+
* proportionally about its CENTER, the center stays planted, and all four corners
|
|
4
|
+
* behave identically.
|
|
5
|
+
*
|
|
6
|
+
* The scale factor is the RADIAL distance from the element's center: how far the
|
|
7
|
+
* pointer is from the center now, divided by how far it was at gesture start. This
|
|
8
|
+
* is inherently proportional, continuous everywhere, and rotation-invariant — a
|
|
9
|
+
* distance is a distance regardless of the element's angle, so there is no per-axis
|
|
10
|
+
* projection and no dominant-axis branch to jump across. Which corner was grabbed
|
|
11
|
+
* is irrelevant to the size (it only picks the resize cursor).
|
|
12
|
+
*
|
|
13
|
+
* All math here is pure and unit-tested; the live wiring (measuring the element's
|
|
14
|
+
* rendered center, feeding the center-pin translate through the manual-offset
|
|
15
|
+
* channel) lives in useDomEditOverlayGestures.ts.
|
|
16
|
+
*/
|
|
17
|
+
import type { ResizeHandle } from "./domEditOverlayGestures";
|
|
18
|
+
|
|
19
|
+
/** Minimum element edge in LOCAL px — mirrors the old MIN_RESIZE_EDGE_PX clamp
|
|
20
|
+
* (no flip-through-zero: clamp, never mirror). */
|
|
21
|
+
const MIN_RESIZE_LOCAL_PX = 1;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Below this pointer-to-center distance (overlay px) the gesture started at (or
|
|
25
|
+
* effectively at) the center, so the ratio is degenerate (division by ~0). Bail to
|
|
26
|
+
* scale 1 rather than blow up.
|
|
27
|
+
*/
|
|
28
|
+
const DEGENERATE_START_DIST_PX = 3;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The proportional scale factor for a center-anchored resize: the ratio of the
|
|
32
|
+
* pointer's radial distance from the element center now to its distance at gesture
|
|
33
|
+
* start. Rotation-invariant (a radial distance ignores the element's angle) and
|
|
34
|
+
* continuous. Never negative — dragging through the center just shrinks toward the
|
|
35
|
+
* clamp; the caller clamps the resulting size, this returns the raw ratio (guarded
|
|
36
|
+
* against a degenerate start-at-center gesture, which returns 1).
|
|
37
|
+
*/
|
|
38
|
+
export function resolveCenterResizeScale(input: {
|
|
39
|
+
pointer: { x: number; y: number };
|
|
40
|
+
pointerStart: { x: number; y: number };
|
|
41
|
+
centerStart: { x: number; y: number };
|
|
42
|
+
}): number {
|
|
43
|
+
const startDist = Math.hypot(
|
|
44
|
+
input.pointerStart.x - input.centerStart.x,
|
|
45
|
+
input.pointerStart.y - input.centerStart.y,
|
|
46
|
+
);
|
|
47
|
+
if (!Number.isFinite(startDist) || startDist < DEGENERATE_START_DIST_PX) return 1;
|
|
48
|
+
const nowDist = Math.hypot(
|
|
49
|
+
input.pointer.x - input.centerStart.x,
|
|
50
|
+
input.pointer.y - input.centerStart.y,
|
|
51
|
+
);
|
|
52
|
+
return nowDist / startDist;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The element's new LOCAL size for a center-anchored corner resize: base size
|
|
57
|
+
* scaled by `resolveCenterResizeScale`, clamped so the smaller edge never drops
|
|
58
|
+
* below MIN_RESIZE_LOCAL_PX (clamp small, never mirror through zero). The scale is
|
|
59
|
+
* a dimensionless ratio, so the base local size and the screen-space pointer
|
|
60
|
+
* distances live in different frames without any display-scale conversion — the
|
|
61
|
+
* ratio cancels the scale.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveCenterResizeSize(input: {
|
|
64
|
+
baseWidth: number;
|
|
65
|
+
baseHeight: number;
|
|
66
|
+
pointer: { x: number; y: number };
|
|
67
|
+
pointerStart: { x: number; y: number };
|
|
68
|
+
centerStart: { x: number; y: number };
|
|
69
|
+
}): { width: number; height: number } {
|
|
70
|
+
const baseWidth = Math.max(input.baseWidth, MIN_RESIZE_LOCAL_PX);
|
|
71
|
+
const baseHeight = Math.max(input.baseHeight, MIN_RESIZE_LOCAL_PX);
|
|
72
|
+
const rawScale = resolveCenterResizeScale({
|
|
73
|
+
pointer: input.pointer,
|
|
74
|
+
pointerStart: input.pointerStart,
|
|
75
|
+
centerStart: input.centerStart,
|
|
76
|
+
});
|
|
77
|
+
const minScale = MIN_RESIZE_LOCAL_PX / Math.min(baseWidth, baseHeight);
|
|
78
|
+
const scale = Math.max(minScale, rawScale);
|
|
79
|
+
return { width: baseWidth * scale, height: baseHeight * scale };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The eight CSS resize cursors, rotated with the object. A corner's base pointing
|
|
84
|
+
* direction (the diagonal it lives on) plus the element rotation, bucketed into
|
|
85
|
+
* 45° slots. So a 90°-rotated NW corner reads as a NE-diagonal cursor, etc.
|
|
86
|
+
*/
|
|
87
|
+
const CURSORS_8 = [
|
|
88
|
+
"ns-resize", // 0° (up)
|
|
89
|
+
"nesw-resize", // 45°
|
|
90
|
+
"ew-resize", // 90° (right)
|
|
91
|
+
"nwse-resize", // 135°
|
|
92
|
+
"ns-resize", // 180° (down)
|
|
93
|
+
"nesw-resize", // 225°
|
|
94
|
+
"ew-resize", // 270° (left)
|
|
95
|
+
"nwse-resize", // 315°
|
|
96
|
+
] as const;
|
|
97
|
+
|
|
98
|
+
/** Base outward diagonal angle of each corner, in degrees, screen convention
|
|
99
|
+
* (0° = up, clockwise). NW points up-left = 315°, NE up-right = 45°, etc. */
|
|
100
|
+
const CORNER_BASE_ANGLE_DEG: Record<ResizeHandle, number> = {
|
|
101
|
+
nw: 315,
|
|
102
|
+
ne: 45,
|
|
103
|
+
se: 135,
|
|
104
|
+
sw: 225,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** Resize cursor for a corner handle on an element rotated by `rotationDeg`. */
|
|
108
|
+
export function resolveRotatedResizeCursor(handle: ResizeHandle, rotationDeg: number): string {
|
|
109
|
+
const angle = CORNER_BASE_ANGLE_DEG[handle] + rotationDeg;
|
|
110
|
+
const normalized = ((angle % 360) + 360) % 360;
|
|
111
|
+
const bucket = Math.round(normalized / 45) % 8;
|
|
112
|
+
return CURSORS_8[bucket]!;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Per-frame anchored-resize center accumulator: ADD the residual center correction
|
|
116
|
+
* (fixedStart − fixedNow) onto the previous anchor so the pin CONVERGES instead of
|
|
117
|
+
* oscillating (fa4f39168). Pure; exported for the release-shift characterization tests. */
|
|
118
|
+
export function computeNextResizeAnchor(
|
|
119
|
+
prev: { dx: number; dy: number } | undefined,
|
|
120
|
+
fixedStart: { x: number; y: number },
|
|
121
|
+
fixedNow: { x: number; y: number },
|
|
122
|
+
): { dx: number; dy: number } {
|
|
123
|
+
const base = prev ?? { dx: 0, dy: 0 };
|
|
124
|
+
return { dx: base.dx + (fixedStart.x - fixedNow.x), dy: base.dy + (fixedStart.y - fixedNow.y) };
|
|
125
|
+
}
|
|
@@ -104,7 +104,7 @@ export function findClosestByAttribute(
|
|
|
104
104
|
// mounted root (it keeps `data-composition-id` but drops `data-composition-src`/
|
|
105
105
|
// `-file`), so a subcomp element's DOM ancestors no longer say which file it came
|
|
106
106
|
// from. This project-global map (composition-id → source file, built once from
|
|
107
|
-
// index.html's clips — see
|
|
107
|
+
// index.html's clips — see NLEContext/EditorShell) recovers it. The studio loads one project at a
|
|
108
108
|
// time, so module scope is the right lifetime; it's empty until set, in which case
|
|
109
109
|
// resolution falls back to the historical attribute-only behavior.
|
|
110
110
|
let compositionSourceMap: Map<string, string> = new Map();
|
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
buildMotionPatches,
|
|
50
50
|
buildClearMotionPatches,
|
|
51
51
|
} from "./manualEditsDomPatches";
|
|
52
|
+
import { applyStudioBoxSize, applyStudioPathOffset } from "./manualEditsDom";
|
|
52
53
|
|
|
53
54
|
/* ── helpers ── */
|
|
54
55
|
|
|
@@ -267,6 +268,52 @@ describe("buildBoxSizePatches / buildClearBoxSizePatches", () => {
|
|
|
267
268
|
});
|
|
268
269
|
});
|
|
269
270
|
|
|
271
|
+
/* ── Combined box-size + path-offset (anchored-corner resize) ──────────────── */
|
|
272
|
+
|
|
273
|
+
describe("anchored-corner combined patch: [...buildBoxSizePatches, ...buildPathOffsetPatches]", () => {
|
|
274
|
+
// NW/NE/SW resize commits size AND anchor offset in ONE persist. The two
|
|
275
|
+
// builders read the same already-mutated element and are concatenated; this
|
|
276
|
+
// is only safe if their {type,property} keys are disjoint (no builder
|
|
277
|
+
// overwrites the other's op when the source patcher applies them in order).
|
|
278
|
+
it("concatenation of both builders emits disjoint {type,property} keys (no collision)", () => {
|
|
279
|
+
const e = div();
|
|
280
|
+
applyStudioBoxSize(e, { width: 300, height: 200 });
|
|
281
|
+
applyStudioPathOffset(e, { x: 10, y: 20 });
|
|
282
|
+
|
|
283
|
+
const combined = [...buildBoxSizePatches(e), ...buildPathOffsetPatches(e)];
|
|
284
|
+
const keys = combined.map(opKey);
|
|
285
|
+
expect(new Set(keys).size, `duplicate {type,property} key in combined patch: ${keys}`).toBe(
|
|
286
|
+
keys.length,
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("combined patch carries BOTH markers so a soft-reload re-hydrates size and offset together", () => {
|
|
291
|
+
const e = div();
|
|
292
|
+
applyStudioBoxSize(e, { width: 300, height: 200 });
|
|
293
|
+
applyStudioPathOffset(e, { x: 10, y: 20 });
|
|
294
|
+
|
|
295
|
+
const combined = [...buildBoxSizePatches(e), ...buildPathOffsetPatches(e)];
|
|
296
|
+
const has = (property: string) =>
|
|
297
|
+
combined.some((op) => op.type === "attribute" && op.property === property);
|
|
298
|
+
expect(has(STUDIO_BOX_SIZE_ATTR)).toBe(true);
|
|
299
|
+
expect(has(STUDIO_PATH_OFFSET_ATTR)).toBe(true);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("order is size-first: every box-size op precedes every path-offset op", () => {
|
|
303
|
+
const e = div();
|
|
304
|
+
applyStudioBoxSize(e, { width: 300, height: 200 });
|
|
305
|
+
applyStudioPathOffset(e, { x: 10, y: 20 });
|
|
306
|
+
|
|
307
|
+
const boxKeys = new Set(buildBoxSizePatches(e).map(opKey));
|
|
308
|
+
const combined = [...buildBoxSizePatches(e), ...buildPathOffsetPatches(e)];
|
|
309
|
+
const lastBoxIdx = combined.reduce((acc, op, i) => (boxKeys.has(opKey(op)) ? i : acc), -1);
|
|
310
|
+
const firstOffsetIdx = combined.findIndex(
|
|
311
|
+
(op) => op.type === "attribute" && op.property === STUDIO_PATH_OFFSET_ATTR,
|
|
312
|
+
);
|
|
313
|
+
expect(firstOffsetIdx).toBeGreaterThan(lastBoxIdx);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
270
317
|
/* ── Rotation ────────────────────────────────────────────────────────────── */
|
|
271
318
|
|
|
272
319
|
describe("buildRotationPatches / buildClearRotationPatches", () => {
|
|
@@ -441,22 +441,26 @@ export function applyManualOffsetDragDraft(
|
|
|
441
441
|
return offset;
|
|
442
442
|
}
|
|
443
443
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
)
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
// element flies off-screen the instant you drop it. The member holds the true
|
|
454
|
-
// gesture-start values in JS, immune to the re-render.
|
|
444
|
+
/**
|
|
445
|
+
* Re-stamp the STABLE gesture-start base/offset before the source commit reads
|
|
446
|
+
* them. A mid-gesture re-render can wipe these attrs; the commit converts the
|
|
447
|
+
* drop offset → gsap x/y via computeDraggedGsapPosition, which without the base
|
|
448
|
+
* falls back to the live (already-dragged) transform and re-adds the delta — so
|
|
449
|
+
* the element flies off-screen the instant you drop it. The member holds the
|
|
450
|
+
* true gesture-start values in JS, immune to the re-render.
|
|
451
|
+
*/
|
|
452
|
+
function restampManualOffsetDragGestureBase(member: ManualOffsetDragMember): void {
|
|
455
453
|
member.element.setAttribute("data-hf-drag-gsap-base-x", String(member.baseGsap.x));
|
|
456
454
|
member.element.setAttribute("data-hf-drag-gsap-base-y", String(member.baseGsap.y));
|
|
457
455
|
member.element.setAttribute("data-hf-drag-initial-offset-x", String(member.initialOffset.x));
|
|
458
456
|
member.element.setAttribute("data-hf-drag-initial-offset-y", String(member.initialOffset.y));
|
|
459
|
-
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function applyManualOffsetCommitValue(
|
|
460
|
+
member: ManualOffsetDragMember,
|
|
461
|
+
offset: { x: number; y: number },
|
|
462
|
+
): { x: number; y: number } {
|
|
463
|
+
restampManualOffsetDragGestureBase(member);
|
|
460
464
|
// Optimistic visual through the GSAP channel (same as the live draft and the
|
|
461
465
|
// committed `tl.set`), so the element holds its dropped position until the
|
|
462
466
|
// source mutation soft-reloads — no transient CSS `--hf-studio-offset` write.
|
|
@@ -467,6 +471,45 @@ export function applyManualOffsetDragCommit(
|
|
|
467
471
|
return offset;
|
|
468
472
|
}
|
|
469
473
|
|
|
474
|
+
export function applyManualOffsetDragCommit(
|
|
475
|
+
member: ManualOffsetDragMember,
|
|
476
|
+
dx: number,
|
|
477
|
+
dy: number,
|
|
478
|
+
): { x: number; y: number } {
|
|
479
|
+
return applyManualOffsetCommitValue(member, resolveManualOffsetDragMemberOffset(member, dx, dy));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Arrow-key nudge, in OFFSET units (composition px), not screen px — "nudge
|
|
484
|
+
* 1px" means one composition pixel regardless of canvas zoom, so the delta
|
|
485
|
+
* adds to the gesture-start offset directly instead of going through the
|
|
486
|
+
* screen→offset matrix. Draft/commit land in the same GSAP channel (with the
|
|
487
|
+
* same CSS fallback) as the drag equivalents above.
|
|
488
|
+
*/
|
|
489
|
+
export function applyManualOffsetNudgeDraft(
|
|
490
|
+
member: ManualOffsetDragMember,
|
|
491
|
+
delta: { x: number; y: number },
|
|
492
|
+
): { x: number; y: number } {
|
|
493
|
+
const offset = {
|
|
494
|
+
x: member.initialOffset.x + delta.x,
|
|
495
|
+
y: member.initialOffset.y + delta.y,
|
|
496
|
+
};
|
|
497
|
+
if (!applyOffsetDragDraftViaGsap(member.element, offset, member.baseGsap)) {
|
|
498
|
+
applyStudioPathOffsetDraft(member.element, offset);
|
|
499
|
+
}
|
|
500
|
+
return offset;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export function applyManualOffsetNudgeCommit(
|
|
504
|
+
member: ManualOffsetDragMember,
|
|
505
|
+
delta: { x: number; y: number },
|
|
506
|
+
): { x: number; y: number } {
|
|
507
|
+
return applyManualOffsetCommitValue(member, {
|
|
508
|
+
x: member.initialOffset.x + delta.x,
|
|
509
|
+
y: member.initialOffset.y + delta.y,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
470
513
|
function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
|
|
471
514
|
restoreStudioPathOffset(member.element, member.initialPathOffset);
|
|
472
515
|
endStudioManualEditGesture(member.element, member.gestureToken);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type React from "react";
|
|
2
2
|
import type { OffCanvasRect } from "./OffCanvasIndicators";
|
|
3
3
|
import { hugRectForElement } from "./domEditOverlayCrop";
|
|
4
|
-
import {
|
|
4
|
+
import { orientedGroupAwareOverlayRect } from "./domEditOverlayGeometry";
|
|
5
5
|
import { isElementComputedVisible } from "./domEditingElement";
|
|
6
6
|
import { collectDomEditLayerItems } from "./domEditingLayers";
|
|
7
7
|
|
|
@@ -13,11 +13,30 @@ function offCanvasSignature(rects: OffCanvasRect[]): string {
|
|
|
13
13
|
return rects
|
|
14
14
|
.map(
|
|
15
15
|
(rect) =>
|
|
16
|
-
`${rect.key}:${rounded(rect.left)},${rounded(rect.top)},${rounded(rect.width)},${rounded(rect.height)}`,
|
|
16
|
+
`${rect.key}:${rounded(rect.left)},${rounded(rect.top)},${rounded(rect.width)},${rounded(rect.height)},${rounded(rect.angle ?? 0)}`,
|
|
17
17
|
)
|
|
18
18
|
.join("|");
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
function extendsOutside(
|
|
22
|
+
rect: Omit<OffCanvasRect, "key">,
|
|
23
|
+
comp: { left: number; top: number; width: number; height: number },
|
|
24
|
+
): boolean {
|
|
25
|
+
const radians = ((rect.angle ?? 0) * Math.PI) / 180;
|
|
26
|
+
const halfWidth =
|
|
27
|
+
(Math.abs(Math.cos(radians)) * rect.width + Math.abs(Math.sin(radians)) * rect.height) / 2;
|
|
28
|
+
const halfHeight =
|
|
29
|
+
(Math.abs(Math.sin(radians)) * rect.width + Math.abs(Math.cos(radians)) * rect.height) / 2;
|
|
30
|
+
const centerX = rect.left + rect.width / 2;
|
|
31
|
+
const centerY = rect.top + rect.height / 2;
|
|
32
|
+
return (
|
|
33
|
+
centerX - halfWidth < comp.left ||
|
|
34
|
+
centerX + halfWidth > comp.left + comp.width ||
|
|
35
|
+
centerY - halfHeight < comp.top ||
|
|
36
|
+
centerY + halfHeight > comp.top + comp.height
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
21
40
|
// fallow-ignore-next-line complexity
|
|
22
41
|
export function recomputeOffCanvasIndicators(
|
|
23
42
|
iframe: HTMLIFrameElement,
|
|
@@ -50,18 +69,20 @@ export function recomputeOffCanvasIndicators(
|
|
|
50
69
|
// whose members sit inside the canvas isn't flagged off-canvas by a stale
|
|
51
70
|
// wrapper box. Crop-hug the result so an inset crop that keeps the visible
|
|
52
71
|
// part on-canvas doesn't flag the element either.
|
|
53
|
-
const base =
|
|
72
|
+
const base = orientedGroupAwareOverlayRect(overlay, iframe, item.element);
|
|
54
73
|
const r = base ? { ...base, ...hugRectForElement(base, item.element) } : null;
|
|
55
74
|
if (!r) continue;
|
|
56
75
|
// Any edge crossing the composition border → gray-zone indicator (the
|
|
57
76
|
// in-canvas portion is clipped away below, so only the sliver shows).
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
77
|
+
if (extendsOutside(r, comp)) {
|
|
78
|
+
rects.push({
|
|
79
|
+
key: item.key,
|
|
80
|
+
left: r.left,
|
|
81
|
+
top: r.top,
|
|
82
|
+
width: r.width,
|
|
83
|
+
height: r.height,
|
|
84
|
+
angle: r.angle,
|
|
85
|
+
});
|
|
65
86
|
elMap.set(item.key, item.element);
|
|
66
87
|
}
|
|
67
88
|
}
|
|
@@ -2,11 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
-
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
6
6
|
import { DomEditOverlay } from "./DomEditOverlay";
|
|
7
7
|
|
|
8
8
|
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
9
9
|
|
|
10
|
+
// happy-dom (20.x) holds each MutationObserver's delivery callback ONLY via a
|
|
11
|
+
// WeakRef (MutationObserverListener: `callback: new WeakRef(...)` — the arrow
|
|
12
|
+
// has no strong referent). If V8 runs a GC between observe() and a mutation,
|
|
13
|
+
// deref() returns undefined and mutation delivery silently stops — the
|
|
14
|
+
// indicator-refresh loop never sees its dirty flag and these tests flake under
|
|
15
|
+
// full-suite memory pressure (passing in isolation). Pin WeakRef to a strong
|
|
16
|
+
// ref for this file so the real observer path stays deterministic.
|
|
17
|
+
const RealWeakRef = globalThis.WeakRef;
|
|
18
|
+
class StrongRef<T extends WeakKey> {
|
|
19
|
+
#value: T;
|
|
20
|
+
constructor(value: T) {
|
|
21
|
+
this.#value = value;
|
|
22
|
+
}
|
|
23
|
+
deref(): T {
|
|
24
|
+
return this.#value;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
beforeAll(() => {
|
|
28
|
+
(globalThis as { WeakRef: unknown }).WeakRef = StrongRef;
|
|
29
|
+
});
|
|
30
|
+
afterAll(() => {
|
|
31
|
+
globalThis.WeakRef = RealWeakRef;
|
|
32
|
+
});
|
|
33
|
+
|
|
10
34
|
const INDICATOR = '[aria-label="Select off-canvas element index.html:headline:0"]';
|
|
11
35
|
|
|
12
36
|
function domRect(left: number, top: number, width: number, height: number): DOMRect {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { GestureState } from "./domEditOverlayGestures";
|
|
2
|
+
import { resolveResizeCenterAnchorOffset } from "./domEditOverlayGestures";
|
|
3
|
+
import type { OverlayRect } from "./domEditOverlayGeometry";
|
|
4
|
+
import {
|
|
5
|
+
cornerEdgeLength,
|
|
6
|
+
elementCornerOverlayPoints,
|
|
7
|
+
overlayCornersCentroid,
|
|
8
|
+
} from "./domEditOverlayGeometry";
|
|
9
|
+
import { computeNextResizeAnchor } from "./domEditResizeLocal";
|
|
10
|
+
import { applyManualOffsetDragDraft } from "./manualOffsetDrag";
|
|
11
|
+
|
|
12
|
+
type Corners = ReturnType<typeof elementCornerOverlayPoints>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The residual center-pin offset for this frame. With measurable corners and a
|
|
16
|
+
* fixed-center start, accumulate `fixedStart - centerNow` onto the previous
|
|
17
|
+
* anchor so it CONVERGES rather than oscillating: `applyManualOffsetDragDraft`
|
|
18
|
+
* treats its argument as the absolute offset, and `centerNow` (measured on the
|
|
19
|
+
* live element) already carries the previous frame's offset, so the difference
|
|
20
|
+
* is only the residual correction. Using it absolutely would drop the offset
|
|
21
|
+
* every other frame and un-pin the center (fa4f39168). Memberless/unmeasurable
|
|
22
|
+
* geometry falls back to the AABB half-delta.
|
|
23
|
+
*/
|
|
24
|
+
function resolveResizeAnchor(
|
|
25
|
+
g: GestureState,
|
|
26
|
+
corners: Corners | null,
|
|
27
|
+
measureOrientedRect: () => OverlayRect | null,
|
|
28
|
+
): { dx: number; dy: number } {
|
|
29
|
+
const fixedStart = g.resizeFixedCenterStart;
|
|
30
|
+
if (corners && fixedStart) {
|
|
31
|
+
return computeNextResizeAnchor(g.lastResizeAnchor, fixedStart, overlayCornersCentroid(corners));
|
|
32
|
+
}
|
|
33
|
+
const fallbackRect = measureOrientedRect();
|
|
34
|
+
return resolveResizeCenterAnchorOffset({
|
|
35
|
+
originWidth: g.originWidth,
|
|
36
|
+
originHeight: g.originHeight,
|
|
37
|
+
overlayWidth: fallbackRect ? fallbackRect.width : g.originWidth,
|
|
38
|
+
overlayHeight: fallbackRect ? fallbackRect.height : g.originHeight,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Center-pinned draft rect: translate the element through the manual-offset
|
|
44
|
+
* channel to keep its gesture-start center planted (rotation-safe for any
|
|
45
|
+
* transform-origin), then hug its true rendered bounds. Mutates
|
|
46
|
+
* `g.lastResizeAnchor` and applies the offset draft as a side effect.
|
|
47
|
+
*/
|
|
48
|
+
function resolveAnchoredResizeDraft(
|
|
49
|
+
g: GestureState,
|
|
50
|
+
member: NonNullable<GestureState["pathOffsetMember"]>,
|
|
51
|
+
element: HTMLElement,
|
|
52
|
+
overlayEl: HTMLDivElement | null,
|
|
53
|
+
iframe: HTMLIFrameElement | null,
|
|
54
|
+
measureOrientedRect: () => OverlayRect | null,
|
|
55
|
+
): OverlayRect {
|
|
56
|
+
// Measure real corners ONCE — reused for the anchor and the fallback size.
|
|
57
|
+
const corners =
|
|
58
|
+
overlayEl && iframe ? elementCornerOverlayPoints(overlayEl, iframe, element) : null;
|
|
59
|
+
const anchor = resolveResizeAnchor(g, corners, measureOrientedRect);
|
|
60
|
+
g.lastResizeAnchor = anchor;
|
|
61
|
+
applyManualOffsetDragDraft(member, anchor.dx, anchor.dy);
|
|
62
|
+
// Re-measure AFTER the anchor translate so it hugs the element every frame.
|
|
63
|
+
return (
|
|
64
|
+
measureOrientedRect() ?? {
|
|
65
|
+
left: g.originLeft + anchor.dx,
|
|
66
|
+
top: g.originTop + anchor.dy,
|
|
67
|
+
width: corners ? cornerEdgeLength(corners.nw, corners.ne) : g.originWidth,
|
|
68
|
+
height: corners ? cornerEdgeLength(corners.nw, corners.sw) : g.originHeight,
|
|
69
|
+
editScaleX: g.editScaleX,
|
|
70
|
+
editScaleY: g.editScaleY,
|
|
71
|
+
angle: g.actualRotation,
|
|
72
|
+
}
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The overlay rect to paint for the current resize pointer-move frame. */
|
|
77
|
+
export function resolveResizeDraftRect(
|
|
78
|
+
g: GestureState,
|
|
79
|
+
element: HTMLElement,
|
|
80
|
+
overlayEl: HTMLDivElement | null,
|
|
81
|
+
iframe: HTMLIFrameElement | null,
|
|
82
|
+
measureOrientedRect: () => OverlayRect | null,
|
|
83
|
+
): OverlayRect {
|
|
84
|
+
if (g.pathOffsetMember) {
|
|
85
|
+
return resolveAnchoredResizeDraft(
|
|
86
|
+
g,
|
|
87
|
+
g.pathOffsetMember,
|
|
88
|
+
element,
|
|
89
|
+
overlayEl,
|
|
90
|
+
iframe,
|
|
91
|
+
measureOrientedRect,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
// Re-measure the element's oriented box AFTER the size write. The size draft
|
|
95
|
+
// rounds/clamps and (with a centered transform-origin + GSAP scale) the real
|
|
96
|
+
// rendered size diverges from the CSS size, so measure rather than trust math.
|
|
97
|
+
return (
|
|
98
|
+
measureOrientedRect() ?? {
|
|
99
|
+
left: g.originLeft,
|
|
100
|
+
top: g.originTop,
|
|
101
|
+
width: g.originWidth,
|
|
102
|
+
height: g.originHeight,
|
|
103
|
+
editScaleX: g.editScaleX,
|
|
104
|
+
editScaleY: g.editScaleY,
|
|
105
|
+
angle: g.actualRotation,
|
|
106
|
+
}
|
|
107
|
+
);
|
|
108
|
+
}
|