@bycrux/editor 0.8.4 → 0.8.6
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/package.json +1 -1
- package/src/types.ts +15 -0
- package/src/video/RenderModal.tsx +118 -23
- package/src/video/VideoEditor.tsx +5 -0
- package/src/video/__tests__/render-progress.test.tsx +50 -0
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +72 -0
- package/src/video/timeline/useItemDragDrop.ts +16 -0
package/package.json
CHANGED
package/src/types.ts
CHANGED
|
@@ -544,6 +544,21 @@ export interface VideoEditorProps<P extends Project = Project> {
|
|
|
544
544
|
*/
|
|
545
545
|
assetsPlacement?: 'sidebar' | 'right' | 'bottom'
|
|
546
546
|
|
|
547
|
+
/**
|
|
548
|
+
* Which progress UI the RenderModal shows while a render runs:
|
|
549
|
+
* - `'phases'` — the compact phase stepper (Preparing → Rendering → … →
|
|
550
|
+
* Saving). Works on any transport: poll-based hosts drive it from the
|
|
551
|
+
* status `phase`; SSE hosts park it on "Rendering". This is the universal
|
|
552
|
+
* default and what Hub clients use.
|
|
553
|
+
* - `'logs'` — the full scrolling render-log panel (colorized lines + Copy).
|
|
554
|
+
* REQUIRES the SSE `adapter.render()` transport, which streams per-line
|
|
555
|
+
* logs; this is the historical montaj-native desktop view. On a poll-based
|
|
556
|
+
* host (no log lines) this panel would sit empty, so only pass `'logs'`
|
|
557
|
+
* from a host whose adapter implements the streaming `render()` path.
|
|
558
|
+
* The host chooses per deployment; the package defaults to `'phases'`.
|
|
559
|
+
*/
|
|
560
|
+
renderProgressView?: 'phases' | 'logs'
|
|
561
|
+
|
|
547
562
|
/**
|
|
548
563
|
* Opt a host OUT of the package's built-in toolbar Render button so it can
|
|
549
564
|
* place Render in its own chrome (e.g. the desktop OS editor's top header).
|
|
@@ -17,6 +17,16 @@ interface RenderModalProps<P extends Project = Project> {
|
|
|
17
17
|
/** Host-supplied export controls (e.g. a "Download all (.zip)" link) rendered
|
|
18
18
|
* in the done state's action area, mirroring the carousel render modal. */
|
|
19
19
|
exportActions?: ReactNode
|
|
20
|
+
/**
|
|
21
|
+
* Which progress UI to show while the render runs:
|
|
22
|
+
* 'phases' — the compact phase stepper (Preparing → … → Saving). Works on
|
|
23
|
+
* any transport; the default when omitted.
|
|
24
|
+
* 'logs' — the full scrolling render-log panel. Requires the SSE
|
|
25
|
+
* `adapter.render()` transport (which streams per-line logs);
|
|
26
|
+
* on a poll-based host it would sit empty.
|
|
27
|
+
* Threaded down from the host as a flag (montaj-native → 'logs', Hub clients
|
|
28
|
+
* → 'phases'), mirroring `VideoEditorProps.renderProgressView`. */
|
|
29
|
+
progressView?: 'phases' | 'logs'
|
|
20
30
|
}
|
|
21
31
|
|
|
22
32
|
/** Promoted render output (R2-presigned), as carried by `RenderStatus.media`. */
|
|
@@ -61,6 +71,16 @@ const STEPPER_PHASES: RenderPhase[] = RENDER_PHASES.filter(p => p !== 'done')
|
|
|
61
71
|
|
|
62
72
|
const POLL_INTERVAL_MS = 2500
|
|
63
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Consecutive status-poll failures tolerated before the modal declares the
|
|
76
|
+
* render failed. A multi-minute 4K render polls many times; a transient network
|
|
77
|
+
* blip (a Cloudflare-tunnel hiccup, a backend redeploy, a flaky hop) must not be
|
|
78
|
+
* mistaken for a render failure — the render keeps running server-side. ~12 ×
|
|
79
|
+
* 2.5s ≈ 30s of continuous unreachability before giving up. An explicit backend
|
|
80
|
+
* `error` status is still terminal immediately.
|
|
81
|
+
*/
|
|
82
|
+
const MAX_POLL_FAILURES = 12
|
|
83
|
+
|
|
64
84
|
// ── Stepper ───────────────────────────────────────────────────────────────────
|
|
65
85
|
|
|
66
86
|
function PhaseStepper({ current }: { current: RenderPhase }) {
|
|
@@ -103,17 +123,49 @@ function PhaseStepper({ current }: { current: RenderPhase }) {
|
|
|
103
123
|
)
|
|
104
124
|
}
|
|
105
125
|
|
|
106
|
-
|
|
126
|
+
// ── Log line (SSE / log-panel view) ───────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
function LogLine({ text }: { text: string }) {
|
|
129
|
+
const t = text.replace(/^\[montaj render\]\s*/, '')
|
|
130
|
+
let color = 'text-[var(--editor-text)]/60'
|
|
131
|
+
if (/ready|complete|done|encoded|assembled/i.test(t)) color = 'text-green-400'
|
|
132
|
+
else if (/rendering|bundling|launching|browsers/i.test(t)) color = 'text-sky-400'
|
|
133
|
+
else if (/trimming|building|composing/i.test(t)) color = 'text-amber-400'
|
|
134
|
+
else if (/frame\s+\d+\/\d+/i.test(t)) color = 'text-[var(--editor-text)]/55'
|
|
135
|
+
else if (/error|fail|warn/i.test(t)) color = 'text-red-400'
|
|
136
|
+
|
|
137
|
+
const prefix = text.startsWith('[montaj render]')
|
|
138
|
+
? <span className="text-[var(--editor-text)]/40">[render] </span>
|
|
139
|
+
: null
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<span className={`leading-relaxed whitespace-pre-wrap break-all ${color}`}>
|
|
143
|
+
{prefix}{t}
|
|
144
|
+
</span>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export default function RenderModal<P extends Project = Project>({ projectId, adapter, onClose, onCancel, exportActions, progressView }: RenderModalProps<P>) {
|
|
107
149
|
const [status, setStatus] = useState<'running' | 'done' | 'error'>('running')
|
|
108
150
|
const [phase, setPhase] = useState<RenderPhase>('preparing')
|
|
151
|
+
const [logs, setLogs] = useState<string[]>([])
|
|
109
152
|
const [media, setMedia] = useState<RenderMedia[] | null>(null)
|
|
110
153
|
const [outputPath, setOutput] = useState<string | null>(null)
|
|
111
154
|
const [errorMsg, setError] = useState<string | null>(null)
|
|
155
|
+
const logRef = useRef<HTMLDivElement>(null)
|
|
112
156
|
const cancelledRef = useRef(false)
|
|
113
157
|
const unmountedRef = useRef(false)
|
|
114
158
|
const cleanupTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
115
159
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
116
160
|
|
|
161
|
+
// `usePolling` is the data-transport decision (does this adapter expose the
|
|
162
|
+
// poll-based render API?) — independent of which progress UI we show.
|
|
163
|
+
const usePolling = !!(adapter.renderAsync && adapter.getRenderStatus)
|
|
164
|
+
// `view` is the host's UI choice, threaded down as a flag (montaj-native →
|
|
165
|
+
// 'logs', Hub clients → 'phases'). Defaults to the universally-safe stepper;
|
|
166
|
+
// 'logs' only renders content on the SSE transport, which streams log lines.
|
|
167
|
+
const view = progressView ?? 'phases'
|
|
168
|
+
|
|
117
169
|
useEffect(() => {
|
|
118
170
|
// React StrictMode in dev fires mount → cleanup → mount synchronously to
|
|
119
171
|
// catch effects that aren't idempotent. Triggering a render is the textbook
|
|
@@ -135,8 +187,6 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
135
187
|
unmountedRef.current = false
|
|
136
188
|
cancelledRef.current = false
|
|
137
189
|
|
|
138
|
-
const usePolling = !!(adapter.renderAsync && adapter.getRenderStatus)
|
|
139
|
-
|
|
140
190
|
void (async () => {
|
|
141
191
|
try {
|
|
142
192
|
if (usePolling) {
|
|
@@ -144,16 +194,28 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
144
194
|
await adapter.renderAsync!(projectId)
|
|
145
195
|
if (unmountedRef.current || cancelledRef.current) return
|
|
146
196
|
|
|
197
|
+
let pollFailures = 0
|
|
147
198
|
const tick = async () => {
|
|
148
199
|
if (unmountedRef.current || cancelledRef.current) return
|
|
149
200
|
let snap: RenderStatus
|
|
150
201
|
try {
|
|
151
202
|
snap = await adapter.getRenderStatus!(projectId)
|
|
203
|
+
pollFailures = 0
|
|
152
204
|
} catch (e) {
|
|
153
205
|
if (unmountedRef.current || cancelledRef.current) return
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
206
|
+
// A transient network blip (Cloudflare-tunnel hiccup, a backend
|
|
207
|
+
// redeploy, a flaky hop) must NOT be mistaken for a render
|
|
208
|
+
// failure — the render is still running server-side. This is what
|
|
209
|
+
// surfaced as "Render failed: Failed to fetch" while the render
|
|
210
|
+
// actually completed and was delivered. Keep polling; only give
|
|
211
|
+
// up after MAX_POLL_FAILURES consecutive failures. An explicit
|
|
212
|
+
// { status: 'error' } reply is still terminal (handled below).
|
|
213
|
+
pollFailures += 1
|
|
214
|
+
if (pollFailures >= MAX_POLL_FAILURES) {
|
|
215
|
+
stopPolling()
|
|
216
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
217
|
+
setStatus('error')
|
|
218
|
+
}
|
|
157
219
|
return
|
|
158
220
|
}
|
|
159
221
|
if (unmountedRef.current || cancelledRef.current) return
|
|
@@ -188,8 +250,10 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
188
250
|
for await (const ev of adapter.render(projectId)) {
|
|
189
251
|
if (unmountedRef.current || cancelledRef.current) break
|
|
190
252
|
if (ev.type === 'log') {
|
|
191
|
-
// Streaming hosts have no phase signal
|
|
192
|
-
//
|
|
253
|
+
// Streaming hosts have no phase signal. Accumulate the line for
|
|
254
|
+
// the log panel, and keep the stepper (if shown) on "rendering"
|
|
255
|
+
// as an honest mid-pipeline indicator.
|
|
256
|
+
setLogs(l => [...l, ev.message])
|
|
193
257
|
setPhase('rendering')
|
|
194
258
|
} else if (ev.type === 'done') {
|
|
195
259
|
setOutput(ev.outputPath)
|
|
@@ -230,6 +294,11 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
230
294
|
}
|
|
231
295
|
}, [projectId, adapter])
|
|
232
296
|
|
|
297
|
+
// Auto-scroll the log panel as lines stream in.
|
|
298
|
+
useEffect(() => {
|
|
299
|
+
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
|
|
300
|
+
}, [logs])
|
|
301
|
+
|
|
233
302
|
// Escape to close only when done/error
|
|
234
303
|
useEffect(() => {
|
|
235
304
|
const onKey = (e: KeyboardEvent) => {
|
|
@@ -319,7 +388,7 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
319
388
|
|
|
320
389
|
return createPortal(
|
|
321
390
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black">
|
|
322
|
-
<div className=
|
|
391
|
+
<div className={`w-full ${view === 'logs' ? 'max-w-3xl' : 'max-w-md'} bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-xl shadow-2xl flex flex-col overflow-hidden`}>
|
|
323
392
|
|
|
324
393
|
{/* Header */}
|
|
325
394
|
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--editor-border)]">
|
|
@@ -337,21 +406,47 @@ export default function RenderModal<P extends Project = Project>({ projectId, ad
|
|
|
337
406
|
)}
|
|
338
407
|
</div>
|
|
339
408
|
|
|
340
|
-
{/* Body */}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
409
|
+
{/* Body — full log panel (montaj-native / SSE) or phase stepper (Hub) */}
|
|
410
|
+
{view === 'logs' ? (
|
|
411
|
+
<div className="relative">
|
|
412
|
+
<button
|
|
413
|
+
onClick={() => navigator.clipboard.writeText(logs.join('\n') + (errorMsg ? '\n' + errorMsg : ''))}
|
|
414
|
+
className="absolute top-2 right-2 z-10 text-[10px] px-2 py-0.5 rounded bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)]/60 hover:text-[var(--editor-text)] hover:border-[var(--editor-border)] transition-colors"
|
|
415
|
+
title="Copy logs"
|
|
416
|
+
>
|
|
417
|
+
Copy
|
|
418
|
+
</button>
|
|
419
|
+
<div
|
|
420
|
+
ref={logRef}
|
|
421
|
+
className="h-96 overflow-y-auto px-4 py-3 font-mono text-[11px] text-[var(--editor-text)]/80 bg-[var(--editor-surface)] flex flex-col gap-0.5"
|
|
422
|
+
>
|
|
423
|
+
{logs.length === 0 && status === 'running' && (
|
|
424
|
+
<span className="text-[var(--editor-text)]/40 italic">Starting render engine…</span>
|
|
425
|
+
)}
|
|
426
|
+
{logs.map((line, i) => (
|
|
427
|
+
<LogLine key={i} text={line} />
|
|
428
|
+
))}
|
|
429
|
+
{status === 'error' && errorMsg && (
|
|
430
|
+
<span className="text-red-400 mt-1">{errorMsg}</span>
|
|
431
|
+
)}
|
|
432
|
+
</div>
|
|
433
|
+
</div>
|
|
434
|
+
) : (
|
|
435
|
+
<div className="px-5 py-5">
|
|
436
|
+
{status === 'running' ? (
|
|
437
|
+
<>
|
|
438
|
+
<PhaseStepper current={phase} />
|
|
439
|
+
<p className="mt-5 text-xs text-[var(--editor-text)]/50">
|
|
440
|
+
This can take a few minutes for longer videos.
|
|
441
|
+
</p>
|
|
442
|
+
</>
|
|
443
|
+
) : (
|
|
444
|
+
<p className="text-sm text-red-400 whitespace-pre-wrap break-words">
|
|
445
|
+
{errorMsg ?? 'Render failed.'}
|
|
347
446
|
</p>
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
{errorMsg ?? 'Render failed.'}
|
|
352
|
-
</p>
|
|
353
|
-
)}
|
|
354
|
-
</div>
|
|
447
|
+
)}
|
|
448
|
+
</div>
|
|
449
|
+
)}
|
|
355
450
|
|
|
356
451
|
{/* Footer */}
|
|
357
452
|
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-[var(--editor-border)]">
|
|
@@ -41,6 +41,7 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
41
41
|
slots,
|
|
42
42
|
onBackToSetup,
|
|
43
43
|
assetsPlacement = 'right',
|
|
44
|
+
renderProgressView = 'phases',
|
|
44
45
|
renderClipInspector,
|
|
45
46
|
renderSubcutRegen,
|
|
46
47
|
regenEnabled,
|
|
@@ -87,6 +88,7 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
87
88
|
onProjectChange={emit}
|
|
88
89
|
slots={slots}
|
|
89
90
|
assetsPlacement={assetsPlacement}
|
|
91
|
+
renderProgressView={renderProgressView}
|
|
90
92
|
getWaveformChunks={getWaveformChunks}
|
|
91
93
|
resolveFilePath={resolveFilePath}
|
|
92
94
|
save={save}
|
|
@@ -121,6 +123,7 @@ interface SurfaceProps<P extends Project> {
|
|
|
121
123
|
onProjectChange: (p: P) => void
|
|
122
124
|
slots?: VideoEditorProps<P>['slots']
|
|
123
125
|
assetsPlacement?: VideoEditorProps<P>['assetsPlacement']
|
|
126
|
+
renderProgressView?: VideoEditorProps<P>['renderProgressView']
|
|
124
127
|
getWaveformChunks?: VideoEditorProps<P>['adapter']['getWaveformChunks']
|
|
125
128
|
resolveFilePath: (path: string) => string
|
|
126
129
|
save: (p: P) => void
|
|
@@ -261,6 +264,7 @@ function ReviewSurface<P extends Project>({
|
|
|
261
264
|
onProjectChange,
|
|
262
265
|
slots,
|
|
263
266
|
assetsPlacement = 'right',
|
|
267
|
+
renderProgressView = 'phases',
|
|
264
268
|
getWaveformChunks,
|
|
265
269
|
resolveFilePath,
|
|
266
270
|
save,
|
|
@@ -615,6 +619,7 @@ function ReviewSurface<P extends Project>({
|
|
|
615
619
|
projectId={project.id}
|
|
616
620
|
adapter={adapter}
|
|
617
621
|
exportActions={slots?.exportActions}
|
|
622
|
+
progressView={renderProgressView}
|
|
618
623
|
onClose={() => setRenderOpen(false)}
|
|
619
624
|
onCancel={() => setRenderOpen(false)}
|
|
620
625
|
/>
|
|
@@ -172,6 +172,56 @@ describe('RenderModal (poll-driven)', () => {
|
|
|
172
172
|
expect(screen.getByText(/interrupted on the server/i)).toBeTruthy()
|
|
173
173
|
expect(screen.getByText('Close')).toBeTruthy()
|
|
174
174
|
})
|
|
175
|
+
|
|
176
|
+
it('tolerates a transient poll failure and still completes', async () => {
|
|
177
|
+
vi.useFakeTimers()
|
|
178
|
+
// poll #1 throws (network blip), then it recovers and finishes. The modal
|
|
179
|
+
// must NOT flip to "Render failed" on the transient throw — the render is
|
|
180
|
+
// still running server-side.
|
|
181
|
+
const steps: Array<() => RenderStatus> = [
|
|
182
|
+
() => { throw new Error('Failed to fetch') },
|
|
183
|
+
() => ({ status: 'running', phase: 'rendering' } as RenderStatus),
|
|
184
|
+
() => ({ status: 'done', phase: 'done', media: [DONE_MEDIA] } as RenderStatus),
|
|
185
|
+
]
|
|
186
|
+
let call = 0
|
|
187
|
+
const adapter = baseAdapter()
|
|
188
|
+
adapter.renderAsync = vi.fn(async () => ({ status: 'running' }))
|
|
189
|
+
adapter.getRenderStatus = vi.fn(async () => steps[Math.min(call++, steps.length - 1)]())
|
|
190
|
+
|
|
191
|
+
render(<RenderModal adapter={adapter} projectId="vid-1" onClose={vi.fn()} />)
|
|
192
|
+
|
|
193
|
+
// Kick + the first poll (which throws) — still "Rendering…", not failed.
|
|
194
|
+
await act(async () => { for (let i = 0; i < 10; i++) await Promise.resolve() })
|
|
195
|
+
expect(screen.queryByText('Render failed')).toBeNull()
|
|
196
|
+
expect(screen.getByText('Rendering…')).toBeTruthy()
|
|
197
|
+
|
|
198
|
+
// Subsequent polls recover → running → done.
|
|
199
|
+
await act(async () => { await vi.advanceTimersByTimeAsync(2500) })
|
|
200
|
+
await act(async () => { await vi.advanceTimersByTimeAsync(2500) })
|
|
201
|
+
|
|
202
|
+
vi.useRealTimers()
|
|
203
|
+
await waitFor(() => {
|
|
204
|
+
expect(document.querySelector('video')?.getAttribute('src')).toBe('https://r2/x.mp4')
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('gives up only after sustained consecutive poll failures', async () => {
|
|
209
|
+
vi.useFakeTimers()
|
|
210
|
+
const adapter = baseAdapter()
|
|
211
|
+
adapter.renderAsync = vi.fn(async () => ({ status: 'running' }))
|
|
212
|
+
adapter.getRenderStatus = vi.fn(async () => { throw new Error('Failed to fetch') })
|
|
213
|
+
|
|
214
|
+
render(<RenderModal adapter={adapter} projectId="vid-1" onClose={vi.fn()} />)
|
|
215
|
+
|
|
216
|
+
// First poll throws — still running, NOT failed (one blip is tolerated).
|
|
217
|
+
await act(async () => { for (let i = 0; i < 10; i++) await Promise.resolve() })
|
|
218
|
+
expect(screen.queryByText('Render failed')).toBeNull()
|
|
219
|
+
|
|
220
|
+
// Keep failing past MAX_POLL_FAILURES (~12 × 2.5s) → terminal error.
|
|
221
|
+
await act(async () => { await vi.advanceTimersByTimeAsync(2500 * 15) })
|
|
222
|
+
expect(screen.getByText('Render failed')).toBeTruthy()
|
|
223
|
+
expect(screen.getByText('Failed to fetch')).toBeTruthy()
|
|
224
|
+
})
|
|
175
225
|
})
|
|
176
226
|
|
|
177
227
|
// ── Component (SSE fallback when poll methods absent) ──────────────────────────
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
import { renderHook } from '@testing-library/react'
|
|
3
|
+
import { useItemDragDrop } from '../useItemDragDrop'
|
|
4
|
+
|
|
5
|
+
// A press that drifts < 4px must stay a CLICK (so the timeline's click-to-select
|
|
6
|
+
// fires); only a press that travels past the threshold becomes a drag. Regression
|
|
7
|
+
// guard for the bug where any pixel of jitter suppressed clip selection.
|
|
8
|
+
|
|
9
|
+
const RECT = {
|
|
10
|
+
width: 1000, height: 100, left: 0, top: 0, right: 1000, bottom: 100, x: 0, y: 0,
|
|
11
|
+
toJSON: () => ({}),
|
|
12
|
+
} as DOMRect
|
|
13
|
+
|
|
14
|
+
function setup() {
|
|
15
|
+
const draggedFlagRef = { current: false }
|
|
16
|
+
const scrollRef = { current: { getBoundingClientRect: () => RECT } }
|
|
17
|
+
const { result } = renderHook(() =>
|
|
18
|
+
useItemDragDrop({
|
|
19
|
+
totalDuration: 100,
|
|
20
|
+
snapBoundaries: [],
|
|
21
|
+
scrollRef: scrollRef as unknown as React.RefObject<HTMLDivElement>,
|
|
22
|
+
draggedFlagRef: draggedFlagRef as unknown as React.MutableRefObject<boolean>,
|
|
23
|
+
}),
|
|
24
|
+
)
|
|
25
|
+
return { beginDrag: result.current.beginDrag, draggedFlagRef }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ITEM = { id: 'a', start: 10, end: 20 }
|
|
29
|
+
|
|
30
|
+
function press(beginDrag: ReturnType<typeof setup>['beginDrag']) {
|
|
31
|
+
const onLivePreview = vi.fn()
|
|
32
|
+
const onCommit = vi.fn()
|
|
33
|
+
beginDrag(
|
|
34
|
+
{ stopPropagation: vi.fn(), clientX: 200, clientY: 200 } as unknown as React.MouseEvent,
|
|
35
|
+
ITEM,
|
|
36
|
+
{ onLivePreview, onCommit },
|
|
37
|
+
)
|
|
38
|
+
return { onLivePreview, onCommit }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
// ensure no stray listeners between tests
|
|
43
|
+
document.dispatchEvent(new MouseEvent('mouseup'))
|
|
44
|
+
vi.restoreAllMocks()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('useItemDragDrop — click vs drag threshold', () => {
|
|
48
|
+
it('sub-threshold drift stays a click: no drag flag, no move, no commit', () => {
|
|
49
|
+
const { beginDrag, draggedFlagRef } = setup()
|
|
50
|
+
const { onLivePreview, onCommit } = press(beginDrag)
|
|
51
|
+
|
|
52
|
+
// ~2.2px of jitter (hypot(2,1)) — under the 4px threshold
|
|
53
|
+
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 202, clientY: 201 }))
|
|
54
|
+
document.dispatchEvent(new MouseEvent('mouseup'))
|
|
55
|
+
|
|
56
|
+
expect(draggedFlagRef.current).toBe(false) // click-to-select is NOT suppressed
|
|
57
|
+
expect(onLivePreview).not.toHaveBeenCalled()
|
|
58
|
+
expect(onCommit).not.toHaveBeenCalled()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('moving past the threshold becomes a drag: flag set, move + commit fire', () => {
|
|
62
|
+
const { beginDrag, draggedFlagRef } = setup()
|
|
63
|
+
const { onLivePreview, onCommit } = press(beginDrag)
|
|
64
|
+
|
|
65
|
+
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 240, clientY: 200 })) // 40px → drag
|
|
66
|
+
expect(draggedFlagRef.current).toBe(true)
|
|
67
|
+
expect(onLivePreview).toHaveBeenCalled()
|
|
68
|
+
|
|
69
|
+
document.dispatchEvent(new MouseEvent('mouseup'))
|
|
70
|
+
expect(onCommit).toHaveBeenCalled()
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -33,6 +33,13 @@ export interface UseItemDragDropConfig {
|
|
|
33
33
|
draggedFlagRef?: React.MutableRefObject<boolean> // sets true during drag (click suppression)
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** A press must travel at least this many pixels before it becomes a drag.
|
|
37
|
+
* Below it the gesture stays a click. Without this threshold, a single pixel of
|
|
38
|
+
* pointer drift during a click flips the drag flag, and the timeline suppresses
|
|
39
|
+
* click-to-select — so clips/overlays can't be selected (or deleted), which
|
|
40
|
+
* bites constantly on trackpads and touchscreens where clicks always jitter. */
|
|
41
|
+
const DRAG_THRESHOLD_PX = 4
|
|
42
|
+
|
|
36
43
|
export function useItemDragDrop(config: UseItemDragDropConfig) {
|
|
37
44
|
const {
|
|
38
45
|
totalDuration,
|
|
@@ -133,8 +140,17 @@ export function useItemDragDrop(config: UseItemDragDropConfig) {
|
|
|
133
140
|
const initStart = item.start
|
|
134
141
|
const initEnd = item.end
|
|
135
142
|
const duration = initEnd - initStart
|
|
143
|
+
let dragStarted = false
|
|
136
144
|
|
|
137
145
|
function onMove(moveE: MouseEvent) {
|
|
146
|
+
// Ignore sub-threshold pointer drift — it's a click, not a drag. Only once
|
|
147
|
+
// the press travels past DRAG_THRESHOLD_PX do we set the drag flag (which
|
|
148
|
+
// suppresses click-to-select) and start moving the item.
|
|
149
|
+
if (!dragStarted) {
|
|
150
|
+
const moved = Math.hypot(moveE.clientX - initX, moveE.clientY - initY)
|
|
151
|
+
if (moved < DRAG_THRESHOLD_PX) return
|
|
152
|
+
dragStarted = true
|
|
153
|
+
}
|
|
138
154
|
if (draggedFlagRef) draggedFlagRef.current = true
|
|
139
155
|
|
|
140
156
|
const rect = scrollRef.current?.getBoundingClientRect()
|