@hyperframes/studio 0.4.17 → 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/index.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <title>HyperFrames Studio</title>
7
- <script type="module" crossorigin src="/assets/index-JZr8f8y8.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-D0VntLIQ.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-kT65pCwW.css">
9
9
  </head>
10
10
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.4.17",
3
+ "version": "0.4.18",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,8 +32,8 @@
32
32
  "@phosphor-icons/react": "^2.1.10",
33
33
  "codemirror": "^6.0.1",
34
34
  "motion": "^12.38.0",
35
- "@hyperframes/core": "0.4.17",
36
- "@hyperframes/player": "0.4.17"
35
+ "@hyperframes/core": "0.4.18",
36
+ "@hyperframes/player": "0.4.18"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/react": "^19.0.0",
@@ -47,7 +47,7 @@
47
47
  "vite": "^6.4.2",
48
48
  "vitest": "^3.2.4",
49
49
  "zustand": "^5.0.0",
50
- "@hyperframes/producer": "0.4.17"
50
+ "@hyperframes/producer": "0.4.18"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "react": "^18.0.0 || ^19.0.0",
package/src/App.tsx CHANGED
@@ -13,6 +13,15 @@ import { LintModal } from "./components/LintModal";
13
13
  import type { LintFinding } from "./components/LintModal";
14
14
  import { MediaPreview } from "./components/MediaPreview";
15
15
  import { isMediaFile } from "./utils/mediaTypes";
16
+ import {
17
+ buildTimelineAssetId,
18
+ buildTimelineAssetInsertHtml,
19
+ buildTimelineFileDropPlacements,
20
+ getTimelineAssetKind,
21
+ insertTimelineAssetIntoSource,
22
+ resolveTimelineAssetSrc,
23
+ type TimelineAssetKind,
24
+ } from "./utils/timelineAssetDrop";
16
25
  import { CaptionOverlay } from "./captions/components/CaptionOverlay";
17
26
  import { CaptionPropertyPanel } from "./captions/components/CaptionPropertyPanel";
18
27
  import { CaptionTimeline } from "./captions/components/CaptionTimeline";
@@ -45,6 +54,56 @@ interface AppToast {
45
54
  tone: "error" | "info";
46
55
  }
47
56
 
57
+ const DEFAULT_TIMELINE_ASSET_DURATION: Record<TimelineAssetKind, number> = {
58
+ image: 3,
59
+ video: 5,
60
+ audio: 5,
61
+ };
62
+
63
+ function collectHtmlIds(source: string): string[] {
64
+ return Array.from(source.matchAll(/\bid="([^"]+)"/g), (match) => match[1] ?? "");
65
+ }
66
+
67
+ async function resolveDroppedAssetDuration(
68
+ projectId: string,
69
+ assetPath: string,
70
+ kind: TimelineAssetKind,
71
+ ): Promise<number> {
72
+ if (kind === "image") return DEFAULT_TIMELINE_ASSET_DURATION.image;
73
+
74
+ const media = document.createElement(kind === "video" ? "video" : "audio");
75
+ media.preload = "metadata";
76
+ media.src = `/api/projects/${projectId}/preview/${assetPath}`;
77
+
78
+ const duration = await new Promise<number>((resolve) => {
79
+ const timeout = window.setTimeout(() => resolve(DEFAULT_TIMELINE_ASSET_DURATION[kind]), 3000);
80
+ const finalize = (value: number) => {
81
+ window.clearTimeout(timeout);
82
+ resolve(value);
83
+ };
84
+
85
+ media.addEventListener(
86
+ "loadedmetadata",
87
+ () => {
88
+ const raw = Number(media.duration);
89
+ finalize(
90
+ Number.isFinite(raw) && raw > 0
91
+ ? Math.round(raw * 100) / 100
92
+ : DEFAULT_TIMELINE_ASSET_DURATION[kind],
93
+ );
94
+ },
95
+ { once: true },
96
+ );
97
+ media.addEventListener("error", () => finalize(DEFAULT_TIMELINE_ASSET_DURATION[kind]), {
98
+ once: true,
99
+ });
100
+ });
101
+
102
+ media.src = "";
103
+ media.load();
104
+ return duration;
105
+ }
106
+
48
107
  // ── Main App ──
