@hyperframes/studio 0.8.113 → 0.8.115
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-DzJbtQVO.js → hyperframes-player-C-9N8nk0.js} +1 -1
- package/dist/assets/{index-n5FUn1xv.js → index-BoUM6Xqb.js} +1 -1
- package/dist/assets/{index-DRhF3v4Z.js → index-CAvZ0AA-.js} +204 -204
- package/dist/assets/{index-DgwDQK5G.js → index-CVuorIMv.js} +1 -1
- package/dist/assets/index-t1LH6H5H.css +1 -0
- package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
- package/dist/chunk-5EKWJZLM.js.map +1 -0
- package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
- package/dist/{chunk-BIUXP3PG.js → chunk-TXZMMOMA.js} +43 -276
- package/dist/chunk-TXZMMOMA.js.map +1 -0
- package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-D7VYFJJD.js} +3 -5
- package/dist/index.d.ts +46 -22
- package/dist/index.html +2 -2
- package/dist/index.js +3899 -3487
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +10 -8
- package/src/App.tsx +84 -86
- package/src/components/EditorShell.tsx +3 -3
- package/src/components/LinkedSelectionToggle.tsx +3 -2
- package/src/components/StudioHeader.tsx +4 -4
- package/src/components/StudioLeftPanels.tsx +3 -3
- package/src/components/StudioOverlays.tsx +4 -4
- package/src/components/StudioRightPanels.tsx +14 -9
- package/src/components/StudioToast.tsx +1 -1
- package/src/components/TimelineToolbar.tsx +3 -3
- package/src/components/dock/Dock.tsx +5 -7
- package/src/components/editor/CanvasContextMenu.tsx +3 -2
- package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
- package/src/components/editor/DomEditCropHandles.tsx +7 -4
- package/src/components/editor/DomEditOverlay.tsx +5 -1
- package/src/components/editor/FileTreeNodes.tsx +13 -10
- package/src/components/editor/InlineTextToolbar.tsx +11 -27
- package/src/components/editor/MotionPathOverlay.tsx +7 -2
- package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
- package/src/components/editor/SnapToolbar.test.tsx +100 -1
- package/src/components/editor/SnapToolbar.tsx +43 -5
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
- package/src/components/editor/audioFxTelemetry.ts +2 -3
- package/src/components/editor/colorBrowserCanvas.test.ts +116 -0
- package/src/components/editor/colorValue.test.ts +16 -0
- package/src/components/editor/colorValue.ts +100 -24
- package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
- package/src/components/editor/cropResize.test.ts +11 -0
- package/src/components/editor/cropResize.ts +6 -6
- package/src/components/editor/domEditingDom.ts +4 -0
- package/src/components/editor/domEditingLayers.ts +1 -5
- package/src/components/editor/gradientValue.ts +28 -36
- package/src/components/editor/gsapAnimatesProperty.ts +0 -54
- package/src/components/editor/inlineTextStyleRange.test.ts +31 -1
- package/src/components/editor/inlineTextStyleRange.ts +5 -2
- package/src/components/editor/inlineTextStyleRead.ts +9 -0
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsDom.ts +2 -2
- package/src/components/editor/manualEditsParsing.ts +7 -0
- package/src/components/editor/manualEditsSeekReapply.ts +2 -2
- package/src/components/editor/marqueeCommit.test.tsx +106 -37
- package/src/components/editor/marqueeCommit.ts +24 -8
- package/src/components/editor/motionPathCommit.test.ts +29 -1
- package/src/components/editor/motionPathCommit.ts +21 -9
- package/src/components/editor/probeSourceElement.test.ts +45 -0
- package/src/components/editor/probeSourceElement.ts +39 -15
- package/src/components/editor/propertyPanelColor.tsx +1 -1
- package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
- package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
- package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
- package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
- package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
- package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
- package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
- package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
- package/src/components/editor/propertyPanelFxSection.tsx +30 -28
- package/src/components/editor/propertyPanelTypes.ts +6 -3
- package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +25 -0
- package/src/components/editor/useDomEditNudge.test.tsx +37 -0
- package/src/components/editor/useDomEditNudge.ts +2 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
- package/src/components/editor/useDomEditZOrder.ts +12 -2
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
- package/src/components/sidebar/AssetContextMenu.tsx +4 -4
- package/src/components/ui/menuStyle.test.ts +61 -0
- package/src/components/ui/menuStyle.ts +10 -0
- package/src/contexts/DomEditContext.tsx +1 -1
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/domEditExports.test.tsx +9 -1
- package/src/hooks/domEditCommitRunner.ts +1 -1
- package/src/hooks/elementOffsetStager.ts +5 -4
- package/src/hooks/gsapDragCommit.ts +2 -20
- package/src/hooks/gsapDragPositionCommit.ts +18 -3
- package/src/hooks/gsapGestureOutcome.test.ts +161 -0
- package/src/hooks/gsapGestureOutcome.ts +64 -0
- package/src/hooks/gsapKeyframeCommit.ts +3 -12
- package/src/hooks/gsapResizeIntercept.test.ts +33 -0
- package/src/hooks/gsapResizeIntercept.ts +1 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +1 -1
- package/src/hooks/gsapRuntimeReaders.test.ts +48 -0
- package/src/hooks/gsapRuntimeReaders.ts +3 -90
- package/src/hooks/gsapScriptCommitTypes.ts +5 -0
- package/src/hooks/gsapShared.writeTarget.test.ts +20 -0
- package/src/hooks/keyframeCacheAstLoad.ts +6 -5
- package/src/hooks/plainRotation.ts +3 -2
- package/src/hooks/previewPromotionSelection.test.tsx +1 -1
- package/src/hooks/refreshTimelineRowText.test.ts +65 -0
- package/src/hooks/refreshTimelineRowText.ts +29 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
- package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
- package/src/hooks/useAppHotkeys.ts +11 -5
- package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
- package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
- package/src/hooks/useDomEditTextCommits.test.tsx +18 -0
- package/src/hooks/useDomEditTextCommits.ts +5 -1
- package/src/hooks/useDomEditWiring.ts +4 -2
- package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
- package/src/hooks/useDomGeometryCommit.ts +7 -4
- package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
- package/src/hooks/useDomGeometryCommits.ts +3 -2
- package/src/hooks/useDomSelection.test.ts +26 -0
- package/src/hooks/useDomSelection.ts +10 -3
- package/src/hooks/useDomSelectionTypes.ts +6 -1
- package/src/hooks/useEnableKeyframes.test.ts +2 -2
- package/src/hooks/useEnableKeyframes.ts +5 -3
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +102 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +31 -6
- package/src/hooks/useFrameCapture.ts +15 -8
- package/src/hooks/useGestureCommit.test.tsx +225 -55
- package/src/hooks/useGestureCommit.ts +90 -56
- package/src/hooks/useGestureTrailOverlay.tsx +33 -0
- package/src/hooks/useGsapAwareEditing.test.tsx +11 -6
- package/src/hooks/useGsapAwareEditing.ts +96 -195
- package/src/hooks/useGsapAwareGroupMove.ts +179 -0
- package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
- package/src/hooks/useGsapKeyframeOps.ts +36 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +57 -0
- package/src/hooks/useGsapScriptCommits.ts +3 -0
- package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
- package/src/hooks/useGsapSelectionHandlers.ts +24 -18
- package/src/hooks/useGsapTweenCache.ts +18 -12
- package/src/hooks/usePanelLayout.ts +5 -1
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
- package/src/hooks/usePreviewInteraction.test.ts +21 -0
- package/src/hooks/usePreviewInteraction.ts +2 -0
- package/src/hooks/useRenderClipContent.test.ts +18 -0
- package/src/hooks/useRenderClipContent.ts +4 -5
- package/src/hooks/useShellSlices.ts +53 -0
- package/src/hooks/useStableHandlers.test.tsx +77 -0
- package/src/hooks/useStableHandlers.ts +44 -0
- package/src/hooks/useStudioSdkSessions.ts +2 -1
- package/src/hooks/useStudioSidePanels.test.tsx +44 -0
- package/src/hooks/useStudioSidePanels.tsx +23 -0
- package/src/hooks/useTimelineAddAtPlayhead.ts +16 -8
- package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
- package/src/hooks/useTimelineDeleteOps.ts +21 -27
- package/src/hooks/useTimelineEditing.test.tsx +1 -1
- package/src/hooks/useTimelineEditing.ts +33 -27
- package/src/player/components/AutomationSelectionMenu.tsx +3 -2
- package/src/player/components/ClipContextMenu.test.tsx +73 -0
- package/src/player/components/ClipContextMenu.tsx +33 -31
- package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
- package/src/player/components/OutOfSyncBadge.tsx +5 -5
- package/src/player/components/TextClipContent.test.ts +19 -0
- package/src/player/components/TextClipContent.tsx +27 -0
- package/src/player/components/TrackGapContextMenu.tsx +3 -6
- package/src/player/components/clipMenuToolItems.test.tsx +23 -0
- package/src/player/components/clipMenuToolItems.tsx +13 -9
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
- package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
- package/src/player/components/timelineClipGestureHandlers.ts +20 -2
- package/src/player/components/timelineEditing.ts +3 -1
- package/src/player/components/timelineLinkSelection.ts +35 -1
- package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
- package/src/player/components/useTimelineClipDrag.ts +6 -7
- package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
- package/src/player/hooks/timelinePlayerSync.ts +6 -1
- package/src/player/lib/timelineElementHelpers.test.ts +87 -1
- package/src/player/lib/timelineElementHelpers.ts +6 -15
- package/src/player/lib/timelineText.ts +40 -0
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/theme.css +3 -0
- package/src/utils/frameCapture.ts +3 -1
- package/src/utils/gsapUndoRestore.browser.test.ts +4 -2
- package/src/utils/keyframeUsage.ts +79 -0
- package/src/utils/previewFeatureUsage.test.ts +32 -0
- package/src/utils/previewFeatureUsage.ts +46 -0
- package/src/utils/studioTelemetry.test.ts +107 -0
- package/src/utils/studioTelemetry.ts +49 -4
- package/src/webmcp/StudioAgentTools.tsx +2 -2
- package/dist/assets/index-DbfKVTId.css +0 -1
- package/dist/chunk-BIUXP3PG.js.map +0 -1
- package/dist/chunk-K47E2ZJA.js.map +0 -1
- /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
- /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-D7VYFJJD.js.map} +0 -0
|
@@ -8,10 +8,10 @@ import type { TimelineClipMenuItem } from "./TimelineTypes";
|
|
|
8
8
|
import { ClipMenuToolItems } from "./clipMenuToolItems";
|
|
9
9
|
import { ClipMenuAudioItems } from "./clipMenuAudioItems";
|
|
10
10
|
import { ClipMenuLinkItems } from "./clipMenuLinkItems";
|
|
11
|
+
import { menuClasses } from "../../components/ui/menuStyle";
|
|
12
|
+
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
11
13
|
|
|
12
14
|
const MENU_MARGIN = 8;
|
|
13
|
-
// Empty groups collapse; every non-empty group before the always-present Delete group ends in a divider.
|
|
14
|
-
const GROUP_CLASS = "empty:hidden mb-1 pb-1 border-b border-neutral-700/60";
|
|
15
15
|
|
|
16
16
|
function useMeasuredHeight(ref: RefObject<HTMLDivElement | null>, anchorKey: string): number {
|
|
17
17
|
const [height, setHeight] = useState(0);
|
|
@@ -42,13 +42,8 @@ interface ClipContextMenuProps {
|
|
|
42
42
|
hostItems?: readonly TimelineClipMenuItem[] | undefined;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
// Same enabled/disabled menu-item pattern as the sibling TrackGapContextMenu.
|
|
46
45
|
const itemClass = (enabled: boolean) =>
|
|
47
|
-
|
|
48
|
-
enabled
|
|
49
|
-
? " focus-visible:bg-neutral-800 text-neutral-300 hover:bg-neutral-800 cursor-pointer"
|
|
50
|
-
: " text-neutral-600 cursor-not-allowed"
|
|
51
|
-
}`;
|
|
46
|
+
`${menuClasses.row} flex items-center justify-between ${enabled ? menuClasses.rowEnabled : menuClasses.rowDisabled}`;
|
|
52
47
|
|
|
53
48
|
/** The host's items, above Studio's. A pick closes the menu; focus the item moves stays where it went. */
|
|
54
49
|
function HostItems({
|
|
@@ -103,6 +98,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
103
98
|
}: ClipContextMenuProps) {
|
|
104
99
|
const menuRef = useContextMenuDismiss(onClose);
|
|
105
100
|
useMenuKeyboardNav(menuRef);
|
|
101
|
+
const tools = useTimelineEditContextOptional().clipMenuTools !== false;
|
|
106
102
|
// The right-clicked clip's own id: a member of the live multi-selection
|
|
107
103
|
// means Copy/Duplicate act on the whole group, matching onContextMenuClip's
|
|
108
104
|
// selection-preserving behaviour for a right-click inside it.
|
|
@@ -134,15 +130,15 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
134
130
|
ref={menuRef}
|
|
135
131
|
role="menu"
|
|
136
132
|
aria-label="Clip actions"
|
|
137
|
-
className=
|
|
133
|
+
className={`${menuClasses.panel} fixed z-200 min-w-[180px]`}
|
|
138
134
|
style={{ left: adjustedX, top: adjustedY }}
|
|
139
135
|
>
|
|
140
136
|
{hostItems.length > 0 && (
|
|
141
|
-
<div role="group" aria-label="Host" className={
|
|
137
|
+
<div role="group" aria-label="Host" className={menuClasses.group}>
|
|
142
138
|
<HostItems items={hostItems} onClose={onClose} />
|
|
143
139
|
</div>
|
|
144
140
|
)}
|
|
145
|
-
<div role="group" aria-label="Time" className={
|
|
141
|
+
<div role="group" aria-label="Time" className={menuClasses.group}>
|
|
146
142
|
{splitLabel && (
|
|
147
143
|
<button
|
|
148
144
|
type="button"
|
|
@@ -164,7 +160,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
164
160
|
<span className="text-neutral-500 text-[10px] ml-3">S</span>
|
|
165
161
|
</button>
|
|
166
162
|
)}
|
|
167
|
-
{splitLabel && (
|
|
163
|
+
{tools && splitLabel && (
|
|
168
164
|
<ClipMenuToolItems
|
|
169
165
|
group="time"
|
|
170
166
|
element={element}
|
|
@@ -174,28 +170,34 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
174
170
|
)}
|
|
175
171
|
</div>
|
|
176
172
|
|
|
177
|
-
<div role="group" aria-label="Sound" className={
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
173
|
+
<div role="group" aria-label="Sound" className={menuClasses.group}>
|
|
174
|
+
{tools && (
|
|
175
|
+
<>
|
|
176
|
+
<ClipMenuAudioItems part="gain" element={element} onClose={onClose} />
|
|
177
|
+
<ClipMenuToolItems
|
|
178
|
+
group="sound"
|
|
179
|
+
element={element}
|
|
180
|
+
currentTime={currentTime}
|
|
181
|
+
onClose={onClose}
|
|
182
|
+
/>
|
|
183
|
+
<ClipMenuLinkItems part="link" element={element} onClose={onClose} />
|
|
184
|
+
<ClipMenuAudioItems part="duck" element={element} onClose={onClose} />
|
|
185
|
+
</>
|
|
186
|
+
)}
|
|
187
187
|
</div>
|
|
188
188
|
|
|
189
|
-
<div role="group" aria-label="Picture" className={
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
189
|
+
<div role="group" aria-label="Picture" className={menuClasses.group}>
|
|
190
|
+
{tools && (
|
|
191
|
+
<ClipMenuToolItems
|
|
192
|
+
group="picture"
|
|
193
|
+
element={element}
|
|
194
|
+
currentTime={currentTime}
|
|
195
|
+
onClose={onClose}
|
|
196
|
+
/>
|
|
197
|
+
)}
|
|
196
198
|
</div>
|
|
197
199
|
|
|
198
|
-
<div role="group" aria-label="Clipboard" className={
|
|
200
|
+
<div role="group" aria-label="Clipboard" className={menuClasses.group}>
|
|
199
201
|
{onCopy && (
|
|
200
202
|
<button
|
|
201
203
|
type="button"
|
|
@@ -255,7 +257,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
255
257
|
<span>Delete</span>
|
|
256
258
|
<span className="text-neutral-500 text-[10px] ml-3">⌫</span>
|
|
257
259
|
</button>
|
|
258
|
-
<ClipMenuLinkItems part="delete" element={element} onClose={onClose} />
|
|
260
|
+
{tools && <ClipMenuLinkItems part="delete" element={element} onClose={onClose} />}
|
|
259
261
|
</div>
|
|
260
262
|
</div>,
|
|
261
263
|
document.body,
|
|
@@ -4,6 +4,7 @@ import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
|
4
4
|
import { useMenuKeyboardNav } from "./menuKeyboardNav";
|
|
5
5
|
import type { TimelineElement } from "../store/playerStore";
|
|
6
6
|
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
|
7
|
+
import { menuClasses } from "../../components/ui/menuStyle";
|
|
7
8
|
|
|
8
9
|
export interface KeyframeDiamondContextMenuState {
|
|
9
10
|
x: number;
|
|
@@ -39,10 +40,8 @@ interface KeyframeDiamondContextMenuProps {
|
|
|
39
40
|
onMoveToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
const ITEM_CLS =
|
|
43
|
-
|
|
44
|
-
const DESTRUCTIVE_ITEM_CLS =
|
|
45
|
-
"w-full flex items-center gap-2 px-3 py-1.5 text-xs text-danger-ink hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden cursor-pointer text-left";
|
|
43
|
+
const ITEM_CLS = `${menuClasses.row} ${menuClasses.rowEnabled} flex items-center gap-2`;
|
|
44
|
+
const DESTRUCTIVE_ITEM_CLS = `${menuClasses.row} text-danger-ink hover:bg-neutral-800 focus-visible:bg-neutral-800 cursor-pointer flex items-center gap-2`;
|
|
46
45
|
|
|
47
46
|
export function KeyframeDiamondContextMenu({
|
|
48
47
|
state,
|
|
@@ -96,7 +95,7 @@ export function KeyframeDiamondContextMenu({
|
|
|
96
95
|
ref={menuRef}
|
|
97
96
|
role="menu"
|
|
98
97
|
aria-label="Keyframe actions"
|
|
99
|
-
className=
|
|
98
|
+
className={`${menuClasses.panel} fixed z-200 min-w-[180px] overflow-y-auto`}
|
|
100
99
|
style={{ left: adjustedX, top: adjustedY, maxHeight: `calc(100vh - ${adjustedY + 8}px)` }}
|
|
101
100
|
>
|
|
102
101
|
{onMoveToPlayhead && (
|
|
@@ -165,14 +164,14 @@ export function KeyframeDiamondContextMenu({
|
|
|
165
164
|
|
|
166
165
|
{/* Deleting every keyframe sat adjacent to the single delete and styled
|
|
167
166
|
identically. Separate and mark it so the two cannot be misread. */}
|
|
168
|
-
<div className=
|
|
169
|
-
|
|
170
|
-
<div className=
|
|
167
|
+
<div className={menuClasses.divider} role="separator" />
|
|
168
|
+
<div className="h-1" aria-hidden="true" />
|
|
169
|
+
<div className={menuClasses.divider} role="separator" />
|
|
171
170
|
|
|
172
171
|
<button
|
|
173
172
|
type="button"
|
|
174
173
|
role="menuitem"
|
|
175
|
-
className=
|
|
174
|
+
className={`${menuClasses.row} ${menuClasses.rowDanger} flex items-center gap-2`}
|
|
176
175
|
onClick={() => {
|
|
177
176
|
onDeleteAll(state.element, state.animationId);
|
|
178
177
|
onClose();
|
|
@@ -7,11 +7,11 @@ import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences";
|
|
|
7
7
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
8
8
|
import { clipSyncState, type ClipSyncState } from "./clipSync";
|
|
9
9
|
import { useMenuKeyboardNav } from "./menuKeyboardNav";
|
|
10
|
+
import { menuClasses } from "../../components/ui/menuStyle";
|
|
10
11
|
|
|
11
12
|
const stop = (e: SyntheticEvent) => e.stopPropagation();
|
|
12
13
|
|
|
13
|
-
const
|
|
14
|
-
"block w-full px-3 py-1.5 text-left text-xs text-neutral-300 outline-hidden hover:bg-neutral-800 focus-visible:bg-neutral-800 disabled:opacity-40";
|
|
14
|
+
const ROW = `${menuClasses.row} ${menuClasses.rowEnabled} block disabled:cursor-not-allowed disabled:opacity-40`;
|
|
15
15
|
|
|
16
16
|
function SyncMenu({
|
|
17
17
|
x,
|
|
@@ -39,13 +39,13 @@ function SyncMenu({
|
|
|
39
39
|
ref={menuRef}
|
|
40
40
|
role="menu"
|
|
41
41
|
aria-label="Out of sync"
|
|
42
|
-
className=
|
|
42
|
+
className={`${menuClasses.panel} fixed z-200 min-w-[170px]`}
|
|
43
43
|
style={{ left, top }}
|
|
44
44
|
>
|
|
45
45
|
<button
|
|
46
46
|
type="button"
|
|
47
47
|
role="menuitem"
|
|
48
|
-
className={
|
|
48
|
+
className={ROW}
|
|
49
49
|
disabled={!onLinkEdit || moveStart === null}
|
|
50
50
|
title={moveStart === null ? "Would move the clip before 0:00" : undefined}
|
|
51
51
|
onClick={() => {
|
|
@@ -59,7 +59,7 @@ function SyncMenu({
|
|
|
59
59
|
<button
|
|
60
60
|
type="button"
|
|
61
61
|
role="menuitem"
|
|
62
|
-
className={
|
|
62
|
+
className={ROW}
|
|
63
63
|
disabled={!onLinkEdit || slipMediaStart === null}
|
|
64
64
|
title={slipMediaStart === null ? "Would slip before the start of the file" : undefined}
|
|
65
65
|
onClick={() => {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { textClipBackground } from "./TextClipContent";
|
|
3
|
+
|
|
4
|
+
describe("textClipBackground", () => {
|
|
5
|
+
it("puts dark text on a light strip and light text on a dark one", () => {
|
|
6
|
+
expect(textClipBackground({ value: "a", color: "rgb(16, 20, 24)" })).toBe(
|
|
7
|
+
"var(--timeline-text-clip-light-bg)",
|
|
8
|
+
);
|
|
9
|
+
expect(textClipBackground({ value: "a", color: "rgb(255, 255, 255)" })).toBe(
|
|
10
|
+
"var(--timeline-text-clip-dark-bg)",
|
|
11
|
+
);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("uses the layer's own background when it paints one", () => {
|
|
15
|
+
expect(
|
|
16
|
+
textClipBackground({ value: "a", color: "rgb(16, 20, 24)", background: "rgb(230, 57, 70)" }),
|
|
17
|
+
).toBe("rgb(230, 57, 70)");
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { parseCssColor } from "../../components/editor/colorValue";
|
|
2
|
+
import type { TimelineText } from "../store/timelineElement";
|
|
3
|
+
|
|
4
|
+
/** The layer's own background, or a strip the text reads on: light behind dark text, dark otherwise. */
|
|
5
|
+
export function textClipBackground(text: TimelineText): string {
|
|
6
|
+
if (text.background) return text.background;
|
|
7
|
+
const color = parseCssColor(text.color ?? "");
|
|
8
|
+
const light = color ? 0.2126 * color.red + 0.7152 * color.green + 0.0722 * color.blue : 255;
|
|
9
|
+
return light < 128 ? "var(--timeline-text-clip-light-bg)" : "var(--timeline-text-clip-dark-bg)";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A text layer's row: its own words in its own font and colour, live, on one clipped line. */
|
|
13
|
+
export function TextClipContent({ text }: { text: TimelineText }) {
|
|
14
|
+
return (
|
|
15
|
+
<div
|
|
16
|
+
className="absolute inset-0 flex items-center overflow-hidden px-2"
|
|
17
|
+
style={{ background: textClipBackground(text) }}
|
|
18
|
+
>
|
|
19
|
+
<span
|
|
20
|
+
className="whitespace-nowrap text-[15px] leading-none"
|
|
21
|
+
style={{ fontFamily: text.fontFamily, fontWeight: text.fontWeight, color: text.color }}
|
|
22
|
+
>
|
|
23
|
+
{text.value}
|
|
24
|
+
</span>
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { memo } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
3
|
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
4
|
+
import { menuClasses } from "../../components/ui/menuStyle";
|
|
4
5
|
|
|
5
6
|
interface TrackGapContextMenuProps {
|
|
6
7
|
x: number;
|
|
@@ -53,11 +54,7 @@ export const TrackGapContextMenu = memo(function TrackGapContextMenu({
|
|
|
53
54
|
const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
|
|
54
55
|
|
|
55
56
|
const itemClass = (enabled: boolean) =>
|
|
56
|
-
|
|
57
|
-
enabled
|
|
58
|
-
? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
|
|
59
|
-
: "text-neutral-600 cursor-not-allowed"
|
|
60
|
-
}`;
|
|
57
|
+
`${menuClasses.row} flex items-center justify-between ${enabled ? menuClasses.rowEnabled : menuClasses.rowDisabled}`;
|
|
61
58
|
|
|
62
59
|
// Disabled reasons: no gap under the pointer beats the lock reason — a
|
|
63
60
|
// pointer not on a gap has nothing to close regardless of movability.
|
|
@@ -75,7 +72,7 @@ export const TrackGapContextMenu = memo(function TrackGapContextMenu({
|
|
|
75
72
|
return createPortal(
|
|
76
73
|
<div
|
|
77
74
|
ref={menuRef}
|
|
78
|
-
className=
|
|
75
|
+
className={`${menuClasses.panel} fixed z-200 min-w-[180px]`}
|
|
79
76
|
style={{ left: adjustedX, top: adjustedY }}
|
|
80
77
|
onPointerLeave={() => onHoverAction(null)}
|
|
81
78
|
>
|
|
@@ -99,6 +99,29 @@ describe("ClipMenuToolItems", () => {
|
|
|
99
99
|
expect(onClose).toHaveBeenCalled();
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
+
it("opens its submenu at the row's edge in window pixels, and flips left near the window's right edge", () => {
|
|
103
|
+
const rowAt = (rect: { top: number; left: number; right: number }) =>
|
|
104
|
+
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(rect as DOMRect);
|
|
105
|
+
const spy = rowAt({ top: 50, left: 100, right: 300 });
|
|
106
|
+
renderItems("sound", video);
|
|
107
|
+
openSubmenu("Voice");
|
|
108
|
+
let menu = document.querySelector<HTMLElement>('[role="menu"]')!;
|
|
109
|
+
expect(menu.className).toContain("fixed");
|
|
110
|
+
expect([menu.style.top, menu.style.left]).toEqual(["50px", "300px"]);
|
|
111
|
+
act(() => root?.unmount());
|
|
112
|
+
document.body.innerHTML = "";
|
|
113
|
+
spy.mockReturnValue({
|
|
114
|
+
top: 50,
|
|
115
|
+
left: window.innerWidth - 200,
|
|
116
|
+
right: window.innerWidth - 10,
|
|
117
|
+
} as DOMRect);
|
|
118
|
+
renderItems("sound", video);
|
|
119
|
+
openSubmenu("Voice");
|
|
120
|
+
menu = document.querySelector<HTMLElement>('[role="menu"]')!;
|
|
121
|
+
expect([menu.style.top, menu.style.right, menu.style.left]).toEqual(["50px", "200px", ""]);
|
|
122
|
+
spy.mockRestore();
|
|
123
|
+
});
|
|
124
|
+
|
|
102
125
|
it("Voice None drops the attribute", () => {
|
|
103
126
|
const { setQuiet } = renderItems("sound", video);
|
|
104
127
|
openSubmenu("Voice");
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type ClipToolChoice,
|
|
16
16
|
} from "./clipToolAttrs";
|
|
17
17
|
import { useClipToolState } from "./useClipToolState";
|
|
18
|
+
import { menuClasses } from "../../components/ui/menuStyle";
|
|
18
19
|
|
|
19
20
|
export type ClipMenuToolGroup = "time" | "sound" | "picture";
|
|
20
21
|
|
|
@@ -25,10 +26,8 @@ interface ClipMenuToolItemsProps {
|
|
|
25
26
|
onClose: () => void;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
const ROW_CLASS =
|
|
29
|
-
|
|
30
|
-
const DISABLED_ROW_CLASS =
|
|
31
|
-
"w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none text-neutral-600 cursor-not-allowed";
|
|
29
|
+
const ROW_CLASS = `${menuClasses.row} flex items-center justify-between ${menuClasses.rowEnabled}`;
|
|
30
|
+
const DISABLED_ROW_CLASS = `${menuClasses.row} flex items-center justify-between ${menuClasses.rowDisabled}`;
|
|
32
31
|
const SUBMENU_WIDTH = 170;
|
|
33
32
|
const HOVER_PREVIEW_DELAY_MS = 80;
|
|
34
33
|
|
|
@@ -98,7 +97,7 @@ function ChoiceSubmenu({
|
|
|
98
97
|
preview?: HoverPreview;
|
|
99
98
|
}) {
|
|
100
99
|
const [open, setOpen] = useState(false);
|
|
101
|
-
const [
|
|
100
|
+
const [anchor, setAnchor] = useState<{ top: number; left?: number; right?: number }>({ top: 0 });
|
|
102
101
|
const rowRef = useRef<HTMLButtonElement | null>(null);
|
|
103
102
|
const submenuRef = useRef<HTMLDivElement | null>(null);
|
|
104
103
|
const hover = useHoverPreview(preview);
|
|
@@ -111,7 +110,12 @@ function ChoiceSubmenu({
|
|
|
111
110
|
|
|
112
111
|
const show = (focusFirst: boolean) => {
|
|
113
112
|
const rect = rowRef.current?.getBoundingClientRect();
|
|
114
|
-
|
|
113
|
+
if (rect)
|
|
114
|
+
setAnchor(
|
|
115
|
+
rect.right + SUBMENU_WIDTH > window.innerWidth
|
|
116
|
+
? { top: rect.top, right: window.innerWidth - rect.left }
|
|
117
|
+
: { top: rect.top, left: rect.right },
|
|
118
|
+
);
|
|
115
119
|
setOpen(true);
|
|
116
120
|
if (!focusFirst) return;
|
|
117
121
|
openingFocusRef.current = true;
|
|
@@ -184,8 +188,8 @@ function ChoiceSubmenu({
|
|
|
184
188
|
ref={submenuRef}
|
|
185
189
|
role="menu"
|
|
186
190
|
aria-label={label}
|
|
187
|
-
className=
|
|
188
|
-
style={{ width: SUBMENU_WIDTH, ...
|
|
191
|
+
className={`${menuClasses.panel} fixed z-10`}
|
|
192
|
+
style={{ width: SUBMENU_WIDTH, ...anchor }}
|
|
189
193
|
onKeyDown={onSubmenuKeyDown}
|
|
190
194
|
>
|
|
191
195
|
{choiceRow(null, "None")}
|
|
@@ -193,7 +197,7 @@ function ChoiceSubmenu({
|
|
|
193
197
|
<div key={section.heading ?? index}>
|
|
194
198
|
{section.heading && (
|
|
195
199
|
<>
|
|
196
|
-
<div className=
|
|
200
|
+
<div className={menuClasses.divider} />
|
|
197
201
|
<div className="px-3 py-1 text-[9px] uppercase tracking-wide text-neutral-500">
|
|
198
202
|
{section.heading}
|
|
199
203
|
</div>
|
|
@@ -137,6 +137,7 @@ export interface TimelineEditCallbacks {
|
|
|
137
137
|
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
138
138
|
onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
|
|
139
139
|
onFreezeFrame?: (element: TimelineElement, time: number) => Promise<void> | void;
|
|
140
|
+
clipMenuTools?: boolean;
|
|
140
141
|
onNotice?: (message: string, tone?: "error" | "info") => void;
|
|
141
142
|
onDeleteKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
|
142
143
|
onDeleteAllKeyframes?: (element: TimelineElement, animationId?: string) => void;
|
|
@@ -195,7 +195,7 @@ export function mountTimelineClipDragGestureLifecycle({
|
|
|
195
195
|
event.clientX - blocked.originClientX,
|
|
196
196
|
event.clientY - blocked.originClientY,
|
|
197
197
|
);
|
|
198
|
-
const threshold = blocked.intent === "move" ? 4 : 2;
|
|
198
|
+
const threshold = blocked.intent === "move" || blocked.intent === "edit-many" ? 4 : 2;
|
|
199
199
|
if (!blocked.started && distance < threshold) return;
|
|
200
200
|
if (!blocked.started) {
|
|
201
201
|
blocked.started = true;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import type { PointerEvent as ReactPointerEvent } from "react";
|
|
5
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
6
|
+
import { createClipGestureHandlers, type ClipGestureDeps } from "./timelineClipGestureHandlers";
|
|
7
|
+
import { MAX_HAND_EDIT_CLIPS } from "./timelineEditing";
|
|
8
|
+
|
|
9
|
+
afterEach(() => usePlayerStore.getState().reset());
|
|
10
|
+
|
|
11
|
+
const clips: TimelineElement[] = Array.from({ length: 6 }, (_, i) => ({
|
|
12
|
+
id: `clip-${i}`,
|
|
13
|
+
domId: `clip-${i}`,
|
|
14
|
+
tag: "div",
|
|
15
|
+
start: i,
|
|
16
|
+
duration: 1,
|
|
17
|
+
track: i,
|
|
18
|
+
}));
|
|
19
|
+
const capabilities = { canMove: true, canTrimStart: true, canTrimEnd: true, readOnly: false };
|
|
20
|
+
|
|
21
|
+
function grabFirstClip(count: number, gesture: "move" | "resize", locked: number[] = []) {
|
|
22
|
+
const store = usePlayerStore.getState();
|
|
23
|
+
store.setElements(
|
|
24
|
+
clips.map((clip, i) => (locked.includes(i) ? { ...clip, timelineLocked: true } : clip)),
|
|
25
|
+
);
|
|
26
|
+
store.setSelectedElementIds(new Set(clips.slice(0, count).map((c) => c.id)));
|
|
27
|
+
const setDraggedClip = vi.fn();
|
|
28
|
+
const setResizingClip = vi.fn();
|
|
29
|
+
const blockedClipRef = { current: null as { intent: string } | null };
|
|
30
|
+
const deps = {
|
|
31
|
+
pps: 100,
|
|
32
|
+
onMoveElement: vi.fn(),
|
|
33
|
+
onResizeElement: vi.fn(),
|
|
34
|
+
blockedClipRef,
|
|
35
|
+
suppressClickRef: { current: false },
|
|
36
|
+
scrollRef: { current: null },
|
|
37
|
+
setShowPopover: vi.fn(),
|
|
38
|
+
setRangeSelection: vi.fn(),
|
|
39
|
+
setResizingClip,
|
|
40
|
+
setDraggedClip,
|
|
41
|
+
setSelectedElementId: vi.fn(),
|
|
42
|
+
} as unknown as ClipGestureDeps;
|
|
43
|
+
const handlers = createClipGestureHandlers(clips[0], clips[0].id, clips[0], capabilities, deps);
|
|
44
|
+
const event = {
|
|
45
|
+
button: 0,
|
|
46
|
+
clientX: 50,
|
|
47
|
+
clientY: 5,
|
|
48
|
+
pointerId: 1,
|
|
49
|
+
stopPropagation: vi.fn(),
|
|
50
|
+
currentTarget: { getBoundingClientRect: () => ({ left: 0, width: 100 }) },
|
|
51
|
+
} as unknown as ReactPointerEvent;
|
|
52
|
+
if (gesture === "move") handlers.onPointerDown(event);
|
|
53
|
+
else handlers.onResizeStart("end", event);
|
|
54
|
+
return { setDraggedClip, setResizingClip, blockedClipRef };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("hand-editing a multi-selection", () => {
|
|
58
|
+
it("moves up to the limit by hand", () => {
|
|
59
|
+
const { setDraggedClip, blockedClipRef } = grabFirstClip(MAX_HAND_EDIT_CLIPS, "move");
|
|
60
|
+
expect(setDraggedClip).toHaveBeenCalledOnce();
|
|
61
|
+
expect(blockedClipRef.current).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("refuses to start a drag above the limit and asks for the toast", () => {
|
|
65
|
+
const { setDraggedClip, blockedClipRef } = grabFirstClip(MAX_HAND_EDIT_CLIPS + 1, "move");
|
|
66
|
+
expect(setDraggedClip).not.toHaveBeenCalled();
|
|
67
|
+
expect(blockedClipRef.current?.intent).toBe("edit-many");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("resizes up to the limit by hand", () => {
|
|
71
|
+
const { setResizingClip, blockedClipRef } = grabFirstClip(MAX_HAND_EDIT_CLIPS, "resize");
|
|
72
|
+
expect(setResizingClip).toHaveBeenCalledOnce();
|
|
73
|
+
expect(blockedClipRef.current).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("refuses to start a resize above the limit and asks for the toast", () => {
|
|
77
|
+
const { setResizingClip, blockedClipRef } = grabFirstClip(MAX_HAND_EDIT_CLIPS + 1, "resize");
|
|
78
|
+
expect(setResizingClip).not.toHaveBeenCalled();
|
|
79
|
+
expect(blockedClipRef.current?.intent).toBe("edit-many");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("counts the clips a move would change: locked ones in the selection stay put", () => {
|
|
83
|
+
const allowed = grabFirstClip(MAX_HAND_EDIT_CLIPS + 2, "move", [3, 4]);
|
|
84
|
+
expect(allowed.setDraggedClip).toHaveBeenCalledOnce();
|
|
85
|
+
const refused = grabFirstClip(MAX_HAND_EDIT_CLIPS + 2, "move", [4]);
|
|
86
|
+
expect(refused.setDraggedClip).not.toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("counts the clips a trim would change: a locked member leaves a trim of the grabbed clip alone", () => {
|
|
90
|
+
const allowed = grabFirstClip(MAX_HAND_EDIT_CLIPS + 1, "resize", [3]);
|
|
91
|
+
expect(allowed.setResizingClip).toHaveBeenCalledOnce();
|
|
92
|
+
expect(allowed.blockedClipRef.current).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -8,7 +8,11 @@ import {
|
|
|
8
8
|
import type { TimelineEditCapabilities } from "./timelineEditCapabilities";
|
|
9
9
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
10
10
|
import { CLIP_HANDLE_W } from "./timelineLayout";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
exceedsHandEditLimit,
|
|
13
|
+
selectClipWithLinks,
|
|
14
|
+
toggleClipWithLinks,
|
|
15
|
+
} from "./timelineLinkSelection";
|
|
12
16
|
import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
|
|
13
17
|
|
|
14
18
|
export interface ClipGestureDeps {
|
|
@@ -73,6 +77,7 @@ function isIntentBlocked(
|
|
|
73
77
|
*/
|
|
74
78
|
function resolvePointerDownAction(
|
|
75
79
|
e: ReactPointerEvent,
|
|
80
|
+
grabbed: TimelineElement,
|
|
76
81
|
capabilities: TimelineEditCapabilities,
|
|
77
82
|
onResizeElement: ClipGestureDeps["onResizeElement"],
|
|
78
83
|
onMoveElement: ClipGestureDeps["onMoveElement"],
|
|
@@ -93,6 +98,8 @@ function resolvePointerDownAction(
|
|
|
93
98
|
}
|
|
94
99
|
|
|
95
100
|
if (!onMoveElement || !capabilities.canMove) return { kind: "ignore" };
|
|
101
|
+
if (exceedsHandEditLimit(grabbed, e.altKey, null))
|
|
102
|
+
return { kind: "block", intent: "edit-many", rect };
|
|
96
103
|
return { kind: "move", rect };
|
|
97
104
|
}
|
|
98
105
|
|
|
@@ -129,6 +136,17 @@ export function createClipGestureHandlers(
|
|
|
129
136
|
const onResizeStart = (edge: "start" | "end", e: ReactPointerEvent): void => {
|
|
130
137
|
if (!canStartResize(edge, e, capabilities, onResizeElement)) return;
|
|
131
138
|
e.stopPropagation();
|
|
139
|
+
if (exceedsHandEditLimit(el, e.altKey, edge)) {
|
|
140
|
+
blockedClipRef.current = {
|
|
141
|
+
pointerId: e.pointerId,
|
|
142
|
+
element: el,
|
|
143
|
+
intent: "edit-many",
|
|
144
|
+
originClientX: e.clientX,
|
|
145
|
+
originClientY: e.clientY,
|
|
146
|
+
started: false,
|
|
147
|
+
};
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
132
150
|
blockedClipRef.current = null;
|
|
133
151
|
setShowPopover(false);
|
|
134
152
|
setRangeSelection(null);
|
|
@@ -147,7 +165,7 @@ export function createClipGestureHandlers(
|
|
|
147
165
|
};
|
|
148
166
|
|
|
149
167
|
const onPointerDown = (e: ReactPointerEvent): void => {
|
|
150
|
-
const action = resolvePointerDownAction(e, capabilities, onResizeElement, onMoveElement);
|
|
168
|
+
const action = resolvePointerDownAction(e, el, capabilities, onResizeElement, onMoveElement);
|
|
151
169
|
if (action.kind === "ignore") return;
|
|
152
170
|
|
|
153
171
|
if (action.kind === "block") {
|
|
@@ -248,7 +248,9 @@ export interface TimelinePromptElement {
|
|
|
248
248
|
track: number;
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
-
export type BlockedTimelineEditIntent = "move" | "resize-start" | "resize-end";
|
|
251
|
+
export type BlockedTimelineEditIntent = "move" | "edit-many" | "resize-start" | "resize-end";
|
|
252
|
+
|
|
253
|
+
export const MAX_HAND_EDIT_CLIPS = 3;
|
|
252
254
|
|
|
253
255
|
export interface TimelineRangeSelection {
|
|
254
256
|
start: number;
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
dropMisalignedTrimPartners,
|
|
4
|
+
expandToLinkedMembers,
|
|
5
|
+
linkedGestureKeys,
|
|
6
|
+
linkedMembersOf,
|
|
7
|
+
} from "./audioClipLink";
|
|
8
|
+
import { buildTimelineGroupResizeMembers } from "./timelineGroupEditing";
|
|
9
|
+
import { resolveGroupMovers } from "./timelineMultiDragPreview";
|
|
10
|
+
import { MAX_HAND_EDIT_CLIPS } from "./timelineEditing";
|
|
3
11
|
import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences";
|
|
4
12
|
|
|
5
13
|
/**
|
|
@@ -39,3 +47,29 @@ export function toggleClipWithLinks(key: string, altKey: boolean): TimelineEleme
|
|
|
39
47
|
const primary = usePlayerStore.getState().selectedElementId;
|
|
40
48
|
return state.elements.find((el) => (el.key ?? el.id) === primary) ?? null;
|
|
41
49
|
}
|
|
50
|
+
|
|
51
|
+
/** Whether a hand drag on `grabbed` (trimming `edge`, or null to move) would change more clips than allowed. */
|
|
52
|
+
export function exceedsHandEditLimit(
|
|
53
|
+
grabbed: TimelineElement,
|
|
54
|
+
altKey: boolean,
|
|
55
|
+
edge: "start" | "end" | null,
|
|
56
|
+
): boolean {
|
|
57
|
+
const { selectedElementIds, elements } = usePlayerStore.getState();
|
|
58
|
+
const grabbedKey = grabbed.key ?? grabbed.id;
|
|
59
|
+
const keys = linkedGestureKeys(
|
|
60
|
+
selectedElementIds,
|
|
61
|
+
grabbed,
|
|
62
|
+
elements,
|
|
63
|
+
altKey,
|
|
64
|
+
isLinkedSelectionOn(),
|
|
65
|
+
);
|
|
66
|
+
const changed = edge
|
|
67
|
+
? buildTimelineGroupResizeMembers(
|
|
68
|
+
elements,
|
|
69
|
+
dropMisalignedTrimPartners(keys, grabbed, elements, edge),
|
|
70
|
+
grabbedKey,
|
|
71
|
+
edge,
|
|
72
|
+
)
|
|
73
|
+
: resolveGroupMovers(elements, keys, grabbedKey);
|
|
74
|
+
return (changed?.length ?? 1) > MAX_HAND_EDIT_CLIPS;
|
|
75
|
+
}
|
|
@@ -49,6 +49,7 @@ function mount(order = [0, 1, 2]) {
|
|
|
49
49
|
const host = document.createElement("div");
|
|
50
50
|
const root = createRoot(host);
|
|
51
51
|
const onMoveElements = vi.fn();
|
|
52
|
+
const onBlockedEditAttempt = vi.fn();
|
|
52
53
|
const trackOrderRef = { current: order };
|
|
53
54
|
const trackInsertLayoutRef = { current: buildTimelineTrackInsertLayout(order, groups) };
|
|
54
55
|
let state: ReturnType<typeof useTimelineClipDrag>;
|
|
@@ -68,6 +69,7 @@ function mount(order = [0, 1, 2]) {
|
|
|
68
69
|
},
|
|
69
70
|
onMoveElement: vi.fn(),
|
|
70
71
|
onMoveElements,
|
|
72
|
+
onBlockedEditAttempt,
|
|
71
73
|
setShowPopover: () => {},
|
|
72
74
|
setRangeSelectionRef: { current: null },
|
|
73
75
|
});
|
|
@@ -83,6 +85,7 @@ function mount(order = [0, 1, 2]) {
|
|
|
83
85
|
key,
|
|
84
86
|
clip,
|
|
85
87
|
onMoveElements,
|
|
88
|
+
onBlockedEditAttempt,
|
|
86
89
|
root,
|
|
87
90
|
elements,
|
|
88
91
|
changeOrder(next: number[]) {
|
|
@@ -161,3 +164,14 @@ it("leaves Space on a nested fade slider to the slider", () => {
|
|
|
161
164
|
expect(event.defaultPrevented).toBe(false);
|
|
162
165
|
act(() => view.root.unmount());
|
|
163
166
|
});
|
|
167
|
+
|
|
168
|
+
it("does not pick up a clip held in a selection above the hand-move limit", () => {
|
|
169
|
+
const view = mount();
|
|
170
|
+
usePlayerStore.getState().setSelection(["top", "hero", "tail", "bottom"], "hero");
|
|
171
|
+
view.key(" ");
|
|
172
|
+
expect(view.drag).toBeNull();
|
|
173
|
+
expect(view.onBlockedEditAttempt).toHaveBeenCalledWith(
|
|
174
|
+
expect.objectContaining({ id: "hero" }),
|
|
175
|
+
"edit-many",
|
|
176
|
+
);
|
|
177
|
+
});
|