@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.
@@ -10,10 +10,14 @@ import { formatTime } from "../lib/time";
10
10
  import { TimelineClip } from "./TimelineClip";
11
11
  import { EditPopover } from "./EditModal";
12
12
  import {
13
+ buildClipRangeSelection,
13
14
  getTimelineEditCapabilities,
15
+ resolveBlockedTimelineEditIntent,
14
16
  resolveTimelineAutoScroll,
15
17
  resolveTimelineMove,
16
18
  resolveTimelineResize,
19
+ type BlockedTimelineEditIntent,
20
+ type TimelineRangeSelection,
17
21
  } from "./timelineEditing";
18
22
  import {
19
23
  defaultTimelineTheme,
@@ -23,12 +27,15 @@ import {
23
27
  type TimelineTheme,
24
28
  } from "./timelineTheme";
25
29
  import { getTimelinePixelsPerSecond } from "./timelineZoom";
30
+ import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
26
31
 
27
32
  /* ── Layout ─────────────────────────────────────────────────────── */
28
33
  const GUTTER = 32;
29
34
  const TRACK_H = 72;
30
35
  const RULER_H = 24;
31
36
  const CLIP_Y = 3; // vertical inset inside track
37
+ const CLIP_HANDLE_W = 18;
38
+ const TIMELINE_SCROLL_BUFFER = 24;
32
39
 
33
40
  interface TrackVisualStyle extends TimelineTrackStyle {
34
41
  icon: ReactNode;
@@ -130,6 +137,74 @@ export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number):
130
137
  return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
131
138
  }
132
139
 
