@hyperframes/studio 0.8.108 → 0.8.110
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-BakGew45.js → hyperframes-player-c6WR3RsF.js} +1 -1
- package/dist/assets/{index-C5dyUHwk.js → index-BJpQzV6h.js} +1 -1
- package/dist/assets/{index-CPe7d_wO.js → index-C0KfbPAo.js} +1 -1
- package/dist/assets/{index-ncVJ5IVu.css → index-C7apTvpr.css} +1 -1
- package/dist/assets/{index-C842tcVa.js → index-Co6W8IVk.js} +211 -208
- package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
- package/dist/chunk-5VSXRGIX.js.map +1 -0
- package/dist/{chunk-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
- package/dist/chunk-QUXZVHOJ.js.map +1 -0
- package/dist/index.d.ts +64 -9
- package/dist/index.html +2 -2
- package/dist/index.js +8216 -6268
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +7 -11
- package/src/components/EditorShell.tsx +4 -0
- package/src/components/LinkedSelectionToggle.tsx +73 -0
- package/src/components/TimelineToolbar.test.tsx +31 -0
- package/src/components/TimelineToolbar.tsx +2 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
- package/src/components/editor/DomEditCropHandles.tsx +2 -1
- package/src/components/editor/InlineTextCaret.test.tsx +150 -0
- package/src/components/editor/InlineTextCaret.tsx +211 -0
- package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
- package/src/components/editor/InlineTextToolbar.tsx +7 -5
- package/src/components/editor/PropertyPanelFlat.tsx +4 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
- package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
- package/src/components/editor/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- package/src/components/editor/domEditOverlayGestures.ts +10 -0
- package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
- package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/marqueeCommit.test.tsx +31 -6
- package/src/components/editor/marqueeCommit.ts +16 -8
- package/src/components/editor/mediaAudioEdits.test.ts +20 -4
- package/src/components/editor/mediaAudioEdits.ts +27 -14
- package/src/components/editor/mediaLinkEdits.test.ts +334 -0
- package/src/components/editor/mediaLinkEdits.ts +303 -0
- package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
- package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/useTimelineEditCallbacks.ts +10 -0
- package/src/components/ui/Menu.test.tsx +3 -22
- package/src/components/ui/Menu.tsx +2 -2
- package/src/components/ui/Popover.tsx +2 -2
- package/src/components/ui/Select.tsx +2 -2
- package/src/components/ui/Tooltip.tsx +2 -2
- package/src/components/ui/mountHost.testHelpers.ts +8 -0
- package/src/components/ui/valueControls.test.tsx +2 -21
- package/src/contexts/TimelineEditContext.tsx +4 -0
- package/src/editorGesturesExports.test.tsx +1 -0
- package/src/hooks/appHotkeysDispatch.ts +11 -1
- package/src/hooks/linkShortcuts.test.ts +70 -0
- package/src/hooks/linkShortcuts.ts +41 -0
- package/src/hooks/timelineElementsAttribute.test.ts +95 -0
- package/src/hooks/timelineElementsAttribute.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +141 -0
- package/src/hooks/timelineLinkEditPlan.ts +128 -0
- package/src/hooks/timelineMoveAdapter.ts +13 -0
- package/src/hooks/timelineTimingSync.test.ts +59 -0
- package/src/hooks/timelineTimingSync.ts +22 -3
- package/src/hooks/useAppHotkeys.test.ts +34 -0
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
- package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
- package/src/hooks/useGsapScriptCommits.ts +23 -3
- package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
- package/src/hooks/usePreviewPersistence.ts +15 -0
- package/src/hooks/useRazorSplit.test.tsx +48 -0
- package/src/hooks/useRazorSplit.ts +12 -1
- package/src/hooks/useRenderClipContent.test.ts +6 -4
- package/src/hooks/useRenderClipContent.ts +9 -8
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +49 -55
- package/src/hooks/useTimelineLinkEditing.test.tsx +124 -0
- package/src/hooks/useTimelineLinkEditing.ts +158 -0
- package/src/index.ts +5 -0
- package/src/player/components/AudioGainDialog.test.tsx +133 -0
- package/src/player/components/AudioGainDialog.tsx +226 -0
- package/src/player/components/AudioWaveform.test.tsx +30 -3
- package/src/player/components/AudioWaveform.tsx +6 -7
- package/src/player/components/ClipBadges.test.tsx +32 -8
- package/src/player/components/ClipBadges.tsx +37 -31
- package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
- package/src/player/components/ClipContextMenu.test.tsx +10 -4
- package/src/player/components/ClipContextMenu.tsx +115 -90
- package/src/player/components/ClipPeakMarks.tsx +2 -14
- package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
- package/src/player/components/OutOfSyncBadge.tsx +119 -0
- package/src/player/components/Timeline.test.ts +15 -0
- package/src/player/components/TimelineClip.test.tsx +58 -0
- package/src/player/components/TimelineClip.tsx +9 -2
- package/src/player/components/TimelineLanes.test.tsx +45 -2
- package/src/player/components/TimelineLanes.tsx +0 -2
- package/src/player/components/TimelineOverlays.tsx +2 -0
- package/src/player/components/audioClipLink.test.ts +288 -31
- package/src/player/components/audioClipLink.ts +151 -27
- package/src/player/components/audioGainApply.test.ts +87 -0
- package/src/player/components/audioGainApply.ts +57 -0
- package/src/player/components/audioGainDialogStore.ts +36 -0
- package/src/player/components/audioGainPlan.test.ts +82 -0
- package/src/player/components/audioGainPlan.ts +53 -0
- package/src/player/components/clipMenuAudioItems.noShell.test.tsx +86 -0
- package/src/player/components/clipMenuAudioItems.test.tsx +59 -50
- package/src/player/components/clipMenuAudioItems.tsx +44 -57
- package/src/player/components/clipMenuDuck.ts +5 -0
- package/src/player/components/clipMenuLinkItems.test.ts +174 -0
- package/src/player/components/clipMenuLinkItems.tsx +166 -0
- package/src/player/components/clipMenuToolItems.test.tsx +130 -4
- package/src/player/components/clipMenuToolItems.tsx +80 -9
- package/src/player/components/clipPeakMap.ts +46 -0
- package/src/player/components/clipPeakRuns.test.ts +14 -1
- package/src/player/components/clipPeakRuns.ts +11 -0
- package/src/player/components/clipSync.test.ts +67 -0
- package/src/player/components/clipSync.ts +80 -0
- package/src/player/components/clipToolAttrs.test.ts +57 -41
- package/src/player/components/clipToolAttrs.ts +48 -45
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/studioShortcuts.ts +2 -0
- package/src/player/components/timelineCallbacks.ts +17 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
- package/src/player/components/timelineClipDragPreview.test.ts +96 -0
- package/src/player/components/timelineClipDragPreview.ts +32 -7
- package/src/player/components/timelineClipDragTypes.ts +2 -0
- package/src/player/components/timelineClipGestureHandlers.ts +29 -31
- package/src/player/components/timelineEditing.test.ts +0 -13
- package/src/player/components/timelineEditing.ts +0 -11
- package/src/player/components/timelineLaneProps.ts +0 -5
- package/src/player/components/timelineLinkSelection.ts +41 -0
- package/src/player/components/timelineViewportGeometry.ts +3 -0
- package/src/player/components/useClipToolState.ts +0 -5
- package/src/player/components/useTimelineClipContextMenu.ts +2 -1
- package/src/player/components/useTimelineClipDrag.ts +28 -0
- package/src/player/components/useTimelineOverlaysState.ts +0 -3
- package/src/player/components/useTimelineProviderState.tsx +3 -2
- package/src/player/components/useTimelineRangeSelection.ts +0 -14
- package/src/player/components/useTimelineRowVirtualization.ts +2 -1
- package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
- package/src/player/hooks/timelinePlayerSync.ts +1 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
- package/src/player/lib/timelineDOM.test.ts +31 -0
- package/src/player/lib/timelineDOM.ts +13 -0
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/components.css +27 -11
- package/src/styles/theme.css +0 -1
- package/src/utils/gsapSoftReload.test.ts +238 -2
- package/src/utils/gsapSoftReload.ts +89 -74
- package/src/utils/gsapUndoRestore.test.ts +4 -3
- package/src/utils/gsapUndoRestore.ts +15 -0
- package/src/utils/linkedClipPreferences.ts +34 -0
- package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
- package/src/utils/softReloadTargets.ts +128 -0
- package/src/utils/studioUiPreferences.ts +8 -0
- package/src/utils/typingTarget.test.ts +8 -0
- package/src/utils/typingTarget.ts +1 -0
- package/dist/chunk-AOWVEVEU.js.map +0 -1
- package/dist/chunk-NCUBAVGK.js.map +0 -1
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
audioGainToText,
|
|
25
25
|
} from "@hyperframes/core/audio-gain";
|
|
26
26
|
import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
|
|
27
|
+
import { useLinkedSpeedCommit, withLinkedPlaybackRate } from "./linkedSpeedEdits";
|
|
27
28
|
import { commitCutout, commitHasAudioToggle, commitMutedToggle } from "./mediaAudioEdits";
|
|
28
29
|
|
|
29
30
|
// fallow-ignore-next-line complexity
|
|
@@ -56,6 +57,10 @@ export function MediaSection({
|
|
|
56
57
|
) => Promise<BackgroundRemovalResult>;
|
|
57
58
|
}) {
|
|
58
59
|
const track = useTrackDesignInput();
|
|
60
|
+
const setRate = withLinkedPlaybackRate(
|
|
61
|
+
onSetAttribute,
|
|
62
|
+
useLinkedSpeedCommit(element, onSetAttributeBatch),
|
|
63
|
+
);
|
|
59
64
|
const isVideo = element.tagName === "video";
|
|
60
65
|
const isAudio = element.tagName === "audio";
|
|
61
66
|
const isImage = element.tagName === "img";
|
|
@@ -279,7 +284,7 @@ export function MediaSection({
|
|
|
279
284
|
displayValue={`${formatNumericValue(playbackRate)}x`}
|
|
280
285
|
formatDisplayValue={(next) => `${formatNumericValue(next / 100)}x`}
|
|
281
286
|
onCommit={(next) => {
|
|
282
|
-
void
|
|
287
|
+
void setRate("playback-rate", formatNumericValue(next / 100));
|
|
283
288
|
}}
|
|
284
289
|
/>
|
|
285
290
|
</div>
|
|
@@ -31,7 +31,7 @@ function gestureHarness(gesture: Partial<GestureState> | null, group: GroupGestu
|
|
|
31
31
|
};
|
|
32
32
|
const handlers = createDomEditOverlayGestureHandlers(opts as never);
|
|
33
33
|
const pointer = (clientX: number, clientY: number, shiftKey: boolean) =>
|
|
34
|
-
({ clientX, clientY, shiftKey, altKey: false }) as never;
|
|
34
|
+
({ clientX, clientY, shiftKey, altKey: false, pointerId: 1, buttons: 1 }) as never;
|
|
35
35
|
return { opts, handlers, pointer };
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -41,6 +41,7 @@ function singleDrag() {
|
|
|
41
41
|
{
|
|
42
42
|
kind: "drag",
|
|
43
43
|
mode: "path-offset",
|
|
44
|
+
pointerId: 1,
|
|
44
45
|
selection: { element } as unknown as DomEditSelection,
|
|
45
46
|
startX: 100,
|
|
46
47
|
startY: 100,
|
|
@@ -57,7 +58,13 @@ function singleDrag() {
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
const groupGesture = () =>
|
|
60
|
-
({
|
|
61
|
+
({
|
|
62
|
+
pointerId: 1,
|
|
63
|
+
startX: 100,
|
|
64
|
+
startY: 100,
|
|
65
|
+
originItems: [],
|
|
66
|
+
members: [],
|
|
67
|
+
}) as unknown as GroupGestureState;
|
|
61
68
|
|
|
62
69
|
describe("shift+drag locks a move to the axis the pointer travels further on", () => {
|
|
63
70
|
it("keeps a mostly horizontal single drag on its row", () => {
|
|
@@ -278,6 +285,7 @@ function singleMemberDrag() {
|
|
|
278
285
|
{
|
|
279
286
|
kind: "drag",
|
|
280
287
|
mode: "path-offset",
|
|
288
|
+
pointerId: 1,
|
|
281
289
|
selection,
|
|
282
290
|
startX: 100,
|
|
283
291
|
startY: 100,
|
|
@@ -300,7 +308,13 @@ function singleMemberDrag() {
|
|
|
300
308
|
function groupMemberDrag() {
|
|
301
309
|
const a = draggableMember("a");
|
|
302
310
|
const b = draggableMember("b");
|
|
303
|
-
const group = {
|
|
311
|
+
const group = {
|
|
312
|
+
pointerId: 1,
|
|
313
|
+
startX: 100,
|
|
314
|
+
startY: 100,
|
|
315
|
+
originItems: [],
|
|
316
|
+
members: [a.member, b.member],
|
|
317
|
+
};
|
|
304
318
|
const harness = gestureHarness(null, group as unknown as GroupGestureState);
|
|
305
319
|
return { ...harness, at: () => [a.at(), b.at()] };
|
|
306
320
|
}
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
ROTATED_SNAP_BYPASS_DEGREES,
|
|
39
39
|
hasDomEditRotationChanged,
|
|
40
40
|
lockDragToDominantAxis,
|
|
41
|
+
movesGesture,
|
|
41
42
|
resolveDomEditRotationGesture,
|
|
42
43
|
} from "./domEditOverlayGestures";
|
|
43
44
|
import { resolveCenterResizeSize } from "./domEditResizeLocal";
|
|
@@ -131,6 +132,8 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
131
132
|
if (!blockedMove && !g && !groupG) {
|
|
132
133
|
opts.onCanvasPointerMoveRef.current(e, { preferClipAncestor: false });
|
|
133
134
|
}
|
|
135
|
+
const held = g ?? groupG;
|
|
136
|
+
if (held && !movesGesture(held, e)) return;
|
|
134
137
|
|
|
135
138
|
if (blockedMove) {
|
|
136
139
|
const dx = e.clientX - blockedMove.startX;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef, type ReactNode, type RefObject } from "react";
|
|
2
|
+
import { InlineTextCaret } from "./InlineTextCaret";
|
|
2
3
|
import { InlineTextToolbar } from "./InlineTextToolbar";
|
|
3
4
|
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
|
|
4
5
|
import { useInlineTextEdit } from "../../hooks/useInlineTextEdit";
|
|
@@ -110,10 +111,16 @@ export function useInlineTextEditing(
|
|
|
110
111
|
return {
|
|
111
112
|
editing: inlineText.session !== null,
|
|
112
113
|
toolbar: (
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
114
|
+
<>
|
|
115
|
+
<InlineTextToolbar
|
|
116
|
+
session={inlineText.session}
|
|
117
|
+
iframe={actions?.previewIframeRef?.current ?? null}
|
|
118
|
+
/>
|
|
119
|
+
<InlineTextCaret
|
|
120
|
+
session={inlineText.session}
|
|
121
|
+
iframe={actions?.previewIframeRef?.current ?? null}
|
|
122
|
+
/>
|
|
123
|
+
</>
|
|
117
124
|
),
|
|
118
125
|
handleKeyDown,
|
|
119
126
|
startFromPress: (event) => {
|
|
@@ -26,6 +26,8 @@ import {
|
|
|
26
26
|
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
|
|
27
27
|
import { SPEED_PRESETS, speedPresetLane, type SpeedPresetId } from "@hyperframes/core/speed-ramp";
|
|
28
28
|
import { clampNumber } from "../../utils/studioHelpers";
|
|
29
|
+
import type { HfAutomation } from "@hyperframes/core/audio-automation";
|
|
30
|
+
import type { LinkedSpeedCommit } from "./linkedSpeedEdits";
|
|
29
31
|
|
|
30
32
|
export interface LaneBinding {
|
|
31
33
|
automated: boolean;
|
|
@@ -59,6 +61,7 @@ export function useVolumeAutomation(
|
|
|
59
61
|
element: DomEditSelection,
|
|
60
62
|
currentTime: number,
|
|
61
63
|
onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise<void>,
|
|
64
|
+
linkedSpeed: LinkedSpeedCommit | null = null,
|
|
62
65
|
): VolumeAutomationBinding {
|
|
63
66
|
// The chain is not needed to resolve a volume or rate lane: both are always valid
|
|
64
67
|
// targets, so this deliberately does not parse it.
|
|
@@ -77,7 +80,22 @@ export function useVolumeAutomation(
|
|
|
77
80
|
// playing track, while the same click on an effect parameter did not.
|
|
78
81
|
void onSetAttributeQuiet(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(next) || null);
|
|
79
82
|
};
|
|
80
|
-
|
|
83
|
+
type Edit = (current: HfAutomation) => HfAutomation;
|
|
84
|
+
const writeOwn = (edit: Edit) => write(edit(automation));
|
|
85
|
+
// Speed moves every data-link member together, so a linked clip's rate lane edits fan out.
|
|
86
|
+
const writeRate = (edit: Edit) => {
|
|
87
|
+
if (!linkedSpeed) return writeOwn(edit);
|
|
88
|
+
void linkedSpeed.commitAttribute(
|
|
89
|
+
HF_AUDIO_AUTOMATION_ATTR,
|
|
90
|
+
(raw) => automationAttrValue(edit(readPanelAutomation(raw, undefined))) || null,
|
|
91
|
+
"Edit speed",
|
|
92
|
+
);
|
|
93
|
+
};
|
|
94
|
+
const laneBinding = (
|
|
95
|
+
target: string,
|
|
96
|
+
seed: number,
|
|
97
|
+
writeLane: (edit: Edit) => void,
|
|
98
|
+
): LaneBinding => {
|
|
81
99
|
const lane = automation.lanes.find((l) => l.target === target);
|
|
82
100
|
return {
|
|
83
101
|
automated: lane !== undefined,
|
|
@@ -85,9 +103,9 @@ export function useVolumeAutomation(
|
|
|
85
103
|
? sampleAutomationLane(lane, clipTimeSec, resolveAutomationRange(target, undefined)?.scale)
|
|
86
104
|
: undefined,
|
|
87
105
|
// Seeded at the level the control already shows, so automating does not change it.
|
|
88
|
-
onAutomate: () =>
|
|
89
|
-
onRemoveAutomation: () =>
|
|
90
|
-
onCommitAt: (v: number) =>
|
|
106
|
+
onAutomate: () => writeLane((a) => withSeededLane(a, target, seed)),
|
|
107
|
+
onRemoveAutomation: () => writeLane((a) => withoutLane(a, target)),
|
|
108
|
+
onCommitAt: (v: number) => writeLane((a) => withPointAt(a, target, clipTimeSec, v)),
|
|
91
109
|
};
|
|
92
110
|
};
|
|
93
111
|
// `??` alone would let an empty `data-volume` through as Number("") === 0, so
|
|
@@ -96,9 +114,13 @@ export function useVolumeAutomation(
|
|
|
96
114
|
const raw = element.dataAttributes?.["volume"];
|
|
97
115
|
const parsed = raw ? Number(raw) : 1;
|
|
98
116
|
const current = Number.isFinite(parsed) ? parsed : 1;
|
|
99
|
-
const volume = laneBinding(VOLUME_TARGET, current);
|
|
117
|
+
const volume = laneBinding(VOLUME_TARGET, current, writeOwn);
|
|
100
118
|
const rateAttr = Number.parseFloat(element.dataAttributes?.["playback-rate"] ?? "");
|
|
101
|
-
const rate = laneBinding(
|
|
119
|
+
const rate = laneBinding(
|
|
120
|
+
RATE_TARGET,
|
|
121
|
+
Number.isFinite(rateAttr) && rateAttr > 0 ? rateAttr : 1,
|
|
122
|
+
writeRate,
|
|
123
|
+
);
|
|
102
124
|
return {
|
|
103
125
|
volumeAutomated: volume.automated,
|
|
104
126
|
automatedVolumeValue: volume.automatedValue,
|
|
@@ -109,7 +131,7 @@ export function useVolumeAutomation(
|
|
|
109
131
|
...rate,
|
|
110
132
|
canApplyPreset: elDuration > 0,
|
|
111
133
|
onApplyPreset: (id) => {
|
|
112
|
-
if (elDuration > 0)
|
|
134
|
+
if (elDuration > 0) writeRate((a) => withLane(a, speedPresetLane(id, elDuration)));
|
|
113
135
|
},
|
|
114
136
|
},
|
|
115
137
|
};
|
|
@@ -54,6 +54,8 @@ export interface TimelineEditCallbackDeps {
|
|
|
54
54
|
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
55
55
|
handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
|
|
56
56
|
handleFreezeFrame?: (element: TimelineElement, time: number) => Promise<void> | void;
|
|
57
|
+
handleLinkEdit?: TimelineEditCallbacks["onLinkEdit"];
|
|
58
|
+
handleTimelineElementDeleteOnly?: TimelineEditCallbacks["onDeleteElementOnly"];
|
|
57
59
|
/** C1's ungrouped-track FX pointer — same auto-grouping write B6's carve uses. */
|
|
58
60
|
handleGroupClips?: (
|
|
59
61
|
clipIds: readonly string[],
|
|
@@ -70,6 +72,7 @@ export interface TimelineEditCallbackDeps {
|
|
|
70
72
|
value: string | null,
|
|
71
73
|
label: string,
|
|
72
74
|
) => Promise<TimelineEditOutcome | void>;
|
|
75
|
+
setMany?: TimelineEditCallbacks["onSetElementsAttributeQuiet"];
|
|
73
76
|
};
|
|
74
77
|
}
|
|
75
78
|
|
|
@@ -134,6 +137,8 @@ export function useTimelineEditCallbacks({
|
|
|
134
137
|
handleRazorSplit,
|
|
135
138
|
handleRazorSplitAll,
|
|
136
139
|
handleFreezeFrame,
|
|
140
|
+
handleLinkEdit,
|
|
141
|
+
handleTimelineElementDeleteOnly,
|
|
137
142
|
handleGroupClips,
|
|
138
143
|
setElementFxAttribute,
|
|
139
144
|
}: TimelineEditCallbackDeps): TimelineEditCallbacks {
|
|
@@ -224,12 +229,15 @@ export function useTimelineEditCallbacks({
|
|
|
224
229
|
onGroupClips: handleGroupClips,
|
|
225
230
|
onSetElementAttributeLive: setElementFxAttribute?.setLive,
|
|
226
231
|
onSetElementAttributeQuiet: setElementFxAttribute?.setQuiet,
|
|
232
|
+
onSetElementsAttributeQuiet: setElementFxAttribute?.setMany,
|
|
227
233
|
onRevertElementAttributeLive: setElementFxAttribute?.revertLive,
|
|
228
234
|
onBlockedEditAttempt: handleBlockedTimelineEdit,
|
|
229
235
|
onSplitElement: handleTimelineElementSplit,
|
|
230
236
|
onRazorSplit: handleRazorSplit,
|
|
231
237
|
onRazorSplitAll: handleRazorSplitAll,
|
|
232
238
|
onFreezeFrame: handleFreezeFrame,
|
|
239
|
+
onLinkEdit: handleLinkEdit,
|
|
240
|
+
onDeleteElementOnly: handleTimelineElementDeleteOnly,
|
|
233
241
|
onDeleteAllKeyframes: (element, animationId) => {
|
|
234
242
|
// Hold the element where it is (collapse keyframes to a static set) rather
|
|
235
243
|
// than deleting the whole animation — deleting strands a stale GSAP base
|
|
@@ -427,6 +435,8 @@ export function useTimelineEditCallbacks({
|
|
|
427
435
|
handleRazorSplit,
|
|
428
436
|
handleRazorSplitAll,
|
|
429
437
|
handleFreezeFrame,
|
|
438
|
+
handleLinkEdit,
|
|
439
|
+
handleTimelineElementDeleteOnly,
|
|
430
440
|
handleGsapRemoveAllKeyframes,
|
|
431
441
|
resolveElementAnimations,
|
|
432
442
|
resolveKeyframeTarget,
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import React, { act, useState } from "react";
|
|
6
|
-
import { createRoot, type Root } from "react-dom/client";
|
|
7
6
|
import { compile } from "tailwindcss";
|
|
8
7
|
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
9
8
|
import { loadStylesheet, STYLES_DIR } from "../../styles/styleSources";
|
|
@@ -11,27 +10,11 @@ import { ContextMenu, Menu, MenuItem, MenuRadioGroup, MenuRadioItem, MenuSeparat
|
|
|
11
10
|
import { Popover } from "./Popover";
|
|
12
11
|
import { isTypingTarget } from "../../utils/typingTarget";
|
|
13
12
|
import { shouldIgnorePlaybackShortcutTarget } from "../../player/lib/playbackShortcuts";
|
|
13
|
+
import { cleanupMounted, mountHost as render } from "./mountHost.testHelpers";
|
|
14
14
|
|
|
15
15
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
function render(element: React.ReactElement): HTMLElement {
|
|
20
|
-
const host = document.createElement("div");
|
|
21
|
-
document.body.append(host);
|
|
22
|
-
const root = createRoot(host);
|
|
23
|
-
mounted = { root, host };
|
|
24
|
-
act(() => root.render(element));
|
|
25
|
-
return host;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
afterEach(() => {
|
|
29
|
-
if (!mounted) return;
|
|
30
|
-
const { root, host } = mounted;
|
|
31
|
-
mounted = null;
|
|
32
|
-
act(() => root.unmount());
|
|
33
|
-
host.remove();
|
|
34
|
-
});
|
|
17
|
+
afterEach(cleanupMounted);
|
|
35
18
|
|
|
36
19
|
/** Base UI moves focus into the popup one task after open, not synchronously. */
|
|
37
20
|
async function settle(): Promise<void> {
|
|
@@ -310,9 +293,7 @@ describe("Popover", () => {
|
|
|
310
293
|
renderOpen(false);
|
|
311
294
|
await settle();
|
|
312
295
|
expect(one("[role=dialog]", "popup").querySelector("svg")).toBeNull();
|
|
313
|
-
|
|
314
|
-
mounted?.host.remove();
|
|
315
|
-
mounted = null;
|
|
296
|
+
cleanupMounted();
|
|
316
297
|
|
|
317
298
|
renderOpen(true);
|
|
318
299
|
await settle();
|
|
@@ -52,8 +52,8 @@ export const popupSurface = cn(
|
|
|
52
52
|
"data-[preview-state=open]:opacity-100 data-[preview-state=open]:scale-100",
|
|
53
53
|
);
|
|
54
54
|
|
|
55
|
-
/**
|
|
56
|
-
const POPUP_LAYER = "z-200";
|
|
55
|
+
/** Every floating list, menu, popover and tooltip sits on this layer, above panel chrome and modals. */
|
|
56
|
+
export const POPUP_LAYER = "z-200";
|
|
57
57
|
|
|
58
58
|
const menuPopup = cn(popupSurface, "min-w-36 p-1 shadow-menu");
|
|
59
59
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { Popover as BasePopover } from "@base-ui/react/popover";
|
|
7
7
|
import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
|
|
8
8
|
import { cn } from "./cn";
|
|
9
|
-
import { popupSurface, type PopupPreviewState } from "./Menu";
|
|
9
|
+
import { POPUP_LAYER, popupSurface, type PopupPreviewState } from "./Menu";
|
|
10
10
|
|
|
11
11
|
type PortalContainer = ComponentPropsWithoutRef<typeof BasePopover.Portal>["container"];
|
|
12
12
|
|
|
@@ -70,7 +70,7 @@ export function Popover({
|
|
|
70
70
|
sideOffset={sideOffset}
|
|
71
71
|
collisionPadding={VIEWPORT_MARGIN}
|
|
72
72
|
arrowPadding={ARROW_CORNER_CLEARANCE}
|
|
73
|
-
className=
|
|
73
|
+
className={POPUP_LAYER}
|
|
74
74
|
>
|
|
75
75
|
<BasePopover.Popup
|
|
76
76
|
aria-label={ariaLabel}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { Select as BaseSelect } from "@base-ui/react/select";
|
|
7
7
|
import { cn } from "./cn";
|
|
8
8
|
import { fieldBase } from "./Input";
|
|
9
|
-
import { floatingMotion } from "./Menu";
|
|
9
|
+
import { floatingMotion, POPUP_LAYER } from "./Menu";
|
|
10
10
|
import type { PreviewState } from "./Button";
|
|
11
11
|
|
|
12
12
|
export interface SelectOption {
|
|
@@ -77,7 +77,7 @@ export function Select({
|
|
|
77
77
|
</BaseSelect.Trigger>
|
|
78
78
|
|
|
79
79
|
<BaseSelect.Portal>
|
|
80
|
-
<BaseSelect.Positioner sideOffset={4} alignItemWithTrigger={false}>
|
|
80
|
+
<BaseSelect.Positioner sideOffset={4} alignItemWithTrigger={false} className={POPUP_LAYER}>
|
|
81
81
|
<BaseSelect.Popup
|
|
82
82
|
className={cn(
|
|
83
83
|
"min-w-[var(--anchor-width)] rounded-md border border-border bg-surface py-1 shadow-menu",
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip";
|
|
7
7
|
import { cloneElement, useId, useRef, useState, type ReactElement } from "react";
|
|
8
8
|
import { cn } from "./cn";
|
|
9
|
-
import { floatingMotion } from "./Menu";
|
|
9
|
+
import { floatingMotion, POPUP_LAYER } from "./Menu";
|
|
10
10
|
|
|
11
11
|
interface TooltipProps {
|
|
12
12
|
label: string;
|
|
@@ -39,7 +39,7 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
|
|
|
39
39
|
side={side}
|
|
40
40
|
sideOffset={SIDE_OFFSET}
|
|
41
41
|
collisionPadding={VIEWPORT_MARGIN}
|
|
42
|
-
className=
|
|
42
|
+
className={POPUP_LAYER}
|
|
43
43
|
>
|
|
44
44
|
<BaseTooltip.Popup
|
|
45
45
|
id={tooltipId}
|
|
@@ -4,6 +4,13 @@ import { createRoot, type Root } from "react-dom/client";
|
|
|
4
4
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
5
5
|
|
|
6
6
|
let mounted: { root: Root; host: HTMLElement } | null = null;
|
|
7
|
+
const tracked: Root[] = [];
|
|
8
|
+
|
|
9
|
+
export function trackedRoot(host: HTMLElement): Root {
|
|
10
|
+
const root = createRoot(host);
|
|
11
|
+
tracked.push(root);
|
|
12
|
+
return root;
|
|
13
|
+
}
|
|
7
14
|
|
|
8
15
|
/** Renders into a fresh host attached to the body; `cleanupMounted` tears it down. */
|
|
9
16
|
export function mountHost(element: ReactElement): HTMLElement {
|
|
@@ -16,6 +23,7 @@ export function mountHost(element: ReactElement): HTMLElement {
|
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
export function cleanupMounted(): void {
|
|
26
|
+
act(() => tracked.splice(0).forEach((root) => root.unmount()));
|
|
19
27
|
if (!mounted) return;
|
|
20
28
|
const { root, host } = mounted;
|
|
21
29
|
mounted = null;
|
|
@@ -4,7 +4,6 @@ import { createRequire } from "node:module";
|
|
|
4
4
|
import { readFileSync } from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import React, { act } from "react";
|
|
7
|
-
import { createRoot, type Root } from "react-dom/client";
|
|
8
7
|
import { compile } from "tailwindcss";
|
|
9
8
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
10
9
|
|
|
@@ -21,33 +20,15 @@ import { Toggle } from "./Toggle";
|
|
|
21
20
|
import { isTypingTarget } from "../../utils/typingTarget";
|
|
22
21
|
import { shouldIgnorePlaybackShortcutTarget } from "../../player/lib/playbackShortcuts";
|
|
23
22
|
import { __resetDesignInputThrottle, trackDesignInput } from "../../utils/designInputTracking";
|
|
23
|
+
import { cleanupMounted, mountHost as render } from "./mountHost.testHelpers";
|
|
24
24
|
|
|
25
25
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
26
26
|
|
|
27
|
-
let mounted: { root: Root; host: HTMLElement } | null = null;
|
|
28
|
-
|
|
29
|
-
function render(element: React.ReactElement): HTMLElement {
|
|
30
|
-
const host = document.createElement("div");
|
|
31
|
-
document.body.append(host);
|
|
32
|
-
const root = createRoot(host);
|
|
33
|
-
mounted = { root, host };
|
|
34
|
-
act(() => root.render(element));
|
|
35
|
-
return host;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function unmount() {
|
|
39
|
-
if (!mounted) return;
|
|
40
|
-
const { root, host } = mounted;
|
|
41
|
-
mounted = null;
|
|
42
|
-
act(() => root.unmount());
|
|
43
|
-
host.remove();
|
|
44
|
-
}
|
|
45
|
-
|
|
46
27
|
beforeEach(() => {
|
|
47
28
|
trackStudioEvent.mockReset();
|
|
48
29
|
__resetDesignInputThrottle();
|
|
49
30
|
});
|
|
50
|
-
afterEach(
|
|
31
|
+
afterEach(cleanupMounted);
|
|
51
32
|
|
|
52
33
|
/** Base UI moves focus a task later than React renders; happy-dom is no faster. */
|
|
53
34
|
const settle = () => act(async () => void (await new Promise((r) => setTimeout(r, 0))));
|
|
@@ -37,12 +37,16 @@ const EDIT_CALLBACK_KEY_SET: Record<keyof TimelineEditCallbacks, true> = {
|
|
|
37
37
|
onGroupClips: true,
|
|
38
38
|
onSetElementAttributeLive: true,
|
|
39
39
|
onSetElementAttributeQuiet: true,
|
|
40
|
+
onSetElementsAttributeQuiet: true,
|
|
40
41
|
onRevertElementAttributeLive: true,
|
|
41
42
|
onBlockedEditAttempt: true,
|
|
43
|
+
onLinkEdit: true,
|
|
44
|
+
onDeleteElementOnly: true,
|
|
42
45
|
onSplitElement: true,
|
|
43
46
|
onRazorSplit: true,
|
|
44
47
|
onRazorSplitAll: true,
|
|
45
48
|
onFreezeFrame: true,
|
|
49
|
+
onNotice: true,
|
|
46
50
|
onDeleteKeyframe: true,
|
|
47
51
|
onDeleteAllKeyframes: true,
|
|
48
52
|
onMoveKeyframeToPlayhead: true,
|
|
@@ -9,6 +9,8 @@ import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
|
|
|
9
9
|
import { canSplitElement } from "../utils/timelineElementSplit";
|
|
10
10
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
11
11
|
import { STUDIO_PLAIN_KEYS } from "../player/components/studioShortcuts";
|
|
12
|
+
import { openAudioGainDialog } from "../player/components/audioGainDialogStore";
|
|
13
|
+
import type { LinkShortcutCallbacks } from "./linkShortcuts";
|
|
12
14
|
|
|
13
15
|
// Extracted from useAppHotkeys.ts to keep it under the studio 600-line cap,
|
|
14
16
|
// following useTimelineDeleteOps's precedent. Pure functions, no hooks — the
|
|
@@ -29,7 +31,7 @@ function handleUndoRedoKey(event: KeyboardEvent, onUndo: () => void, onRedo: ()
|
|
|
29
31
|
return false;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
export interface HotkeyCallbacks {
|
|
34
|
+
export interface HotkeyCallbacks extends LinkShortcutCallbacks {
|
|
33
35
|
handleTimelineElementsDelete: (elements: TimelineElement[]) => Promise<void>;
|
|
34
36
|
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void>;
|
|
35
37
|
handleDomEditElementDelete: (
|
|
@@ -189,6 +191,14 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
|
|
|
189
191
|
}
|
|
190
192
|
}
|
|
191
193
|
|
|
194
|
+
// Only with a sound clip selected, so bare G still reaches the canvas grid toggle otherwise.
|
|
195
|
+
if (key === STUDIO_PLAIN_KEYS.audioGain && !event.shiftKey && !event.altKey) {
|
|
196
|
+
if (!cb.readOnlyPreview && openAudioGainDialog()) {
|
|
197
|
+
event.preventDefault();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
192
202
|
if (key === "b" && !event.shiftKey && !event.altKey) {
|
|
193
203
|
event.preventDefault();
|
|
194
204
|
const { activeTool, setActiveTool } = usePlayerStore.getState();
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { usePlayerStore, type TimelineElement } from "../player";
|
|
4
|
+
import { dispatchLinkShortcut } from "./linkShortcuts";
|
|
5
|
+
|
|
6
|
+
const clip = (id: string, tag: string, extra: Partial<TimelineElement> = {}): TimelineElement => ({
|
|
7
|
+
id,
|
|
8
|
+
tag,
|
|
9
|
+
src: "talk.mp4",
|
|
10
|
+
start: 0,
|
|
11
|
+
duration: 4,
|
|
12
|
+
track: 0,
|
|
13
|
+
...extra,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function key(init: KeyboardEventInit): KeyboardEvent {
|
|
17
|
+
const event = new KeyboardEvent("keydown", { cancelable: true, ...init });
|
|
18
|
+
vi.spyOn(event, "preventDefault");
|
|
19
|
+
return event;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function select(elements: TimelineElement[], id: string) {
|
|
23
|
+
usePlayerStore.getState().setElements(elements);
|
|
24
|
+
usePlayerStore.getState().setSelection([id], id);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("dispatchLinkShortcut", () => {
|
|
28
|
+
afterEach(() => vi.restoreAllMocks());
|
|
29
|
+
|
|
30
|
+
it("⌘L unlinks the selected linked clip", () => {
|
|
31
|
+
const video = clip("talk", "video", { muted: true, link: "lk-1" });
|
|
32
|
+
const audio = clip("talk-audio", "audio", { link: "lk-1" });
|
|
33
|
+
select([video, audio], "talk");
|
|
34
|
+
const handleLinkEdit = vi.fn();
|
|
35
|
+
const event = key({ metaKey: true, key: "l" });
|
|
36
|
+
expect(dispatchLinkShortcut(event, { handleLinkEdit })).toBe(true);
|
|
37
|
+
expect(handleLinkEdit).toHaveBeenCalledWith({ kind: "unlink", elements: [video, audio] });
|
|
38
|
+
expect(event.preventDefault).toHaveBeenCalled();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("⌥⇧D detaches audio, read by key code since Option changes the character", () => {
|
|
42
|
+
const talk = clip("talk", "video", { hasAudio: true });
|
|
43
|
+
select([talk], "talk");
|
|
44
|
+
const handleLinkEdit = vi.fn();
|
|
45
|
+
expect(
|
|
46
|
+
dispatchLinkShortcut(key({ altKey: true, shiftKey: true, key: "Î", code: "KeyD" }), {
|
|
47
|
+
handleLinkEdit,
|
|
48
|
+
}),
|
|
49
|
+
).toBe(true);
|
|
50
|
+
expect(handleLinkEdit).toHaveBeenCalledWith({ kind: "detach", element: talk });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("⌥⌫ deletes one linked member", () => {
|
|
54
|
+
const video = clip("talk", "video", { muted: true, link: "lk-1" });
|
|
55
|
+
select([video, clip("talk-audio", "audio", { link: "lk-1" })], "talk");
|
|
56
|
+
const handleTimelineElementDeleteOnly = vi.fn();
|
|
57
|
+
dispatchLinkShortcut(key({ altKey: true, key: "Backspace" }), {
|
|
58
|
+
handleLinkEdit: vi.fn(),
|
|
59
|
+
handleTimelineElementDeleteOnly,
|
|
60
|
+
});
|
|
61
|
+
expect(handleTimelineElementDeleteOnly).toHaveBeenCalledWith(video);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("leaves the key alone when no link item applies", () => {
|
|
65
|
+
select([clip("title", "div")], "title");
|
|
66
|
+
const event = key({ metaKey: true, key: "l" });
|
|
67
|
+
expect(dispatchLinkShortcut(event, { handleLinkEdit: vi.fn() })).toBe(false);
|
|
68
|
+
expect(event.preventDefault).not.toHaveBeenCalled();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { usePlayerStore } from "../player";
|
|
2
|
+
import type { TimelineEditCallbacks } from "../player/components/timelineCallbacks";
|
|
3
|
+
import { resolveLinkMenuItems } from "../player/components/clipMenuLinkItems";
|
|
4
|
+
|
|
5
|
+
export interface LinkShortcutCallbacks {
|
|
6
|
+
handleLinkEdit?: TimelineEditCallbacks["onLinkEdit"];
|
|
7
|
+
handleTimelineElementDeleteOnly?: TimelineEditCallbacks["onDeleteElementOnly"];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const isDeleteKey = (event: KeyboardEvent) => event.key === "Backspace" || event.key === "Delete";
|
|
11
|
+
const hasMod = (event: KeyboardEvent) => event.metaKey || event.ctrlKey;
|
|
12
|
+
|
|
13
|
+
const SHORTCUTS: ReadonlyArray<[string, (event: KeyboardEvent) => boolean]> = [
|
|
14
|
+
["⌘L", (e) => hasMod(e) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "l"],
|
|
15
|
+
["⌥⇧D", (e) => !hasMod(e) && e.altKey && e.shiftKey && e.code === "KeyD"],
|
|
16
|
+
["⌥⌫", (e) => !hasMod(e) && e.altKey && !e.shiftKey && isDeleteKey(e)],
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function shortcutOf(event: KeyboardEvent): string | null {
|
|
20
|
+
return SHORTCUTS.find(([, matches]) => matches(event))?.[0] ?? null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** ⌘L unlink/link, ⌥⇧D detach audio, ⌥⌫ delete one linked clip — the clip menu's shortcuts. */
|
|
24
|
+
export function dispatchLinkShortcut(event: KeyboardEvent, cb: LinkShortcutCallbacks): boolean {
|
|
25
|
+
const shortcut = shortcutOf(event);
|
|
26
|
+
if (!shortcut) return false;
|
|
27
|
+
const { elements, selectedElementId, selectedElementIds } = usePlayerStore.getState();
|
|
28
|
+
const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
|
|
29
|
+
if (!element) return false;
|
|
30
|
+
const item = resolveLinkMenuItems({
|
|
31
|
+
element,
|
|
32
|
+
elements,
|
|
33
|
+
selectedKeys: selectedElementIds,
|
|
34
|
+
onLinkEdit: cb.handleLinkEdit,
|
|
35
|
+
onDeleteElementOnly: cb.handleTimelineElementDeleteOnly,
|
|
36
|
+
}).find((candidate) => candidate.shortcut === shortcut);
|
|
37
|
+
if (!item) return false;
|
|
38
|
+
event.preventDefault();
|
|
39
|
+
item.run();
|
|
40
|
+
return true;
|
|
41
|
+
}
|