@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.
- package/LICENSE +1 -1
- package/dist/apply-overrides.d.ts +32 -0
- package/dist/apply-overrides.d.ts.map +1 -0
- package/dist/apply-overrides.js +388 -0
- package/dist/apply-overrides.js.map +1 -0
- package/dist/vite-plugin-studio.d.ts.map +1 -1
- package/dist/vite-plugin-studio.js +81 -0
- package/dist/vite-plugin-studio.js.map +1 -1
- package/package.json +3 -3
- package/ui/Preview.tsx +1 -0
- package/ui/StudioApp.tsx +215 -14
- package/ui/TimelineEditor.tsx +573 -0
- package/ui/timeline/track-layout.ts +94 -0
- package/ui/timeline/types.ts +39 -0
- package/ui/timeline/use-timeline-drag.ts +98 -0
- package/ui/timeline/use-timeline-zoom.ts +65 -0
|
@@ -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
|
+
}
|