140
+ export function getTimelineCanvasHeight(trackCount: number): number {
141
+ return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
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
+
133
208
  /* ── Component ──────────────────────────────────────────────────── */
134
209
  interface TimelineProps {
135
210
  /** Called when user seeks via ruler/track click or playhead drag */
@@ -144,8 +219,19 @@ interface TimelineProps {
144
219
  /** Optional overlay renderer for clips (e.g. badges, cursors) */
145
220
  renderClipOverlay?: (element: import("../store/playerStore").TimelineElement) => ReactNode;
146
221
  /** Called when files are dropped onto the empty timeline */
147
- 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;
148
231
  /** Persist a clip move back into source HTML */
232
+ onDeleteElement?: (
233
+ element: import("../store/playerStore").TimelineElement,
234
+ ) => Promise<void> | void;
149
235
  onMoveElement?: (
150
236
  element: import("../store/playerStore").TimelineElement,
151
237
  updates: Pick<import("../store/playerStore").TimelineElement, "start" | "track">,
@@ -157,6 +243,10 @@ interface TimelineProps {
157
243
  "start" | "duration" | "playbackStart"
158
244
  >,
159
245
  ) => Promise<void> | void;
246
+ onBlockedEditAttempt?: (
247
+ element: import("../store/playerStore").TimelineElement,
248
+ intent: BlockedTimelineEditIntent,
249
+ ) => void;
160
250
  theme?: Partial<TimelineTheme>;
161
251
  }
162
252
 
@@ -185,14 +275,25 @@ interface ResizingClipState {
185
275
  started: boolean;
186
276
  }
187
277
 
278
+ interface BlockedClipState {
279
+ element: TimelineElement;
280
+ intent: BlockedTimelineEditIntent;
281
+ originClientX: number;
282
+ originClientY: number;
283
+ started: boolean;
284
+ }
285
+
188
286
  export const Timeline = memo(function Timeline({
189
287
  onSeek,
190
288
  onDrillDown,
191
289
  renderClipContent,
192
290
  renderClipOverlay,
193
291
  onFileDrop,
292
+ onAssetDrop,
293
+ onDeleteElement,
194
294
  onMoveElement,
195
295
  onResizeElement,
296
+ onBlockedEditAttempt,
196
297
  theme: themeOverrides,
197
298
  }: TimelineProps = {}) {
198
299
  const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
@@ -210,6 +311,11 @@ export const Timeline = memo(function Timeline({
210
311
  const scrollRef = useRef<HTMLDivElement>(null);
211
312
  const [hoveredClip, setHoveredClip] = useState<string | null>(null);
212
313
  const isDragging = useRef(false);
314
+ const shiftClickClipRef = useRef<{
315
+ element: TimelineElement;
316
+ anchorX: number;
317
+ anchorY: number;
318
+ } | null>(null);
213
319
  // Range selection (Shift+drag)
214
320
  const [shiftHeld, setShiftHeld] = useState(false);
215
321
  useMountEffect(() => {
@@ -227,22 +333,21 @@ export const Timeline = memo(function Timeline({
227
333
  });
228
334
  const isRangeSelecting = useRef(false);
229
335
  const rangeAnchorTime = useRef(0);
230
- const [rangeSelection, setRangeSelection] = useState<{
231
- start: number;
232
- end: number;
233
- anchorX: number;
234
- anchorY: number;
235
- } | null>(null);
336
+ const [rangeSelection, setRangeSelection] = useState<TimelineRangeSelection | null>(null);
236
337
  const [draggedClip, setDraggedClip] = useState<DraggedClipState | null>(null);
237
338
  const draggedClipRef = useRef<DraggedClipState | null>(null);
238
339
  draggedClipRef.current = draggedClip;
239
340
  const [resizingClip, setResizingClip] = useState<ResizingClipState | null>(null);
240
341
  const resizingClipRef = useRef<ResizingClipState | null>(null);
241
342
  resizingClipRef.current = resizingClip;
343
+ const blockedClipRef = useRef<BlockedClipState | null>(null);
344
+ const deleteInFlightRef = useRef(false);
242
345
  const onMoveElementRef = useRef(onMoveElement);
243
346
  onMoveElementRef.current = onMoveElement;
244
347
  const onResizeElementRef = useRef(onResizeElement);
245
348
  onResizeElementRef.current = onResizeElement;
349
+ const onDeleteElementRef = useRef(onDeleteElement);
350
+ onDeleteElementRef.current = onDeleteElement;
246
351
  const suppressClickRef = useRef(false);
247
352
  const [showPopover, setShowPopover] = useState(false);
248
353
  const [viewportWidth, setViewportWidth] = useState(0);
@@ -313,6 +418,12 @@ export const Timeline = memo(function Timeline({
313
418
  }
314
419
  return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
315
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;
316
427
 
317
428
  // Calculate effective pixels per second
318
429
  // In fit mode, use clientWidth (excludes scrollbar) with a small padding
@@ -546,6 +657,7 @@ export const Timeline = memo(function Timeline({
546
657
  const handleWindowPointerMove = (e: PointerEvent) => {
547
658
  const drag = draggedClipRef.current;
548
659
  const resize = resizingClipRef.current;
660
+ const blocked = blockedClipRef.current;
549
661
  if (resize) {
550
662
  const distance = Math.abs(e.clientX - resize.originClientX);
551
663
  if (!resize.started && distance < 2) return;
@@ -561,6 +673,8 @@ export const Timeline = memo(function Timeline({
561
673
  Math.max(resize.element.playbackRate ?? 1, 0.1),
562
674
  )
563
675
  : Number.POSITIVE_INFINITY;
676
+ const normalizedTag = resize.element.tag.toLowerCase();
677
+ const canSeedPlaybackStart = normalizedTag === "audio" || normalizedTag === "video";
564
678
  const nextResize = resolveTimelineResize(
565
679
  {
566
680
  start: resize.element.start,
@@ -569,7 +683,10 @@ export const Timeline = memo(function Timeline({
569
683
  pixelsPerSecond: ppsRef.current,
570
684
  minStart: 0,
571
685
  maxEnd: Math.min(durationRef.current, resize.element.start + sourceRemaining),
572
- playbackStart: resize.element.playbackStart,
686
+ playbackStart:
687
+ resize.edge === "start" && canSeedPlaybackStart
688
+ ? (resize.element.playbackStart ?? 0)
689
+ : resize.element.playbackStart,
573
690
  playbackRate: resize.element.playbackRate,
574
691
  },
575
692
  resize.edge,
@@ -589,6 +706,23 @@ export const Timeline = memo(function Timeline({
589
706
  );
590
707
  return;
591
708
  }
709
+ if (blocked) {
710
+ const distance = Math.hypot(
711
+ e.clientX - blocked.originClientX,
712
+ e.clientY - blocked.originClientY,
713
+ );
714
+ const threshold = blocked.intent === "move" ? 4 : 2;
715
+ if (!blocked.started && distance < threshold) return;
716
+ if (!blocked.started) {
717
+ blocked.started = true;
718
+ blockedClipRef.current = blocked;
719
+ suppressClickRef.current = true;
720
+ setShowPopover(false);
721
+ setRangeSelection(null);
722
+ onBlockedEditAttempt?.(blocked.element, blocked.intent);
723
+ }
724
+ return;
725
+ }
592
726
  if (!drag) return;
593
727
 
594
728
  const distance = Math.hypot(e.clientX - drag.originClientX, e.clientY - drag.originClientY);
@@ -644,6 +778,14 @@ export const Timeline = memo(function Timeline({
644
778
  return;
645
779
  }
646
780
 
781
+ const blocked = blockedClipRef.current;
782
+ if (blocked) {
783
+ blockedClipRef.current = null;
784
+ if (!blocked.started) return;
785
+ clearSuppressedClick();
786
+ return;
787
+ }
788
+
647
789
  const drag = draggedClipRef.current;
648
790
  if (!drag) return;
649
791
  draggedClipRef.current = null;
@@ -688,6 +830,28 @@ export const Timeline = memo(function Timeline({
688
830
  };
689
831
  });
690
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
+
691
855
  const handlePointerDown = useCallback(
692
856
  (e: React.PointerEvent) => {
693
857
  if (e.button !== 0) return;
@@ -707,6 +871,7 @@ export const Timeline = memo(function Timeline({
707
871
  return;
708
872
  }
709
873
 
874
+ shiftClickClipRef.current = null;
710
875
  // Normal click on a clip — let the clip handle it
711
876
  if ((e.target as HTMLElement).closest("[data-clip]")) return;
712
877
  (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
@@ -740,8 +905,14 @@ export const Timeline = memo(function Timeline({
740
905
  const handlePointerUp = useCallback(() => {
741
906
  if (isRangeSelecting.current) {
742
907
  isRangeSelecting.current = false;
743
- // Show popover if range is meaningful (> 0.2s)
908
+ const pendingShiftClick = shiftClickClipRef.current;
909
+ shiftClickClipRef.current = null;
744
910
  setRangeSelection((prev) => {
911
+ if (prev && pendingShiftClick && Math.abs(prev.end - prev.start) <= 0.2) {
912
+ setShowPopover(true);
913
+ return buildClipRangeSelection(pendingShiftClick.element, pendingShiftClick);
914
+ }
915
+ // Show popover if range is meaningful (> 0.2s)
745
916
  if (prev && Math.abs(prev.end - prev.start) > 0.2) {
746
917
  setShowPopover(true);
747
918
  return prev;
@@ -771,6 +942,74 @@ export const Timeline = memo(function Timeline({
771
942
  );
772
943
 
773
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
+ );
774
1013
 
775
1014
  if (!timelineReady || elements.length === 0) {
776
1015
  return (
@@ -778,18 +1017,9 @@ export const Timeline = memo(function Timeline({
778
1017
  className={`h-full border-t bg-[#0a0a0b] flex flex-col select-none transition-colors duration-150 ${
779
1018
  isDragOver ? "border-studio-accent/50 bg-studio-accent/[0.03]" : "border-neutral-800/50"
780
1019
  }`}
781
- onDragOver={(e) => {
782
- e.preventDefault();
783
- setIsDragOver(true);
784
- }}
1020
+ onDragOver={handleAssetDragOver}
785
1021
  onDragLeave={() => setIsDragOver(false)}
786
- onDrop={(e) => {
787
- e.preventDefault();
788
- setIsDragOver(false);
789
- if (onFileDrop && e.dataTransfer.files.length > 0) {
790
- onFileDrop(Array.from(e.dataTransfer.files));
791
- }
792
- }}
1022
+ onDrop={handleAssetDrop}
793
1023
  >
794
1024
  {/* Ruler */}
795
1025
  <div
@@ -869,7 +1099,7 @@ export const Timeline = memo(function Timeline({
869
1099
  );
870
1100
  }
871
1101
 
872
- const totalH = RULER_H + displayTrackOrder.length * TRACK_H;
1102
+ const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
873
1103
  const draggedElement = draggedClip?.element ?? null;
874
1104
  const activeDraggedElement =
875
1105
  draggedClip?.started === true && draggedElement
@@ -953,6 +1183,9 @@ export const Timeline = memo(function Timeline({
953
1183
  <div
954
1184
  ref={scrollRef}
955
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}
956
1189
  onPointerDown={handlePointerDown}
957
1190
  onPointerMove={handlePointerMove}
958
1191
  onPointerUp={handlePointerUp}
@@ -990,7 +1223,7 @@ export const Timeline = memo(function Timeline({
990
1223
  {shiftHeld && !rangeSelection && (
991
1224
  <div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
992
1225
  <span className="text-[9px] font-medium" style={{ color: theme.textSecondary }}>
993
- Drag to select range
1226
+ Drag or click a clip to edit range
994
1227
  </span>
995
1228
  </div>
996
1229
  )}
@@ -1108,6 +1341,7 @@ export const Timeline = memo(function Timeline({
1108
1341
  if (edge === "start" && !capabilities.canTrimStart) return;
1109
1342
  if (edge === "end" && !capabilities.canTrimEnd) return;
1110
1343
  e.stopPropagation();
1344
+ blockedClipRef.current = null;
1111
1345
  setShowPopover(false);
1112
1346
  setRangeSelection(null);
1113
1347
  setResizingClip({
@@ -1121,16 +1355,41 @@ export const Timeline = memo(function Timeline({
1121
1355
  });
1122
1356
  }}
1123
1357
  onPointerDown={(e) => {
1358
+ if (e.button !== 0) return;
1359
+ if (e.shiftKey) {
1360
+ shiftClickClipRef.current = {
1361
+ element: el,
1362
+ anchorX: e.clientX,
1363
+ anchorY: e.clientY,
1364
+ };
1365
+ return;
1366
+ }
1367
+ const target = e.currentTarget as HTMLElement;
1368
+ const rect = target.getBoundingClientRect();
1369
+ const blockedIntent = resolveBlockedTimelineEditIntent({
1370
+ width: rect.width,
1371
+ offsetX: e.clientX - rect.left,
1372
+ handleWidth: CLIP_HANDLE_W,
1373
+ capabilities,
1374
+ });
1124
1375
  if (
1125
- e.button !== 0 ||
1126
- e.shiftKey ||
1127
- !onMoveElement ||
1128
- !capabilities.canMove
1129
- )
1376
+ blockedIntent &&
1377
+ ((blockedIntent === "move" && onMoveElement) ||
1378
+ (blockedIntent !== "move" && onResizeElement))
1379
+ ) {
1380
+ blockedClipRef.current = {
1381
+ element: el,
1382
+ intent: blockedIntent,
1383
+ originClientX: e.clientX,
1384
+ originClientY: e.clientY,
1385
+ started: false,
1386
+ };
1130
1387
  return;
1388
+ }
1389
+ if (!onMoveElement || !capabilities.canMove) return;
1390
+ blockedClipRef.current = null;
1131
1391
  setShowPopover(false);
1132
1392
  setRangeSelection(null);
1133
- const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
1134
1393
  setDraggedClip({
1135
1394
  element: el,
1136
1395
  originClientX: e.clientX,
@@ -1270,7 +1529,7 @@ export const Timeline = memo(function Timeline({
1270
1529
  Shift
1271
1530
  </kbd>
1272
1531
  <span className="text-[9px]" style={{ color: theme.textSecondary }}>
1273
- + drag to edit range
1532
+ + drag/click to edit range
1274
1533
  </span>
1275
1534
  </div>
1276
1535
  </div>
@@ -147,7 +147,7 @@ export const TimelineClip = memo(function TimelineClip({
147
147
  top: 0,
148
148
  bottom: 0,
149
149
  width: 18,
150
- opacity: showHandles ? 1 : 0,
150
+ opacity: showHandles && capabilities.canTrimEnd ? 1 : 0,
151
151
  pointerEvents: onResizeStart && capabilities.canTrimEnd ? "auto" : "none",
152
152
  zIndex: 4,
153
153
  transition: "opacity 120ms ease-out",