@hyperframes/studio 0.4.17 → 0.4.19

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.
@@ -27,6 +27,7 @@ import {
27
27
  type TimelineTheme,
28
28
  } from "./timelineTheme";
29
29
  import { getTimelinePixelsPerSecond } from "./timelineZoom";
30
+ import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
30
31
 
31
32
  /* ── Layout ─────────────────────────────────────────────────────── */
32
33
  const GUTTER = 32;
@@ -140,6 +141,70 @@ export function getTimelineCanvasHeight(trackCount: number): number {
140
141
  return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
141
142
  }
142
143
 
144
+ export function shouldHandleTimelineDeleteKey(input: {
145
+ key: string;
146
+ metaKey?: boolean;
147
+ ctrlKey?: boolean;
148
+ altKey?: boolean;
149
+ target?: EventTarget | null;
150
+ }): boolean {
151
+ if (input.key !== "Delete" && input.key !== "Backspace") return false;
152
+ if (input.metaKey || input.ctrlKey || input.altKey) return false;
153
+ const target =
154
+ input.target && typeof input.target === "object"
155
+ ? (input.target as {
156
+ tagName?: string;
157
+ isContentEditable?: boolean;
158
+ closest?: (selector: string) => Element | null;
159
+ })
160
+ : null;
161
+ if (target) {
162
+ const tag = target.tagName?.toLowerCase() ?? "";
163
+ if (target.isContentEditable) return false;
164
+ if (["input", "textarea", "select"].includes(tag)) return false;
165
+ if (typeof target.closest === "function" && target.closest("[contenteditable='true']")) {
166
+ return false;
167
+ }
168
+ }
169
+ return true;
170
+ }
171
+
172
+ export function getDefaultDroppedTrack(trackOrder: number[], rowIndex?: number): number {
173
+ if (trackOrder.length === 0) return 0;
174
+ if (rowIndex == null || rowIndex < 0) return trackOrder[0];
175
+ if (rowIndex >= trackOrder.length) {
176
+ return Math.max(...trackOrder) + 1;
177
+ }
178
+ return trackOrder[rowIndex] ?? trackOrder[trackOrder.length - 1] ?? 0;
179
+ }
180
+
181
+ export function resolveTimelineAssetDrop(
182
+ input: {
183
+ rectLeft: number;
184
+ rectTop: number;
185
+ scrollLeft: number;
186
+ scrollTop: number;
187
+ pixelsPerSecond: number;
188
+ duration: number;
189
+ trackHeight: number;
190
+ trackOrder: number[];
191
+ },
192
+ clientX: number,
193
+ clientY: number,
194
+ ): { start: number; track: number } {
195
+ const x = clientX - input.rectLeft + input.scrollLeft - GUTTER;
196
+ const y = clientY - input.rectTop + input.scrollTop - RULER_H;
197
+ const start = Math.max(
198
+ 0,
199
+ Math.min(input.duration, Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100),
200
+ );
201
+ const rowIndex = Math.floor(y / Math.max(input.trackHeight, 1));
202
+ return {
203
+ start,
204
+ track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
205
+ };
206
+ }
207
+
143
208
  /* ── Component ──────────────────────────────────────────────────── */
