@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
|
@@ -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,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
|
+
});
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas arrow-key nudge for DomEditOverlay: arrows move the selected
|
|
3
|
+
* element(s) 1 composition px, Shift = 10. Each keypress previews through the
|
|
4
|
+
* same GSAP/CSS channel as a drag draft, and the burst commits ONCE through
|
|
5
|
+
* the same onPathOffsetCommit / onGroupPathOffsetCommit path a drag drop uses
|
|
6
|
+
* — so a nudge burst is exactly a tiny drag: one source patch, one undo entry.
|
|
7
|
+
*/
|
|
8
|
+
import { useCallback, useEffect, useRef, type RefObject } from "react";
|
|
9
|
+
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
10
|
+
import { isEditableTarget } from "../../utils/timelineDiscovery";
|
|
11
|
+
import { acquireCanvasNudgeKeys } from "../../utils/canvasNudgeGate";
|
|
12
|
+
import type { DomEditSelection } from "./domEditing";
|
|
13
|
+
import {
|
|
14
|
+
type GroupOverlayItem,
|
|
15
|
+
type OverlayRect,
|
|
16
|
+
filterNestedDomEditGroupItems,
|
|
17
|
+
} from "./domEditOverlayGeometry";
|
|
18
|
+
import type {
|
|
19
|
+
BlockedMoveState,
|
|
20
|
+
DomEditGroupPathOffsetCommit,
|
|
21
|
+
GestureState,
|
|
22
|
+
GroupGestureState,
|
|
23
|
+
} from "./domEditOverlayGestures";
|
|
24
|
+
import {
|
|
25
|
+
applyManualOffsetNudgeCommit,
|
|
26
|
+
applyManualOffsetNudgeDraft,
|
|
27
|
+
createManualOffsetDragMember,
|
|
28
|
+
endManualOffsetDragMembers,
|
|
29
|
+
restoreManualOffsetDragMembers,
|
|
30
|
+
type ManualOffsetDragMember,
|
|
31
|
+
} from "./manualOffsetDrag";
|
|
32
|
+
import { isStudioManualEditGestureCurrent, restoreStudioPathOffset } from "./manualEdits";
|
|
33
|
+
import {
|
|
34
|
+
CANVAS_NUDGE_COMMIT_DEBOUNCE_MS,
|
|
35
|
+
canCanvasNudgeTargets,
|
|
36
|
+
resolveCanvasNudgeDelta,
|
|
37
|
+
} from "./domEditNudge";
|
|
38
|
+
|
|
39
|
+
interface NudgeSession {
|
|
40
|
+
members: ManualOffsetDragMember[];
|
|
41
|
+
isGroup: boolean;
|
|
42
|
+
/** Accumulated delta of the burst, in composition px. */
|
|
43
|
+
accum: { x: number; y: number };
|
|
44
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface UseDomEditNudgeParams {
|
|
48
|
+
selection: DomEditSelection | null;
|
|
49
|
+
groupSelections: DomEditSelection[];
|
|
50
|
+
allowCanvasMovement: boolean;
|
|
51
|
+
selectionRef: RefObject<DomEditSelection | null>;
|
|
52
|
+
overlayRectRef: RefObject<OverlayRect | null>;
|
|
53
|
+
groupOverlayItemsRef: RefObject<GroupOverlayItem[]>;
|
|
54
|
+
gestureRef: RefObject<GestureState | null>;
|
|
55
|
+
groupGestureRef: RefObject<GroupGestureState | null>;
|
|
56
|
+
blockedMoveRef: RefObject<BlockedMoveState | null>;
|
|
57
|
+
onManualDragStartRef: RefObject<(() => void) | undefined>;
|
|
58
|
+
onPathOffsetCommitRef: RefObject<
|
|
59
|
+
(
|
|
60
|
+
s: DomEditSelection,
|
|
61
|
+
n: { x: number; y: number },
|
|
62
|
+
m?: { altKey?: boolean },
|
|
63
|
+
) => Promise<void> | void
|
|
64
|
+
>;
|
|
65
|
+
onGroupPathOffsetCommitRef: RefObject<
|
|
66
|
+
(updates: DomEditGroupPathOffsetCommit[]) => Promise<void> | void
|
|
67
|
+
>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type NudgeTarget = {
|
|
71
|
+
key: string;
|
|
72
|
+
selection: DomEditSelection;
|
|
73
|
+
element: HTMLElement;
|
|
74
|
+
rect: OverlayRect;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A selection's stable identity, not its object reference. A parent that
|
|
79
|
+
* doesn't memoize the selection it passes down hands us a new object every
|
|
80
|
+
* render for the SAME element — keying an effect on the object itself would
|
|
81
|
+
* then re-fire on every render instead of on an actual selection change.
|
|
82
|
+
*
|
|
83
|
+
* All discriminators are folded in (id / hfId / selector / selectorIndex /
|
|
84
|
+
* label), not just the first present one: two id-less siblings can share a
|
|
85
|
+
* selector, so `id ?? selector ?? label` collapsed them to the same key —
|
|
86
|
+
* selecting sibling B mid-burst then didn't flush A's pending nudge, so the
|
|
87
|
+
* next arrow kept moving A. selectorIndex is the discriminator that separates
|
|
88
|
+
* them. The parts are only ever compared for equality (never parsed back),
|
|
89
|
+
* so they're joined on a NUL delimiter — a byte no discriminator can contain,
|
|
90
|
+
* so distinct selections stay distinct even when a value (e.g. a label) holds
|
|
91
|
+
* a space. It's written as the escape "\u0000", not a literal 0x00 byte, so
|
|
92
|
+
* this source file stays text (a raw NUL makes git treat it as binary).
|
|
93
|
+
*/
|
|
94
|
+
function selectionIdentityKey(selection: DomEditSelection | null): string {
|
|
95
|
+
if (!selection) return "";
|
|
96
|
+
return [
|
|
97
|
+
selection.id ?? "",
|
|
98
|
+
selection.hfId ?? "",
|
|
99
|
+
selection.selector ?? "",
|
|
100
|
+
selection.selectorIndex ?? "",
|
|
101
|
+
selection.label,
|
|
102
|
+
].join("\u0000");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function groupSelectionsIdentityKey(selections: DomEditSelection[]): string {
|
|
106
|
+
return selections.map(selectionIdentityKey).join(" ");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Drag members for a multi-selection nudge (same snapshot a group drag uses). */
|
|
110
|
+
function resolveGroupNudgeTargets(groupItems: GroupOverlayItem[]): NudgeTarget[] | null {
|
|
111
|
+
if (!canCanvasNudgeTargets(groupItems.map((item) => item.selection))) return null;
|
|
112
|
+
return filterNestedDomEditGroupItems(groupItems);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Drag member for a single-selection nudge, or null when it can't be moved. */
|
|
116
|
+
function resolveSingleNudgeTarget(
|
|
117
|
+
sel: DomEditSelection | null,
|
|
118
|
+
rect: OverlayRect | null,
|
|
119
|
+
): NudgeTarget[] | null {
|
|
120
|
+
if (!sel || !rect || !sel.capabilities.canApplyManualOffset || !sel.element.isConnected) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
return [{ key: sel.id ?? sel.selector ?? sel.label, selection: sel, element: sel.element, rect }];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* True when a keydown must not start/extend a nudge: canvas movement disabled,
|
|
128
|
+
* a pointer gesture already owns the element, or the user is typing in a field.
|
|
129
|
+
*/
|
|
130
|
+
function shouldIgnoreNudgeKey(p: UseDomEditNudgeParams, event: KeyboardEvent): boolean {
|
|
131
|
+
if (!p.allowCanvasMovement || event.defaultPrevented) return true;
|
|
132
|
+
if (p.gestureRef.current || p.groupGestureRef.current || p.blockedMoveRef.current) return true;
|
|
133
|
+
return isEditableTarget(event.target);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: () => void } {
|
|
137
|
+
const sessionRef = useRef<NudgeSession | null>(null);
|
|
138
|
+
const paramsRef = useRef(params);
|
|
139
|
+
paramsRef.current = params;
|
|
140
|
+
|
|
141
|
+
// Commit the pending burst: one source write per burst = one undo entry.
|
|
142
|
+
// Mirrors the drag's onPointerUp — same commit callbacks, same failure
|
|
143
|
+
// restore, same member teardown.
|
|
144
|
+
const commitSession = () => {
|
|
145
|
+
const session = sessionRef.current;
|
|
146
|
+
if (!session) return;
|
|
147
|
+
sessionRef.current = null;
|
|
148
|
+
if (session.timer) clearTimeout(session.timer);
|
|
149
|
+
const updates: DomEditGroupPathOffsetCommit[] = session.members.map((member) => ({
|
|
150
|
+
selection: member.selection,
|
|
151
|
+
next: applyManualOffsetNudgeCommit(member, session.accum),
|
|
152
|
+
}));
|
|
153
|
+
const p = paramsRef.current;
|
|
154
|
+
const commit = session.isGroup
|
|
155
|
+
? p.onGroupPathOffsetCommitRef.current(updates)
|
|
156
|
+
: p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next);
|
|
157
|
+
void Promise.resolve(commit)
|
|
158
|
+
.catch(() => {
|
|
159
|
+
for (const member of session.members) {
|
|
160
|
+
if (isStudioManualEditGestureCurrent(member.element, member.gestureToken)) {
|
|
161
|
+
restoreStudioPathOffset(member.element, member.initialPathOffset);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
.finally(() => endManualOffsetDragMembers(session.members));
|
|
166
|
+
};
|
|
167
|
+
const commitSessionRef = useRef(commitSession);
|
|
168
|
+
commitSessionRef.current = commitSession;
|
|
169
|
+
|
|
170
|
+
// Build drag members for the current target set — the same member snapshot a
|
|
171
|
+
// pointer drag starts from (startGesture / startGroupDrag), so the nudge
|
|
172
|
+
// commit converts offsets → GSAP x/y with identical math.
|
|
173
|
+
const beginSession = (): NudgeSession | null => {
|
|
174
|
+
const p = paramsRef.current;
|
|
175
|
+
const groupItems = p.groupOverlayItemsRef.current;
|
|
176
|
+
const isGroup = groupItems.length > 1;
|
|
177
|
+
const targets = isGroup
|
|
178
|
+
? resolveGroupNudgeTargets(groupItems)
|
|
179
|
+
: resolveSingleNudgeTarget(p.selectionRef.current, p.overlayRectRef.current);
|
|
180
|
+
if (!targets) return null;
|
|
181
|
+
const members: ManualOffsetDragMember[] = [];
|
|
182
|
+
for (const target of targets) {
|
|
183
|
+
const result = createManualOffsetDragMember(target);
|
|
184
|
+
if (!result.ok) {
|
|
185
|
+
restoreManualOffsetDragMembers(members);
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
members.push(result.member);
|
|
189
|
+
}
|
|
190
|
+
if (members.length === 0) return null;
|
|
191
|
+
// Same side effect a drag start has (pauses preview playback).
|
|
192
|
+
p.onManualDragStartRef.current?.();
|
|
193
|
+
return { members, isGroup, accum: { x: 0, y: 0 }, timer: null };
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
197
|
+
const p = paramsRef.current;
|
|
198
|
+
if (shouldIgnoreNudgeKey(p, event)) return;
|
|
199
|
+
const delta = resolveCanvasNudgeDelta(event);
|
|
200
|
+
if (!delta) return;
|
|
201
|
+
const session = sessionRef.current ?? beginSession();
|
|
202
|
+
if (!session) return;
|
|
203
|
+
sessionRef.current = session;
|
|
204
|
+
event.preventDefault();
|
|
205
|
+
session.accum = { x: session.accum.x + delta.dx, y: session.accum.y + delta.dy };
|
|
206
|
+
for (const member of session.members) applyManualOffsetNudgeDraft(member, session.accum);
|
|
207
|
+
if (session.timer) clearTimeout(session.timer);
|
|
208
|
+
session.timer = setTimeout(() => commitSessionRef.current(), CANVAS_NUDGE_COMMIT_DEBOUNCE_MS);
|
|
209
|
+
};
|
|
210
|
+
const handleKeyDownRef = useRef(handleKeyDown);
|
|
211
|
+
handleKeyDownRef.current = handleKeyDown;
|
|
212
|
+
|
|
213
|
+
useMountEffect(() => {
|
|
214
|
+
const listener = (event: KeyboardEvent) => handleKeyDownRef.current(event);
|
|
215
|
+
// Capture, like the other app-level key handlers, so a focused panel
|
|
216
|
+
// can't swallow the nudge before it reaches us.
|
|
217
|
+
window.addEventListener("keydown", listener, true);
|
|
218
|
+
return () => {
|
|
219
|
+
window.removeEventListener("keydown", listener, true);
|
|
220
|
+
commitSessionRef.current();
|
|
221
|
+
};
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Selection change ends the burst: commit to the OLD target before the
|
|
225
|
+
// arrows start moving the new one. Keyed on the selection's stable identity
|
|
226
|
+
// (id/selector/label), NOT the object reference — a parent that re-creates
|
|
227
|
+
// the selection object on every render (without memoizing it) must not
|
|
228
|
+
// flush a burst that's still in progress for the same element.
|
|
229
|
+
const selectionKey = selectionIdentityKey(params.selection);
|
|
230
|
+
const groupSelectionsKey = groupSelectionsIdentityKey(params.groupSelections);
|
|
231
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
232
|
+
useEffect(() => () => commitSessionRef.current(), [selectionKey, groupSelectionsKey]);
|
|
233
|
+
|
|
234
|
+
// Claim the arrow keys from the playback frame-step while the selection is
|
|
235
|
+
// nudgeable (see canvasNudgeGate — listener order is mount-dependent, so
|
|
236
|
+
// preventDefault alone can't arbitrate).
|
|
237
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
const targets =
|
|
240
|
+
params.groupSelections.length > 1
|
|
241
|
+
? params.groupSelections
|
|
242
|
+
: params.selection
|
|
243
|
+
? [params.selection]
|
|
244
|
+
: [];
|
|
245
|
+
if (!params.allowCanvasMovement || !canCanvasNudgeTargets(targets)) return;
|
|
246
|
+
return acquireCanvasNudgeKeys();
|
|
247
|
+
}, [params.selection, params.groupSelections, params.allowCanvasMovement]);
|
|
248
|
+
|
|
249
|
+
// A pointer gesture supersedes a pending burst — DomEditOverlay flushes on
|
|
250
|
+
// pointerdown-capture so the drag's member snapshot starts from the nudged,
|
|
251
|
+
// committed position.
|
|
252
|
+
const flushNudge = useCallback(() => commitSessionRef.current(), []);
|
|
253
|
+
return { flushNudge };
|
|
254
|
+
}
|