@bycrux/editor 1.1.0 → 1.2.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.
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The canvas rAF clock's CEILING, driven through the real effect.
3
+ *
4
+ * `canvasMaxEndRef` is the only thing standing between "space plays" and "space
5
+ * does nothing" on a project with no track-0 video. It used to read
6
+ * `overlayTracks` (`tracks.slice(1)`), which silently skips track 0 — and track
7
+ * 0 in canvas mode is not the primary footage track, it is content: the
8
+ * background images, and on an agent-authored project frequently the overlays
9
+ * themselves. A project that is ONE track of nothing but overlays (what the
10
+ * animations workflow emits) therefore got a ceiling of 0, and the first tick
11
+ * clamped the playhead to 0 and called `setIsPlaying(false)` in the same frame.
12
+ *
13
+ * These drive the hook's own rAF effect rather than asserting on a helper: the
14
+ * ceiling lives in a ref that nothing exports, so the only honest way to pin it
15
+ * is to run the clock and watch where time goes. Each case fails on the
16
+ * pre-fix `overlayTracks` read.
17
+ */
18
+ import { describe, it, expect, vi, afterEach } from 'vitest'
19
+ import { act, renderHook } from '@testing-library/react'
20
+ import { useVideoPlayback } from '../useVideoPlayback'
21
+ import type { EditorProject, VisualItem } from '../../../schema'
22
+
23
+ const overlay = (id: string, start: number, end: number): VisualItem =>
24
+ ({ id, type: 'overlay', src: `/overlays/${id}.jsx`, start, end }) as VisualItem
25
+
26
+ const image = (id: string, start: number, end: number): VisualItem =>
27
+ ({ id, type: 'image', src: `/img/${id}.png`, start, end }) as VisualItem
28
+
29
+ /** The last emission. Not `.at(-1)` — this package targets ES2020. */
30
+ const last = (xs: number[]): number | undefined => xs[xs.length - 1]
31
+
32
+ function projectOf(...tracks: VisualItem[][]): EditorProject {
33
+ return {
34
+ id: 'canvas-clock',
35
+ status: 'draft',
36
+ settings: { resolution: [1920, 1080] },
37
+ tracks: tracks.map((items, i) => ({ id: `trk-${i}`, items })),
38
+ } as EditorProject
39
+ }
40
+
41
+ /**
42
+ * Mount the hook on a canvas project and run the clock for one second of wall
43
+ * time, returning every playhead value it emitted.
44
+ *
45
+ * Two ticks, because the first only anchors `rafLastMs` — it has no `dt` yet
46
+ * and emits nothing. The second is the first that can move the playhead, and
47
+ * the one the collapsed ceiling used to kill.
48
+ */
49
+ function runClock(project: EditorProject) {
50
+ const times: number[] = []
51
+ const scheduled: FrameRequestCallback[] = []
52
+ vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => {
53
+ scheduled.push(cb)
54
+ return scheduled.length
55
+ })
56
+
57
+ const view = renderHook(() => useVideoPlayback(project, 0, (t) => times.push(t), (p) => p))
58
+ expect(view.result.current.isCanvasProject).toBe(true)
59
+
60
+ scheduled.length = 0
61
+ act(() => { view.result.current.setIsPlaying(true) })
62
+ expect(scheduled.length).toBeGreaterThan(0)
63
+
64
+ act(() => { scheduled[0](0) }) // anchor only
65
+ act(() => { scheduled[scheduled.length - 1](1000) }) // +1s of wall clock
66
+
67
+ return { times, isPlaying: view.result.current.isPlaying }
68
+ }
69
+
70
+ describe('useVideoPlayback — the canvas clock counts track 0', () => {
71
+ afterEach(() => { vi.restoreAllMocks() })
72
+
73
+ it('advances on a single track holding nothing but overlays', () => {
74
+ // The Daubert-demo shape: one track, 14 overlays, no captions, no video.
75
+ const { times, isPlaying } = runClock(projectOf([overlay('o1', 0, 5), overlay('o2', 5, 12)]))
76
+ expect(last(times)).toBeCloseTo(1, 6)
77
+ expect(isPlaying).toBe(true)
78
+ })
79
+
80
+ it('advances on a track-0 image that outlasts every overlay track', () => {
81
+ // Same defect, other content kind — the background image was invisible to
82
+ // the ceiling, so a slideshow stopped at its last overlay.
83
+ //
84
+ // The overlay track has to end INSIDE the second this clock runs (0.4 < 1),
85
+ // or `overlayTracks` alone already carries the playhead to 1 and the case
86
+ // passes on the broken code without ever reading track 0.
87
+ const { times } = runClock(projectOf([image('bg', 0, 20)], [overlay('o', 0, 0.4)]))
88
+ expect(last(times)).toBeCloseTo(1, 6)
89
+ })
90
+
91
+ it('still stops at the ceiling rather than running past the content', () => {
92
+ // The clamp itself is not what was wrong, and must survive: one second of
93
+ // wall clock against a half-second project lands on the end, not past it,
94
+ // and playback stops there.
95
+ const { times, isPlaying } = runClock(projectOf([overlay('o', 0, 0.5)]))
96
+ expect(last(times)).toBeCloseTo(0.5, 6)
97
+ expect(isPlaying).toBe(false)
98
+ })
99
+ })
@@ -322,18 +322,33 @@ export function useVideoPlayback(
322
322
  applyClipVolume(clip)
323
323
  }, [clips, videoTrack, activeSlot])
