@bycrux/editor 1.1.0 → 1.2.1

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 (39) hide show
  1. package/package.json +2 -2
  2. package/src/ControlsInfoModal.tsx +1 -0
  3. package/src/engine/__tests__/scheduler.test.ts +56 -1
  4. package/src/engine/scheduler.ts +23 -5
  5. package/src/index.ts +36 -0
  6. package/src/lib/__tests__/font-faces.test.ts +244 -0
  7. package/src/lib/__tests__/font-loader-parity.test.tsx +236 -0
  8. package/src/lib/__tests__/google-fonts.test.ts +319 -2
  9. package/src/lib/font-families.ts +286 -0
  10. package/src/lib/google-fonts.ts +132 -10
  11. package/src/schema.ts +21 -0
  12. package/src/text/FontPicker.tsx +82 -11
  13. package/src/text/__tests__/FontPicker.baseUrl.test.tsx +112 -0
  14. package/src/video/VersionPanel.tsx +1 -1
  15. package/src/video/VideoEditor.tsx +98 -4
  16. package/src/video/__tests__/VideoEditor.keymap.test.tsx +64 -0
  17. package/src/video/__tests__/cuts.test.ts +84 -0
  18. package/src/video/__tests__/exportDurationSec.test.ts +60 -0
  19. package/src/video/__tests__/markerDropTime.test.ts +25 -0
  20. package/src/video/cuts.ts +30 -2
  21. package/src/video/preview/PreviewPlayer.tsx +52 -2
  22. package/src/video/preview/__tests__/useVideoPlayback.canvasClock.test.ts +99 -0
  23. package/src/video/preview/__tests__/useVideoPlayback.muted.test.ts +239 -0
  24. package/src/video/preview/useEnginePlayback.ts +58 -8
  25. package/src/video/preview/useVideoPlayback.ts +64 -17
  26. package/src/video/timeline/Timeline.tsx +6 -0
  27. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +16 -0
  28. package/src/video/timeline/__tests__/markers.test.ts +125 -0
  29. package/src/video/timeline/canvas/TimelineCanvas.tsx +110 -12
  30. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +38 -7
  31. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +106 -2
  32. package/src/video/timeline/canvas/__tests__/draw.test.ts +73 -0
  33. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +76 -0
  34. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +100 -1
  35. package/src/video/timeline/canvas/draw.ts +182 -8
  36. package/src/video/timeline/canvas/hit-test.ts +72 -1
  37. package/src/video/timeline/canvas/pointer-machine.ts +83 -4
  38. package/src/video/timeline/markers.ts +109 -0
  39. package/src/video/timeline/timeline-model.ts +19 -0
@@ -10,11 +10,11 @@
10
10
  */
11
11
  import { StrictMode } from 'react'
12
12
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
13
- import { render, act, cleanup } from '@testing-library/react'
13
+ import { render, act, cleanup, screen, fireEvent } from '@testing-library/react'
14
14
  import { createPlaybackClock } from '../../../playback-clock'
15
15
  import type { Project } from '../../../../types'
16
16
  import TimelineCanvas from '../TimelineCanvas'
17
- import { LIGHT_TIMELINE_COLORS, TIMELINE_COLORS, type TimelineMode } from '../draw'
17
+ import { LIGHT_TIMELINE_COLORS, TIMELINE_COLORS, computeTimelineLayout, type TimelineMode } from '../draw'
18
18
  import { createViewportStore, type ViewportStore } from '../viewport'
19
19
 
20
20
  interface RecordedCall { method: string; args: unknown[] }
@@ -405,3 +405,107 @@ describe('TimelineCanvas — theme mode', () => {
405
405
  expect(overlay).toHaveLength(0)
406
406
  })
407
407
  })
