@hyperframes/studio 0.4.16 → 0.4.18
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-D0VntLIQ.js +115 -0
- package/dist/assets/index-kT65pCwW.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +387 -67
- package/src/components/nle/NLELayout.tsx +19 -0
- package/src/components/nle/TimelineEditorNotice.tsx +156 -0
- package/src/components/sidebar/AssetsTab.tsx +7 -0
- package/src/components/sidebar/CompositionsTab.test.ts +37 -0
- package/src/components/sidebar/CompositionsTab.tsx +45 -2
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/Timeline.tsx +288 -29
- package/src/player/components/TimelineClip.tsx +1 -1
- package/src/player/components/timelineEditing.test.ts +149 -0
- package/src/player/components/timelineEditing.ts +45 -6
- package/src/player/hooks/useTimelinePlayer.ts +5 -1
- package/src/utils/timelineAssetDrop.test.ts +80 -0
- package/src/utils/timelineAssetDrop.ts +87 -0
- package/dist/assets/index-CVm-zeM9.css +0 -1
- package/dist/assets/index-RzXlAX2g.js +0 -93
|
@@ -2,6 +2,7 @@ import { useState, useCallback, useRef, useEffect, memo, type ReactNode } from "
|
|
|
2
2
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
3
|
import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
|
|
4
4
|
import type { TimelineElement } from "../../player";
|
|
5
|
+
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
|
|
5
6
|
import { NLEPreview } from "./NLEPreview";
|
|
6
7
|
import { CompositionBreadcrumb, type CompositionLevel } from "./CompositionBreadcrumb";
|
|
7
8
|
|
|
@@ -27,6 +28,15 @@ interface NLELayoutProps {
|
|
|
27
28
|
element: TimelineElement,
|
|
28
29
|
style: { clip: string; label: string },
|
|
29
30
|
) => ReactNode;
|
|
31
|
+
onFileDrop?: (
|
|
32
|
+
files: File[],
|
|
33
|
+
placement?: Pick<TimelineElement, "start" | "track">,
|
|
34
|
+
) => Promise<void> | void;
|
|
35
|
+
onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
|
|
36
|
+
onAssetDrop?: (
|
|
37
|
+
assetPath: string,
|
|
38
|
+
placement: Pick<TimelineElement, "start" | "track">,
|
|
39
|
+
) => Promise<void> | void;
|
|
30
40
|
/** Persist timeline move actions back into source HTML */
|
|
31
41
|
onMoveElement?: (
|
|
32
42
|
element: TimelineElement,
|
|
@@ -36,6 +46,7 @@ interface NLELayoutProps {
|
|
|
36
46
|
element: TimelineElement,
|
|
37
47
|
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
38
48
|
) => Promise<void> | void;
|
|
49
|
+
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
39
50
|
/** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
|
|
40
51
|
onCompIdToSrcChange?: (map: Map<string, string>) => void;
|
|
41
52
|
/** Whether the timeline panel is visible (default: true) */
|
|
@@ -59,8 +70,12 @@ export const NLELayout = memo(function NLELayout({
|
|
|
59
70
|
onIframeRef,
|
|
60
71
|
onCompositionChange,
|
|
61
72
|
renderClipContent,
|
|
73
|
+
onFileDrop,
|
|
74
|
+
onDeleteElement,
|
|
75
|
+
onAssetDrop,
|
|
62
76
|
onMoveElement,
|
|
63
77
|
onResizeElement,
|
|
78
|
+
onBlockedEditAttempt,
|
|
64
79
|
onCompIdToSrcChange,
|
|
65
80
|
timelineVisible,
|
|
66
81
|
onToggleTimeline,
|
|
@@ -390,8 +405,12 @@ export const NLELayout = memo(function NLELayout({
|
|
|
390
405
|
onSeek={seek}
|
|
391
406
|
onDrillDown={handleDrillDown}
|
|
392
407
|
renderClipContent={renderClipContent}
|
|
408
|
+
onFileDrop={onFileDrop}
|
|
409
|
+
onDeleteElement={onDeleteElement}
|
|
410
|
+
onAssetDrop={onAssetDrop}
|
|
393
411
|
onMoveElement={onMoveElement}
|
|
394
412
|
onResizeElement={onResizeElement}
|
|
413
|
+
onBlockedEditAttempt={onBlockedEditAttempt}
|
|
395
414
|
/>
|
|
396
415
|
</div>
|
|
397
416
|
{timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { TIMELINE_TOGGLE_SHORTCUT_LABEL } from "../../utils/timelineDiscovery";
|
|
2
|
+
|
|
3
|
+
interface TimelineEditorNoticeProps {
|
|
4
|
+
onDismiss: () => void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function TimelineEditorNotice({ onDismiss }: TimelineEditorNoticeProps) {
|
|
8
|
+
return (
|
|
9
|
+
<aside
|
|
10
|
+
aria-live="polite"
|
|
11
|
+
className="pointer-events-none relative w-[320px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-white/10 bg-[#0f1115]/88 text-neutral-100 shadow-[0_18px_40px_rgba(0,0,0,0.3),0_4px_14px_rgba(0,0,0,0.18)] backdrop-blur-xl"
|
|
12
|
+
>
|
|
13
|
+
<style>{`
|
|
14
|
+
@keyframes hfTimelineNoticeClipNudge {
|
|
15
|
+
0%, 100% { transform: translate3d(0, 0, 0); }
|
|
16
|
+
20% { transform: translate3d(0, 0, 0); }
|
|
17
|
+
52% { transform: translate3d(12px, 0, 0); }
|
|
18
|
+
72% { transform: translate3d(12px, 0, 0); }
|
|
19
|
+
100% { transform: translate3d(0, 0, 0); }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@keyframes hfTimelineNoticePlayheadSweep {
|
|
23
|
+
0% { transform: translateX(0); opacity: 0; }
|
|
24
|
+
10% { opacity: 1; }
|
|
25
|
+
75% { opacity: 1; }
|
|
26
|
+
100% { transform: translateX(218px); opacity: 0; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@media (prefers-reduced-motion: reduce) {
|
|
30
|
+
.hf-timeline-notice-clip,
|
|
31
|
+
.hf-timeline-notice-playhead {
|
|
32
|
+
animation: none !important;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
`}</style>
|
|
36
|
+
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
onClick={onDismiss}
|
|
40
|
+
aria-label="Dismiss timeline editor notice"
|
|
41
|
+
className="pointer-events-auto absolute right-3 top-3 z-10 flex h-7 w-7 items-center justify-center rounded-lg text-neutral-500 transition-colors duration-150 hover:bg-white/[0.06] hover:text-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-studio-accent/50"
|
|
42
|
+
>
|
|
43
|
+
<svg
|
|
44
|
+
width="11"
|
|
45
|
+
height="11"
|
|
46
|
+
viewBox="0 0 24 24"
|
|
47
|
+
fill="none"
|
|
48
|
+
stroke="currentColor"
|
|
49
|
+
strokeWidth="2.25"
|
|
50
|
+
strokeLinecap="round"
|
|
51
|
+
aria-hidden="true"
|
|
52
|
+
>
|
|
53
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
54
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
55
|
+
</svg>
|
|
56
|
+
</button>
|
|
57
|
+
|
|
58
|
+
<div className="flex items-start gap-3 px-4 py-3.5">
|
|
59
|
+
<div className="min-w-0 flex-1">
|
|
60
|
+
<div
|
|
61
|
+
aria-hidden="true"
|
|
62
|
+
className="mb-3 overflow-hidden rounded-[14px] bg-[#0d1117] p-2.5"
|
|
63
|
+
>
|
|
64
|
+
<div className="relative overflow-hidden rounded-[11px] bg-[#0f141c] px-2.5 pb-2 pt-1.5">
|
|
65
|
+
<div className="mb-1.5 flex items-center justify-between pl-6 pr-1 text-[8px] font-medium text-[#7f8796]">
|
|
66
|
+
<span>0:00</span>
|
|
67
|
+
<span>0:05</span>
|
|
68
|
+
<span>0:10</span>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<div className="pointer-events-none absolute inset-x-0 top-[18px] h-px bg-white/[0.04]" />
|
|
72
|
+
<div
|
|
73
|
+
className="hf-timeline-notice-playhead pointer-events-none absolute left-[31px] top-[18px] h-[70px] w-0"
|
|
74
|
+
style={{
|
|
75
|
+
animation:
|
|
76
|
+
"hfTimelineNoticePlayheadSweep 2.8s cubic-bezier(0.4, 0, 0.2, 1) infinite",
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
79
|
+
<div
|
|
80
|
+
className="absolute top-0 bottom-0"
|
|
81
|
+
style={{
|
|
82
|
+
left: "50%",
|
|
83
|
+
width: 2,
|
|
84
|
+
marginLeft: -1,
|
|
85
|
+
background: "var(--hf-accent, #3CE6AC)",
|
|
86
|
+
boxShadow: "0 0 8px rgba(60,230,172,0.5)",
|
|
87
|
+
}}
|
|
88
|
+
/>
|
|
89
|
+
<div
|
|
90
|
+
className="absolute"
|
|
91
|
+
style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}
|
|
92
|
+
>
|
|
93
|
+
<div
|
|
94
|
+
style={{
|
|
95
|
+
width: 0,
|
|
96
|
+
height: 0,
|
|
97
|
+
borderLeft: "6px solid transparent",
|
|
98
|
+
borderRight: "6px solid transparent",
|
|
99
|
+
borderTop: "8px solid var(--hf-accent, #3CE6AC)",
|
|
100
|
+
filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
|
|
101
|
+
}}
|
|
102
|
+
/>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<div className="flex flex-col gap-1.5">
|
|
107
|
+
{[0, 1, 2].map((trackIndex) => (
|
|
108
|
+
<div
|
|
109
|
+
key={trackIndex}
|
|
110
|
+
className="relative h-6 overflow-hidden rounded-[10px] bg-white/[0.035]"
|
|
111
|
+
>
|
|
112
|
+
<div className="absolute inset-y-0 left-[24px] w-px bg-white/[0.035]" />
|
|
113
|
+
<div className="absolute inset-y-0 left-[100px] w-px bg-white/[0.035]" />
|
|
114
|
+
<div className="absolute inset-y-0 left-[176px] w-px bg-white/[0.035]" />
|
|
115
|
+
</div>
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<div className="pointer-events-none absolute inset-x-0 top-[21px] h-[70px]">
|
|
120
|
+
<div className="absolute left-[34px] top-[3px] h-[18px] w-[56px] rounded-[9px] bg-white/[0.07]" />
|
|
121
|
+
<div
|
|
122
|
+
className="hf-timeline-notice-clip absolute left-[82px] top-[27px] h-[18px] w-[110px] rounded-[9px] bg-studio-accent/18 ring-1 ring-inset ring-studio-accent/28"
|
|
123
|
+
style={{
|
|
124
|
+
animation:
|
|
125
|
+
"hfTimelineNoticeClipNudge 2.8s cubic-bezier(0.4, 0, 0.2, 1) infinite",
|
|
126
|
+
}}
|
|
127
|
+
/>
|
|
128
|
+
<div className="absolute left-[52px] top-[51px] h-[18px] w-[72px] rounded-[9px] bg-white/[0.07]" />
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<div className="min-w-0 pr-9">
|
|
134
|
+
<p className="text-[12px] font-semibold leading-none tracking-tight text-neutral-100">
|
|
135
|
+
Timeline editing is on
|
|
136
|
+
</p>
|
|
137
|
+
<p className="mt-1.5 text-[12px] leading-5 text-neutral-300">
|
|
138
|
+
Drag clips to move timing, use{" "}
|
|
139
|
+
<span className="font-mono text-[11px] text-studio-accent">Shift</span> + click to
|
|
140
|
+
edit a full clip range, and watch for resize handles only on clips Studio can patch
|
|
141
|
+
safely. Toggle the timeline with{" "}
|
|
142
|
+
<span className="rounded-md border border-white/8 bg-white/[0.04] px-1.5 py-0.5 font-mono text-[11px] text-studio-accent">
|
|
143
|
+
{TIMELINE_TOGGLE_SHORTCUT_LABEL}
|
|
144
|
+
</span>
|
|
145
|
+
.
|
|
146
|
+
</p>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<div className="mt-2 text-[10px] leading-none text-neutral-500">
|
|
150
|
+
Dismiss once and it stays hidden.
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
</aside>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { memo, useState, useCallback, useRef } from "react";
|
|
2
2
|
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
|
3
3
|
import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../../utils/mediaTypes";
|
|
4
|
+
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
4
5
|
|
|
5
6
|
interface AssetsTabProps {
|
|
6
7
|
projectId: string;
|
|
@@ -106,7 +107,13 @@ function AssetCard({
|
|
|
106
107
|
return (
|
|
107
108
|
<>
|
|
108
109
|
<div
|
|
110
|
+
draggable
|
|
109
111
|
onClick={() => onCopy(asset)}
|
|
112
|
+
onDragStart={(e) => {
|
|
113
|
+
e.dataTransfer.effectAllowed = "copy";
|
|
114
|
+
e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset }));
|
|
115
|
+
e.dataTransfer.setData("text/plain", asset);
|
|
116
|
+
}}
|
|
110
117
|
onContextMenu={(e) => {
|
|
111
118
|
e.preventDefault();
|
|
112
119
|
setContextMenu({ x: e.clientX, y: e.clientY });
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { resolveCompositionPreviewScale } from "./CompositionsTab";
|
|
3
|
+
|
|
4
|
+
describe("resolveCompositionPreviewScale", () => {
|
|
5
|
+
it("scales a 16:9 stage to fit the composition card", () => {
|
|
6
|
+
expect(
|
|
7
|
+
resolveCompositionPreviewScale({
|
|
8
|
+
cardWidth: 80,
|
|
9
|
+
cardHeight: 45,
|
|
10
|
+
stageWidth: 1920,
|
|
11
|
+
stageHeight: 1080,
|
|
12
|
+
}),
|
|
13
|
+
).toBeCloseTo(80 / 1920);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("scales non-16:9 stages against their actual dimensions", () => {
|
|
17
|
+
expect(
|
|
18
|
+
resolveCompositionPreviewScale({
|
|
19
|
+
cardWidth: 80,
|
|
20
|
+
cardHeight: 45,
|
|
21
|
+
stageWidth: 1280,
|
|
22
|
+
stageHeight: 720,
|
|
23
|
+
}),
|
|
24
|
+
).toBeCloseTo(80 / 1280);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("falls back to the default stage when dimensions are invalid", () => {
|
|
28
|
+
expect(
|
|
29
|
+
resolveCompositionPreviewScale({
|
|
30
|
+
cardWidth: 80,
|
|
31
|
+
cardHeight: 45,
|
|
32
|
+
stageWidth: 0,
|
|
33
|
+
stageHeight: Number.NaN,
|
|
34
|
+
}),
|
|
35
|
+
).toBeCloseTo(80 / 1920);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -7,6 +7,27 @@ interface CompositionsTabProps {
|
|
|
7
7
|
onSelect: (comp: string) => void;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
const DEFAULT_PREVIEW_STAGE = { width: 1920, height: 1080 };
|
|
11
|
+
|
|
12
|
+
export function resolveCompositionPreviewScale(input: {
|
|
13
|
+
cardWidth: number;
|
|
14
|
+
cardHeight: number;
|
|
15
|
+
stageWidth: number;
|
|
16
|
+
stageHeight: number;
|
|
17
|
+
}): number {
|
|
18
|
+
const safeStageWidth =
|
|
19
|
+
Number.isFinite(input.stageWidth) && input.stageWidth > 0
|
|
20
|
+
? input.stageWidth
|
|
21
|
+
: DEFAULT_PREVIEW_STAGE.width;
|
|
22
|
+
const safeStageHeight =
|
|
23
|
+
Number.isFinite(input.stageHeight) && input.stageHeight > 0
|
|
24
|
+
? input.stageHeight
|
|
25
|
+
: DEFAULT_PREVIEW_STAGE.height;
|
|
26
|
+
const scaleX = input.cardWidth / safeStageWidth;
|
|
27
|
+
const scaleY = input.cardHeight / safeStageHeight;
|
|
28
|
+
return Math.min(scaleX, scaleY);
|
|
29
|
+
}
|
|
30
|
+
|
|
10
31
|
function CompCard({
|
|
11
32
|
projectId,
|
|
12
33
|
comp,
|
|
@@ -19,6 +40,7 @@ function CompCard({
|
|
|
19
40
|
onSelect: () => void;
|
|
20
41
|
}) {
|
|
21
42
|
const [hovered, setHovered] = useState(false);
|
|
43
|
+
const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
|
|
22
44
|
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
23
45
|
const handleEnter = () => {
|
|
24
46
|
hoverTimer.current = setTimeout(() => setHovered(true), 300);
|
|
@@ -33,6 +55,12 @@ function CompCard({
|
|
|
33
55
|
const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
|
|
34
56
|
const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=2`;
|
|
35
57
|
const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
|
|
58
|
+
const previewScale = resolveCompositionPreviewScale({
|
|
59
|
+
cardWidth: 80,
|
|
60
|
+
cardHeight: 45,
|
|
61
|
+
stageWidth: stageSize.width,
|
|
62
|
+
stageHeight: stageSize.height,
|
|
63
|
+
});
|
|
36
64
|
|
|
37
65
|
return (
|
|
38
66
|
<div
|
|
@@ -51,10 +79,25 @@ function CompCard({
|
|
|
51
79
|
<iframe
|
|
52
80
|
src={previewUrl}
|
|
53
81
|
sandbox="allow-scripts allow-same-origin"
|
|
54
|
-
className="absolute
|
|
82
|
+
className="absolute left-0 top-0 border-none pointer-events-none"
|
|
55
83
|
style={{
|
|
56
84
|
transformOrigin: "0 0",
|
|
57
|
-
|
|
85
|
+
width: stageSize.width,
|
|
86
|
+
height: stageSize.height,
|
|
87
|
+
transform: `scale(${previewScale})`,
|
|
88
|
+
}}
|
|
89
|
+
onLoad={(e) => {
|
|
90
|
+
try {
|
|
91
|
+
const iframe = e.currentTarget;
|
|
92
|
+
const root = iframe.contentDocument?.querySelector("[data-composition-id]");
|
|
93
|
+
const width =
|
|
94
|
+
Number(root?.getAttribute("data-width")) || DEFAULT_PREVIEW_STAGE.width;
|
|
95
|
+
const height =
|
|
96
|
+
Number(root?.getAttribute("data-height")) || DEFAULT_PREVIEW_STAGE.height;
|
|
97
|
+
setStageSize({ width, height });
|
|
98
|
+
} catch {
|
|
99
|
+
setStageSize(DEFAULT_PREVIEW_STAGE);
|
|
100
|
+
}
|
|
58
101
|
}}
|
|
59
102
|
tabIndex={-1}
|
|
60
103
|
/>
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import {
|
|
3
3
|
generateTicks,
|
|
4
|
+
getDefaultDroppedTrack,
|
|
5
|
+
getTimelineCanvasHeight,
|
|
6
|
+
resolveTimelineAssetDrop,
|
|
4
7
|
getTimelinePlayheadLeft,
|
|
5
8
|
getTimelineScrollLeftForZoomTransition,
|
|
9
|
+
shouldHandleTimelineDeleteKey,
|
|
6
10
|
shouldAutoScrollTimeline,
|
|
7
11
|
} from "./Timeline";
|
|
8
12
|
import { formatTime } from "../lib/time";
|
|
@@ -151,3 +155,83 @@ describe("getTimelinePlayheadLeft", () => {
|
|
|
151
155
|
expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(32);
|
|
152
156
|
});
|
|
153
157
|
});
|
|
158
|
+
|
|
159
|
+
describe("getTimelineCanvasHeight", () => {
|
|
160
|
+
it("includes bottom scroll buffer below the last track", () => {
|
|
161
|
+
expect(getTimelineCanvasHeight(3)).toBeGreaterThan(24 + 3 * 72);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("still keeps ruler space when there are no tracks", () => {
|
|
165
|
+
expect(getTimelineCanvasHeight(0)).toBeGreaterThan(24);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("shouldHandleTimelineDeleteKey", () => {
|
|
170
|
+
it("handles Delete and Backspace when focus is not in an editor", () => {
|
|
171
|
+
expect(shouldHandleTimelineDeleteKey({ key: "Delete" })).toBe(true);
|
|
172
|
+
expect(shouldHandleTimelineDeleteKey({ key: "Backspace" })).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("ignores modifier shortcuts", () => {
|
|
176
|
+
expect(shouldHandleTimelineDeleteKey({ key: "Delete", metaKey: true })).toBe(false);
|
|
177
|
+
expect(shouldHandleTimelineDeleteKey({ key: "Backspace", ctrlKey: true })).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("ignores input and editable targets", () => {
|
|
181
|
+
const input = { tagName: "INPUT", isContentEditable: false };
|
|
182
|
+
const editable = { tagName: "DIV", isContentEditable: true };
|
|
183
|
+
|
|
184
|
+
expect(shouldHandleTimelineDeleteKey({ key: "Delete", target: input })).toBe(false);
|
|
185
|
+
expect(shouldHandleTimelineDeleteKey({ key: "Delete", target: editable })).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("getDefaultDroppedTrack", () => {
|
|
190
|
+
it("defaults to track 0 when there are no rows yet", () => {
|
|
191
|
+
expect(getDefaultDroppedTrack([])).toBe(0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("creates a new bottom track when dropped below existing rows", () => {
|
|
195
|
+
expect(getDefaultDroppedTrack([0, 1, 5], 10)).toBe(6);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("resolveTimelineAssetDrop", () => {
|
|
200
|
+
it("maps drop coordinates to a start time and visible track", () => {
|
|
201
|
+
expect(
|
|
202
|
+
resolveTimelineAssetDrop(
|
|
203
|
+
{
|
|
204
|
+
rectLeft: 100,
|
|
205
|
+
rectTop: 200,
|
|
206
|
+
scrollLeft: 0,
|
|
207
|
+
scrollTop: 0,
|
|
208
|
+
pixelsPerSecond: 100,
|
|
209
|
+
duration: 10,
|
|
210
|
+
trackHeight: 72,
|
|
211
|
+
trackOrder: [0, 3, 7],
|
|
212
|
+
},
|
|
213
|
+
432,
|
|
214
|
+
310,
|
|
215
|
+
),
|
|
216
|
+
).toEqual({ start: 3, track: 3 });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("can create a new bottom track when dropped below the last visible row", () => {
|
|
220
|
+
expect(
|
|
221
|
+
resolveTimelineAssetDrop(
|
|
222
|
+
{
|
|
223
|
+
rectLeft: 100,
|
|
224
|
+
rectTop: 200,
|
|
225
|
+
scrollLeft: 0,
|
|
226
|
+
scrollTop: 0,
|
|
227
|
+
pixelsPerSecond: 100,
|
|
228
|
+
duration: 10,
|
|
229
|
+
trackHeight: 72,
|
|
230
|
+
trackOrder: [0, 3, 7],
|
|
231
|
+
},
|
|
232
|
+
250,
|
|
233
|
+
600,
|
|
234
|
+
),
|
|
235
|
+
).toEqual({ start: 1.18, track: 8 });
|
|
236
|
+
});
|
|
237
|
+
});
|