@devbycrux/editor 0.1.0 → 0.3.0

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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/video-adapter-contract.test.ts +89 -0
  3. package/src/carousel/CarouselEditor.tsx +28 -16
  4. package/src/carousel/SlideCanvas.tsx +12 -0
  5. package/src/carousel/SlidePropertyPanel.tsx +40 -2
  6. package/src/carousel/__tests__/CarouselEditor.test.tsx +52 -0
  7. package/src/index.ts +23 -1
  8. package/src/types.ts +161 -0
  9. package/src/video/RenderModal.tsx +252 -0
  10. package/src/video/VersionPanel.tsx +83 -0
  11. package/src/video/VideoEditor.tsx +508 -0
  12. package/src/video/__tests__/VideoEditor.test.tsx +213 -0
  13. package/src/video/__tests__/captionRepair.test.ts +134 -0
  14. package/src/video/__tests__/cuts.test.ts +198 -0
  15. package/src/video/captionRepair.ts +41 -0
  16. package/src/video/cuts.ts +369 -0
  17. package/src/video/design-canvas.ts +11 -0
  18. package/src/video/preview/CaptionPreview.tsx +83 -0
  19. package/src/video/preview/CarouselPreview.tsx +35 -0
  20. package/src/video/preview/OverlayItemsLayer.tsx +603 -0
  21. package/src/video/preview/PreviewPlayer.tsx +178 -0
  22. package/src/video/preview/useDragOverlay.ts +167 -0
  23. package/src/video/preview/useVideoPlayback.ts +761 -0
  24. package/src/video/timeline/AudioTrackRow.tsx +406 -0
  25. package/src/video/timeline/AudioWaveformLayer.tsx +117 -0
  26. package/src/video/timeline/EditableSegment.tsx +30 -0
  27. package/src/video/timeline/Scrubber.tsx +184 -0
  28. package/src/video/timeline/Timeline.tsx +375 -0
  29. package/src/video/timeline/TimelineContext.ts +25 -0
  30. package/src/video/timeline/TranscriptModal.tsx +63 -0
  31. package/src/video/timeline/TranscriptPanel.tsx +86 -0
  32. package/src/video/timeline/VisualTrackRow.tsx +293 -0
  33. package/src/video/timeline/makeCaptionEdit.ts +32 -0
  34. package/src/video/timeline/multiSelectOps.ts +157 -0
  35. package/src/video/timeline/useItemDragDrop.ts +190 -0
  36. package/src/video/timeline/useTimelineZoom.ts +48 -0
  37. package/src/video/timeline/utils.ts +17 -0
