@bycrux/editor 0.6.3 → 0.7.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.
@@ -1,249 +0,0 @@
1
- import * as React from 'react'
2
- import type { VisualItem } from '../schema'
3
- import {
4
- renderedSourceRect,
5
- fractionToWrapperPx,
6
- applyCropHandleDrag,
7
- type CropFraction,
8
- type CropHandle,
9
- } from './crop-math'
10
-
11
- // Sibling of CanvasCropOverlay, typed to a tracks[0] VisualItem (video) instead
12
- // of an ImageElement. The full source clip is letterboxed into the preview via a
13
- // muted <video>, an 8-handle free-form crop window is drawn on top, and handle
14
- // drags emit the updated `sourceCrop` as source fractions clamped to [0, 1].
15
- //
16
- // Source dims come either from the item (sourceWidth/sourceHeight, set on a prior
17
- // crop) or from the <video>'s videoWidth/videoHeight on loadedmetadata — exactly
18
- // analogous to CanvasCropOverlay reading naturalWidth/Height from <img> onLoad.
19
-
20
- const CROP_HANDLES: CropHandle[] = ['nw', 'n', 'ne', 'w', 'e', 'sw', 's', 'se']
21
-
22
- const HANDLE_OFFSET: Record<CropHandle, { dx: 0 | 0.5 | 1; dy: 0 | 0.5 | 1; cursor: string }> = {
23
- nw: { dx: 0, dy: 0, cursor: 'nw-resize' },
24
- n: { dx: 0.5, dy: 0, cursor: 'n-resize' },
25
- ne: { dx: 1, dy: 0, cursor: 'ne-resize' },
26
- w: { dx: 0, dy: 0.5, cursor: 'w-resize' },
27
- e: { dx: 1, dy: 0.5, cursor: 'e-resize' },
28
- sw: { dx: 0, dy: 1, cursor: 'sw-resize' },
29
- s: { dx: 0.5, dy: 1, cursor: 's-resize' },
30
- se: { dx: 1, dy: 1, cursor: 'se-resize' },
31
- }
32
-
33
- const DEFAULT_CROP: CropFraction = { x: 0, y: 0, w: 1, h: 1 }
34
-
35
- function clampFraction(c: CropFraction): CropFraction {
36
- const x = Math.min(1, Math.max(0, c.x))
37
- const y = Math.min(1, Math.max(0, c.y))
38
- return {
39
- x,
40
- y,
41
- w: Math.min(1 - x, Math.max(0, c.w)),
42
- h: Math.min(1 - y, Math.max(0, c.h)),
43
- }
44
- }
45
-
46
- export type VideoSourceCropOverlayProps = {
47
- /** The tracks[0] video item being cropped. */
48
- item: VisualItem
49
- /**
50
- * Host-supplied resolver for the clip's preview URL. Implement on the caller
51
- * side as `(item) => adapter.fileUrl(item.nobg_preview_src ?? item.src)`.
52
- */
53
- resolveSrc: (item: VisualItem) => string
54
- /** Wrapper width in CSS px (the rect the overlay fills — the preview area). */
55
- wrapperWidth: number
56
- /** Wrapper height in CSS px. */
57
- wrapperHeight: number
58
- /** Called once on pointer-up (drag end) with the final clamped [0,1] fractions. */
59
- onChange: (next: CropFraction) => void
60
- /** Called once when the <video> fires loadedmetadata with the source's intrinsic dims. */
61
- onSrcDimsLoaded: (dims: { width: number; height: number }) => void
62
- }
63
-
64
- export function VideoSourceCropOverlay({
65
- item,
66
- resolveSrc,
67
- wrapperWidth,
68
- wrapperHeight,
69
- onChange,
70
- onSrcDimsLoaded,
71
- }: VideoSourceCropOverlayProps) {
72
- const srcUrl = resolveSrc(item)
73
-
74
- // Local mirror of dims loaded from the <video> when the item doesn't already
75
- // carry them, so the crop window renders as soon as metadata arrives.
76
- const [loadedDims, setLoadedDims] = React.useState<{ width: number; height: number } | null>(null)
77
- const srcDims =
78
- item.sourceWidth && item.sourceHeight
79
- ? { width: item.sourceWidth, height: item.sourceHeight }
80
- : loadedDims
81
-
82
- // Prop-driven crop (source of truth when not dragging)
83
- const propCrop = item.sourceCrop ?? DEFAULT_CROP
84
-
85
- // In-flight drag crop — non-null only during an active handle drag.
86
- // While dragging, the rendered window follows this local state for smoothness.
87
- // On pointer-up it's cleared after calling onChange once with the final value.
88
- const [liveCrop, setLiveCrop] = React.useState<CropFraction | null>(null)
89
- // Ref mirror so onHandlePointerUp always reads the latest value regardless of closure age.
90
- const liveCropRef = React.useRef<CropFraction | null>(null)
91
-
92
- // The crop we actually render: live state during drag, props otherwise.
93
- const localCrop = liveCrop ?? propCrop
94
-
95
- const rendered =
96
- srcDims && wrapperWidth > 0 && wrapperHeight > 0
97
- ? renderedSourceRect({
98
- wrapperW: wrapperWidth,
99
- wrapperH: wrapperHeight,
100
- srcWidth: srcDims.width,
101
- srcHeight: srcDims.height,
102
- })
103
- : null
104
- const windowPx = rendered ? fractionToWrapperPx({ crop: localCrop, rendered }) : null
105
-
106
- const dragStateRef = React.useRef<{
107
- handle: CropHandle
108
- startClient: { x: number; y: number }
109
- startCrop: CropFraction
110
- } | null>(null)
111
-
112
- const onHandlePointerDown = (handle: CropHandle, e: React.PointerEvent<HTMLDivElement>) => {
113
- e.stopPropagation()
114
- e.currentTarget.setPointerCapture?.(e.pointerId)
115
- dragStateRef.current = {
116
- handle,
117
- startClient: { x: e.clientX, y: e.clientY },
118
- startCrop: propCrop,
119
- }
120
- }
121
-
122
- const onHandlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
123
- const drag = dragStateRef.current
124
- if (!drag || !srcDims) return
125
- const dx = e.clientX - drag.startClient.x
126
- const dy = e.clientY - drag.startClient.y
127
- const next = applyCropHandleDrag({
128
- handle: drag.handle,
129
- initialCrop: drag.startCrop,
130
- deltaPx: { x: dx, y: dy },
131
- wrapperW: wrapperWidth,
132
- wrapperH: wrapperHeight,
133
- srcWidth: srcDims.width,
134
- srcHeight: srcDims.height,
135
- })
136
- // Accumulate in local state for smooth visual feedback — do NOT call onChange yet.
137
- const clamped = clampFraction(next)
138
- liveCropRef.current = clamped
139
- setLiveCrop(clamped)
140
- }
141
-
142
- const onHandlePointerUp = () => {
143
- const drag = dragStateRef.current
144
- dragStateRef.current = null
145
- const final = liveCropRef.current
146
- liveCropRef.current = null
147
- setLiveCrop(null)
148
- if (drag && final !== null) {
149
- // Call onChange exactly once with the final value.
150
- onChange(final)
151
- }
152
- }
153
-
154
- return (
155
- <div
156
- style={{ position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'auto' }}
157
- onPointerDown={(e) => {
158
- e.stopPropagation()
159
- }}
160
- >
161
- {/* Letterbox preview of the full source clip */}
162
- <video
163
- src={srcUrl}
164
- muted
165
- playsInline
166
- preload="metadata"
167
- onLoadedMetadata={(e) => {
168
- const v = e.currentTarget
169
- const dims = { width: v.videoWidth, height: v.videoHeight }
170
- setLoadedDims(dims)
171
- onSrcDimsLoaded(dims)
172
- }}
173
- style={{
174
- position: 'absolute',
175
- inset: 0,
176
- width: '100%',
177
- height: '100%',
178
- objectFit: 'contain',
179
- pointerEvents: 'none',
180
- }}
181
- />
182
-
183
- {windowPx && (
184
- <>
185
- {/* Dark dimming mask outside the crop window */}
186
- <div
187
- data-testid="crop-dim"
188
- style={{
189
- position: 'absolute',
190
- inset: 0,
191
- background: 'rgba(0, 0, 0, 0.55)',
192
- clipPath: `polygon(
193
- 0 0, 100% 0, 100% 100%, 0 100%, 0 0,
194
- ${windowPx.x}px ${windowPx.y}px,
195
- ${windowPx.x}px ${windowPx.y + windowPx.h}px,
196
- ${windowPx.x + windowPx.w}px ${windowPx.y + windowPx.h}px,
197
- ${windowPx.x + windowPx.w}px ${windowPx.y}px,
198
- ${windowPx.x}px ${windowPx.y}px
199
- )`,
200
- pointerEvents: 'none',
201
- }}
202
- />
203
-
204
- {/* Crop window border */}
205
- <div
206
- data-testid="crop-window"
207
- style={{
208
- position: 'absolute',
209
- left: windowPx.x,
210
- top: windowPx.y,
211
- width: windowPx.w,
212
- height: windowPx.h,
213
- outline: '2px solid var(--editor-selection)',
214
- pointerEvents: 'none',
215
- }}
216
- />
217
-
218
- {/* 8 resize handles */}
219
- {CROP_HANDLES.map((handle) => {
220
- const o = HANDLE_OFFSET[handle]
221
- return (
222
- <div
223
- key={handle}
224
- data-testid={`crop-handle-${handle}`}
225
- onPointerDown={(e) => onHandlePointerDown(handle, e)}
226
- onPointerMove={onHandlePointerMove}
227
- onPointerUp={onHandlePointerUp}
228
- style={{
229
- position: 'absolute',
230
- left: windowPx.x + o.dx * windowPx.w - 6,
231
- top: windowPx.y + o.dy * windowPx.h - 6,
232
- width: 12,
233
- height: 12,
234
- backgroundColor: '#fff',
235
- border: '1.5px solid var(--editor-selection)',
236
- borderRadius: 2,
237
- cursor: o.cursor,
238
- zIndex: 10,
239
- pointerEvents: 'auto',
240
- touchAction: 'none',
241
- }}
242
- />
243
- )
244
- })}
245
- </>
246
- )}
247
- </div>
248
- )
249
- }
@@ -1,105 +0,0 @@
1
- import { describe, it, expect, vi, afterEach } from 'vitest'
2
- import { fireEvent, render, cleanup } from '@testing-library/react'
3
- import type { VisualItem } from '../../schema'
4
- import { VideoSourceCropOverlay } from '../VideoSourceCropOverlay'
5
-
6
- afterEach(() => cleanup())
7
-
8
- // Aspect-matched fixture: wrapper 400×500, source 800×1000. The rendered source
9
- // fills the wrapper with no letterbox, so crop fractions map 1:1 onto wrapper px
10
- // (x over 400, y over 500). Initial crop (0.2,0.2,0.6,0.6) → px rect (80,100)–(320,400).
11
- function makeItem(overrides: Partial<VisualItem> = {}): VisualItem {
12
- return {
13
- id: 'v1',
14
- type: 'video',
15
- src: '/clip.mov',
16
- start: 0,
17
- end: 5,
18
- sourceWidth: 800,
19
- sourceHeight: 1000,
20
- sourceCrop: { x: 0.2, y: 0.2, w: 0.6, h: 0.6 },
21
- ...overrides,
22
- }
23
- }
24
-
25
- describe('VideoSourceCropOverlay', () => {
26
- it('renders the 8 crop handles for a video item with known source dims', () => {
27
- const { getByTestId } = render(
28
- <VideoSourceCropOverlay
29
- item={makeItem()}
30
- resolveSrc={() => '/clip.mov'}
31
- wrapperWidth={400}
32
- wrapperHeight={500}
33
- onChange={vi.fn()}
34
- onSrcDimsLoaded={vi.fn()}
35
- />,
36
- )
37
- for (const h of ['nw', 'n', 'ne', 'w', 'e', 'sw', 's', 'se']) {
38
- expect(getByTestId(`crop-handle-${h}`)).toBeTruthy()
39
- }
40
- })
41
-
42
- it('emits onChange exactly once on pointer-up (not per move) with the final clamped [0,1] fractions', () => {
43
- const onChange = vi.fn()
44
- const { getByTestId } = render(
45
- <VideoSourceCropOverlay
46
- item={makeItem()}
47
- resolveSrc={() => '/clip.mov'}
48
- wrapperWidth={400}
49
- wrapperHeight={500}
50
- onChange={onChange}
51
- onSrcDimsLoaded={vi.fn()}
52
- />,
53
- )
54
-
55
- const se = getByTestId('crop-handle-se')
56
- // Simulate a full drag sequence: pointerdown → multiple pointermoves → pointerup.
57
- // jsdom has no PointerEvent constructor, so fireEvent.pointer* drops
58
- // clientX/Y. Dispatch MouseEvents under the pointer* type names instead —
59
- // MouseEvent carries clientX/Y and React listens on the event type string.
60
- se.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 320, clientY: 400 }))
61
- // Multiple intermediate moves — onChange must NOT be called during these.
62
- se.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 290, clientY: 400 }))
63
- se.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 270, clientY: 400 }))
64
-
65
- // onChange must not fire during move — only after release.
66
- expect(onChange).not.toHaveBeenCalled()
67
-
68
- // Release — final position: SE handle moved left 50 px from start (320 → 270).
69
- se.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 270, clientY: 400 }))
70
-
71
- // onChange called exactly once on pointer-up.
72
- expect(onChange).toHaveBeenCalledTimes(1)
73
-
74
- const next = onChange.mock.calls[0][0]
75
- // dx = -50 px over a 400-px-wide rendered source = -0.125 fractions.
76
- expect(next.x).toBeCloseTo(0.2)
77
- expect(next.y).toBeCloseTo(0.2)
78
- expect(next.w).toBeCloseTo(0.6 - 50 / 400) // 0.475
79
- expect(next.h).toBeCloseTo(0.6)
80
- // All emitted fractions stay within [0, 1].
81
- for (const v of [next.x, next.y, next.w, next.h]) {
82
- expect(v).toBeGreaterThanOrEqual(0)
83
- expect(v).toBeLessThanOrEqual(1)
84
- }
85
- })
86
-
87
- it('reads source dims from the <video> loadedmetadata when not supplied on the item', () => {
88
- const onSrcDimsLoaded = vi.fn()
89
- const { container } = render(
90
- <VideoSourceCropOverlay
91
- item={makeItem({ sourceWidth: undefined, sourceHeight: undefined })}
92
- resolveSrc={() => '/clip.mov'}
93
- wrapperWidth={400}
94
- wrapperHeight={500}
95
- onChange={vi.fn()}
96
- onSrcDimsLoaded={onSrcDimsLoaded}
97
- />,
98
- )
99
- const video = container.querySelector('video') as HTMLVideoElement
100
- Object.defineProperty(video, 'videoWidth', { value: 800, configurable: true })
101
- Object.defineProperty(video, 'videoHeight', { value: 1000, configurable: true })
102
- fireEvent.loadedMetadata(video)
103
- expect(onSrcDimsLoaded).toHaveBeenCalledWith({ width: 800, height: 1000 })
104
- })
105
- })