408
+
409
+ // ── Marker rename box ────────────────────────────────────────────────────
410
+ //
411
+ // The gesture DECISION (double-click on a marker → `editMarker` effect) is
412
+ // covered as pure data in pointer-machine.test.ts. What only a mounted
413
+ // component can show is the box itself: that it mounts, that it prefills,
414
+ // and that Enter/Escape commit or cancel through the same
415
+ // onProjectChange/onOverlayEdit pair every other discrete edit here uses.
416
+ describe('marker rename box', () => {
417
+ const markerProject = {
418
+ id: 'p-marker',
419
+ tracks: [[{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 8 }]],
420
+ markers: [{ id: 'm1', t: 2, label: '1' }],
421
+ } as unknown as Project
422
+
423
+ // Derived from the layout itself, not hardcoded, so a strip-height change
424
+ // elsewhere can't quietly desync this from where the flag actually paints.
425
+ const MARKER_STRIP = computeTimelineLayout(markerProject).markers!
426
+ const MARKER_STRIP_Y = Math.round(MARKER_STRIP.y + MARKER_STRIP.height / 2)
427
+
428
+ function renderMarkerCanvas(overrides: Partial<React.ComponentProps<typeof TimelineCanvas>> = {}) {
429
+ const store = createViewportStore()
430
+ const clock = createPlaybackClock()
431
+ const utils = render(
432
+ <TimelineCanvas
433
+ project={markerProject}
434
+ clock={clock}
435
+ store={store}
436
+ totalDuration={20}
437
+ fps={30}
438
+ selectedIds={NO_SELECTION}
439
+ {...overrides}
440
+ />,
441
+ )
442
+ act(() => { vi.advanceTimersByTime(32) })
443
+ // Pin the scale so the marker's on-screen x is deterministic: t=2s at
444
+ // 100px/s is x=200.
445
+ act(() => { store.set({ pxPerSecond: 100, scrollSeconds: 0, widthPx: 1000 }) })
446
+ act(() => { vi.advanceTimersByTime(32) })
447
+ return { ...utils, store, clock }
448
+ }
449
+
450
+ /** Dispatches a real `dblclick` at marker m1's surface coordinates, exactly
451
+ * the way an operator's browser event would arrive — not by poking state. */
452
+ function doubleClickAtMarker(container: HTMLElement) {
453
+ const surface = container.querySelector('[data-timeline-canvas]') as HTMLElement
454
+ act(() => {
455
+ surface.dispatchEvent(new MouseEvent('dblclick', {
456
+ clientX: 200, clientY: MARKER_STRIP_Y, bubbles: true, cancelable: true,
457
+ }))
458
+ })
459
+ }
460
+
461
+ it('is not mounted until a marker is double-clicked', () => {
462
+ renderMarkerCanvas()
463
+ expect(screen.queryByLabelText('Rename marker')).toBeNull()
464
+ })
465
+
466
+ it('mounts prefilled with the marker label after an editMarker effect', () => {
467
+ const { container } = renderMarkerCanvas()
468
+ doubleClickAtMarker(container)
469
+ // The state update lands synchronously inside `doubleClickAtMarker`'s own
470
+ // `act()`, so the box is already in the DOM — no need for `findBy`'s
471
+ // real-timer polling, which never fires under `vi.useFakeTimers()`.
472
+ const input = screen.getByLabelText('Rename marker')
473
+ expect((input as HTMLInputElement).value).toBe('1')
474
+ })
475
+
476
+ it('Enter commits the new name through onProjectChange and onOverlayEdit', () => {
477
+ const onProjectChange = vi.fn()
478
+ const onOverlayEdit = vi.fn()
479
+ const { container } = renderMarkerCanvas({ onProjectChange, onOverlayEdit })
480
+ doubleClickAtMarker(container)
481
+ const input = screen.getByLabelText('Rename marker')
482
+ fireEvent.change(input, { target: { value: 'intro' } })
483
+ fireEvent.keyDown(input, { key: 'Enter' })
484
+ expect(onProjectChange).toHaveBeenCalled()
485
+ const next = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0] as Project
486
+ expect(next.markers![0].label).toBe('intro')
487
+ expect(onOverlayEdit).toHaveBeenCalled() // one undo entry
488
+ expect(screen.queryByLabelText('Rename marker')).toBeNull()
489
+ })
490
+
491
+ it('Escape cancels and commits nothing', () => {
492
+ const onProjectChange = vi.fn()
493
+ const { container } = renderMarkerCanvas({ onProjectChange })
494
+ doubleClickAtMarker(container)
495
+ const input = screen.getByLabelText('Rename marker')
496
+ fireEvent.change(input, { target: { value: 'nope' } })
497
+ fireEvent.keyDown(input, { key: 'Escape' })
498
+ expect(onProjectChange).not.toHaveBeenCalled()
499
+ expect(screen.queryByLabelText('Rename marker')).toBeNull()
500
+ })
501
+
502
+ it('is a real <input>, so the document keymap ignores typing in it', () => {
503
+ // useKeymap's isTypingTarget guards INPUT/TEXTAREA/contentEditable — this
504
+ // pins that the box is a real input in the document, not a canvas-drawn
505
+ // fake.
506
+ const { container } = renderMarkerCanvas()
507
+ doubleClickAtMarker(container)
508
+ const input = screen.getByLabelText('Rename marker')
509
+ expect(input.tagName).toBe('INPUT')
510
+ })
511
+ })
@@ -26,8 +26,10 @@ import {
26
26
  LIGHT_TRACK_PALETTE,
27
27
  timelinePalette,
28
28
  drawCursorLine,
29
+ drawMarkers,
29
30
  drawMarquee,
30
31
  drawRuler,
32
+ MARKER_STRIP_HEIGHT_PX,
31
33
  type TimelinePalette,
32
34
  CLIP_HANDLE_WIDTH_PX,
33
35
  FADE_GRIP_SIZE_PX,
@@ -2196,3 +2198,74 @@ describe('drawTimelineContent / drawTimelineOverlay — mode', () => {
2196
2198
  expect(styles).not.toContain(LIGHT_TIMELINE_COLORS.marqueeFill)
2197
2199
  })
2198
2200
  })