144
209
  interface TimelineProps {
145
210
  /** Called when user seeks via ruler/track click or playhead drag */
@@ -154,8 +219,19 @@ interface TimelineProps {
154
219
  /** Optional overlay renderer for clips (e.g. badges, cursors) */
155
220
  renderClipOverlay?: (element: import("../store/playerStore").TimelineElement) => ReactNode;
156
221
  /** Called when files are dropped onto the empty timeline */
157
- onFileDrop?: (files: File[]) => void;
222
+ onFileDrop?: (
223
+ files: File[],
224
+ placement?: { start: number; track: number },
225
+ ) => Promise<void> | void;
226
+ /** Called when an existing asset is dropped from the Assets tab */
227
+ onAssetDrop?: (
228
+ assetPath: string,
229
+ placement: { start: number; track: number },
230
+ ) => Promise<void> | void;
158
231
  /** Persist a clip move back into source HTML */
232
+ onDeleteElement?: (
233
+ element: import("../store/playerStore").TimelineElement,
234
+ ) => Promise<void> | void;
159
235
  onMoveElement?: (
160
236
  element: import("../store/playerStore").TimelineElement,
161
237
  updates: Pick<import("../store/playerStore").TimelineElement, "start" | "track">,
@@ -213,6 +289,8 @@ export const Timeline = memo(function Timeline({
213
289
  renderClipContent,
214
290
  renderClipOverlay,
215
291
  onFileDrop,
292
+ onAssetDrop,
293
+ onDeleteElement,
216
294
  onMoveElement,
217
295
  onResizeElement,
218
296
  onBlockedEditAttempt,
@@ -263,10 +341,13 @@ export const Timeline = memo(function Timeline({
263
341
  const resizingClipRef = useRef<ResizingClipState | null>(null);
264
342
  resizingClipRef.current = resizingClip;
265
343
  const blockedClipRef = useRef<BlockedClipState | null>(null);
344
+ const deleteInFlightRef = useRef(false);
266
345
  const onMoveElementRef = useRef(onMoveElement);
267
346
  onMoveElementRef.current = onMoveElement;
268
347
  const onResizeElementRef = useRef(onResizeElement);
269
348
  onResizeElementRef.current = onResizeElement;
349
+ const onDeleteElementRef = useRef(onDeleteElement);
350
+ onDeleteElementRef.current = onDeleteElement;
270
351
  const suppressClickRef = useRef(false);
271
352
  const [showPopover, setShowPopover] = useState(false);
272
353
  const [viewportWidth, setViewportWidth] = useState(0);
@@ -337,6 +418,12 @@ export const Timeline = memo(function Timeline({
337
418
  }
338
419
  return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
339
420
  }, [draggedClip, trackOrder]);
421
+ const selectedElement = useMemo(
422
+ () => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
423
+ [elements, selectedElementId],
424
+ );
425
+ const selectedElementRef = useRef<TimelineElement | null>(selectedElement);
426
+ selectedElementRef.current = selectedElement;
340
427
 
341
428
  // Calculate effective pixels per second
342
429
  // In fit mode, use clientWidth (excludes scrollbar) with a small padding
@@ -743,6 +830,28 @@ export const Timeline = memo(function Timeline({
743
830
  };
744
831
  });
745
832
 
833
+ useMountEffect(() => {
834
+ const handleKeyDown = (event: KeyboardEvent) => {
835
+ if (!shouldHandleTimelineDeleteKey(event)) return;
836
+ const selected = selectedElementRef.current;
837
+ const onDelete = onDeleteElementRef.current;
838
+ if (!selected || !onDelete || deleteInFlightRef.current) return;
839
+ event.preventDefault();
840
+ deleteInFlightRef.current = true;
841
+ suppressClickRef.current = true;
842
+ setShowPopover(false);
843
+ setRangeSelection(null);
844
+ Promise.resolve(onDelete(selected)).finally(() => {
845
+ deleteInFlightRef.current = false;
846
+ requestAnimationFrame(() => {
847
+ suppressClickRef.current = false;
848
+ });
849
+ });
850
+ };
851
+ window.addEventListener("keydown", handleKeyDown);
852
+ return () => window.removeEventListener("keydown", handleKeyDown);
853
+ });
854
+
746
855
  const handlePointerDown = useCallback(
747
856
  (e: React.PointerEvent) => {
748
857
  if (e.button !== 0) return;
@@ -833,6 +942,74 @@ export const Timeline = memo(function Timeline({
833
942
  );
834
943
 
835
944
  const [isDragOver, setIsDragOver] = useState(false);
945
+ const handleAssetDragOver = useCallback((e: React.DragEvent) => {
946
+ const hasFiles = e.dataTransfer.files.length > 0;
947
+ const hasAsset = Array.from(e.dataTransfer.types).includes(TIMELINE_ASSET_MIME);
948
+ if (!hasFiles && !hasAsset) return;
949
+ e.preventDefault();
950
+ if (hasAsset) {
951
+ e.dataTransfer.dropEffect = "copy";
952
+ }
953
+ setIsDragOver(true);
954
+ }, []);
955
+
956
+ const handleAssetDrop = useCallback(
957
+ (e: React.DragEvent) => {
958
+ e.preventDefault();
959
+ setIsDragOver(false);
960
+ if (onFileDrop && e.dataTransfer.files.length > 0) {
961
+ const scroll = scrollRef.current;
962
+ const rect = scroll?.getBoundingClientRect();
963
+ const placement =
964
+ scroll && rect
965
+ ? resolveTimelineAssetDrop(
966
+ {
967
+ rectLeft: rect.left,
968
+ rectTop: rect.top,
969
+ scrollLeft: scroll.scrollLeft,
970
+ scrollTop: scroll.scrollTop,
971
+ pixelsPerSecond: ppsRef.current,
972
+ duration: durationRef.current,
973
+ trackHeight: TRACK_H,
974
+ trackOrder: trackOrderRef.current,
975
+ },
976
+ e.clientX,
977
+ e.clientY,
978
+ )
979
+ : undefined;
980
+ void onFileDrop(Array.from(e.dataTransfer.files), placement);
981
+ return;
982
+ }
983
+
984
+ const assetPayload = e.dataTransfer.getData(TIMELINE_ASSET_MIME);
985
+ if (!assetPayload || !onAssetDrop) return;
986
+ try {
987
+ const parsed = JSON.parse(assetPayload) as { path?: string };
988
+ if (!parsed.path) return;
989
+ const scroll = scrollRef.current;
990
+ const rect = scroll?.getBoundingClientRect();
991
+ if (!scroll || !rect) return;
992
+ const placement = resolveTimelineAssetDrop(
993
+ {
994
+ rectLeft: rect.left,
995
+ rectTop: rect.top,
996
+ scrollLeft: scroll.scrollLeft,
997
+ scrollTop: scroll.scrollTop,
998
+ pixelsPerSecond: ppsRef.current,
999
+ duration: durationRef.current,
1000
+ trackHeight: TRACK_H,
1001
+ trackOrder: trackOrderRef.current,
1002
+ },
1003
+ e.clientX,
1004
+ e.clientY,
1005
+ );
1006
+ void onAssetDrop(parsed.path, placement);
1007
+ } catch {
1008
+ // ignore malformed drag payloads
1009
+ }
1010
+ },
1011
+ [onAssetDrop, onFileDrop],
1012
+ );
836
1013
 
837
1014
  if (!timelineReady || elements.length === 0) {
838
1015
  return (
@@ -840,18 +1017,9 @@ export const Timeline = memo(function Timeline({
840
1017
  className={`h-full border-t bg-[#0a0a0b] flex flex-col select-none transition-colors duration-150 ${
841
1018
  isDragOver ? "border-studio-accent/50 bg-studio-accent/[0.03]" : "border-neutral-800/50"
842
1019
  }`}
843
- onDragOver={(e) => {
844
- e.preventDefault();
845
- setIsDragOver(true);
846
- }}
1020
+ onDragOver={handleAssetDragOver}
847
1021
  onDragLeave={() => setIsDragOver(false)}
848
- onDrop={(e) => {
849
- e.preventDefault();
850
- setIsDragOver(false);
851
- if (onFileDrop && e.dataTransfer.files.length > 0) {
852
- onFileDrop(Array.from(e.dataTransfer.files));
853
- }
854
- }}
1022
+ onDrop={handleAssetDrop}
855
1023
  >
856
1024
  {/* Ruler */}
857
1025
  <div
@@ -1015,6 +1183,9 @@ export const Timeline = memo(function Timeline({
1015
1183
  <div
1016
1184
  ref={scrollRef}
1017
1185
  className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full`}
1186
+ onDragOver={handleAssetDragOver}
1187
+ onDragLeave={() => setIsDragOver(false)}
1188
+ onDrop={handleAssetDrop}
1018
1189
  onPointerDown={handlePointerDown}
1019
1190
  onPointerMove={handlePointerMove}
1020
1191
  onPointerUp={handlePointerUp}
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildTimelineFileDropPlacements,
4
+ buildTimelineAssetInsertHtml,
5
+ getTimelineAssetKind,
6
+ insertTimelineAssetIntoSource,
7
+ resolveTimelineAssetSrc,
8
+ } from "./timelineAssetDrop";
9
+
10
+ describe("getTimelineAssetKind", () => {
11
+ it("detects image, video, and audio assets", () => {
12
+ expect(getTimelineAssetKind("assets/photo.png")).toBe("image");
13
+ expect(getTimelineAssetKind("assets/clip.mp4")).toBe("video");
14
+ expect(getTimelineAssetKind("assets/music.wav")).toBe("audio");
15
+ });
16
+ });
17
+
18
+ describe("buildTimelineAssetInsertHtml", () => {
19
+ it("builds an image clip with explicit timing and track", () => {
20
+ expect(
21
+ buildTimelineAssetInsertHtml({
22
+ id: "photo_asset",
23
+ assetPath: "assets/photo.png",
24
+ kind: "image",
25
+ start: 1.25,
26
+ duration: 3,
27
+ track: 2,
28
+ zIndex: 4,
29
+ }),
30
+ ).toContain('img id="photo_asset"');
31
+ });
32
+
33
+ it("builds an audio clip without visual layout styles", () => {
34
+ const html = buildTimelineAssetInsertHtml({
35
+ id: "music_asset",
36
+ assetPath: "assets/music.wav",
37
+ kind: "audio",
38
+ start: 0.5,
39
+ duration: 5,
40
+ track: 0,
41
+ zIndex: 1,
42
+ });
43
+ expect(html).toContain("<audio");
44
+ expect(html).not.toContain("object-fit");
45
+ });
46
+ });
47
+
48
+ describe("resolveTimelineAssetSrc", () => {
49
+ it("keeps project-root asset paths for index.html", () => {
50
+ expect(resolveTimelineAssetSrc("index.html", "assets/photo.png")).toBe("assets/photo.png");
51
+ });
52
+
53
+ it("rewrites asset paths relative to sub-compositions", () => {
54
+ expect(resolveTimelineAssetSrc("compositions/scene-a.html", "assets/photo.png")).toBe(
55
+ "../assets/photo.png",
56
+ );
57
+ });
58
+ });
59
+
60
+ describe("buildTimelineFileDropPlacements", () => {
61
+ it("uses the dropped start and stacks multiple files onto successive tracks", () => {
62
+ expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, 3)).toEqual([
63
+ { start: 1.5, track: 2 },
64
+ { start: 1.5, track: 3 },
65
+ { start: 1.5, track: 4 },
66
+ ]);
67
+ });
68
+ });
69
+
70
+ describe("insertTimelineAssetIntoSource", () => {
71
+ it("appends the new asset inside the root composition", () => {
72
+ const source = `<!doctype html><html><body><div id="root" data-composition-id="main"></div></body></html>`;
73
+ const html = insertTimelineAssetIntoSource(
74
+ source,
75
+ '<img id="photo_asset" data-start="0" data-duration="3" />',
76
+ );
77
+
78
+ expect(html).toContain('<div id="root" data-composition-id="main"><img id="photo_asset"');
79
+ });
80
+ });
@@ -0,0 +1,87 @@
1
+ import { AUDIO_EXT, IMAGE_EXT, VIDEO_EXT } from "./mediaTypes";
2
+
3
+ export const TIMELINE_ASSET_MIME = "application/x-hyperframes-asset";
4
+
5
+ export type TimelineAssetKind = "image" | "video" | "audio";
6
+
7
+ export function getTimelineAssetKind(assetPath: string): TimelineAssetKind | null {
8
+ if (IMAGE_EXT.test(assetPath)) return "image";
9
+ if (VIDEO_EXT.test(assetPath)) return "video";
10
+ if (AUDIO_EXT.test(assetPath)) return "audio";
11
+ return null;
12
+ }
13
+
14
+ export function buildTimelineAssetId(assetPath: string, existingIds: Iterable<string>): string {
15
+ const baseName = assetPath.split("/").pop() ?? "asset";
16
+ const normalized = baseName
17
+ .replace(/\.[^.]+$/, "")
18
+ .replace(/[^a-zA-Z0-9_-]+/g, "_")
19
+ .replace(/^_+|_+$/g, "")
20
+ .toLowerCase();
21
+ const baseId = normalized || "asset";
22
+ const ids = new Set(existingIds);
23
+ if (!ids.has(baseId)) return baseId;
24
+ let suffix = 2;
25
+ while (ids.has(`${baseId}_${suffix}`)) suffix += 1;
26
+ return `${baseId}_${suffix}`;
27
+ }
28
+
29
+ export function resolveTimelineAssetSrc(targetPath: string, assetPath: string): string {
30
+ const targetDir = targetPath.includes("/")
31
+ ? targetPath.slice(0, targetPath.lastIndexOf("/"))
32
+ : "";
33
+ if (!targetDir) return assetPath;
34
+
35
+ const fromParts = targetDir.split("/").filter(Boolean);
36
+ const toParts = assetPath.split("/").filter(Boolean);
37
+ while (fromParts.length > 0 && toParts.length > 0 && fromParts[0] === toParts[0]) {
38
+ fromParts.shift();
39
+ toParts.shift();
40
+ }
41
+
42
+ const up = fromParts.map(() => "..");
43
+ const relative = [...up, ...toParts].join("/");
44
+ return relative || assetPath.split("/").pop() || assetPath;
45
+ }
46
+
47
+ export function buildTimelineFileDropPlacements(
48
+ placement: { start: number; track: number },
49
+ count: number,
50
+ ): Array<{ start: number; track: number }> {
51
+ return Array.from({ length: Math.max(0, count) }, (_, index) => ({
52
+ start: placement.start,
53
+ track: placement.track + index,
54
+ }));
55
+ }
56
+
57
+ export function buildTimelineAssetInsertHtml(input: {
58
+ id: string;
59
+ assetPath: string;
60
+ kind: TimelineAssetKind;
61
+ start: number;
62
+ duration: number;
63
+ track: number;
64
+ zIndex: number;
65
+ }): string {
66
+ const sharedAttrs = `id="${input.id}" class="clip" src="${input.assetPath}" data-start="${input.start}" data-duration="${input.duration}" data-track-index="${input.track}"`;
67
+
68
+ if (input.kind === "image") {
69
+ return `<img ${sharedAttrs} style="position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; z-index: ${input.zIndex}" />`;
70
+ }
71
+
72
+ if (input.kind === "video") {
73
+ return `<video ${sharedAttrs} muted playsinline style="position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; z-index: ${input.zIndex}"></video>`;
74
+ }
75
+
76
+ return `<audio ${sharedAttrs} style="z-index: ${input.zIndex}"></audio>`;
77
+ }
78
+
79
+ export function insertTimelineAssetIntoSource(source: string, assetHtml: string): string {
80
+ const rootOpenTag = /<[^>]*data-composition-id="[^"]+"[^>]*>/i;
81
+ const match = rootOpenTag.exec(source);
82
+ if (!match || match.index == null) {
83
+ throw new Error("No composition root found in target source");
84
+ }
85
+ const insertAt = match.index + match[0].length;
86
+ return `${source.slice(0, insertAt)}${assetHtml}${source.slice(insertAt)}`;
87
+ }