49
108
 
50
109
  export function StudioApp() {
@@ -717,7 +776,135 @@ export function StudioApp() {
717
776
  [activeCompPath],
718
777
  );
719
778
 
720
- // ── File Management Handlers ──
779
+ const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
780
+ if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
781
+ setAppToast({ message, tone });
782
+ toastTimerRef.current = setTimeout(() => setAppToast(null), 4000);
783
+ }, []);
784
+
785
+ const handleTimelineElementDelete = useCallback(
786
+ async (element: TimelineElement) => {
787
+ const pid = projectIdRef.current;
788
+ if (!pid) throw new Error("No active project");
789
+
790
+ const targetPath = element.sourceFile || activeCompPath || "index.html";
791
+ try {
792
+ const response = await fetch(
793
+ `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
794
+ );
795
+ if (!response.ok) {
796
+ throw new Error(`Failed to read ${targetPath}`);
797
+ }
798
+
799
+ const data = (await response.json()) as { content?: string };
800
+ const originalContent = data.content;
801
+ if (typeof originalContent !== "string") {
802
+ throw new Error(`Missing file contents for ${targetPath}`);
803
+ }
804
+
805
+ const patchTarget = element.domId
806
+ ? { id: element.domId, selector: element.selector, selectorIndex: element.selectorIndex }
807
+ : element.selector
808
+ ? { selector: element.selector, selectorIndex: element.selectorIndex }
809
+ : null;
810
+ if (!patchTarget) {
811
+ throw new Error(`Timeline element ${element.id} is missing a patchable target`);
812
+ }
813
+
814
+ const resolvedTargetPath = targetPath || "index.html";
815
+ const remainingElements = timelineElements.filter(
816
+ (timelineElement) =>
817
+ (timelineElement.key ?? timelineElement.id) !== (element.key ?? element.id) &&
818
+ (timelineElement.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
819
+ );
820
+ const trackZIndices = buildTrackZIndexMap(
821
+ remainingElements.map((timelineElement) => timelineElement.track),
822
+ );
823
+
824
+ const removeResponse = await fetch(
825
+ `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
826
+ {
827
+ method: "POST",
828
+ headers: { "Content-Type": "application/json" },
829
+ body: JSON.stringify({ target: patchTarget }),
830
+ },
831
+ );
832
+ if (!removeResponse.ok) {
833
+ throw new Error(`Failed to delete ${element.id} from ${targetPath}`);
834
+ }
835
+
836
+ const removeData = (await removeResponse.json()) as {
837
+ changed?: boolean;
838
+ content?: string;
839
+ };
840
+ let patchedContent =
841
+ typeof removeData.content === "string" ? removeData.content : originalContent;
842
+ for (const timelineElement of remainingElements) {
843
+ const elementTarget = timelineElement.domId
844
+ ? {
845
+ id: timelineElement.domId,
846
+ selector: timelineElement.selector,
847
+ selectorIndex: timelineElement.selectorIndex,
848
+ }
849
+ : timelineElement.selector
850
+ ? {
851
+ selector: timelineElement.selector,
852
+ selectorIndex: timelineElement.selectorIndex,
853
+ }
854
+ : null;
855
+ if (!elementTarget) continue;
856
+ const nextZIndex = trackZIndices.get(timelineElement.track);
857
+ if (nextZIndex == null) continue;
858
+ patchedContent = applyPatchByTarget(patchedContent, elementTarget, {
859
+ type: "inline-style",
860
+ property: "z-index",
861
+ value: String(nextZIndex),
862
+ });
863
+ }
864
+
865
+ const saveResponse = await fetch(
866
+ `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
867
+ {
868
+ method: "PUT",
869
+ headers: { "Content-Type": "text/plain" },
870
+ body: patchedContent,
871
+ },
872
+ );
873
+ if (!saveResponse.ok) {
874
+ throw new Error(`Failed to save ${targetPath}`);
875
+ }
876
+
877
+ if (editingPathRef.current === targetPath) {
878
+ setEditingFile({ path: targetPath, content: patchedContent });
879
+ }
880
+
881
+ usePlayerStore
882
+ .getState()
883
+ .setElements(
884
+ timelineElements.filter(
885
+ (timelineElement) =>
886
+ (timelineElement.key ?? timelineElement.id) !== (element.key ?? element.id),
887
+ ),
888
+ );
889
+ usePlayerStore.getState().setSelectedElementId(null);
890
+ setRefreshKey((k) => k + 1);
891
+ } catch (error) {
892
+ const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
893
+ showToast(message);
894
+ }
895
+ },
896
+ [activeCompPath, showToast, timelineElements],
897
+ );
898
+
899
+ const handleBlockedTimelineEdit = useCallback(
900
+ (_element: TimelineElement) => {
901
+ const now = Date.now();
902
+ if (now - lastBlockedTimelineToastAtRef.current < 1500) return;
903
+ lastBlockedTimelineToastAtRef.current = now;
904
+ showToast("This clip can’t be moved or resized from the timeline yet.", "info");
905
+ },
906
+ [showToast],
907
+ );
721
908
 
722
909
  const refreshFileTree = useCallback(async () => {
723
910
  const pid = projectIdRef.current;
@@ -727,6 +914,171 @@ export function StudioApp() {
727
914
  if (data.files) setFileTree(data.files);
728
915
  }, []);
729
916
 
917
+ const uploadProjectFiles = useCallback(
918
+ async (files: Iterable<File>, dir?: string): Promise<string[]> => {
919
+ const pid = projectIdRef.current;
920
+ const fileList = Array.from(files);
921
+ if (!pid || fileList.length === 0) return [];
922
+
923
+ const formData = new FormData();
924
+ for (const file of fileList) {
925
+ formData.append("file", file);
926
+ }
927
+
928
+ const qs = dir ? `?dir=${encodeURIComponent(dir)}` : "";
929
+ try {
930
+ const res = await fetch(`/api/projects/${pid}/upload${qs}`, {
931
+ method: "POST",
932
+ body: formData,
933
+ });
934
+ if (res.ok) {
935
+ const data = await res.json();
936
+ if (data.skipped?.length) {
937
+ showToast(`Skipped (too large): ${data.skipped.join(", ")}`);
938
+ }
939
+ if (data.invalid?.length) {
940
+ const names = data.invalid.map((entry: { name: string }) => entry.name).join(", ");
941
+ showToast(`Unsupported media skipped: ${names}`);
942
+ }
943
+ await refreshFileTree();
944
+ setRefreshKey((k) => k + 1);
945
+ return Array.isArray(data.files) ? data.files : [];
946
+ } else if (res.status === 413) {
947
+ showToast("Upload rejected: payload too large");
948
+ } else {
949
+ showToast(`Upload failed (${res.status})`);
950
+ }
951
+ } catch {
952
+ showToast("Upload failed: network error");
953
+ }
954
+ return [];
955
+ },
956
+ [refreshFileTree, showToast],
957
+ );
958
+
959
+ const handleTimelineAssetDrop = useCallback(
960
+ async (assetPath: string, placement: Pick<TimelineElement, "start" | "track">) => {
961
+ const pid = projectIdRef.current;
962
+ if (!pid) throw new Error("No active project");
963
+
964
+ const kind = getTimelineAssetKind(assetPath);
965
+ if (!kind) {
966
+ showToast("Only image, video, and audio assets can be dropped onto the timeline.");
967
+ return;
968
+ }
969
+
970
+ const targetPath = activeCompPath || "index.html";
971
+ try {
972
+ const response = await fetch(
973
+ `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
974
+ );
975
+ if (!response.ok) {
976
+ throw new Error(`Failed to read ${targetPath}`);
977
+ }
978
+
979
+ const data = (await response.json()) as { content?: string };
980
+ const originalContent = data.content;
981
+ if (typeof originalContent !== "string") {
982
+ throw new Error(`Missing file contents for ${targetPath}`);
983
+ }
984
+
985
+ const normalizedStart = Number(formatTimelineAttributeNumber(placement.start));
986
+ const normalizedDuration = Number(
987
+ formatTimelineAttributeNumber(await resolveDroppedAssetDuration(pid, assetPath, kind)),
988
+ );
989
+ const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
990
+ const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
991
+
992
+ const resolvedTargetPath = targetPath || "index.html";
993
+ const relevantElements = timelineElements.filter(
994
+ (timelineElement) =>
995
+ (timelineElement.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
996
+ );
997
+ const trackZIndices = buildTrackZIndexMap([
998
+ ...relevantElements.map((timelineElement) => timelineElement.track),
999
+ placement.track,
1000
+ ]);
1001
+
1002
+ let patchedContent = originalContent;
1003
+ for (const timelineElement of relevantElements) {
1004
+ const elementTarget = timelineElement.domId
1005
+ ? {
1006
+ id: timelineElement.domId,
1007
+ selector: timelineElement.selector,
1008
+ selectorIndex: timelineElement.selectorIndex,
1009
+ }
1010
+ : timelineElement.selector
1011
+ ? {
1012
+ selector: timelineElement.selector,
1013
+ selectorIndex: timelineElement.selectorIndex,
1014
+ }
1015
+ : null;
1016
+ if (!elementTarget) continue;
1017
+ const nextZIndex = trackZIndices.get(timelineElement.track);
1018
+ if (nextZIndex == null) continue;
1019
+ patchedContent = applyPatchByTarget(patchedContent, elementTarget, {
1020
+ type: "inline-style",
1021
+ property: "z-index",
1022
+ value: String(nextZIndex),
1023
+ });
1024
+ }
1025
+
1026
+ patchedContent = insertTimelineAssetIntoSource(
1027
+ patchedContent,
1028
+ buildTimelineAssetInsertHtml({
1029
+ id: newId,
1030
+ assetPath: resolvedAssetSrc,
1031
+ kind,
1032
+ start: normalizedStart,
1033
+ duration: normalizedDuration,
1034
+ track: placement.track,
1035
+ zIndex: trackZIndices.get(placement.track) ?? 1,
1036
+ }),
1037
+ );
1038
+
1039
+ const saveResponse = await fetch(
1040
+ `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
1041
+ {
1042
+ method: "PUT",
1043
+ headers: { "Content-Type": "text/plain" },
1044
+ body: patchedContent,
1045
+ },
1046
+ );
1047
+ if (!saveResponse.ok) {
1048
+ throw new Error(`Failed to save ${targetPath}`);
1049
+ }
1050
+
1051
+ if (editingPathRef.current === targetPath) {
1052
+ setEditingFile({ path: targetPath, content: patchedContent });
1053
+ }
1054
+
1055
+ setRefreshKey((k) => k + 1);
1056
+ } catch (error) {
1057
+ const message =
1058
+ error instanceof Error ? error.message : "Failed to drop asset onto timeline";
1059
+ showToast(message);
1060
+ }
1061
+ },
1062
+ [activeCompPath, showToast, timelineElements],
1063
+ );
1064
+
1065
+ const handleTimelineFileDrop = useCallback(
1066
+ async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
1067
+ const uploaded = await uploadProjectFiles(files);
1068
+ if (uploaded.length === 0) return;
1069
+ const placements = buildTimelineFileDropPlacements(
1070
+ placement ?? { start: 0, track: 0 },
1071
+ uploaded.length,
1072
+ );
1073
+ for (const [index, assetPath] of uploaded.entries()) {
1074
+ await handleTimelineAssetDrop(assetPath, placements[index] ?? placements[0]);
1075
+ }
1076
+ },
1077
+ [handleTimelineAssetDrop, uploadProjectFiles],
1078
+ );
1079
+
1080
+ // ── File Management Handlers ──
1081
+
730
1082
  const handleCreateFile = useCallback(
731
1083
  async (path: string) => {
732
1084
  const pid = projectIdRef.current;
@@ -840,55 +1192,11 @@ export function StudioApp() {
840
1192
 
841
1193
  const handleMoveFile = handleRenameFile;
842
1194
 
843
- const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
844
- if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
845
- setAppToast({ message, tone });
846
- toastTimerRef.current = setTimeout(() => setAppToast(null), 4000);
847
- }, []);
848
-
849
- const handleBlockedTimelineEdit = useCallback(
850
- (_element: TimelineElement) => {
851
- const now = Date.now();
852
- if (now - lastBlockedTimelineToastAtRef.current < 1500) return;
853
- lastBlockedTimelineToastAtRef.current = now;
854
- showToast("This clip can’t be moved or resized from the timeline yet.", "info");
855
- },
856
- [showToast],
857
- );
858
-
859
1195
  const handleImportFiles = useCallback(
860
- async (files: FileList, dir?: string) => {
861
- const pid = projectIdRef.current;
862
- if (!pid || files.length === 0) return;
863
-
864
- const formData = new FormData();
865
- for (const file of Array.from(files)) {
866
- formData.append("file", file);
867
- }
868
-
869
- const qs = dir ? `?dir=${encodeURIComponent(dir)}` : "";
870
- try {
871
- const res = await fetch(`/api/projects/${pid}/upload${qs}`, {
872
- method: "POST",
873
- body: formData,
874
- });
875
- if (res.ok) {
876
- const data = await res.json();
877
- if (data.skipped?.length) {
878
- showToast(`Skipped (too large): ${data.skipped.join(", ")}`);
879
- }
880
- await refreshFileTree();
881
- setRefreshKey((k) => k + 1);
882
- } else if (res.status === 413) {
883
- showToast("Upload rejected: payload too large");
884
- } else {
885
- showToast(`Upload failed (${res.status})`);
886
- }
887
- } catch {
888
- showToast("Upload failed: network error");
889
- }
1196
+ async (files: FileList | File[], dir?: string) => {
1197
+ void uploadProjectFiles(Array.from(files), dir);
890
1198
  },
891
- [refreshFileTree, showToast],
1199
+ [uploadProjectFiles],
892
1200
  );
893
1201
 
894
1202
  const handleLint = useCallback(async () => {
@@ -1151,6 +1459,9 @@ export function StudioApp() {
1151
1459
  activeCompositionPath={activeCompPath}
1152
1460
  timelineToolbar={timelineToolbar}
1153
1461
  renderClipContent={renderClipContent}
1462
+ onDeleteElement={handleTimelineElementDelete}
1463
+ onAssetDrop={handleTimelineAssetDrop}
1464
+ onFileDrop={handleTimelineFileDrop}
1154
1465
  onMoveElement={handleTimelineElementMove}
1155
1466
  onResizeElement={handleTimelineElementResize}
1156
1467
  onBlockedEditAttempt={handleBlockedTimelineEdit}
@@ -28,6 +28,15 @@ interface NLELayoutProps {
28
28
  element: TimelineElement,
29
29
  style: { clip: string; label: string },
30
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;
31
40
  /** Persist timeline move actions back into source HTML */
32
41
  onMoveElement?: (
33
42
  element: TimelineElement,
@@ -61,6 +70,9 @@ export const NLELayout = memo(function NLELayout({
61
70
  onIframeRef,
62
71
  onCompositionChange,
63
72
  renderClipContent,
73
+ onFileDrop,
74
+ onDeleteElement,
75
+ onAssetDrop,
64
76
  onMoveElement,
65
77
  onResizeElement,
66
78
  onBlockedEditAttempt,
@@ -393,6 +405,9 @@ export const NLELayout = memo(function NLELayout({
393
405
  onSeek={seek}
394
406
  onDrillDown={handleDrillDown}
395
407
  renderClipContent={renderClipContent}
408
+ onFileDrop={onFileDrop}
409
+ onDeleteElement={onDeleteElement}
410
+ onAssetDrop={onAssetDrop}
396
411
  onMoveElement={onMoveElement}
397
412
  onResizeElement={onResizeElement}
398
413
  onBlockedEditAttempt={onBlockedEditAttempt}
@@ -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 });
@@ -1,9 +1,12 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import {
3
3
  generateTicks,
4
+ getDefaultDroppedTrack,
4
5
  getTimelineCanvasHeight,
6
+ resolveTimelineAssetDrop,
5
7
  getTimelinePlayheadLeft,
6
8
  getTimelineScrollLeftForZoomTransition,
9
+ shouldHandleTimelineDeleteKey,
7
10
  shouldAutoScrollTimeline,
8
11
  } from "./Timeline";
9
12
  import { formatTime } from "../lib/time";
@@ -162,3 +165,73 @@ describe("getTimelineCanvasHeight", () => {
162
165
  expect(getTimelineCanvasHeight(0)).toBeGreaterThan(24);
163
166
  });
164
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
+ });