2201
+
2202
+ describe('marker strip layout', () => {
2203
+ it('is absent, and the ruler still starts at 0, when the project has no markers', () => {
2204
+ // A marker-less project must lay out byte-identically to before the feature.
2205
+ const layout = computeTimelineLayout(project({ tracks: [{ id: 'trk-0', items: [] }] }))
2206
+ expect(layout.markers).toBeUndefined()
2207
+ expect(layout.ruler).toEqual({ y: 0, height: RULER_HEIGHT_PX })
2208
+ })
2209
+
2210
+ it('claims the top strip and pushes the ruler down once markers exist', () => {
2211
+ const layout = computeTimelineLayout(project({
2212
+ tracks: [{ id: 'trk-0', items: [] }],
2213
+ markers: [{ id: 'a', t: 1, label: '1' }],
2214
+ }))
2215
+ expect(layout.markers).toEqual({ y: 0, height: MARKER_STRIP_HEIGHT_PX })
2216
+ expect(layout.ruler).toEqual({ y: MARKER_STRIP_HEIGHT_PX, height: RULER_HEIGHT_PX })
2217
+ })
2218
+
2219
+ it('pushes every row down by exactly the strip height', () => {
2220
+ const withOut = computeTimelineLayout(project({ tracks: [{ id: 'trk-0', items: [clip()] }] }))
2221
+ const withIn = computeTimelineLayout(project({
2222
+ tracks: [{ id: 'trk-0', items: [clip()] }],
2223
+ markers: [{ id: 'a', t: 1, label: '1' }],
2224
+ }))
2225
+ expect(withIn.rows[0].y - withOut.rows[0].y).toBe(MARKER_STRIP_HEIGHT_PX)
2226
+ expect(withIn.height - withOut.height).toBe(MARKER_STRIP_HEIGHT_PX)
2227
+ })
2228
+ })
2229
+
2230
+ describe('drawMarkers', () => {
2231
+ const strip = { y: 0, height: MARKER_STRIP_HEIGHT_PX }
2232
+
2233
+ it('draws nothing at all for an empty list', () => {
2234
+ const r = recordingContext()
2235
+ drawMarkers(r.ctx, [], viewport(), strip, 800, [])
2236
+ expect(r.calls).toHaveLength(0) // not even a background fill
2237
+ })
2238
+
2239
+ it('draws a flag and its label at the marker time', () => {
2240
+ const r = recordingContext()
2241
+ const vp = viewport({ pxPerSecond: 10, scrollSeconds: 0 })
2242
+ drawMarkers(r.ctx, [{ id: 'a', t: 5, label: 'intro' }], vp, strip, 800, [])
2243
+ const text = r.of('fillText')
2244
+ expect(text).toHaveLength(1)
2245
+ expect(text[0].args[0]).toBe('intro')
2246
+ // Label sits just right of the flag, which stands at timeToX(5) = 50.
2247
+ expect(text[0].args[1]).toBeGreaterThanOrEqual(50)
2248
+ expect(r.of('fillRect').length).toBeGreaterThan(0)
2249
+ })
2250
+
2251
+ it('skips a marker scrolled out of view', () => {
2252
+ const r = recordingContext()
2253
+ drawMarkers(r.ctx, [{ id: 'a', t: 500, label: '1' }], viewport({ pxPerSecond: 10, scrollSeconds: 0 }), strip, 800, [])
2254
+ expect(r.of('fillText')).toHaveLength(0)
2255
+ })
2256
+
2257
+ it('paints a selected marker in the selected colour', () => {
2258
+ const base = recordingContext()
2259
+ drawMarkers(base.ctx, [{ id: 'a', t: 5, label: '1' }], viewport({ pxPerSecond: 10 }), strip, 800, [])
2260
+ const sel = recordingContext()
2261
+ drawMarkers(sel.ctx, [{ id: 'a', t: 5, label: '1' }], viewport({ pxPerSecond: 10 }), strip, 800, ['a'])
2262
+ const fills = (r: typeof base) => r.calls.filter(c => c.method === 'set:fillStyle').map(c => c.args[0])
2263
+ expect(fills(sel)).not.toEqual(fills(base))
2264
+ })
2265
+
2266
+ it('truncates a long label instead of letting it run across the strip', () => {
2267
+ const r = recordingContext()
2268
+ drawMarkers(r.ctx, [{ id: 'a', t: 0, label: 'a'.repeat(400) }], viewport({ pxPerSecond: 10 }), strip, 800, [])
2269
+ expect((r.of('fillText')[0].args[0] as string).length).toBeLessThan(400)
2270
+ })
2271
+ })
@@ -18,6 +18,7 @@ import { AUDIO_ITEM_INSET_PX, RULER_HEIGHT_PX, computeTimelineLayout } from '../
18
18
  import {
19
19
  AUDIO_EDGE_TOLERANCE_PX,
20
20
  FADE_GRIP_ZONE_HEIGHT_PX,
21
+ MARKER_HIT_WIDTH_PX,
21
22
  VISUAL_EDGE_TOLERANCE_PX,
22
23
  hitTest,
23
24
  isCaptionHit,
@@ -474,6 +475,81 @@ describe('hitTest — outside the rows', () => {
474
475
  })
475
476
  })
