@rendiv/studio 0.1.1 → 0.1.3

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.
@@ -0,0 +1,98 @@
1
+ import { useRef, useCallback, useEffect } from 'react';
2
+ import type { DragOperation, DragEdge } from './types';
3
+
4
+ interface UseTimelineDragOptions {
5
+ pixelsPerFrame: number;
6
+ trackHeight: number;
7
+ trackGap: number;
8
+ onOverrideChange: (namePath: string, override: { from: number; durationInFrames: number; trackIndex?: number }) => void;
9
+ /** Check whether the dragged block can be placed on the target track without overlapping. */
10
+ canPlaceOnTrack: (namePath: string, from: number, duration: number, trackIndex: number) => boolean;
11
+ }
12
+
13
+ interface UseTimelineDragReturn {
14
+ isDragging: boolean;
15
+ dragNamePath: string | null;
16
+ startDrag: (namePath: string, edge: DragEdge, clientX: number, clientY: number, originalFrom: number, originalDuration: number, originalTrackIndex: number) => void;
17
+ }
18
+
19
+ export function useTimelineDrag({ pixelsPerFrame, trackHeight, trackGap, onOverrideChange, canPlaceOnTrack }: UseTimelineDragOptions): UseTimelineDragReturn {
20
+ const dragRef = useRef<DragOperation | null>(null);
21
+ const isDraggingRef = useRef(false);
22
+ const dragNamePathRef = useRef<string | null>(null);
23
+ // Force re-renders when drag state changes
24
+ const forceUpdateRef = useRef(0);
25
+
26
+ const startDrag = useCallback((namePath: string, edge: DragEdge, clientX: number, clientY: number, originalFrom: number, originalDuration: number, originalTrackIndex: number) => {
27
+ dragRef.current = { namePath, edge, startClientX: clientX, startClientY: clientY, originalFrom, originalDuration, originalTrackIndex };
28
+ isDraggingRef.current = true;
29
+ dragNamePathRef.current = namePath;
30
+ forceUpdateRef.current++;
31
+ }, []);
32
+
33
+ useEffect(() => {
34
+ const handleMouseMove = (e: MouseEvent) => {
35
+ const drag = dragRef.current;
36
+ if (!drag) return;
37
+
38
+ const deltaPixelsX = e.clientX - drag.startClientX;
39
+ const deltaFrames = Math.round(deltaPixelsX / pixelsPerFrame);
40
+
41
+ let newFrom = drag.originalFrom;
42
+ let newDuration = drag.originalDuration;
43
+ let newTrackIndex: number | undefined;
44
+
45
+ switch (drag.edge) {
46
+ case 'body': {
47
+ newFrom = Math.max(0, drag.originalFrom + deltaFrames);
48
+ // Vertical drag: compute new track from Y delta
49
+ const deltaPixelsY = e.clientY - drag.startClientY;
50
+ const rowHeight = trackHeight + trackGap;
51
+ const trackDelta = Math.round(deltaPixelsY / rowHeight);
52
+ const candidateTrack = Math.max(0, drag.originalTrackIndex + trackDelta);
53
+
54
+ // Only move to the new track if the block fits without overlapping
55
+ if (canPlaceOnTrack(drag.namePath, newFrom, newDuration, candidateTrack)) {
56
+ newTrackIndex = candidateTrack;
57
+ } else {
58
+ // Stay on current track
59
+ newTrackIndex = drag.originalTrackIndex;
60
+ }
61
+ break;
62
+ }
63
+ case 'left':
64
+ newFrom = Math.max(0, drag.originalFrom + deltaFrames);
65
+ newDuration = Math.max(1, drag.originalDuration - deltaFrames);
66
+ newTrackIndex = drag.originalTrackIndex;
67
+ break;
68
+ case 'right':
69
+ newDuration = Math.max(1, drag.originalDuration + deltaFrames);
70
+ newTrackIndex = drag.originalTrackIndex;
71
+ break;
72
+ }
73
+
74
+ onOverrideChange(drag.namePath, { from: newFrom, durationInFrames: newDuration, trackIndex: newTrackIndex });
75
+ };
76
+
77
+ const handleMouseUp = () => {
78
+ if (dragRef.current) {
79
+ dragRef.current = null;
80
+ isDraggingRef.current = false;
81
+ dragNamePathRef.current = null;
82
+ }
83
+ };
84
+
85
+ window.addEventListener('mousemove', handleMouseMove);
86
+ window.addEventListener('mouseup', handleMouseUp);
87
+ return () => {
88
+ window.removeEventListener('mousemove', handleMouseMove);
89
+ window.removeEventListener('mouseup', handleMouseUp);
90
+ };
91
+ }, [pixelsPerFrame, trackHeight, trackGap, onOverrideChange, canPlaceOnTrack]);
92
+
93
+ return {
94
+ isDragging: isDraggingRef.current,
95
+ dragNamePath: dragNamePathRef.current,
96
+ startDrag,
97
+ };
98
+ }
@@ -0,0 +1,65 @@
1
+ import { useState, useCallback, useRef, useEffect } from 'react';
2
+
3
+ interface UseTimelineZoomOptions {
4
+ totalFrames: number;
5
+ containerRef: React.RefObject<HTMLDivElement | null>;
6
+ }
7
+
8
+ interface UseTimelineZoomReturn {
9
+ pixelsPerFrame: number;
10
+ scrollLeft: number;
11
+ setScrollLeft: (v: number) => void;
12
+ setPixelsPerFrame: (v: number) => void;
13
+ handleWheel: (e: WheelEvent) => void;
14
+ }
15
+
16
+ const MIN_PX_PER_FRAME = 0.5;
17
+ const MAX_PX_PER_FRAME = 20;
18
+
19
+ export function useTimelineZoom({ totalFrames, containerRef }: UseTimelineZoomOptions): UseTimelineZoomReturn {
20
+ const [pixelsPerFrame, setPixelsPerFrame] = useState(() => {
21
+ // Start at a reasonable default — will be adjusted on mount
22
+ return 3;
23
+ });
24
+ const [scrollLeft, setScrollLeft] = useState(0);
25
+ const ppfRef = useRef(pixelsPerFrame);
26
+ ppfRef.current = pixelsPerFrame;
27
+
28
+ // Fit to width on mount
29
+ useEffect(() => {
30
+ const el = containerRef.current;
31
+ if (!el || totalFrames <= 0) return;
32
+ const fitPpf = Math.max(MIN_PX_PER_FRAME, el.clientWidth / totalFrames);
33
+ setPixelsPerFrame(Math.min(fitPpf, MAX_PX_PER_FRAME));
34
+ setScrollLeft(0);
35
+ }, [totalFrames, containerRef]);
36
+
37
+ const handleWheel = useCallback((e: WheelEvent) => {
38
+ const el = containerRef.current;
39
+ if (!el) return;
40
+
41
+ if (e.ctrlKey || e.metaKey) {
42
+ // Zoom: Ctrl/Cmd + wheel
43
+ e.preventDefault();
44
+ const rect = el.getBoundingClientRect();
45
+ const cursorX = e.clientX - rect.left;
46
+ const oldPpf = ppfRef.current;
47
+ const zoomFactor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
48
+ const newPpf = Math.min(MAX_PX_PER_FRAME, Math.max(MIN_PX_PER_FRAME, oldPpf * zoomFactor));
49
+
50
+ // Keep the frame under the cursor in place
51
+ const frameAtCursor = (cursorX + el.scrollLeft) / oldPpf;
52
+ const newScrollLeft = Math.max(0, frameAtCursor * newPpf - cursorX);
53
+
54
+ setPixelsPerFrame(newPpf);
55
+ setScrollLeft(newScrollLeft);
56
+ } else {
57
+ // Horizontal scroll
58
+ e.preventDefault();
59
+ const delta = e.deltaX !== 0 ? e.deltaX : e.deltaY;
60
+ setScrollLeft((prev) => Math.max(0, prev + delta));
61
+ }
62
+ }, [containerRef]);
63
+
64
+ return { pixelsPerFrame, scrollLeft, setScrollLeft, setPixelsPerFrame, handleWheel };
65
+ }