@@ -0,0 +1,190 @@
1
+ import type React from 'react'
2
+
3
+ export interface Draggable {
4
+ id: string
5
+ start: number
6
+ end: number
7
+ inPoint?: number
8
+ outPoint?: number
9
+ type?: string
10
+ sourceDuration?: number
11
+ }
12
+
13
+ export interface DragEventContext {
14
+ /** The item with updated start/end (and inPoint/outPoint for resize) */
15
+ item: Draggable
16
+ /** Horizontal delta in timeline seconds from initial position */
17
+ dx: number
18
+ /** Vertical delta in raw pixels from initial position */
19
+ dy: number
20
+ }
21
+
22
+ export interface UseItemDragDropConfig {
23
+ totalDuration: number
24
+ snapBoundaries: number[]
25
+ snapThresholdPx?: number // default 8
26
+ /** Element used for getBoundingClientRect() → pixel-to-time conversion */
27
+ scrollRef: React.RefObject<HTMLDivElement | null>
28
+ /** Timeline zoom factor — content width is zoom × scrollRef.width. Required so
29
+ * pixel-to-time conversion matches the visible scale (without this, drag/resize
30
+ * is `zoom`× too sensitive when zoomed in). */
31
+ zoomRef?: React.RefObject<number>
32
+ draggedFlagRef?: React.MutableRefObject<boolean> // sets true during drag (click suppression)
33
+ }
34
+
35
+ export function useItemDragDrop(config: UseItemDragDropConfig) {
36
+ const {
37
+ totalDuration,
38
+ snapBoundaries,
39
+ snapThresholdPx = 8,
40
+ scrollRef,
41
+ zoomRef,
42
+ draggedFlagRef,
43
+ } = config
44
+
45
+ function beginResize(
46
+ e: React.MouseEvent,
47
+ item: Draggable,
48
+ edge: 'start' | 'end',
49
+ callbacks: {
50
+ onLivePreview: (ctx: DragEventContext) => void
51
+ onCommit: () => void
52
+ },
53
+ ): void {
54
+ e.stopPropagation()
55
+ e.preventDefault()
56
+
57
+ const initX = e.clientX
58
+ const initTime = edge === 'start' ? item.start : item.end
59
+
60
+ function computeResized(moveE: MouseEvent): Draggable {
61
+ if (!scrollRef.current) return item
62
+ const rect = scrollRef.current.getBoundingClientRect()
63
+ const contentWidth = rect.width * (zoomRef?.current ?? 1)
64
+ const dt = ((moveE.clientX - initX) / contentWidth) * totalDuration
65
+ const raw = Math.max(0, Math.min(totalDuration, initTime + dt))
66
+
67
+ // Snap to any boundary within threshold
68
+ const snapThreshold = (snapThresholdPx / contentWidth) * totalDuration
69
+ let t = raw
70
+ let bestDist = snapThreshold
71
+ for (const b of snapBoundaries) {
72
+ const d = Math.abs(raw - b)
73
+ if (d < bestDist) {
74
+ bestDist = d
75
+ t = b
76
+ }
77
+ }
78
+
79
+ if (edge === 'start') {
80
+ const newStart = Math.min(t, item.end - 0.1)
81
+ if (item.type !== 'video') return { ...item, start: newStart }
82
+ const dtActual = newStart - item.start
83
+ return {
84
+ ...item,
85
+ start: newStart,
86
+ inPoint: Math.min(
87
+ Math.max(0, (item.inPoint ?? 0) + dtActual),
88
+ (item.outPoint ?? (item.inPoint ?? 0) + (item.end - item.start)) - 0.1,
89
+ ),
90
+ }
91
+ } else {
92
+ const newEnd = Math.max(t, item.start + 0.1)
93
+ if (item.type !== 'video') return { ...item, end: newEnd }
94
+ const origOut = item.outPoint ?? (item.inPoint ?? 0) + (item.end - item.start)
95
+ const dtActual = newEnd - item.end
96
+ return {
97
+ ...item,
98
+ end: newEnd,
99
+ outPoint: Math.max(
100
+ (item.inPoint ?? 0) + 0.1,
101
+ Math.min(origOut + dtActual, item.sourceDuration ?? Infinity),
102
+ ),
103
+ }
104
+ }
105
+ }
106
+
107
+ function onMove(moveE: MouseEvent) {
108
+ const resized = computeResized(moveE)
109
+ callbacks.onLivePreview({ item: resized, dx: 0, dy: 0 })
110
+ }
111
+ function onUp() {
112
+ callbacks.onCommit()
113
+ document.removeEventListener('mousemove', onMove)
114
+ document.removeEventListener('mouseup', onUp)
115
+ }
116
+ document.addEventListener('mousemove', onMove)
117
+ document.addEventListener('mouseup', onUp)
118
+ }
119
+
120
+ function beginDrag(
121
+ e: React.MouseEvent,
122
+ item: Draggable,
123
+ callbacks: {
124
+ onLivePreview: (ctx: DragEventContext) => void
125
+ onCommit: () => void
126
+ },
127
+ ): void {
128
+ e.stopPropagation()
129
+
130
+ const initX = e.clientX
131
+ const initY = e.clientY
132
+ const initStart = item.start
133
+ const initEnd = item.end
134
+ const duration = initEnd - initStart
135
+
136
+ function onMove(moveE: MouseEvent) {
137
+ if (draggedFlagRef) draggedFlagRef.current = true
138
+
139
+ const rect = scrollRef.current?.getBoundingClientRect()
140
+ const contentWidth = rect ? rect.width * (zoomRef?.current ?? 1) : 0
141
+ const dx = contentWidth ? ((moveE.clientX - initX) / contentWidth) * totalDuration : 0
142
+ const dy = moveE.clientY - initY
143
+
144
+ const rawStart = Math.max(0, Math.min(totalDuration - duration, initStart + dx))
145
+ const rawEnd = rawStart + duration
146
+
147
+ // Snap leading/trailing edge to nearest item edge within threshold
148
+ const snapThreshold = contentWidth ? (snapThresholdPx / contentWidth) * totalDuration : 0
149
+ let newStart = rawStart
150
+ let newEnd = rawEnd
151
+ let bestDist = snapThreshold
152
+ for (const b of snapBoundaries) {
153
+ const dEnd = Math.abs(rawEnd - b)
154
+ if (dEnd < bestDist) {
155
+ bestDist = dEnd
156
+ newStart = b - duration
157
+ newEnd = b
158
+ }
159
+ const dStart = Math.abs(rawStart - b)
160
+ if (dStart < bestDist) {
161
+ bestDist = dStart
162
+ newStart = b
163
+ newEnd = b + duration
164
+ }
165
+ }
166
+ newStart = Math.max(0, Math.min(totalDuration - duration, newStart))
167
+ newEnd = newStart + duration
168
+
169
+ const movedItem = { ...item, start: newStart, end: newEnd }
170
+ callbacks.onLivePreview({ item: movedItem, dx, dy })
171
+ }
172
+
173
+ function onUp() {
174
+ document.removeEventListener('mousemove', onMove)
175
+ document.removeEventListener('mouseup', onUp)
176
+ if (draggedFlagRef && draggedFlagRef.current) {
177
+ callbacks.onCommit()
178
+ // reset after click event fires
179
+ setTimeout(() => {
180
+ draggedFlagRef!.current = false
181
+ }, 0)
182
+ }
183
+ }
184
+
185
+ document.addEventListener('mousemove', onMove)
186
+ document.addEventListener('mouseup', onUp)
187
+ }
188
+
189
+ return { beginDrag, beginResize }
190
+ }
@@ -0,0 +1,48 @@
1
+ import { useLayoutEffect, useRef, useState } from 'react'
2
+
3
+ export function useTimelineZoom(totalDuration: number) {
4
+ const [zoom, setZoom] = useState(1)
5
+ const zoomRef = useRef(zoom)
6
+ zoomRef.current = zoom
7
+ const scrollRef = useRef<HTMLDivElement>(null)
8
+ const pendingScrollRef = useRef<number | null>(null)
9
+
10
+ useLayoutEffect(() => {
11
+ if (pendingScrollRef.current !== null && scrollRef.current) {
12
+ scrollRef.current.scrollLeft = pendingScrollRef.current
13
+ pendingScrollRef.current = null
14
+ }
15
+ })
16
+
17
+ function zoomTo(newZoom: number, pivotClientX?: number) {
18
+ const clamped = Math.max(1, Math.min(20, newZoom))
19
+ if (!scrollRef.current || totalDuration === 0) { setZoom(clamped); return }
20
+ const container = scrollRef.current
21
+ const containerWidth = container.clientWidth
22
+ const currentScrollLeft = container.scrollLeft
23
+ const rect = container.getBoundingClientRect()
24
+ const pivotOffset = pivotClientX !== undefined
25
+ ? Math.max(0, Math.min(containerWidth, pivotClientX - rect.left))
26
+ : containerWidth / 2
27
+ const pivotPct = Math.max(0, Math.min(1,
28
+ (currentScrollLeft + pivotOffset) / (containerWidth * zoomRef.current)
29
+ ))
30
+ pendingScrollRef.current = Math.max(0, pivotPct * containerWidth * clamped - pivotOffset)
31
+ setZoom(clamped)
32
+ }
33
+
34
+ function handleTimelineWheel(e: React.WheelEvent) {
35
+ if (e.ctrlKey || e.metaKey) {
36
+ e.preventDefault()
37
+ // Multiplicative step so perceived speed is consistent across zoom levels.
38
+ // deltaY ≈ 100 per mouse-wheel tick → factor ≈ 0.82 (≈ 18% zoom change).
39
+ const factor = Math.exp(-e.deltaY * 0.002)
40
+ zoomTo(zoomRef.current * factor, e.clientX)
41
+ } else if (e.altKey) {
42
+ e.preventDefault()
43
+ if (scrollRef.current) scrollRef.current.scrollLeft += e.deltaY
44
+ }
45
+ }
46
+
47
+ return { zoom, zoomRef, scrollRef, pendingScrollRef, zoomTo, handleTimelineWheel }
48
+ }
@@ -0,0 +1,17 @@
1
+ export function formatTime(s: number): string {
2
+ const m = Math.floor(s / 60)
3
+ const sec = (s % 60).toFixed(1)
4
+ return `${m}:${sec.padStart(4, '0')}`
5
+ }
6
+
7
+ export function pct(t: number, totalDuration: number): number {
8
+ return totalDuration > 0 ? (t / totalDuration) * 100 : 0
9
+ }
10
+
11
+ export function ratioFromClientX(clientX: number, scrubberRect: DOMRect): number {
12
+ const x = clientX - scrubberRect.left
13
+ return Math.max(0, Math.min(1, x / scrubberRect.width))
14
+ }
15
+
16
+ export const trackRow = 'relative h-10 bg-gray-100 dark:bg-gray-900 rounded overflow-hidden cursor-pointer'
17
+ export const trackRowTall = 'relative h-14 bg-gray-100 dark:bg-gray-900 rounded overflow-hidden cursor-pointer'