476
477
 
478
+ describe('marker hit region', () => {
479
+ // A marker-bearing twin of the module fixture. Local, because adding markers
480
+ // to the shared `project` would shift every other probe's Y by the strip
481
+ // height and rewrite this whole file's expectations.
482
+ const markers = [{ id: 'm1', t: 5, label: 'intro' }]
483
+ const markerProject = { ...project, markers } as unknown as Project
484
+ const markerLayout = () => computeTimelineLayout(markerProject)
485
+ // x === t * 100 at this viewport, so the flag stands at 500.
486
+ const FLAG_X = 500
487
+
488
+ it('hits the marker when the pointer is on its flag in the strip', () => {
489
+ const hit = hitTest({ x: FLAG_X, y: 8 }, markerLayout(), VIEWPORT, { markers })
490
+ expect(hit.kind).toBe('marker')
491
+ expect(hit.markerId).toBe('m1')
492
+ })
493
+
494
+ it('claims the whole label box, not just the flag stem', () => {
495
+ // The label is what the operator aims at — a 2px stem is not a target.
496
+ const hit = hitTest({ x: FLAG_X + 40, y: 8 }, markerLayout(), VIEWPORT, { markers })
497
+ expect(hit.kind).toBe('marker')
498
+ })
499
+
500
+ it('does not claim strip for a marker scrolled off the LEFT edge', () => {
501
+ // `drawMarkers` culls a marker whose flag has scrolled left of x=0 — the
502
+ // whole marker, label included. Without the same cull here, the hit region
503
+ // outlives the picture: the marker is invisible but its 72px claim still
504
+ // covers the left of the strip, so a click on apparently-empty strip
505
+ // selects (and can then DRAG) a marker nobody can see. Painting and hitting
506
+ // have to cull on the same rule or one of them is lying.
507
+ const scrolled = { ...VIEWPORT, scrollSeconds: 5.05 } // marker t=5 → x = -5
508
+ const hit = hitTest({ x: 10, y: 8 }, markerLayout(), scrolled, { markers })
509
+ expect(hit.kind).toBe('background')
510
+ })
511
+
512
+ it('still claims a marker whose flag is just INSIDE the left edge', () => {
513
+ // The boundary the cull sits on, from the visible side — proves the fix
514
+ // culls off-screen markers rather than simply narrowing the hit region.
515
+ const scrolled = { ...VIEWPORT, scrollSeconds: 4.99 } // marker t=5 → x = +1
516
+ const hit = hitTest({ x: 10, y: 8 }, markerLayout(), scrolled, { markers })
517
+ expect(hit.kind).toBe('marker')
518
+ expect(hit.markerId).toBe('m1')
519
+ })
520
+
521
+ it('stops claiming past the label box', () => {
522
+ const hit = hitTest({ x: FLAG_X + MARKER_HIT_WIDTH_PX + 10, y: 8 }, markerLayout(), VIEWPORT, { markers })
523
+ expect(hit.kind).not.toBe('marker')
524
+ })
525
+
526
+ it('is background, NOT ruler, on empty strip — a click there must not seek', () => {
527
+ const hit = hitTest({ x: 20, y: 8 }, markerLayout(), VIEWPORT, { markers })
528
+ expect(hit.kind).toBe('background')
529
+ })
530
+
531
+ it('never returns a marker for a point in the RULER band', () => {
532
+ // The ruler keeps its band and its scrub behaviour, untouched.
533
+ const layout = markerLayout()
534
+ const hit = hitTest({ x: FLAG_X, y: layout.ruler.y + 4 }, layout, VIEWPORT, { markers })
535
+ expect(hit.kind).toBe('ruler')
536
+ })
537
+
538
+ it('catches a pointer that has run off the TOP of the surface', () => {
539
+ // The ruler's own early return used to be what caught this; the strip now
540
+ // sits above it and must inherit the same duty.
541
+ const hit = hitTest({ x: FLAG_X, y: -20 }, markerLayout(), VIEWPORT, { markers })
542
+ expect(hit.kind).toBe('marker')
543
+ })
544
+
545
+ it('returns ruler in the top band when the project has no markers', () => {
546
+ // No strip exists, so y=8 is inside the ruler exactly as it was before —
547
+ // this is the guard that every existing probe in this file still holds.
548
+ const layout = computeTimelineLayout(project as unknown as Project)
549
+ expect(hitTest({ x: 50, y: 8 }, layout, VIEWPORT).kind).toBe('ruler')
550
+ })
551
+ })
552
+
477
553
  describe('hitTest — time', () => {
478
554
  it('always reports the time under x, whatever was hit', () => {
479
555
  expect(at(250, BASE_Y).t).toBeCloseTo(2.5)
@@ -38,7 +38,7 @@ import {
38
38
  } from '../pointer-machine'
39
39
  import { FADE_GRIP_ZONE_HEIGHT_PX, hitTest } from '../hit-test'
40
40
  import type { SnapConfig } from '../snap'
41
- import { xToTime, type Viewport } from '../viewport'
41
+ import { timeToX, xToTime, type Viewport } from '../viewport'
42
42
 
43
43
  const VIEWPORT: Viewport = { pxPerSecond: 100, scrollSeconds: 0, widthPx: 1000 }
44
44
 
@@ -3472,3 +3472,102 @@ describe('caption lanes — hole collapse at commit', () => {
3472
3472
  expect(lanes(committed[0].project)).toEqual({ low: 0, mid: 1, top: 2 })
3473
3473
  })
3474
3474
  })
3475
+
3476
+ describe('marker drag', () => {
3477
+ const MARKERS = [{ id: 'm1', t: 5, label: '1' }]
3478
+ /** The module fixture plus markers, so the strip exists in the layout. */
3479
+ function markerCtx(over: Partial<PointerContext> = {}): PointerContext {
3480
+ return makeContext({ project: { ...baseProject(), markers: MARKERS }, ...over })
3481
+ }
3482
+ /** x of the flag, and a y inside the strip. */
3483
+ const flagX = (ctx: PointerContext) => timeToX(5, ctx.viewport)
3484
+ const STRIP_Y = 8
3485
+
3486
+ it('pressing a marker does NOT scrub — the ruler branch must not claim it', () => {
3487
+ // The whole risk of this feature: pointerDown scrubs on sight for the ruler
3488
+ // and for the playhead grab band, with no drag threshold.
3489
+ const ctx = markerCtx()
3490
+ const m = createPointerMachine()
3491
+ const effects = m.dispatch({ type: 'pointerDown', point: { x: flagX(ctx), y: STRIP_Y }, modifiers: NO_MODIFIERS, ctx })
3492
+ expect(effects.some(e => e.type === 'seek')).toBe(false)
3493
+ expect(m.state.kind).toBe('pressed')
3494
+ })
3495
+
3496
+ it('still scrubs on the ruler band itself', () => {
3497
+ const ctx = markerCtx()
3498
+ const m = createPointerMachine()
3499
+ const y = ctx.layout.ruler.y + 4
3500
+ const effects = m.dispatch({ type: 'pointerDown', point: { x: 200, y }, modifiers: NO_MODIFIERS, ctx })
3501
+ expect(effects.some(e => e.type === 'seek')).toBe(true)
3502
+ })
3503
+
3504
+ it('a marker sitting under the playhead is still grabbable', () => {
3505
+ // grabsPlayhead is not y-gated, so without an explicit exclusion the
3506
+ // playhead swallows any marker that happens to line up with it — and a
3507
+ // marker dropped with M lands exactly on the playhead, so that is the
3508
+ // COMMONEST marker, not an edge case.
3509
+ const ctx = markerCtx({ playheadTime: 5 })
3510
+ const m = createPointerMachine()
3511
+ const effects = m.dispatch({ type: 'pointerDown', point: { x: flagX(ctx), y: STRIP_Y }, modifiers: NO_MODIFIERS, ctx })
3512
+ expect(effects.some(e => e.type === 'seek')).toBe(false)
3513
+ })
3514
+
3515
+ it('dragging past the threshold retimes the marker', () => {
3516
+ const ctx = markerCtx()
3517
+ const m = createPointerMachine()
3518
+ const x0 = flagX(ctx)
3519
+ m.dispatch({ type: 'pointerDown', point: { x: x0, y: STRIP_Y }, modifiers: NO_MODIFIERS, ctx })
3520
+ const effects = m.dispatch({ type: 'pointerMove', point: { x: x0 + 100, y: STRIP_Y }, modifiers: NO_MODIFIERS, ctx })
3521
+ const changes = of(effects, 'projectChange')
3522
+ expect(changes).toHaveLength(1)
3523
+ const moved = changes[0].project.markers![0]
3524
+ expect(moved.t).toBeGreaterThan(5)
3525
+ expect(moved.id).toBe('m1')
3526
+ })
3527
+
3528
+ it('drags grab-relative — pressing the label does not teleport the marker to the cursor', () => {
3529
+ // The 40px press offset is the entire point of this test. The hit region is
3530
+ // a flat MARKER_HIT_WIDTH_PX (72) wide, so a press 40px right of the flag
3531
+ // lands on the LABEL — the common case, not a contrived one — and the two
3532
+ // candidate behaviours diverge there by exactly that offset. At 100px/s a
3533
+ // 100px drag is 1s: grab-relative lands the marker at 6, absolute lands it
3534
+ // at 6.4, the cursor's own time. Only one of those can pass, which is what
3535
+ // the `> 5` assertion in the test above cannot distinguish.
3536
+ const ctx = markerCtx()
3537
+ const m = createPointerMachine()
3538
+ const x0 = flagX(ctx) + 40
3539
+ m.dispatch({ type: 'pointerDown', point: { x: x0, y: STRIP_Y }, modifiers: NO_MODIFIERS, ctx })
3540
+ const effects = m.dispatch({ type: 'pointerMove', point: { x: x0 + 100, y: STRIP_Y }, modifiers: NO_MODIFIERS, ctx })
3541
+ const changes = of(effects, 'projectChange')
3542
+ expect(changes).toHaveLength(1)
3543
+ const moved = changes[0].project.markers![0]
3544
+ expect(moved.t).toBeCloseTo(6, 6)
3545
+ expect(moved.t).not.toBeCloseTo(xToTime(x0 + 100, ctx.viewport), 6)
3546
+ })
3547
+
3548
+ it('commits once on release', () => {
3549
+ const ctx = markerCtx()
3550
+ const m = createPointerMachine()
3551
+ const x0 = flagX(ctx)
3552
+ m.dispatch({ type: 'pointerDown', point: { x: x0, y: STRIP_Y }, modifiers: NO_MODIFIERS, ctx })
3553
+ m.dispatch({ type: 'pointerMove', point: { x: x0 + 100, y: STRIP_Y }, modifiers: NO_MODIFIERS, ctx })
3554
+ const up = m.dispatch({ type: 'pointerUp', point: { x: x0 + 100, y: STRIP_Y }, modifiers: NO_MODIFIERS, ctx })
3555
+ expect(up.filter(e => e.type === 'commit')).toHaveLength(1)
3556
+ })
3557
+
3558
+ it('a press with no drag selects the marker instead of moving it', () => {
3559
+ const ctx = markerCtx()
3560
+ const m = createPointerMachine()
3561
+ const x0 = flagX(ctx)
3562
+ m.dispatch({ type: 'pointerDown', point: { x: x0, y: STRIP_Y }, modifiers: NO_MODIFIERS, ctx })
3563
+ const up = m.dispatch({ type: 'pointerUp', point: { x: x0, y: STRIP_Y }, modifiers: NO_MODIFIERS, ctx })
3564
+ expect(up).toContainEqual(expect.objectContaining({ type: 'select', id: 'm1' }))
3565
+ })
3566
+
3567
+ it('double-clicking a marker asks the host to edit it', () => {
3568
+ const ctx = markerCtx()
3569
+ const m = createPointerMachine()
3570
+ const effects = m.dispatch({ type: 'doubleClick', point: { x: flagX(ctx), y: STRIP_Y }, modifiers: NO_MODIFIERS, ctx })
3571
+ expect(effects).toContainEqual({ type: 'editMarker', id: 'm1' })
3572
+ })
3573
+ })