@hyperframes/studio 0.7.55 → 0.7.57
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--Z69cEkE.js +459 -0
- package/dist/assets/{index-CPetwHFV.js → index-Bf1x1y8H.js} +1 -1
- package/dist/assets/index-DfmYkU44.js +423 -0
- package/dist/assets/{index-BXaqaVKt.js → index-KsfE1bUu.js} +1 -1
- package/dist/assets/index-_pqzyxB1.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 +172 -144
- package/dist/index.html +2 -2
- package/dist/index.js +43613 -40346
- 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 +152 -1
- package/src/components/editor/CanvasContextMenu.tsx +33 -18
- 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 +126 -125
- 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 +53 -2
- 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 +106 -10
- package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
- 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/propertyPanelColorGradingSection.tsx +15 -21
- 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/useCanvasContextMenuState.ts +92 -0
- 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/editor/useZOrderCrossedFlash.tsx +68 -0
- package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
- package/src/components/nle/AssetPreviewOverlay.tsx +171 -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 +260 -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.test.tsx +106 -0
- package/src/components/sidebar/AssetCard.tsx +343 -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 +56 -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 +23 -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 +125 -1
- package/src/hooks/timelineEditingHelpers.ts +53 -292
- package/src/hooks/timelineMoveAdapter.test.ts +81 -0
- package/src/hooks/timelineMoveAdapter.ts +39 -0
- package/src/hooks/timelineTimingSync.test.ts +150 -0
- package/src/hooks/timelineTimingSync.ts +412 -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/useCaptionDetection.ts +2 -0
- package/src/hooks/useCompositionDimensions.ts +32 -5
- package/src/hooks/useContextMenuDismiss.ts +31 -7
- package/src/hooks/useDomEditCommits.test.tsx +245 -1
- package/src/hooks/useDomEditCommits.ts +162 -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 +423 -0
- package/src/hooks/useElementLifecycleOps.ts +53 -71
- package/src/hooks/useElementPicker.ts +7 -8
- 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 +148 -18
- 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/useTimelineAssetDropOps.ts +175 -0
- package/src/hooks/useTimelineEditing.test.tsx +398 -241
- package/src/hooks/useTimelineEditing.ts +181 -265
- package/src/hooks/useTimelineEditingTypes.ts +6 -0
- package/src/hooks/useTimelineGroupEditing.ts +167 -132
- 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/PlayerControls.tsx +5 -5
- package/src/player/components/PlayheadIndicator.tsx +55 -5
- package/src/player/components/Timeline.test.ts +183 -23
- package/src/player/components/Timeline.tsx +194 -194
- package/src/player/components/TimelineCanvas.tsx +218 -523
- 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/TimelineRuler.tsx +58 -48
- 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 +1026 -0
- package/src/player/components/timelineClipDragCommit.ts +498 -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 +42 -2
- 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 +207 -75
- package/src/player/components/timelineMarquee.test.ts +197 -0
- package/src/player/components/timelineMarquee.ts +112 -0
- package/src/player/components/timelineOptimisticRevision.ts +42 -0
- package/src/player/components/timelineRevealScroll.test.ts +133 -0
- package/src/player/components/timelineRevealScroll.ts +91 -0
- package/src/player/components/timelineStackingSync.test.ts +60 -0
- package/src/player/components/timelineStackingSync.ts +27 -2
- package/src/player/components/timelineTheme.ts +6 -1
- package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
- package/src/player/components/timelineZones.test.ts +122 -310
- package/src/player/components/timelineZones.ts +82 -135
- 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/useTimelineRevealClip.ts +56 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +77 -0
- package/src/player/components/useTimelineStackingSync.ts +94 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -3
- package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
- package/src/player/hooks/useTimelinePlayer.ts +52 -53
- package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/index.ts +5 -2
- package/src/player/lib/runtimeProtocol.test.ts +48 -0
- package/src/player/lib/runtimeProtocol.ts +65 -0
- 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 +21 -81
- package/src/player/lib/timelineElementHelpers.ts +73 -5
- package/src/player/lib/timelineIframeHelpers.ts +4 -4
- package/src/player/store/playerStore.test.ts +38 -13
- package/src/player/store/playerStore.ts +99 -3
- package/src/styles/studio.css +27 -0
- package/src/utils/assetPreviewDismiss.test.ts +27 -0
- package/src/utils/assetPreviewDismiss.ts +29 -0
- package/src/utils/assetPreviewStore.ts +33 -0
- package/src/utils/blockInstaller.ts +10 -2
- 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/studioUrlState.test.ts +0 -1
- package/src/utils/timelineAssetDrop.test.ts +144 -52
- package/src/utils/timelineAssetDrop.ts +67 -29
- package/src/utils/timelineDiscovery.ts +0 -17
- package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
- package/dist/assets/index-BRwkMj0w.js +0 -423
- package/dist/assets/index-Dq7FEg0K.css +0 -1
- 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,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
|
+
}
|
|
@@ -5,8 +5,8 @@ import {
|
|
|
5
5
|
buildCompositionSnapTarget,
|
|
6
6
|
buildGridSnapEdges,
|
|
7
7
|
resolveSnapAdjustment,
|
|
8
|
-
resolveResizeSnapAdjustment,
|
|
9
8
|
resolveEquidistanceGuides,
|
|
9
|
+
resolveGuideLineRect,
|
|
10
10
|
SNAP_THRESHOLD_PX,
|
|
11
11
|
type SnapTarget,
|
|
12
12
|
} from "./snapEngine";
|
|
@@ -385,88 +385,22 @@ describe("resolveSnapAdjustment", () => {
|
|
|
385
385
|
});
|
|
386
386
|
|
|
387
387
|
// ---------------------------------------------------------------------------
|
|
388
|
-
//
|
|
388
|
+
// resolveGuideLineRect
|
|
389
389
|
// ---------------------------------------------------------------------------
|
|
390
390
|
|
|
391
|
-
describe("
|
|
392
|
-
|
|
393
|
-
// Moving rect at (100, 100) size 50x50, right=150.
|
|
394
|
-
// Target left at 200. Propose dx=47 => proposed right=197. Dist to 200=3.
|
|
395
|
-
const t = target("a", 200, 100, 100, 100);
|
|
396
|
-
const result = resolveResizeSnapAdjustment({
|
|
397
|
-
movingRect: rect(100, 100, 50, 50),
|
|
398
|
-
proposedDx: 47,
|
|
399
|
-
proposedDy: 0,
|
|
400
|
-
targets: [t],
|
|
401
|
-
threshold: SNAP_THRESHOLD_PX,
|
|
402
|
-
disabled: false,
|
|
403
|
-
});
|
|
404
|
-
expect(result.dx).toBe(50); // right edge snaps to 200
|
|
405
|
-
});
|
|
391
|
+
describe("resolveGuideLineRect", () => {
|
|
392
|
+
const composition = rect(120, 80, 640, 360); // letterboxed inside the overlay
|
|
406
393
|
|
|
407
|
-
test("
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const result = resolveResizeSnapAdjustment({
|
|
412
|
-
movingRect: rect(100, 100, 50, 50),
|
|
413
|
-
proposedDx: 0,
|
|
414
|
-
proposedDy: 47,
|
|
415
|
-
targets: [t],
|
|
416
|
-
threshold: SNAP_THRESHOLD_PX,
|
|
417
|
-
disabled: false,
|
|
418
|
-
});
|
|
419
|
-
expect(result.dy).toBe(50); // bottom edge snaps to 200
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
test("left edge does NOT snap during resize", () => {
|
|
423
|
-
// Target right at 150. Moving rect left=100. If drag were active,
|
|
424
|
-
// left would snap. But during resize, only right edge snaps.
|
|
425
|
-
// Moving rect at (100, 100) size 200x200, right=300.
|
|
426
|
-
// Target right=150. Proposed dx=-153 => proposed right=147. Dist to 150=3.
|
|
427
|
-
// This SHOULD snap right to 150 (dx = -150). But left stays at 100.
|
|
428
|
-
const t = target("a", 50, 100, 100, 100); // right=150
|
|
429
|
-
const result = resolveResizeSnapAdjustment({
|
|
430
|
-
movingRect: rect(100, 100, 200, 200),
|
|
431
|
-
proposedDx: -153,
|
|
432
|
-
proposedDy: 0,
|
|
433
|
-
targets: [t],
|
|
434
|
-
threshold: SNAP_THRESHOLD_PX,
|
|
435
|
-
disabled: false,
|
|
436
|
-
});
|
|
437
|
-
// Right edge: 300 + (-153) = 147 => snaps to 150, adjustment = +3, dx = -150
|
|
438
|
-
expect(result.dx).toBe(-150);
|
|
439
|
-
});
|
|
440
|
-
|
|
441
|
-
test("disabled=true returns passthrough for resize", () => {
|
|
442
|
-
const t = target("a", 200, 200, 100, 100);
|
|
443
|
-
const result = resolveResizeSnapAdjustment({
|
|
444
|
-
movingRect: rect(100, 100, 50, 50),
|
|
445
|
-
proposedDx: 47,
|
|
446
|
-
proposedDy: 47,
|
|
447
|
-
targets: [t],
|
|
448
|
-
threshold: SNAP_THRESHOLD_PX,
|
|
449
|
-
disabled: true,
|
|
450
|
-
});
|
|
451
|
-
expect(result.dx).toBe(47);
|
|
452
|
-
expect(result.dy).toBe(47);
|
|
453
|
-
expect(result.guides).toHaveLength(0);
|
|
394
|
+
test("vertical guide (axis x) spans the composition's height at the snap x", () => {
|
|
395
|
+
expect(resolveGuideLineRect({ axis: "x", position: 440, from: 0, to: 0 }, composition)).toEqual(
|
|
396
|
+
{ left: 440, top: 80, width: 1, height: 360 },
|
|
397
|
+
);
|
|
454
398
|
});
|
|
455
399
|
|
|
456
|
-
test("
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
proposedDx: 47,
|
|
461
|
-
proposedDy: 0,
|
|
462
|
-
targets: [t],
|
|
463
|
-
threshold: SNAP_THRESHOLD_PX,
|
|
464
|
-
disabled: false,
|
|
465
|
-
});
|
|
466
|
-
expect(result.guides.length).toBeGreaterThanOrEqual(1);
|
|
467
|
-
const xGuide = result.guides.find((g) => g.axis === "x");
|
|
468
|
-
expect(xGuide).toBeDefined();
|
|
469
|
-
expect(xGuide!.position).toBe(200);
|
|
400
|
+
test("horizontal guide (axis y) spans the composition's width at the snap y", () => {
|
|
401
|
+
expect(resolveGuideLineRect({ axis: "y", position: 260, from: 0, to: 0 }, composition)).toEqual(
|
|
402
|
+
{ left: 120, top: 260, width: 640, height: 1 },
|
|
403
|
+
);
|
|
470
404
|
});
|
|
471
405
|
});
|
|
472
406
|
|
|
@@ -410,62 +410,22 @@ export function resolveSnapAdjustment(input: {
|
|
|
410
410
|
}
|
|
411
411
|
|
|
412
412
|
// ---------------------------------------------------------------------------
|
|
413
|
-
//
|
|
413
|
+
// resolveGuideLineRect — screen rect for rendering a snap guide line
|
|
414
414
|
// ---------------------------------------------------------------------------
|
|
415
415
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
return DISABLED_RESULT(input.proposedDx, input.proposedDy);
|
|
416
|
+
/**
|
|
417
|
+
* Full-length guide line spanning the composition: a vertical line (axis "x")
|
|
418
|
+
* runs the composition's height at the snapped x position; a horizontal line
|
|
419
|
+
* (axis "y") runs the composition's width. `composition` is the composition
|
|
420
|
+
* rect in overlay space — guide positions are already overlay-space, so the
|
|
421
|
+
* line must be offset by the composition's left/top (the canvas is usually
|
|
422
|
+
* letterboxed inside the overlay).
|
|
423
|
+
*/
|
|
424
|
+
export function resolveGuideLineRect(guide: SnapGuide, composition: Rect): Rect {
|
|
425
|
+
if (guide.axis === "x") {
|
|
426
|
+
return { left: guide.position, top: composition.top, width: 1, height: composition.height };
|
|
428
427
|
}
|
|
429
|
-
|
|
430
|
-
const mr = input.movingRect;
|
|
431
|
-
const proposedRight = rectRight(mr) + input.proposedDx;
|
|
432
|
-
const proposedBottom = rectBottom(mr) + input.proposedDy;
|
|
433
|
-
|
|
434
|
-
const xCandidates = collectCandidates(
|
|
435
|
-
[proposedRight],
|
|
436
|
-
input.targets,
|
|
437
|
-
(t) => [t.left, t.centerX, t.right],
|
|
438
|
-
input.gridEdges?.x,
|
|
439
|
-
input.threshold,
|
|
440
|
-
);
|
|
441
|
-
const yCandidates = collectCandidates(
|
|
442
|
-
[proposedBottom],
|
|
443
|
-
input.targets,
|
|
444
|
-
(t) => [t.top, t.centerY, t.bottom],
|
|
445
|
-
input.gridEdges?.y,
|
|
446
|
-
input.threshold,
|
|
447
|
-
);
|
|
448
|
-
|
|
449
|
-
const bestX = pickBest(xCandidates);
|
|
450
|
-
const bestY = pickBest(yCandidates);
|
|
451
|
-
const adjustedDx = input.proposedDx + (bestX?.adjustment ?? 0);
|
|
452
|
-
const adjustedDy = input.proposedDy + (bestY?.adjustment ?? 0);
|
|
453
|
-
|
|
454
|
-
const adjustedRect: Rect = {
|
|
455
|
-
left: mr.left,
|
|
456
|
-
top: mr.top,
|
|
457
|
-
width: mr.width + adjustedDx,
|
|
458
|
-
height: mr.height + adjustedDy,
|
|
459
|
-
};
|
|
460
|
-
|
|
461
|
-
const targetMap = new Map(input.targets.map((t) => [t.id, t]));
|
|
462
|
-
|
|
463
|
-
return {
|
|
464
|
-
dx: adjustedDx,
|
|
465
|
-
dy: adjustedDy,
|
|
466
|
-
guides: buildGuidesFromMatches(bestX, bestY, adjustedRect, targetMap),
|
|
467
|
-
spacingGuides: [], // computed separately via resolveEquidistanceGuides
|
|
468
|
-
};
|
|
428
|
+
return { left: composition.left, top: guide.position, width: composition.width, height: 1 };
|
|
469
429
|
}
|
|
470
430
|
|
|
471
431
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas right-click context-menu state for DomEditOverlay: where the menu is
|
|
3
|
+
* open (viewport x/y) and which selection it targets, plus the right-click
|
|
4
|
+
* handler that resolves/selects the element under the pointer before opening.
|
|
5
|
+
*/
|
|
6
|
+
import { useCallback, useEffect, useState, type RefObject } from "react";
|
|
7
|
+
import type { DomEditSelection } from "./domEditing";
|
|
8
|
+
|
|
9
|
+
export interface CanvasContextMenuState {
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
sel: DomEditSelection;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface UseCanvasContextMenuStateParams {
|
|
16
|
+
selection: DomEditSelection | null;
|
|
17
|
+
selectionRef: RefObject<DomEditSelection | null>;
|
|
18
|
+
hoverSelectionRef: RefObject<DomEditSelection | null>;
|
|
19
|
+
onCanvasPointerMoveRef: RefObject<
|
|
20
|
+
(
|
|
21
|
+
event: React.PointerEvent<HTMLDivElement>,
|
|
22
|
+
options?: { preferClipAncestor?: boolean },
|
|
23
|
+
) => Promise<DomEditSelection | null>
|
|
24
|
+
>;
|
|
25
|
+
onSelectionChangeRef: RefObject<
|
|
26
|
+
(selection: DomEditSelection, options?: { revealPanel?: boolean; additive?: boolean }) => void
|
|
27
|
+
>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useCanvasContextMenuState({
|
|
31
|
+
selection,
|
|
32
|
+
selectionRef,
|
|
33
|
+
hoverSelectionRef,
|
|
34
|
+
onCanvasPointerMoveRef,
|
|
35
|
+
onSelectionChangeRef,
|
|
36
|
+
}: UseCanvasContextMenuStateParams): {
|
|
37
|
+
contextMenu: CanvasContextMenuState | null;
|
|
38
|
+
closeContextMenu: () => void;
|
|
39
|
+
handleContextMenu: (event: React.MouseEvent<HTMLDivElement>) => Promise<void>;
|
|
40
|
+
} {
|
|
41
|
+
// Context menu state: position of the right-click that opened it.
|
|
42
|
+
// contextMenu.sel is the element the menu targets — captured at right-click
|
|
43
|
+
// time so the menu can open even before the React selection state settles.
|
|
44
|
+
const [contextMenu, setContextMenu] = useState<CanvasContextMenuState | null>(null);
|
|
45
|
+
const closeContextMenu = useCallback(() => setContextMenu(null), []);
|
|
46
|
+
|
|
47
|
+
// Close the context menu whenever the selection moves off the element the menu
|
|
48
|
+
// targets (a click that reselects elsewhere, a deselect, or a preview reload
|
|
49
|
+
// that rebuilds the selection). Without this the menu can linger — orphaned —
|
|
50
|
+
// over a stale target after the underlying element is gone. A right-click that
|
|
51
|
+
// OPENS the menu also selects its target, so the common open path keeps the
|
|
52
|
+
// menu (same element) rather than immediately dismissing it.
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!contextMenu) return;
|
|
55
|
+
if (!selection || selection.element !== contextMenu.sel.element) {
|
|
56
|
+
setContextMenu(null);
|
|
57
|
+
}
|
|
58
|
+
}, [selection, contextMenu]);
|
|
59
|
+
|
|
60
|
+
// Right-click: select element first (if not already selected), then open menu.
|
|
61
|
+
const handleContextMenu = useCallback(
|
|
62
|
+
async (event: React.MouseEvent<HTMLDivElement>) => {
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
|
|
65
|
+
// If no element is selected yet, resolve it from the pointer position first.
|
|
66
|
+
const currentSel = selectionRef.current;
|
|
67
|
+
let activeSel: DomEditSelection | null = currentSel;
|
|
68
|
+
if (!currentSel) {
|
|
69
|
+
const pointerEvent = event as unknown as React.PointerEvent<HTMLDivElement>;
|
|
70
|
+
const resolved = await onCanvasPointerMoveRef.current(pointerEvent);
|
|
71
|
+
if (!resolved) return; // Nothing under the cursor — skip menu.
|
|
72
|
+
onSelectionChangeRef.current(resolved, { revealPanel: true });
|
|
73
|
+
// Use `resolved` directly: React state (and therefore selectionRef) won't
|
|
74
|
+
// update synchronously after onSelectionChange — we'd be reading stale null.
|
|
75
|
+
activeSel = resolved;
|
|
76
|
+
} else {
|
|
77
|
+
// Check if the user right-clicked on an unselected element (hover target).
|
|
78
|
+
const hover = hoverSelectionRef.current;
|
|
79
|
+
if (hover && hover.element !== currentSel.element) {
|
|
80
|
+
onSelectionChangeRef.current(hover, { revealPanel: true });
|
|
81
|
+
activeSel = hover;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!activeSel) return;
|
|
86
|
+
setContextMenu({ x: event.clientX, y: event.clientY, sel: activeSel });
|
|
87
|
+
},
|
|
88
|
+
[selectionRef, hoverSelectionRef, onCanvasPointerMoveRef, onSelectionChangeRef],
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return { contextMenu, closeContextMenu, handleContextMenu };
|
|
92
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React, { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { installReactActEnvironment, makeSelection } from "../../hooks/domSelectionTestHarness";
|
|
6
|
+
import { useDomEditNudge, type UseDomEditNudgeParams } from "./useDomEditNudge";
|
|
7
|
+
import { CANVAS_NUDGE_COMMIT_DEBOUNCE_MS, CANVAS_NUDGE_STEP_PX } from "./domEditNudge";
|
|
8
|
+
import { __resetForTests } from "../../utils/canvasNudgeGate";
|
|
9
|
+
import type { DomEditSelection } from "./domEditing";
|
|
10
|
+
import type { OverlayRect } from "./domEditOverlayGeometry";
|
|
11
|
+
|
|
12
|
+
installReactActEnvironment();
|
|
13
|
+
|
|
14
|
+
function makeRef<T>(current: T): { current: T } {
|
|
15
|
+
return { current };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const REST_RECT: OverlayRect = {
|
|
19
|
+
left: 0,
|
|
20
|
+
top: 0,
|
|
21
|
+
width: 100,
|
|
22
|
+
height: 50,
|
|
23
|
+
editScaleX: 1,
|
|
24
|
+
editScaleY: 1,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Stable across renders on purpose: the test targets the `selection` identity
|
|
28
|
+
// key specifically, so `groupSelections` must not itself be a source of churn.
|
|
29
|
+
const EMPTY_GROUP_SELECTIONS: DomEditSelection[] = [];
|
|
30
|
+
|
|
31
|
+
function Harness({
|
|
32
|
+
selection,
|
|
33
|
+
onPathOffsetCommit,
|
|
34
|
+
}: {
|
|
35
|
+
selection: DomEditSelection | null;
|
|
36
|
+
onPathOffsetCommit: UseDomEditNudgeParams["onPathOffsetCommitRef"]["current"];
|
|
37
|
+
}) {
|
|
38
|
+
useDomEditNudge({
|
|
39
|
+
selection,
|
|
40
|
+
groupSelections: EMPTY_GROUP_SELECTIONS,
|
|
41
|
+
allowCanvasMovement: true,
|
|
42
|
+
selectionRef: makeRef(selection),
|
|
43
|
+
overlayRectRef: makeRef(REST_RECT),
|
|
44
|
+
groupOverlayItemsRef: makeRef([]),
|
|
45
|
+
gestureRef: makeRef(null),
|
|
46
|
+
groupGestureRef: makeRef(null),
|
|
47
|
+
blockedMoveRef: makeRef(null),
|
|
48
|
+
onManualDragStartRef: makeRef(() => {}),
|
|
49
|
+
onPathOffsetCommitRef: makeRef(onPathOffsetCommit),
|
|
50
|
+
onGroupPathOffsetCommitRef: makeRef(async () => {}),
|
|
51
|
+
});
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function dispatchArrowRight(): void {
|
|
56
|
+
window.dispatchEvent(
|
|
57
|
+
new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("useDomEditNudge — selection cleanup keyed on stable identity", () => {
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
vi.useFakeTimers();
|
|
64
|
+
__resetForTests();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
vi.useRealTimers();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("keeps a nudge burst alive when the parent hands down a new selection object for the same element", () => {
|
|
72
|
+
const host = document.createElement("div");
|
|
73
|
+
document.body.append(host);
|
|
74
|
+
const root = createRoot(host);
|
|
75
|
+
|
|
76
|
+
const element = document.createElement("div");
|
|
77
|
+
element.id = "dot-a";
|
|
78
|
+
document.body.append(element);
|
|
79
|
+
|
|
80
|
+
const commit = vi.fn();
|
|
81
|
+
const firstSelection = makeSelection("Dot", element);
|
|
82
|
+
|
|
83
|
+
act(() => {
|
|
84
|
+
root.render(
|
|
85
|
+
React.createElement(Harness, { selection: firstSelection, onPathOffsetCommit: commit }),
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
act(() => {
|
|
90
|
+
dispatchArrowRight();
|
|
91
|
+
});
|
|
92
|
+
expect(commit).not.toHaveBeenCalled();
|
|
93
|
+
|
|
94
|
+
// Re-render with a BRAND NEW selection object describing the SAME element
|
|
95
|
+
// (same id) — exactly what an un-memoized parent does on every render.
|
|
96
|
+
// Before the fix, the cleanup effect was keyed on this object's identity
|
|
97
|
+
// and would flush the burst right here, one arrow-press early.
|
|
98
|
+
const secondSelection = makeSelection("Dot", element);
|
|
99
|
+
act(() => {
|
|
100
|
+
root.render(
|
|
101
|
+
React.createElement(Harness, { selection: secondSelection, onPathOffsetCommit: commit }),
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
expect(commit).not.toHaveBeenCalled();
|
|
105
|
+
|
|
106
|
+
act(() => {
|
|
107
|
+
dispatchArrowRight();
|
|
108
|
+
});
|
|
109
|
+
expect(commit).not.toHaveBeenCalled();
|
|
110
|
+
|
|
111
|
+
act(() => {
|
|
112
|
+
vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// One combined commit for both presses, not two separate (premature) ones.
|
|
116
|
+
expect(commit).toHaveBeenCalledTimes(1);
|
|
117
|
+
const [, next] = commit.mock.calls[0] as [DomEditSelection, { x: number; y: number }];
|
|
118
|
+
expect(next.x).toBeCloseTo(2 * CANVAS_NUDGE_STEP_PX);
|
|
119
|
+
expect(next.y).toBeCloseTo(0);
|
|
120
|
+
|
|
121
|
+
act(() => root.unmount());
|
|
122
|
+
host.remove();
|
|
123
|
+
element.remove();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("still flushes the burst when the selection actually changes to a different element", () => {
|
|
127
|
+
const host = document.createElement("div");
|
|
128
|
+
document.body.append(host);
|
|
129
|
+
const root = createRoot(host);
|
|
130
|
+
|
|
131
|
+
const elementA = document.createElement("div");
|
|
132
|
+
elementA.id = "dot-a";
|
|
133
|
+
document.body.append(elementA);
|
|
134
|
+
const elementB = document.createElement("div");
|
|
135
|
+
elementB.id = "dot-b";
|
|
136
|
+
document.body.append(elementB);
|
|
137
|
+
|
|
138
|
+
const commit = vi.fn();
|
|
139
|
+
const selectionA = makeSelection("Dot A", elementA);
|
|
140
|
+
const selectionB = makeSelection("Dot B", elementB);
|
|
141
|
+
|
|
142
|
+
act(() => {
|
|
143
|
+
root.render(
|
|
144
|
+
React.createElement(Harness, { selection: selectionA, onPathOffsetCommit: commit }),
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
act(() => {
|
|
149
|
+
dispatchArrowRight();
|
|
150
|
+
});
|
|
151
|
+
expect(commit).not.toHaveBeenCalled();
|
|
152
|
+
|
|
153
|
+
// A genuine selection change (different id) must still flush immediately —
|
|
154
|
+
// only same-identity re-renders should be ignored.
|
|
155
|
+
act(() => {
|
|
156
|
+
root.render(
|
|
157
|
+
React.createElement(Harness, { selection: selectionB, onPathOffsetCommit: commit }),
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
expect(commit).toHaveBeenCalledTimes(1);
|
|
161
|
+
const [committedSelection, next] = commit.mock.calls[0] as [
|
|
162
|
+
DomEditSelection,
|
|
163
|
+
{ x: number; y: number },
|
|
164
|
+
];
|
|
165
|
+
expect(committedSelection.element).toBe(elementA);
|
|
166
|
+
expect(next.x).toBeCloseTo(CANVAS_NUDGE_STEP_PX);
|
|
167
|
+
|
|
168
|
+
act(() => root.unmount());
|
|
169
|
+
host.remove();
|
|
170
|
+
elementA.remove();
|
|
171
|
+
elementB.remove();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("flushes the burst when switching between two id-less siblings that share a selector", () => {
|
|
175
|
+
const host = document.createElement("div");
|
|
176
|
+
document.body.append(host);
|
|
177
|
+
const root = createRoot(host);
|
|
178
|
+
|
|
179
|
+
// Two id-less siblings with the SAME selector — distinguished only by
|
|
180
|
+
// selectorIndex. Under the old `id ?? selector ?? label` key they shared
|
|
181
|
+
// one identity, so selecting B mid-burst didn't flush A and the next arrow
|
|
182
|
+
// kept moving A. The key now folds in selectorIndex, so they're distinct.
|
|
183
|
+
const elementA = document.createElement("div");
|
|
184
|
+
document.body.append(elementA);
|
|
185
|
+
const elementB = document.createElement("div");
|
|
186
|
+
document.body.append(elementB);
|
|
187
|
+
|
|
188
|
+
const commit = vi.fn();
|
|
189
|
+
const base = (label: string, element: HTMLElement) => ({
|
|
190
|
+
...makeSelection(label, element),
|
|
191
|
+
id: undefined,
|
|
192
|
+
selector: "div.row",
|
|
193
|
+
});
|
|
194
|
+
const selectionA: DomEditSelection = { ...base("Row", elementA), selectorIndex: 0 };
|
|
195
|
+
const selectionB: DomEditSelection = { ...base("Row", elementB), selectorIndex: 1 };
|
|
196
|
+
|
|
197
|
+
act(() => {
|
|
198
|
+
root.render(
|
|
199
|
+
React.createElement(Harness, { selection: selectionA, onPathOffsetCommit: commit }),
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
act(() => {
|
|
204
|
+
dispatchArrowRight();
|
|
205
|
+
});
|
|
206
|
+
expect(commit).not.toHaveBeenCalled();
|
|
207
|
+
|
|
208
|
+
act(() => {
|
|
209
|
+
root.render(
|
|
210
|
+
React.createElement(Harness, { selection: selectionB, onPathOffsetCommit: commit }),
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// The sibling switch must flush A's pending burst exactly once, for A.
|
|
215
|
+
expect(commit).toHaveBeenCalledTimes(1);
|
|
216
|
+
const [committedSelection, next] = commit.mock.calls[0] as [
|
|
217
|
+
DomEditSelection,
|
|
218
|
+
{ x: number; y: number },
|
|
219
|
+
];
|
|
220
|
+
expect(committedSelection.element).toBe(elementA);
|
|
221
|
+
expect(next.x).toBeCloseTo(CANVAS_NUDGE_STEP_PX);
|
|
222
|
+
|
|
223
|
+
act(() => root.unmount());
|
|
224
|
+
host.remove();
|
|
225
|
+
elementA.remove();
|
|
226
|
+
elementB.remove();
|
|
227
|
+
});
|
|
228
|
+
});
|