324
324
 
325
- // maxEnd for the canvas rAF clock — the furthest overlay/caption end. Kept in
325
+ // maxEnd for the canvas rAF clock — the furthest visual/caption end. Kept in
326
326
  // a ref, updated by its own cheap effect, so the rAF effect below doesn't tear
327
327
  // down and rebuild on every project spread (only isPlaying/onTimeUpdate matter
328
328
  // to it). onTimeUpdate is the stable clock.set identity.
329
+ //
330
+ // Spans EVERY enabled track, track 0 INCLUDED — not `overlayTracks`
331
+ // (`slice(1)`). This clock only ever runs for canvas projects, where track 0
332
+ // holds content rather than the primary footage: the background images, and
333
+ // on an agent-authored project frequently the overlays themselves (an
334
+ // animations-workflow project is often ONE track of nothing but overlays).
335
+ // Reading `overlayTracks` here collapsed the ceiling to 0 for exactly those,
336
+ // so the very first tick clamped to 0 and immediately called
337
+ // `setIsPlaying(false)` — space appeared to do nothing at all.
338
+ // `OverlayItemsLayer` already draws track 0 in canvas mode; this keeps the
339
+ // clock's ceiling and what's on screen in agreement. Mirrored in the engine
340
+ // path's `transportEndFor` (engine/scheduler.ts) — change both together.
341
+ //
342
+ // Audio stays OUT, unchanged: the canvas/video divergence over the audio tail
343
+ // is documented in timeline-core's `durations.js` and is not this fix.
329
344
  const canvasMaxEndRef = useRef(0)
330
345
  useEffect(() => {
331
346
  const captionEnd = (project.captions?.segments ?? []).reduce((m: number, s) => Math.max(m, s.end), 0)
332
347
  canvasMaxEndRef.current = Math.max(
333
- overlayTracks.flat().reduce((m, i) => Math.max(m, i.end), 0),
348
+ enabledTrackItems(project).flat().reduce((m, i) => Math.max(m, i.end ?? 0), 0),
334
349
  captionEnd,
335
350
  )
336
- }, [overlayTracks, project.captions])
351
+ }, [project])
337
352
 
