@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
|
@@ -446,12 +446,14 @@ export function StudioRightPanel({
|
|
|
446
446
|
|
|
447
447
|
return (
|
|
448
448
|
<>
|
|
449
|
+
{/* Vertical resize divider: 3px visible seam, 8px pointer-capture zone via
|
|
450
|
+
the absolutely-positioned inner hit area. */}
|
|
449
451
|
<div
|
|
450
452
|
role="separator"
|
|
451
453
|
aria-label="Resize inspector panel"
|
|
452
454
|
aria-orientation="vertical"
|
|
453
455
|
tabIndex={0}
|
|
454
|
-
className="group w-
|
|
456
|
+
className="group relative w-[3px] flex-shrink-0 cursor-col-resize outline-none focus-visible:bg-studio-accent/20"
|
|
455
457
|
style={{ touchAction: "none" }}
|
|
456
458
|
onPointerDown={(e) => handlePanelResizeStart("right", e)}
|
|
457
459
|
onPointerMove={handlePanelResizeMove}
|
|
@@ -465,10 +467,13 @@ export function StudioRightPanel({
|
|
|
465
467
|
setRightWidth(Math.max(160, Math.min(600, rightWidth + delta)));
|
|
466
468
|
}}
|
|
467
469
|
>
|
|
468
|
-
|
|
470
|
+
{/* Expanded hit zone: 8px wide, centered on the 3px seam */}
|
|
471
|
+
<div className="absolute inset-y-0 -left-[2.5px] w-2" />
|
|
472
|
+
{/* Visible hairline */}
|
|
473
|
+
<div className="absolute top-1/2 left-0 h-[52px] w-[3px] -translate-y-1/2 bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
|
|
469
474
|
</div>
|
|
470
475
|
<div
|
|
471
|
-
className="flex min-w-0 flex-shrink-0 flex-col overflow-hidden
|
|
476
|
+
className="flex min-w-0 flex-shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-800 bg-neutral-900"
|
|
472
477
|
style={{ width: rightWidth }}
|
|
473
478
|
>
|
|
474
479
|
{captionEditMode ? (
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
|
-
import { afterEach, describe, expect, it
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
6
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
7
7
|
import { TimelineToolbar } from "./TimelineToolbar";
|
|
8
8
|
|
|
@@ -18,7 +18,7 @@ function renderToolbar() {
|
|
|
18
18
|
document.body.append(host);
|
|
19
19
|
const root = createRoot(host);
|
|
20
20
|
act(() => {
|
|
21
|
-
root.render(<TimelineToolbar
|
|
21
|
+
root.render(<TimelineToolbar />);
|
|
22
22
|
});
|
|
23
23
|
return { host, root };
|
|
24
24
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { useRef } from "react";
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { Magnet, MagnifyingGlassMinus, MagnifyingGlassPlus } from "@phosphor-icons/react";
|
|
2
3
|
import {
|
|
3
4
|
useEnableKeyframes,
|
|
4
5
|
isPlayheadWithinTween,
|
|
@@ -9,9 +10,10 @@ import { useKeyframeKeyboard } from "../hooks/useKeyframeKeyboard";
|
|
|
9
10
|
import {
|
|
10
11
|
getNextTimelineZoomPercent,
|
|
11
12
|
getTimelineZoomPercent,
|
|
13
|
+
timelineZoomPercentToSlider,
|
|
14
|
+
timelineSliderToZoomPercent,
|
|
12
15
|
} from "../player/components/timelineZoom";
|
|
13
16
|
import { useTimelineZoom } from "../player/components/useTimelineZoom";
|
|
14
|
-
import { getTimelineToggleTitle } from "../utils/timelineDiscovery";
|
|
15
17
|
import { usePlayerStore, type TimelineElement } from "../player";
|
|
16
18
|
import {
|
|
17
19
|
STUDIO_KEYFRAMES_ENABLED,
|
|
@@ -30,7 +32,6 @@ interface DomEditSessionSlice extends EnableKeyframesSession {
|
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
interface TimelineToolbarProps {
|
|
33
|
-
toggleTimelineVisibility: () => void;
|
|
34
35
|
domEditSession?: DomEditSessionSlice;
|
|
35
36
|
onSplitElement?: (element: TimelineElement, splitTime: number) => void;
|
|
36
37
|
}
|
|
@@ -72,18 +73,20 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
|
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
// fallow-ignore-next-line complexity
|
|
75
|
-
export function TimelineToolbar({
|
|
76
|
-
toggleTimelineVisibility,
|
|
77
|
-
domEditSession,
|
|
78
|
-
onSplitElement,
|
|
79
|
-
}: TimelineToolbarProps) {
|
|
76
|
+
export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineToolbarProps) {
|
|
80
77
|
const activeTool = usePlayerStore((s) => s.activeTool);
|
|
81
78
|
const setActiveTool = usePlayerStore((s) => s.setActiveTool);
|
|
79
|
+
const timelineSnapEnabled = usePlayerStore((s) => s.timelineSnapEnabled);
|
|
80
|
+
const setTimelineSnapEnabled = usePlayerStore((s) => s.setTimelineSnapEnabled);
|
|
82
81
|
const autoKeyframeEnabled = usePlayerStore((s) => s.autoKeyframeEnabled);
|
|
83
82
|
const setAutoKeyframeEnabled = usePlayerStore((s) => s.setAutoKeyframeEnabled);
|
|
84
83
|
// Subscribe so the add-beat button reacts to playhead movement and analysis load.
|
|
85
84
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
86
85
|
const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null);
|
|
86
|
+
// Subscribe (not getState) so the split button enables/disables the moment
|
|
87
|
+
// the selection changes, not only on the next playhead tick.
|
|
88
|
+
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
|
89
|
+
const elements = usePlayerStore((s) => s.elements);
|
|
87
90
|
const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
|
|
88
91
|
const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
|
|
89
92
|
const {
|
|
@@ -99,28 +102,48 @@ export function TimelineToolbar({
|
|
|
99
102
|
onAddKeyframe: onToggleKeyframe,
|
|
100
103
|
});
|
|
101
104
|
|
|
105
|
+
// "N" toggles timeline snapping (industry convention: Resolve/FCP).
|
|
106
|
+
// Skip when typing in an input/contenteditable.
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
// fallow-ignore-next-line complexity
|
|
109
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
110
|
+
if (e.key !== "n" && e.key !== "N") return;
|
|
111
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
112
|
+
const target = e.target instanceof HTMLElement ? e.target : null;
|
|
113
|
+
if (target?.isContentEditable) return;
|
|
114
|
+
const tag = target?.tagName?.toLowerCase() ?? "";
|
|
115
|
+
if (tag === "input" || tag === "textarea" || tag === "select") return;
|
|
116
|
+
const store = usePlayerStore.getState();
|
|
117
|
+
store.setTimelineSnapEnabled(!store.timelineSnapEnabled);
|
|
118
|
+
};
|
|
119
|
+
window.addEventListener("keydown", onKeyDown);
|
|
120
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
121
|
+
}, []);
|
|
122
|
+
|
|
123
|
+
// CapCut-flat icon buttons: no per-button border/box chrome — a transparent
|
|
124
|
+
// 28px hit area with a subtle rounded hover wash, consistent 16px glyphs.
|
|
125
|
+
const flatBtn = "flex h-7 w-7 items-center justify-center rounded-md transition-colors";
|
|
126
|
+
const flatIdle = `${flatBtn} text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200 active:scale-[0.98]`;
|
|
127
|
+
const flatActive = `${flatBtn} bg-white/[0.08] text-neutral-100 active:scale-[0.98]`;
|
|
128
|
+
const flatDisabled = `${flatBtn} text-neutral-700 cursor-not-allowed`;
|
|
129
|
+
|
|
102
130
|
return (
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
</div>
|
|
131
|
+
// The "TIMELINE" label is dropped for CapCut-like density — the pane's
|
|
132
|
+
// position (tracks right below) makes it self-evident.
|
|
133
|
+
<div className="border-b border-neutral-800/60">
|
|
134
|
+
<div className="flex items-center justify-between px-2 py-0.5">
|
|
135
|
+
<div className="flex items-center gap-0.5">
|
|
109
136
|
{STUDIO_RAZOR_TOOL_ENABLED && (
|
|
110
|
-
|
|
137
|
+
<>
|
|
111
138
|
<Tooltip label="Selection tool (V)">
|
|
112
139
|
<button
|
|
113
140
|
type="button"
|
|
114
141
|
onClick={() => setActiveTool("select")}
|
|
115
142
|
aria-label="Selection tool"
|
|
116
143
|
aria-pressed={activeTool === "select"}
|
|
117
|
-
className={
|
|
118
|
-
activeTool === "select"
|
|
119
|
-
? "bg-neutral-700 text-neutral-200"
|
|
120
|
-
: "text-neutral-500 hover:text-neutral-300"
|
|
121
|
-
}`}
|
|
144
|
+
className={activeTool === "select" ? flatActive : flatIdle}
|
|
122
145
|
>
|
|
123
|
-
<svg width="
|
|
146
|
+
<svg width="16" height="16" viewBox="0 0 12 12" fill="currentColor">
|
|
124
147
|
<path d="M2 0.5L10 6L6.5 6.5L8.5 11L6.5 11.5L4.5 7L2 9Z" />
|
|
125
148
|
</svg>
|
|
126
149
|
</button>
|
|
@@ -131,46 +154,65 @@ export function TimelineToolbar({
|
|
|
131
154
|
onClick={() => setActiveTool("razor")}
|
|
132
155
|
aria-label="Razor tool"
|
|
133
156
|
aria-pressed={activeTool === "razor"}
|
|
134
|
-
className={
|
|
135
|
-
activeTool === "razor"
|
|
136
|
-
? "bg-neutral-700 text-neutral-200"
|
|
137
|
-
: "text-neutral-500 hover:text-neutral-300"
|
|
138
|
-
}`}
|
|
157
|
+
className={activeTool === "razor" ? flatActive : flatIdle}
|
|
139
158
|
>
|
|
140
|
-
<Scissors size={
|
|
159
|
+
<Scissors size={16} />
|
|
141
160
|
</button>
|
|
142
161
|
</Tooltip>
|
|
143
|
-
|
|
162
|
+
{/* Divider: tool-mode | editing-actions */}
|
|
163
|
+
<div aria-hidden="true" className="mx-1 h-4 w-px bg-neutral-800" />
|
|
164
|
+
</>
|
|
144
165
|
)}
|
|
145
|
-
{
|
|
166
|
+
<Tooltip label={timelineSnapEnabled ? "Snapping on (N)" : "Snapping off (N)"}>
|
|
167
|
+
<button
|
|
168
|
+
type="button"
|
|
169
|
+
onClick={() => setTimelineSnapEnabled(!timelineSnapEnabled)}
|
|
170
|
+
aria-label="Toggle timeline snapping"
|
|
171
|
+
aria-pressed={timelineSnapEnabled}
|
|
172
|
+
className={timelineSnapEnabled ? flatActive : flatIdle}
|
|
173
|
+
>
|
|
174
|
+
<Magnet size={16} weight="bold" aria-hidden="true" />
|
|
175
|
+
</button>
|
|
176
|
+
</Tooltip>
|
|
177
|
+
{STUDIO_KEYFRAMES_ENABLED && (
|
|
178
|
+
// Always rendered (CapCut-style): with no keyframeable selection the
|
|
179
|
+
// button fades to a disabled state instead of unmounting, so the
|
|
180
|
+
// toolbar layout never shifts.
|
|
146
181
|
<Tooltip
|
|
147
182
|
label={
|
|
148
|
-
|
|
149
|
-
? "
|
|
150
|
-
: keyframeState === "
|
|
151
|
-
?
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
183
|
+
!onToggleKeyframe
|
|
184
|
+
? "Select an animated element to add keyframes"
|
|
185
|
+
: keyframeState === "active"
|
|
186
|
+
? "Remove keyframe at playhead (K)"
|
|
187
|
+
: keyframeState === "inactive"
|
|
188
|
+
? keyframeWillExtend
|
|
189
|
+
? "Add keyframe at playhead, extends animation (K)"
|
|
190
|
+
: "Add keyframe at playhead (K)"
|
|
191
|
+
: "Add keyframe (K)"
|
|
155
192
|
}
|
|
156
193
|
>
|
|
157
194
|
<button
|
|
158
195
|
type="button"
|
|
196
|
+
disabled={!onToggleKeyframe}
|
|
159
197
|
onClick={onToggleKeyframe}
|
|
160
198
|
aria-label={
|
|
161
199
|
keyframeState === "active"
|
|
162
200
|
? "Remove keyframe at playhead"
|
|
163
201
|
: "Add keyframe at playhead"
|
|
164
202
|
}
|
|
165
|
-
className={
|
|
166
|
-
|
|
167
|
-
?
|
|
168
|
-
:
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
203
|
+
className={
|
|
204
|
+
!onToggleKeyframe
|
|
205
|
+
? flatDisabled
|
|
206
|
+
: `${flatBtn} active:scale-[0.98] hover:bg-white/[0.06] ${
|
|
207
|
+
keyframeState === "active"
|
|
208
|
+
? "text-studio-accent"
|
|
209
|
+
: keyframeState === "inactive"
|
|
210
|
+
? "text-neutral-400 hover:text-studio-accent"
|
|
211
|
+
: "text-neutral-600 hover:text-neutral-400"
|
|
212
|
+
}`
|
|
213
|
+
}
|
|
172
214
|
>
|
|
173
|
-
<svg width="
|
|
215
|
+
<svg width="16" height="16" viewBox="0 0 10 10" fill="currentColor">
|
|
174
216
|
{keyframeState === "active" ? (
|
|
175
217
|
<path d="M5 0.5L9.5 5L5 9.5L0.5 5Z" />
|
|
176
218
|
) : (
|
|
@@ -198,13 +240,13 @@ export function TimelineToolbar({
|
|
|
198
240
|
onClick={() => setAutoKeyframeEnabled(!autoKeyframeEnabled)}
|
|
199
241
|
aria-label="Auto-record manual edits as keyframes"
|
|
200
242
|
aria-pressed={autoKeyframeEnabled}
|
|
201
|
-
className={
|
|
243
|
+
className={`${flatBtn} active:scale-[0.98] hover:bg-white/[0.06] ${
|
|
202
244
|
autoKeyframeEnabled
|
|
203
245
|
? "text-red-400 hover:text-red-300"
|
|
204
246
|
: "text-neutral-600 hover:text-neutral-400"
|
|
205
247
|
}`}
|
|
206
248
|
>
|
|
207
|
-
<svg width="
|
|
249
|
+
<svg width="16" height="16" viewBox="0 0 10 10" fill="none">
|
|
208
250
|
{/* Same diamond outline as the Add-keyframe icon, with a
|
|
209
251
|
record-style dot inside: filled = auto-recording,
|
|
210
252
|
hollow = manual edits won't be keyframed. */}
|
|
@@ -225,7 +267,7 @@ export function TimelineToolbar({
|
|
|
225
267
|
(() => {
|
|
226
268
|
// Render the button unconditionally (disabled when unusable):
|
|
227
269
|
// mounting/unmounting mid-task shifts the neighboring controls.
|
|
228
|
-
|
|
270
|
+
// Mirrors the S-key gate: selected clip + playhead strictly inside it.
|
|
229
271
|
const el = selectedElementId
|
|
230
272
|
? elements.find((e) => (e.key ?? e.id) === selectedElementId)
|
|
231
273
|
: null;
|
|
@@ -236,7 +278,7 @@ export function TimelineToolbar({
|
|
|
236
278
|
<Tooltip
|
|
237
279
|
label={
|
|
238
280
|
canSplit
|
|
239
|
-
? "Split
|
|
281
|
+
? "Split at playhead (S)"
|
|
240
282
|
: splittable
|
|
241
283
|
? "Move the playhead inside the clip to split"
|
|
242
284
|
: "Select a clip to split"
|
|
@@ -245,64 +287,84 @@ export function TimelineToolbar({
|
|
|
245
287
|
<button
|
|
246
288
|
type="button"
|
|
247
289
|
disabled={!canSplit}
|
|
248
|
-
aria-label="Split
|
|
290
|
+
aria-label="Split at playhead"
|
|
249
291
|
onClick={() => {
|
|
250
292
|
if (canSplit && el) onSplitElement(el, currentTime);
|
|
251
293
|
}}
|
|
252
|
-
className={
|
|
253
|
-
canSplit
|
|
254
|
-
? "text-neutral-500 hover:text-neutral-200 active:scale-[0.98]"
|
|
255
|
-
: "text-neutral-700 cursor-not-allowed"
|
|
256
|
-
}`}
|
|
257
|
-
>
|
|
258
|
-
<Scissors size={15} />
|
|
259
|
-
</button>
|
|
260
|
-
</Tooltip>
|
|
261
|
-
);
|
|
262
|
-
})()}
|
|
263
|
-
{beatAnalysisReady &&
|
|
264
|
-
(() => {
|
|
265
|
-
const canAdd = canAddBeatAt(currentTime);
|
|
266
|
-
return (
|
|
267
|
-
<Tooltip
|
|
268
|
-
label={canAdd ? "Add beat at playhead" : "A beat already exists at the playhead"}
|
|
269
|
-
>
|
|
270
|
-
<button
|
|
271
|
-
type="button"
|
|
272
|
-
disabled={!canAdd}
|
|
273
|
-
aria-label="Add beat at playhead"
|
|
274
|
-
onClick={() => {
|
|
275
|
-
if (canAdd) addBeatAtCompositionTime(currentTime);
|
|
276
|
-
}}
|
|
277
|
-
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
|
|
278
|
-
canAdd
|
|
279
|
-
? "text-neutral-500 hover:text-[#22c55e] active:scale-[0.98]"
|
|
280
|
-
: "text-neutral-700 cursor-not-allowed"
|
|
281
|
-
}`}
|
|
294
|
+
className={canSplit ? flatIdle : flatDisabled}
|
|
282
295
|
>
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
296
|
+
{/* "][" split glyph: two outward-facing brackets with a center gap */}
|
|
297
|
+
<svg
|
|
298
|
+
width="16"
|
|
299
|
+
height="16"
|
|
300
|
+
viewBox="0 0 16 16"
|
|
301
|
+
fill="none"
|
|
302
|
+
stroke="currentColor"
|
|
303
|
+
strokeWidth="1.6"
|
|
304
|
+
strokeLinecap="round"
|
|
305
|
+
strokeLinejoin="round"
|
|
306
|
+
aria-hidden="true"
|
|
307
|
+
>
|
|
308
|
+
{/* Right bracket of left half: ] */}
|
|
309
|
+
<path d="M5 3 L7 3 L7 13 L5 13" />
|
|
310
|
+
{/* Left bracket of right half: [ */}
|
|
311
|
+
<path d="M11 3 L9 3 L9 13 L11 13" />
|
|
291
312
|
</svg>
|
|
292
313
|
</button>
|
|
293
314
|
</Tooltip>
|
|
294
315
|
);
|
|
295
316
|
})()}
|
|
317
|
+
{(() => {
|
|
318
|
+
// Always rendered (CapCut-style): before beat analysis loads (or when
|
|
319
|
+
// the project has no analyzed music) the button fades to a disabled
|
|
320
|
+
// state instead of unmounting, so the toolbar layout never shifts.
|
|
321
|
+
const canAdd = beatAnalysisReady && canAddBeatAt(currentTime);
|
|
322
|
+
return (
|
|
323
|
+
<Tooltip
|
|
324
|
+
label={
|
|
325
|
+
!beatAnalysisReady
|
|
326
|
+
? "Add a music track with beat analysis to place beats"
|
|
327
|
+
: canAdd
|
|
328
|
+
? "Add beat at playhead"
|
|
329
|
+
: "A beat already exists at the playhead"
|
|
330
|
+
}
|
|
331
|
+
>
|
|
332
|
+
<button
|
|
333
|
+
type="button"
|
|
334
|
+
disabled={!canAdd}
|
|
335
|
+
aria-label="Add beat at playhead"
|
|
336
|
+
onClick={() => {
|
|
337
|
+
if (canAdd) addBeatAtCompositionTime(currentTime);
|
|
338
|
+
}}
|
|
339
|
+
className={
|
|
340
|
+
canAdd
|
|
341
|
+
? `${flatBtn} text-neutral-400 hover:bg-white/[0.06] hover:text-[#22c55e] active:scale-[0.98]`
|
|
342
|
+
: flatDisabled
|
|
343
|
+
}
|
|
344
|
+
>
|
|
345
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
|
346
|
+
<path
|
|
347
|
+
d="M21 10C21 12.2091 16.9706 14 12 14M21 10C21 7.79086 16.9706 6 12 6C7.02944 6 3 7.79086 3 10M21 10V16C21 18.2091 16.9706 20 12 20M12 14C7.02944 14 3 12.2091 3 10M12 14V20M3 10V16C3 18.2091 7.02944 20 12 20M7 19.3264V13.3264M17 19.3264V13.3264M12 10L20 4"
|
|
348
|
+
stroke="currentColor"
|
|
349
|
+
strokeWidth="2"
|
|
350
|
+
strokeLinecap="round"
|
|
351
|
+
strokeLinejoin="round"
|
|
352
|
+
/>
|
|
353
|
+
</svg>
|
|
354
|
+
</button>
|
|
355
|
+
</Tooltip>
|
|
356
|
+
);
|
|
357
|
+
})()}
|
|
296
358
|
</div>
|
|
297
|
-
<div className="flex items-center gap-
|
|
359
|
+
<div className="flex items-center gap-0.5">
|
|
298
360
|
<Tooltip label="Fit timeline to width">
|
|
299
361
|
<button
|
|
300
362
|
type="button"
|
|
301
363
|
onClick={() => setZoomMode("fit")}
|
|
302
|
-
className={`h-7 px-2
|
|
364
|
+
className={`h-7 px-2 rounded-md text-[11px] font-medium transition-colors ${
|
|
303
365
|
zoomMode === "fit"
|
|
304
|
-
? "
|
|
305
|
-
: "
|
|
366
|
+
? "bg-studio-accent/10 text-studio-accent"
|
|
367
|
+
: "text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200"
|
|
306
368
|
}`}
|
|
307
369
|
>
|
|
308
370
|
Fit
|
|
@@ -311,55 +373,51 @@ export function TimelineToolbar({
|
|
|
311
373
|
<Tooltip label="Zoom out">
|
|
312
374
|
<button
|
|
313
375
|
type="button"
|
|
376
|
+
aria-label="Zoom out"
|
|
314
377
|
onClick={() => {
|
|
315
378
|
setZoomMode("manual");
|
|
316
379
|
setManualZoomPercent(
|
|
317
380
|
getNextTimelineZoomPercent("out", zoomMode, manualZoomPercent),
|
|
318
381
|
);
|
|
319
382
|
}}
|
|
320
|
-
className=
|
|
383
|
+
className={flatIdle}
|
|
321
384
|
>
|
|
322
|
-
-
|
|
385
|
+
<MagnifyingGlassMinus size={16} aria-hidden="true" />
|
|
323
386
|
</button>
|
|
324
387
|
</Tooltip>
|
|
325
|
-
<
|
|
326
|
-
|
|
327
|
-
|
|
388
|
+
<input
|
|
389
|
+
type="range"
|
|
390
|
+
min="0"
|
|
391
|
+
max="100"
|
|
392
|
+
value={timelineZoomPercentToSlider(displayedTimelineZoomPercent)}
|
|
393
|
+
title={`${displayedTimelineZoomPercent}%`}
|
|
394
|
+
aria-label="Timeline zoom"
|
|
395
|
+
onChange={(e) => {
|
|
396
|
+
setZoomMode("manual");
|
|
397
|
+
setManualZoomPercent(timelineSliderToZoomPercent(Number(e.target.value)));
|
|
398
|
+
}}
|
|
399
|
+
className="mx-1 w-[96px] cursor-pointer appearance-none bg-transparent [&::-webkit-slider-runnable-track]:h-[2px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-neutral-700 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-[10px] [&::-webkit-slider-thumb]:h-[10px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_#0a0a0a,0_1px_3px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb:active]:cursor-grabbing"
|
|
400
|
+
/>
|
|
328
401
|
<Tooltip label="Zoom in">
|
|
329
402
|
<button
|
|
330
403
|
type="button"
|
|
404
|
+
aria-label="Zoom in"
|
|
331
405
|
onClick={() => {
|
|
332
406
|
setZoomMode("manual");
|
|
333
407
|
setManualZoomPercent(getNextTimelineZoomPercent("in", zoomMode, manualZoomPercent));
|
|
334
408
|
}}
|
|
335
|
-
className=
|
|
409
|
+
className={flatIdle}
|
|
336
410
|
>
|
|
337
|
-
|
|
338
|
-
</button>
|
|
339
|
-
</Tooltip>
|
|
340
|
-
<Tooltip label={getTimelineToggleTitle(true)}>
|
|
341
|
-
<button
|
|
342
|
-
type="button"
|
|
343
|
-
onClick={toggleTimelineVisibility}
|
|
344
|
-
className="ml-1 flex h-7 w-7 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-900 hover:text-neutral-200"
|
|
345
|
-
aria-label="Hide timeline editor"
|
|
346
|
-
>
|
|
347
|
-
<svg
|
|
348
|
-
width="14"
|
|
349
|
-
height="14"
|
|
350
|
-
viewBox="0 0 24 24"
|
|
351
|
-
fill="none"
|
|
352
|
-
stroke="currentColor"
|
|
353
|
-
strokeWidth="1.8"
|
|
354
|
-
strokeLinecap="round"
|
|
355
|
-
strokeLinejoin="round"
|
|
356
|
-
aria-hidden="true"
|
|
357
|
-
>
|
|
358
|
-
<path d="M5 7h14" />
|
|
359
|
-
<path d="m8 11 4 4 4-4" />
|
|
360
|
-
</svg>
|
|
411
|
+
<MagnifyingGlassPlus size={16} aria-hidden="true" />
|
|
361
412
|
</button>
|
|
362
413
|
</Tooltip>
|
|
414
|
+
{/* Numeric zoom readout (main-parity): "Fit" in fit mode, N% in manual. */}
|
|
415
|
+
<span
|
|
416
|
+
className="ml-1 w-[38px] text-right font-mono text-[11px] tabular-nums text-neutral-500 select-none"
|
|
417
|
+
aria-label="Timeline zoom level"
|
|
418
|
+
>
|
|
419
|
+
{zoomMode === "fit" ? "Fit" : `${displayedTimelineZoomPercent}%`}
|
|
420
|
+
</span>
|
|
363
421
|
</div>
|
|
364
422
|
</div>
|
|
365
423
|
</div>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React, { act } from "react";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { installReactActEnvironment, makeSelection } from "../../hooks/domSelectionTestHarness";
|
|
6
|
+
import { CanvasContextMenu } from "./CanvasContextMenu";
|
|
7
|
+
import type { DomEditSelection } from "./domEditing";
|
|
8
|
+
|
|
9
|
+
installReactActEnvironment();
|
|
10
|
+
|
|
11
|
+
let host: HTMLDivElement;
|
|
12
|
+
let root: Root | null = null;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
host = document.createElement("div");
|
|
16
|
+
document.body.append(host);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
act(() => root?.unmount());
|
|
21
|
+
root = null;
|
|
22
|
+
document.body.innerHTML = "";
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function renderMenu(props: {
|
|
26
|
+
selection: DomEditSelection;
|
|
27
|
+
onApplyZIndex?: () => void;
|
|
28
|
+
onDelete?: (selection: DomEditSelection) => void;
|
|
29
|
+
}) {
|
|
30
|
+
root = createRoot(host);
|
|
31
|
+
act(() => {
|
|
32
|
+
root!.render(
|
|
33
|
+
React.createElement(CanvasContextMenu, {
|
|
34
|
+
x: 10,
|
|
35
|
+
y: 10,
|
|
36
|
+
selection: props.selection,
|
|
37
|
+
onClose: () => {},
|
|
38
|
+
onApplyZIndex: props.onApplyZIndex,
|
|
39
|
+
onDelete: props.onDelete,
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** All menu buttons live in the portal under document.body. */
|
|
46
|
+
function menuButtons(): HTMLButtonElement[] {
|
|
47
|
+
return [...document.body.querySelectorAll("button")];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hasDeleteItem(): boolean {
|
|
51
|
+
return menuButtons().some((b) => b.textContent?.includes("Delete"));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function zOrderButtons(): HTMLButtonElement[] {
|
|
55
|
+
return menuButtons().filter((b) => !b.textContent?.includes("Delete"));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("CanvasContextMenu — handler gating", () => {
|
|
59
|
+
it("renders all four z-order items, a divider, and Delete when both handlers are present", () => {
|
|
60
|
+
const el = document.createElement("div");
|
|
61
|
+
el.id = "target";
|
|
62
|
+
document.body.append(el);
|
|
63
|
+
|
|
64
|
+
renderMenu({
|
|
65
|
+
selection: makeSelection("Target", el),
|
|
66
|
+
onApplyZIndex: vi.fn(),
|
|
67
|
+
onDelete: vi.fn(),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(zOrderButtons()).toHaveLength(4);
|
|
71
|
+
expect(hasDeleteItem()).toBe(true);
|
|
72
|
+
// The divider only appears between the two groups.
|
|
73
|
+
expect(document.body.querySelector(".border-t")).not.toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("hides every item and does NOT render the menu when no handlers are present", () => {
|
|
77
|
+
const el = document.createElement("div");
|
|
78
|
+
el.id = "target";
|
|
79
|
+
// A z-index that a stray optimistic write would clobber — assert it is
|
|
80
|
+
// untouched, since the menu must not mutate the DOM without a persist path.
|
|
81
|
+
el.style.zIndex = "3";
|
|
82
|
+
document.body.append(el);
|
|
83
|
+
|
|
84
|
+
renderMenu({ selection: makeSelection("Target", el) });
|
|
85
|
+
|
|
86
|
+
// No menu opened at all — no buttons, no dead-end items, no DOM mutation.
|
|
87
|
+
expect(menuButtons()).toHaveLength(0);
|
|
88
|
+
expect(document.body.querySelector(".fixed.z-50")).toBeNull();
|
|
89
|
+
expect(el.style.zIndex).toBe("3");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("shows only the z-order items (no Delete, no divider) when onDelete is absent", () => {
|
|
93
|
+
const el = document.createElement("div");
|
|
94
|
+
el.id = "target";
|
|
95
|
+
document.body.append(el);
|
|
96
|
+
|
|
97
|
+
renderMenu({ selection: makeSelection("Target", el), onApplyZIndex: vi.fn() });
|
|
98
|
+
|
|
99
|
+
expect(zOrderButtons()).toHaveLength(4);
|
|
100
|
+
expect(hasDeleteItem()).toBe(false);
|
|
101
|
+
expect(document.body.querySelector(".border-t")).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("shows only Delete (no z-order items, no divider) when onApplyZIndex is absent", () => {
|
|
105
|
+
const el = document.createElement("div");
|
|
106
|
+
el.id = "target";
|
|
107
|
+
document.body.append(el);
|
|
108
|
+
|
|
109
|
+
renderMenu({ selection: makeSelection("Target", el), onDelete: vi.fn() });
|
|
110
|
+
|
|
111
|
+
expect(zOrderButtons()).toHaveLength(0);
|
|
112
|
+
expect(hasDeleteItem()).toBe(true);
|
|
113
|
+
expect(document.body.querySelector(".border-t")).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
});
|