338
353
  useEffect(() => {
339
354
  if (!isCanvasProject) return
@@ -18,6 +18,7 @@ import { VISUAL_EDGE_TOLERANCE_PX } from './canvas/hit-test'
18
18
  import { keyframeUnionTimes } from './canvas/keyframe-strip'
19
19
  import { mapTrackItems, normalizeTracks, updateAudioTrack } from './timeline-model'
20
20
  import { deleteSelection, toggleSelection } from './multiSelectOps'
21
+ import { removeMarkers } from './markers'
21
22
  import { computeAutoCrossfade, computeDerivedTiming, computeVisualCrossfade, trackItems } from './timeline-model'
22
23
  import TimelineCanvas, { useCanvasZoomControls, type ZoomControls } from './canvas/TimelineCanvas'
23
24
  import type { KeyframeSelection } from './canvas/pointer-machine'
@@ -836,6 +837,11 @@ export default function Timeline({ project, clock, onProjectChange, onOverlayEdi
836
837
  updated = { ...updated, captions: normalizeCaptionLanes({ ...captionTrack, segments: kept }) }
837
838
  }
838
839
  }
840
+ // Markers live at project.markers, outside tracks/audio, so `deleteSelection`
841
+ // cannot see them — same reason captions need their own strip above. Folded
842
+ // into the SAME commit so a mixed clip + caption + marker delete is one
843
+ // undo entry.
844
+ updated = removeMarkers(updated, new Set(selectedIds))
839
845
  if (rippleMode) updated = collapseGaps(updated)
840
846
  // Deleting a clip out of a magnetic audio lane leaves a gap exactly
841
847
  // like a trim or a move would — close it the same way, on release.
@@ -360,6 +360,22 @@ describe('Timeline — T9 keymap (arrows / delete / enter / escape)', () => {
360
360
  expect(onProjectChange).not.toHaveBeenCalled()
361
361
  })
362
362
 
363
+ it('Delete removes a selected marker, in the same commit as everything else', () => {
364
+ // Markers live outside tracks/audio, so deleteSelection cannot see them —
365
+ // the same reason captions needed their own strip in this action.
366
+ const clock = createPlaybackClock(0)
367
+ const onProjectChange = vi.fn()
368
+ const project = { ...makeProject(), markers: [{ id: 'm1', t: 5, label: '1' }] }
369
+ const { container } = render(
370
+ <Timeline project={project} clock={clock} selectedIds={['m1']} onProjectChange={onProjectChange} />,
371
+ )
372
+ focusTimelineRoot(container)
373
+ fireEvent.keyDown(document.body, { key: 'Delete' })
374
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
375
+ const updated = onProjectChange.mock.calls[0][0] as Project
376
+ expect(updated.markers).toBeUndefined() // last one gone → key dropped
377
+ })
378
+
363
379
  it('exposes zoomFit through actionsRef for a host-level palette', () => {
364
380
  const clock = createPlaybackClock(1)
365
381
  const actionsRef: { current: TimelineActions | null } = { current: null }
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { addMarker, moveMarker, removeMarkers, renameMarker, nextMarkerLabel } from '../markers'
3
+ import type { EditorProject, Marker } from '../../../schema'
4
+
5
+ function proj(markers?: Marker[]): EditorProject {
6
+ return {
7
+ id: 'p1', status: 'draft', settings: { resolution: [1920, 1080], fps: 30 },
8
+ tracks: [{ id: 'trk-0', items: [] }],
9
+ ...(markers ? { markers } : {}),
10
+ } as EditorProject
11
+ }
12
+ const mk = (id: string, t: number, label: string): Marker => ({ id, t, label })
13
+
14
+ describe('nextMarkerLabel', () => {
15
+ it('starts at 1 on a project with no markers', () => {
16
+ expect(nextMarkerLabel([])).toBe('1')
17
+ })
18
+
19
+ it('continues past the highest NUMERIC label, ignoring renamed ones', () => {
20
+ // A renamed marker must not stall the counter, and must not be parsed as 0.
21
+ expect(nextMarkerLabel([mk('a', 1, '1'), mk('b', 2, 'cut this'), mk('c', 3, '7')])).toBe('8')
22
+ })
23
+
24
+ it('does not reuse a number after a delete', () => {
25
+ // Counting markers instead of reading the max would hand out '2' twice here.
26
+ expect(nextMarkerLabel([mk('a', 1, '1'), mk('c', 3, '3')])).toBe('4')
27
+ })
28
+ })
29
+
30
+ describe('addMarker', () => {
31
+ it('creates the markers array on a project that has none', () => {
32
+ const out = addMarker(proj(), 4.25)
33
+ expect(out.markers).toHaveLength(1)
34
+ expect(out.markers![0]).toMatchObject({ t: 4.25, label: '1' })
35
+ expect(typeof out.markers![0].id).toBe('string')
36
+ expect(out.markers![0].id.length).toBeGreaterThan(0)
37
+ })
38
+
39
+ it('keeps markers sorted by time whatever order they are added in', () => {
40
+ let p = addMarker(proj(), 10)
41
+ p = addMarker(p, 2)
42
+ p = addMarker(p, 6)
43
+ expect(p.markers!.map(m => m.t)).toEqual([2, 6, 10])
44
+ // Labels record creation order, not position.
45
+ expect(p.markers!.map(m => m.label)).toEqual(['2', '3', '1'])
46
+ })
47
+
48
+ it('returns the SAME reference when a marker already sits within half a frame', () => {
49
+ // Key repeat: holding M must not spray a pile of stacked markers.
50
+ const p = addMarker(proj(), 5)
51
+ const again = addMarker(p, 5 + (1 / 30) * 0.4, 30)
52
+ expect(again).toBe(p)
53
+ })
54
+
55
+ it('does allow a second marker just outside the half-frame window', () => {
56
+ const p = addMarker(proj(), 5)
57
+ const again = addMarker(p, 5 + (1 / 30) * 0.6, 30)
58
+ expect(again).not.toBe(p)
59
+ expect(again.markers).toHaveLength(2)
60
+ })
61
+
62
+ it('clamps a negative time to 0', () => {
63
+ expect(addMarker(proj(), -3).markers![0].t).toBe(0)
64
+ })
65
+
66
+ it('never mutates the project it was given', () => {
67
+ const p = proj([mk('a', 1, '1')])
68
+ const before = JSON.stringify(p)
69
+ addMarker(p, 9)
70
+ expect(JSON.stringify(p)).toBe(before)
71
+ })
72
+ })
73
+
74
+ describe('moveMarker', () => {
75
+ it('retimes and re-sorts', () => {
76
+ const p = proj([mk('a', 1, '1'), mk('b', 2, '2'), mk('c', 3, '3')])
77
+ const out = moveMarker(p, 'a', 2.5)
78
+ expect(out.markers!.map(m => m.id)).toEqual(['b', 'a', 'c'])
79
+ expect(out.markers!.find(m => m.id === 'a')!.t).toBe(2.5)
80
+ })
81
+
82
+ it('clamps to 0 and returns the same reference for an unknown id or an unchanged time', () => {
83
+ const p = proj([mk('a', 1, '1')])
84
+ expect(moveMarker(p, 'a', -5).markers![0].t).toBe(0)
85
+ expect(moveMarker(p, 'nope', 4)).toBe(p)
86
+ expect(moveMarker(p, 'a', 1)).toBe(p)
87
+ })
88
+ })
89
+
90
+ describe('renameMarker', () => {
91
+ it('replaces the label', () => {
92
+ const out = renameMarker(proj([mk('a', 1, '1')]), 'a', 'intro')
93
+ expect(out.markers![0].label).toBe('intro')
94
+ })
95
+
96
+ it('trims, and rejects an empty label by returning the same reference', () => {
97
+ const p = proj([mk('a', 1, '1')])
98
+ expect(renameMarker(p, 'a', ' hi ').markers![0].label).toBe('hi')
99
+ expect(renameMarker(p, 'a', ' ')).toBe(p)
100
+ expect(renameMarker(p, 'a', '1')).toBe(p) // unchanged label, no commit
101
+ })
102
+ })
103
+
104
+ describe('removeMarkers', () => {
105
+ it('drops every id in the set and leaves the rest', () => {
106
+ const p = proj([mk('a', 1, '1'), mk('b', 2, '2'), mk('c', 3, '3')])
107
+ expect(removeMarkers(p, new Set(['a', 'c'])).markers!.map(m => m.id)).toEqual(['b'])
108
+ })
109
+
110
+ it('drops the markers key entirely when the last one goes', () => {
111
+ // Keeps a marker-less project byte-identical to one that never had markers.
112
+ const out = removeMarkers(proj([mk('a', 1, '1')]), new Set(['a']))
113
+ expect(out).not.toHaveProperty('markers')
114
+ })
115
+
116
+ it('returns the same reference when nothing matched', () => {
117
+ const p = proj([mk('a', 1, '1')])
118
+ expect(removeMarkers(p, new Set(['zzz']))).toBe(p)
119
+ })
120
+
121
+ it('returns the same reference when the project has no markers at all', () => {
122
+ const p = proj()
123
+ expect(removeMarkers(p, new Set(['a']))).toBe(p)
124
+ })
125
+ })
@@ -36,13 +36,14 @@
36
36
  import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
37
37
  import type { GetFilmstripArgs, GetWaveformPeaksArgs, FilmstripIndex, PeaksData, PendingDrop, Project, FootageDropPayload, ResolveFilePath, TimelineDropPlacement } from '../../../types'
38
38
  import { FOOTAGE_DND_MIME } from '../../../types'
39
- import type { KeyframeProp } from '../../../schema'
39
+ import type { KeyframeProp, Marker } from '../../../schema'
40
40
  import type { PlaybackClock } from '../../playback-clock'
41
41
  import { BASE_VISUAL_ROW_RENDER_HEIGHT_PX, ROW_GAP_PX, VISUAL_ROW_RENDER_HEIGHT_PX } from '../timeline-model'
42
42
  import { placeDroppedClip, resolveDropPoint } from '../placement'
43
- import { computeTimelineLayout, drawTimelineContent, drawTimelineOverlay, type PendingDropBand, type TimelineLayout, type TimelineMode, type VisualRowLayout } from './draw'
43
+ import { computeTimelineLayout, drawTimelineContent, drawTimelineOverlay, MARKER_FLAG_WIDTH_PX, MARKER_LABEL_GAP_PX, type PendingDropBand, type TimelineLayout, type TimelineMode, type VisualRowLayout } from './draw'
44
44
  import { hitTest, isEdgeHit, type Point, type SurfaceRect } from './hit-test'
45
45
  import { keyframeUnionTimes } from './keyframe-strip'
46
+ import { renameMarker } from '../markers'
46
47
  import {
47
48
  createPointerMachine,
48
49
  type KeyframeSelection,
@@ -67,6 +68,7 @@ import {
67
68
  reclampForDuration,
68
69
  scaleContextToDpr,
69
70
  syncCanvasBackingStore,
71
+ timeToX,
70
72
  useViewportValue,
71
73
  wheelIntent,
72
74
  withSurfaceWidth,
@@ -183,6 +185,53 @@ export interface TimelineCanvasProps {
183
185
  pendingDrops?: readonly PendingDrop[]
184
186
  }
185
187
 
188
+ /**
189
+ * The marker rename box, isolated into its own component for the same reason
190
+ * `CanvasZoomBadge` (bottom of this file) is: it owns the viewport
191
+ * subscription itself, so a zoom or scroll while the box is open moves it
192
+ * without making `TimelineCanvas`'s own render body subscribe to the
193
+ * viewport — which would re-render the whole surface on every wheel-zoom
194
+ * tick and pan frame, the exact thing this file's header doc says never
195
+ * happens. The subscription only exists for as long as this component is
196
+ * mounted, i.e. only while a rename is actually open.
197
+ */
198
+ function MarkerRenameBox({
199
+ marker,
200
+ store,
201
+ top,
202
+ onCommit,
203
+ onCancel,
204
+ }: {
205
+ marker: Marker
206
+ store: ViewportStore
207
+ top: number
208
+ onCommit: (value: string) => void
209
+ onCancel: () => void
210
+ }) {
211
+ const viewport = useViewportValue(store)
212
+ return (
213
+ <input
214
+ aria-label="Rename marker"
215
+ autoFocus
216
+ defaultValue={marker.label}
217
+ className="absolute z-10 h-4 rounded-sm border border-sky-400 bg-slate-900 px-1 text-[11px] text-slate-50 outline-none"
218
+ style={{
219
+ left: Math.round(timeToX(marker.t, viewport)) + MARKER_FLAG_WIDTH_PX + MARKER_LABEL_GAP_PX,
220
+ top,
221
+ width: 120,
222
+ }}
223
+ onKeyDown={e => {
224
+ // Stop Escape/Enter reaching the document keymap: this box owns
225
+ // them while it is open.
226
+ e.stopPropagation()
227
+ if (e.key === 'Enter') onCommit((e.target as HTMLInputElement).value)
228
+ if (e.key === 'Escape') onCancel()
229
+ }}
230
+ onBlur={e => onCommit(e.target.value)}
231
+ />
232
+ )
233
+ }
234
+
186
235
  /**
187
236
  * Which video row a drop at surface-y `y` landed on, as an index into the
188
237
  * NORMALIZED track order `placeDroppedClip` measures in — or `-1` for "no
@@ -418,6 +467,13 @@ export default function TimelineCanvas({
418
467
  const [paneFill, setPaneFill] = useState(0)
419
468
  const surfaceHeight = Math.max(layout.height, VISUAL_ROW_RENDER_HEIGHT_PX, paneFill)
420
469
 
470
+ // Which marker's rename box is open, or null for none. The box itself is a
471
+ // real DOM `<input>` (rendered below, as a third child of the wrapper div)
472
+ // rather than a canvas-drawn fake — `editCaption` has no equivalent here
473
+ // because it routes to the transcript sidebar instead, so this is the one
474
+ // inline text editor this canvas hosts directly.
475
+ const [editingMarkerId, setEditingMarkerId] = useState<string | null>(null)
476
+
421
477
  // Latest draw inputs, readable from the imperative paint without making the
422
478
  // paint a dependency of every effect (the ref-to-latest pattern
423
479
  // `useTimelineZoom` uses for its wheel handler).
@@ -785,6 +841,7 @@ export default function TimelineCanvas({
785
841
  case 'commit': p.onOverlayEdit?.(effect.project); break
786
842
  case 'inspect': (effect.target === 'visual' ? p.onInspectClip : p.onInspectAudio)?.(effect.id); break
787
843
  case 'editCaption': p.onEditCaption?.(effect.id); break
844
+ case 'editMarker': setEditingMarkerId(effect.id); break
788
845
  // Cursor is written straight to the node: an affordance that changes on
789
846
  // every hover must not cost a React render.
790
847
  case 'cursor': if (containerRef.current) containerRef.current.style.cursor = effect.cursor; break
@@ -888,7 +945,7 @@ export default function TimelineCanvas({
888
945
  // precedence here, same as it does in the machine's own hit-test — a
889
946
  // trim handle must not light up underneath a diamond that would win the
890
947
  // actual press.
891
- const hit = hitTest(point, p.layout, store.get(), { selectedIds: p.selectedIds })
948
+ const hit = hitTest(point, p.layout, store.get(), { selectedIds: p.selectedIds, markers: p.project.markers })
892
949
  if (!isEdgeHit(hit) || hit.itemId === undefined || hit.edge === undefined) return null
893
950
  return p.selectedIds.includes(hit.itemId) ? { itemId: hit.itemId, edge: hit.edge } : null
894
951
  }
@@ -1137,7 +1194,7 @@ export default function TimelineCanvas({
1137
1194
  if (!p.onFadeCurveMenu && !p.onKeyframeMenu) return
1138
1195
  const point = surfacePoint(e)
1139
1196
  if (!point) return
1140
- const hit = hitTest(point, p.layout, store.get(), { selectedIds: p.selectedIds })
1197
+ const hit = hitTest(point, p.layout, store.get(), { selectedIds: p.selectedIds, markers: p.project.markers })
1141
1198
  if (p.onFadeCurveMenu && hit.kind === 'audio-fade' && hit.itemId !== undefined && hit.side) {
1142
1199
  e.preventDefault()
1143
1200
  p.onFadeCurveMenu({ trackId: hit.itemId, side: hit.side, x: e.clientX, y: e.clientY })
@@ -1368,6 +1425,36 @@ export default function TimelineCanvas({
1368
1425
  }
1369
1426
  }, [])
1370
1427
 
1428
+ // ── Marker rename box ──
1429
+ //
1430
+ // Unlike the imperative paint (which reads `store.get()` fresh per frame),
1431
+ // the box's `left` is a React-owned DOM style, so it needs a subscription to
1432
+ // follow a zoom or scroll that happens while it's open. That subscription
1433
+ // lives on `MarkerRenameBox` itself, below, rather than here — the same
1434
+ // reason `CanvasZoomBadge` is its own component: TimelineCanvas must not
1435
+ // subscribe to the viewport in its own render body, or every wheel-zoom
1436
+ // tick and pan frame re-renders this whole surface, which is exactly what
1437
+ // this file's header doc promises never happens.
1438
+ const editingMarker = editingMarkerId
1439
+ ? (project.markers ?? []).find(m => m.id === editingMarkerId) ?? null
1440
+ : null
1441
+
1442
+ const commitRename = (value: string) => {
1443
+ // The id is captured and null-checked here because this same function is
1444
+ // also the input's blur handler, and it must be safe to call when there
1445
+ // is nothing being edited. Escape does not route through here at all —
1446
+ // its handler clears `editingMarkerId` directly (see `MarkerRenameBox`
1447
+ // below) — so the null check is for blur-with-nothing-open, not for
1448
+ // "Escape already cleared it before blur could commit."
1449
+ const id = editingMarkerId
1450
+ setEditingMarkerId(null)
1451
+ if (!id) return
1452
+ const next = renameMarker(project, id, value)
1453
+ if (next === project) return // blank or unchanged — no undo entry
1454
+ onProjectChange?.(next)
1455
+ onOverlayEdit?.(next) // one commit, one undo step
1456
+ }
1457
+
1371
1458
  return (
1372
1459
  <div
1373
1460
  ref={containerRef}
@@ -1399,6 +1486,15 @@ export default function TimelineCanvas({
1399
1486
  >
1400
1487
  <canvas ref={contentCanvasRef} className="absolute inset-0 w-full h-full" />
1401
1488
  <canvas ref={overlayCanvasRef} className="absolute inset-0 w-full h-full pointer-events-none" />
1489
+ {editingMarker && (
1490
+ <MarkerRenameBox
1491
+ marker={editingMarker}
1492
+ store={store}
1493
+ top={layout.markers?.y ?? 0}
1494
+ onCommit={commitRename}
1495
+ onCancel={() => setEditingMarkerId(null)}
1496
+ />
1497
+ )}
1402
1498
  </div>
1403
1499
  )
1404
1500
  }
@@ -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
+ })