@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
@@ -15,7 +15,7 @@
15
15
  * design.
16
16
  */
17
17
 
18
- import type { AudioTrack, CaptionSegment, VisualItem } from '../../../schema'
18
+ import type { AudioTrack, CaptionSegment, Marker, VisualItem } from '../../../schema'
19
19
  import type { Project } from '../../../types'
20
20
  import { groupCaptionLanes } from '../../captionLanes'
21
21
  import {
@@ -305,6 +305,14 @@ export const TIMELINE_COLORS = {
305
305
  rulerTick: 'rgba(148,163,184,0.55)',
306
306
  rulerTickMinor: 'rgba(148,163,184,0.25)',
307
307
  rulerText: 'rgba(148,163,184,0.9)',
308
+ /** A marker's flag stem, in the strip above the ruler. Sky-400 — every
309
+ * saturated hue below it is spoken for (red is playback, yellow the
310
+ * preview axis, cyan the snap guide, amber a keyframe, emerald audio), and
311
+ * a marker is none of those: it is a bookmark, not a state of the edit. */
312
+ markerFlag: '#38bdf8',
313
+ /** The one marker in the current selection. White, the same selection
314
+ * vocabulary `clipSelectedOutline`/`handleFill` already use. */
315
+ markerFlagSelected: '#f8fafc',
308
316
  /** The marquee (rubber-band) selection box. Same white as the selection
309
317
  * vocabulary, since what it is doing IS selecting. */
310
318
  marqueeFill: 'rgba(255,255,255,0.08)',
@@ -462,6 +470,13 @@ export const LIGHT_TIMELINE_COLORS: TimelineColors = {
462
470
  rulerTick: 'rgba(71,85,105,0.55)',
463
471
  rulerTickMinor: 'rgba(71,85,105,0.3)',
464
472
  rulerText: 'rgba(51,65,85,0.85)',
473
+ /** Still sky, stepped from sky-400 to sky-600 for the same reason
474
+ * `snapGuide` steps cyan-400 to cyan-600: the lighter tone doesn't clear a
475
+ * light row. */
476
+ markerFlag: '#0284c7',
477
+ /** The selected marker. Near-black, following `clipSelectedOutline` into
478
+ * the same "white becomes near-black here" rule. */
479
+ markerFlagSelected: '#0f172a',
465
480
  /** The marquee. Selection vocabulary again, so it follows the outline and
466
481
  * the handles into near-black — at the dark set's own alphas, which were
467
482
  * already tuned to "a wash you can see through and a border you can't miss". */
@@ -503,6 +518,35 @@ export const RULER_STEPS_SECONDS = [
503
518
  60, 120, 300, 600, 900, 1800, 3600,
504
519
  ] as const
505
520
 
521
+ /**
522
+ * The marker strip: a thin band ABOVE the ruler.
523
+ *
524
+ * Markers do NOT share the ruler's 18px. Two reasons, and the second is the
525
+ * load-bearing one:
526
+ *
527
+ * 1. The ruler already spends that band on tick labels. A marker name would
528
+ * overprint them at any useful zoom.
529
+ * 2. `hitTest` returns `'ruler'` for the WHOLE ruler band before it tests
530
+ * anything else, and the pointer machine scrubs on pointerDown the instant
531
+ * it sees that — no drag threshold, no deferral. A marker sharing the band
532
+ * would have to fight that branch for precedence. Its own band means the
533
+ * ruler's behaviour is untouched and a marker hit is simply a different
534
+ * rectangle.
535
+ *
536
+ * Present ONLY when the project has markers, the same discipline
537
+ * `TimelineLayout.captions` follows: a marker-less project lays out exactly as
538
+ * it did before this existed, so no existing layout expectation moves.
539
+ */
540
+ export const MARKER_STRIP_HEIGHT_PX = 16
541
+ /** Width of a marker's flag stem, and the gap before its label. */
542
+ export const MARKER_FLAG_WIDTH_PX = 2
543
+ export const MARKER_LABEL_GAP_PX = 4
544
+ /** Baseline for a marker label, measured from the top of the strip. */
545
+ export const MARKER_LABEL_BASELINE_PX = 11
546
+ /** Longest label drawn, in characters. Past this the name is clipped with an
547
+ * ellipsis — a marker is a flag, not a notes field. */
548
+ export const MARKER_LABEL_MAX_CHARS = 24
549
+
506
550
  export const ROW_RADIUS_PX = 4 // Tailwind `rounded`
507
551
  /** Horizontal inset per side between a clip's time span and its drawn body —
508
552
  * two touching clips therefore show twice this as a dark gutter. */
@@ -559,6 +603,17 @@ export const LABEL_FONT = '10px ui-sans-serif, system-ui, sans-serif'
559
603
  export const LABEL_PAD_PX = 6
560
604
  /** Below this width a label is more smear than information, so it is skipped. */
561
605
  export const MIN_LABEL_WIDTH_PX = 28
606
+ /** Muted-speaker glyph drawn at the head of a muted audio bar. Mute used to be
607
+ * carried by fill alone (`audioMutedFill` plus a dropped border), which reads
608
+ * as "a slightly different colour" rather than "this is off" — operators asked
609
+ * why a lane was silent while looking straight at the thing telling them. */
610
+ export const MUTED_ICON_SIZE_PX = 9
611
+ /** Gap between the glyph and the label that follows it. */
612
+ export const MUTED_ICON_GAP_PX = 4
613
+ /** Below this bar width even the glyph is dropped. Lower than
614
+ * `MIN_LABEL_WIDTH_PX` on purpose: on a bar too narrow to name, "is it muted"
615
+ * is the more valuable of the two answers, so the glyph outlives the label. */
616
+ export const MIN_MUTED_ICON_WIDTH_PX = 14
562
617
  /** Middle-baseline offset from a clip's top edge — half the font's 10px plus a
563
618
  * 4px margin, so the label rides just inside the clip's top border. */
564
619
  export const LABEL_TOP_OFFSET_PX = 9
@@ -673,10 +728,14 @@ export interface CaptionRowLayout {
673
728
  }
674
729
 
675
730
  export interface TimelineLayout {
676
- /** The time ruler strip across the top. Always present and always at y=0;
677
- * carried in the layout rather than assumed so hit-testing and painting
678
- * read the same rectangle, the same contract the rows have. */
731
+ /** The time ruler strip. Always present; carried in the layout rather than
732
+ * assumed so hit-testing and painting read the same rectangle, the same
733
+ * contract the rows have. Sits at y=0 when the project has no markers, and
734
+ * below the marker strip (see `MARKER_STRIP_HEIGHT_PX`) when it does. */
679
735
  ruler: { y: number; height: number }
736
+ /** The marker strip above the ruler. Absent — not a zero-height rect — when
737
+ * the project has no markers; see `MARKER_STRIP_HEIGHT_PX`. */
738
+ markers?: { y: number; height: number }
680
739
  /** Visual rows in DRAW order (top of the surface first). */
681
740
  rows: VisualRowLayout[]
682
741
  lanes: AudioLaneLayout[]
@@ -717,9 +776,13 @@ export function computeTimelineLayout(project: Project): TimelineLayout {
717
776
  // legacy-shaped project has no settings, so every row is enabled.
718
777
  const trackSettings = normalizeTracks(project).tracks ?? []
719
778
  const rows: VisualRowLayout[] = []
720
- // The ruler owns the top strip; everything else starts below it.
721
- const ruler = { y: 0, height: RULER_HEIGHT_PX }
722
- let y = RULER_HEIGHT_PX + ROW_GAP_PX
779
+ // The marker strip owns the very top when there is anything to put in it;
780
+ // the ruler starts below it, and every row below that.
781
+ const hasMarkers = (project.markers?.length ?? 0) > 0
782
+ const markers = hasMarkers ? { y: 0, height: MARKER_STRIP_HEIGHT_PX } : undefined
783
+ const stripHeight = hasMarkers ? MARKER_STRIP_HEIGHT_PX : 0
784
+ const ruler = { y: stripHeight, height: RULER_HEIGHT_PX }
785
+ let y = stripHeight + RULER_HEIGHT_PX + ROW_GAP_PX
723
786
 
724
787
  // Only stored when there is something to show — an empty `segments` (or no
725
788
  // `project.captions` at all) means no bands at all, not an empty array, so a
@@ -811,6 +874,7 @@ export function computeTimelineLayout(project: Project): TimelineLayout {
811
874
  })
812
875
 
813
876
  const layout: TimelineLayout = { ruler, rows, lanes, height: Math.max(0, y - ROW_GAP_PX) }
877
+ if (markers) layout.markers = markers
814
878
  if (captions.length > 0) layout.captions = captions
815
879
  return layout
816
880
  }
@@ -1322,6 +1386,57 @@ export interface AudioItemDrawArgs {
1322
1386
  hoveredFadeSide?: 'in' | 'out' | null
1323
1387
  }
1324
1388
 
1389
+ /**
1390
+ * A speaker with a cross beside it, drawn in a `size`×`size` box anchored at
1391
+ * (x, y). Filled for the speaker body and stroked for the cross, so it holds
1392
+ * up both against the washed-out muted fill and over whatever waveform the
1393
+ * content layer painted underneath.
1394
+ *
1395
+ * Deliberately geometry rather than a font glyph or an inline SVG: this runs
1396
+ * inside the timeline's canvas painter, where the only other text is drawn with
1397
+ * `LABEL_FONT`, and a missing icon font would fail as a blank box with no
1398
+ * fallback path to notice it.
1399
+ */
1400
+ function drawMutedGlyph(
1401
+ ctx: DrawContext,
1402
+ x: number,
1403
+ y: number,
1404
+ size: number,
1405
+ color: string,
1406
+ ): void {
1407
+ const s = size
1408
+ ctx.save()
1409
+ ctx.strokeStyle = color
1410
+ ctx.fillStyle = color
1411
+ // `DrawContext` is the painter's narrowed surface and carries no
1412
+ // `lineJoin`/`lineCap`; at 9px neither is visible anyway.
1413
+ ctx.lineWidth = 1
1414
+
1415
+ // Speaker: a small back plate, then the cone flaring out of it.
1416
+ const bodyW = s * 0.26
1417
+ const bodyH = s * 0.34
1418
+ const bodyX = x + s * 0.04
1419
+ const bodyY = y + (s - bodyH) / 2
1420
+ ctx.beginPath()
1421
+ ctx.moveTo(bodyX, bodyY)
1422
+ ctx.lineTo(bodyX + bodyW, bodyY)
1423
+ ctx.lineTo(x + s * 0.48, y + s * 0.14)
1424
+ ctx.lineTo(x + s * 0.48, y + s * 0.86)
1425
+ ctx.lineTo(bodyX + bodyW, bodyY + bodyH)
1426
+ ctx.lineTo(bodyX, bodyY + bodyH)
1427
+ ctx.closePath()
1428
+ ctx.fill()
1429
+
1430
+ // The cross — what actually says "off" at a glance.
1431
+ ctx.beginPath()
1432
+ ctx.moveTo(x + s * 0.62, y + s * 0.3)
1433
+ ctx.lineTo(x + s * 0.96, y + s * 0.7)
1434
+ ctx.moveTo(x + s * 0.96, y + s * 0.3)
1435
+ ctx.lineTo(x + s * 0.62, y + s * 0.7)
1436
+ ctx.stroke()
1437
+ ctx.restore()
1438
+ }
1439
+
1325
1440
  export function drawAudioItem(
1326
1441
  ctx: DrawContext,
1327
1442
  args: AudioItemDrawArgs,
@@ -1359,11 +1474,26 @@ export function drawAudioItem(
1359
1474
  if (fadeInPx > 0) drawFadeEnvelope(ctx, rect, 'in', fadeInPx, fadeSpanX, fadeSpanWidth, fadeInCurve, palette)
1360
1475
  if (fadeOutPx > 0) drawFadeEnvelope(ctx, rect, 'out', fadeOutPx, fadeSpanX, fadeSpanWidth, fadeOutCurve, palette)
1361
1476
 
1477
+ // Mute glyph leads, and the label starts after it, so the two can never
1478
+ // overlap on a narrow bar.
1479
+ const headX = rect.x + LABEL_PAD_PX + handleWidth
1480
+ let labelX = headX
1481
+ if (muted && rect.width >= MIN_MUTED_ICON_WIDTH_PX) {
1482
+ drawMutedGlyph(
1483
+ ctx,
1484
+ headX,
1485
+ rect.y + (rect.height - MUTED_ICON_SIZE_PX) / 2,
1486
+ MUTED_ICON_SIZE_PX,
1487
+ palette.colors.audioText,
1488
+ )
1489
+ labelX = headX + MUTED_ICON_SIZE_PX + MUTED_ICON_GAP_PX
1490
+ }
1491
+
1362
1492
  if (rect.width >= MIN_LABEL_WIDTH_PX) {
1363
1493
  ctx.fillStyle = palette.colors.audioText
1364
1494
  ctx.font = LABEL_FONT
1365
1495
  ctx.textBaseline = 'middle'
1366
- ctx.fillText(label, rect.x + LABEL_PAD_PX + handleWidth, rect.y + rect.height / 2)
1496
+ ctx.fillText(label, labelX, rect.y + rect.height / 2)
1367
1497
  }
1368
1498
  ctx.restore()
1369
1499
 
@@ -1752,6 +1882,47 @@ export function drawRuler(
1752
1882
  ctx.restore()
1753
1883
  }
1754
1884
 
1885
+ /**
1886
+ * The operator's markers, as small flags along the top strip.
1887
+ *
1888
+ * Content-layer, not overlay: markers are project data that changes on an edit,
1889
+ * not per frame. A drag repaints the content layer per move exactly the way a
1890
+ * clip drag already does (`applyMove` emits `projectChange`, the host redraws
1891
+ * content) — there is no ghost, because there is no ghost for clips either.
1892
+ *
1893
+ * `markers` is expected sorted by `t` (the model guarantees it), so this walks
1894
+ * in draw order and a later marker's label overprints an earlier one's when
1895
+ * they crowd. That is the correct z-order: the one further right is the one
1896
+ * whose flag you can still see.
1897
+ */
1898
+ export function drawMarkers(
1899
+ ctx: DrawContext,
1900
+ markers: readonly Marker[],
1901
+ viewport: Viewport,
1902
+ rect: { y: number; height: number },
1903
+ surfaceWidth: number,
1904
+ selectedIds: readonly string[],
1905
+ palette: TimelinePalette = DARK_TIMELINE_PALETTE,
1906
+ ): void {
1907
+ if (markers.length === 0) return
1908
+ ctx.save()
1909
+ ctx.font = LABEL_FONT
1910
+ ctx.textBaseline = 'alphabetic'
1911
+ for (const m of markers) {
1912
+ const x = Math.round(timeToX(m.t, viewport)) + 0.5
1913
+ if (x < 0 || x > surfaceWidth) continue
1914
+ const selected = selectedIds.includes(m.id)
1915
+ ctx.fillStyle = selected ? palette.colors.markerFlagSelected : palette.colors.markerFlag
1916
+ ctx.fillRect(x, rect.y + 2, MARKER_FLAG_WIDTH_PX, rect.height - 4)
1917
+ ctx.fillStyle = selected ? palette.colors.markerFlagSelected : palette.colors.rulerText
1918
+ const label = m.label.length > MARKER_LABEL_MAX_CHARS
1919
+ ? `${m.label.slice(0, MARKER_LABEL_MAX_CHARS - 1)}…`
1920
+ : m.label
1921
+ ctx.fillText(label, x + MARKER_FLAG_WIDTH_PX + MARKER_LABEL_GAP_PX, rect.y + MARKER_LABEL_BASELINE_PX)
1922
+ }
1923
+ ctx.restore()
1924
+ }
1925
+
1755
1926
  // ── Pending-drop ghost ───────────────────────────────────────────────────
1756
1927
 
1757
1928
  /** Corner radius of a ghost band. The same 4px a clip uses — the band stands
@@ -1886,6 +2057,9 @@ export function drawTimelineContent(ctx: DrawContext, scene: TimelineScene): Dra
1886
2057
 
1887
2058
  ctx.clearRect(0, 0, surfaceWidth, surfaceHeight)
1888
2059
 
2060
+ if (layout.markers) {
2061
+ drawMarkers(ctx, scene.project.markers ?? [], viewport, layout.markers, surfaceWidth, selectedIds, themePalette)
2062
+ }
1889
2063
  drawRuler(ctx, viewport, layout.ruler, surfaceWidth, themePalette)
1890
2064
 
1891
2065
 
@@ -22,7 +22,7 @@
22
22
  * which is why captions hit-test at the audio tolerance.
23
23
  */
24
24
 
25
- import type { AudioTrack, CaptionSegment, VisualItem } from '../../../schema'
25
+ import type { AudioTrack, CaptionSegment, Marker, VisualItem } from '../../../schema'
26
26
  import { canKeyframe, isKeyframed } from '../../keyframeOps'
27
27
  import { AUDIO_ITEM_INSET_PX, type TimelineLayout } from './draw'
28
28
  import { KEYFRAME_HIT_HALF_WIDTH_PX, KEYFRAME_STRIP_ZONE_HEIGHT_PX, keyframeDiamondX, keyframeUnionTimes } from './keyframe-strip'
@@ -58,6 +58,10 @@ export const FADE_GRIP_HALF_WIDTH_PX = 5
58
58
  * this. */
59
59
  export const FADE_GRIP_ZONE_HEIGHT_PX = 10
60
60
 
61
+ /** How far right of a marker's flag the label claims as a target. Wide enough
62
+ * that the LABEL is clickable, not just the 2px stem. */
63
+ export const MARKER_HIT_WIDTH_PX = 72
64
+
61
65
  export interface HitTestOptions {
62
66
  visualEdgeTolerancePx?: number
63
67
  audioEdgeTolerancePx?: number
@@ -68,9 +72,18 @@ export interface HitTestOptions {
68
72
  * is also what every caller that predates T3.3 gets — this option is
69
73
  * purely additive. */
70
74
  selectedIds?: readonly string[]
75
+ /** The project's markers, threaded the same way `selectedIds` is: the layout
76
+ * carries the strip's RECTANGLE but not what sits in it, so the flags have
77
+ * to arrive here to be hit-testable. Absent means no marker hits are
78
+ * possible, which is what every caller that predates this gets. */
79
+ markers?: readonly Marker[]
71
80
  }
72
81
 
73
82
  export type HitKind =
83
+ /** On a marker's flag or its label, in the strip ABOVE the ruler. Only ever
84
+ * produced when the project HAS markers (the strip is absent otherwise) and
85
+ * the caller passed them through `HitTestOptions.markers`. */
86
+ | 'marker'
74
87
  /** The time ruler strip along the top. Scrubbing lives here, and only here:
75
88
  * the track area's empty space belongs to the marquee now. */
76
89
  | 'ruler'
@@ -111,6 +124,10 @@ export interface HitResult {
111
124
  * point outside the rows is still a point in time. */
112
125
  t: number
113
126
  itemId?: string
127
+ /** The hit marker's id, on a `'marker'` hit. A field of its own rather than a
128
+ * reuse of `itemId`: a marker is not an item, and every caller that reads
129
+ * `itemId` means "a thing on a track" by it. */
130
+ markerId?: string
114
131
  /** Which trim handle, on the two `*-edge` kinds. `in` is the left/start
115
132
  * handle, `out` the right/end one — the vocabulary `cuts.ts` uses for source
116
133
  * windows, rather than the DOM hook's `start`/`end`. */
@@ -144,6 +161,10 @@ export interface HitResult {
144
161
  * press time by the pointer machine and used as the gesture's origin. */
145
162
  item?: VisualItem
146
163
  track?: AudioTrack
164
+ /** The hit marker itself, for the same reason `item`/`track` are here: the
165
+ * pointer machine captures it at press time and reads its `t` for the whole
166
+ * gesture rather than re-scanning `project.markers` per move. */
167
+ marker?: Marker
147
168
  /** The hit caption segment, for the same reason `item`/`track` are here: the
148
169
  * pointer machine captures it at press time and reads its `start`/`end` for
149
170
  * the whole gesture, rather than re-scanning `project.captions` per move. */
@@ -307,6 +328,51 @@ export function hitTest(
307
328
  const visualTolerance = opts.visualEdgeTolerancePx ?? VISUAL_EDGE_TOLERANCE_PX
308
329
  const audioTolerance = opts.audioEdgeTolerancePx ?? AUDIO_EDGE_TOLERANCE_PX
309
330
 
331
+ // The marker strip sits ABOVE the ruler and is tested first, because the
332
+ // ruler's own check below claims its whole band unconditionally. Ordering is
333
+ // the entire mechanism here: reverse these two and every marker click scrubs.
334
+ //
335
+ // NO LOWER BOUND on the band, deliberately. The ruler's check below is
336
+ // written the same way, and its comment says why: a pointer that has run off
337
+ // the TOP of the surface is still aiming at the topmost strip. With a marker
338
+ // strip present that strip is the topmost one, so it inherits that duty —
339
+ // adding a `point.y >= strip.y` guard would drop those points through to the
340
+ // ruler and scrub instead.
341
+ const strip = layout.markers
342
+ if (strip && point.y < strip.y + strip.height) {
343
+ const markers = opts.markers ?? []
344
+ // Right-to-left, matching draw order: a later marker paints OVER an earlier
345
+ // one, so it must also win the click.
346
+ for (let i = markers.length - 1; i >= 0; i--) {
347
+ const m = markers[i]
348
+ const x = timeToX(m.t, viewport)
349
+ // Cull exactly where `drawMarkers` culls, so the hit region cannot
350
+ // outlive the picture. A marker scrolled off the LEFT is not drawn at
351
+ // all — label included — but its claim reaches MARKER_HIT_WIDTH_PX to
352
+ // the RIGHT of the flag, so without this a click on apparently-empty
353
+ // strip near the left edge grabs a marker nobody can see, and can then
354
+ // drag it.
355
+ //
356
+ // The predicate is copied from the painter (`Math.round(x) + 0.5 < 0`)
357
+ // but the hit region is measured from the TRUE `x`. That asymmetry is
358
+ // deliberate: the painter's `+ 0.5` is a half-pixel crispness offset for
359
+ // drawing a sharp 1px stem, not a position. Folding it into the hit
360
+ // region shifts every marker's target half a pixel right of its own
361
+ // flag, so a click landing exactly on the flag misses.
362
+ //
363
+ // `drawMarkers` also culls `x > surfaceWidth`; that needs no counterpart
364
+ // here, since a hit requires `point.x >= x` and `point.x` never exceeds
365
+ // the surface.
366
+ if (Math.round(x) + 0.5 < 0) continue
367
+ if (point.x >= x && point.x <= x + MARKER_HIT_WIDTH_PX) {
368
+ return { kind: 'marker', t, markerId: m.id, marker: m }
369
+ }
370
+ }
371
+ // Bare strip: not a marker, and deliberately NOT the ruler either — a click
372
+ // on empty strip should do nothing, not seek.
373
+ return { kind: 'background', t }
374
+ }
375
+
310
376
  // Before the rows, and including everything above the strip: a pointer that
311
377
  // has run off the top of the surface is still aiming at the ruler, and
312
378
  // clamping it there keeps a scrub alive when the hand drifts upward
@@ -490,6 +556,11 @@ export function isItemHit(hit: HitResult): boolean {
490
556
  || hit.kind === 'audio-body' || hit.kind === 'audio-edge'
491
557
  }
492
558
 
559
+ /** Is this a hit on a marker's flag or label in the strip? */
560
+ export function isMarkerHit(hit: HitResult): boolean {
561
+ return hit.kind === 'marker'
562
+ }
563
+
493
564
  /** Is this a hit on a caption block (either body or edge)? */
494
565
  export function isCaptionHit(hit: HitResult): boolean {
495
566
  return hit.kind === 'caption-body' || hit.kind === 'caption-edge'
@@ -60,6 +60,7 @@ import { reflowMagneticLanes } from '../../audioMagnet'
60
60
  import { laneOf, normalizeCaptionLanes, resolveDropLane, sameLaneNeighbours } from '../../captionLanes'
61
61
  import { collapseGaps, rollEdit, slideItem, slipItem } from '../../cuts'
62
62
  import { canKeyframe, enableKeyframing, moveKeyframe, setKeyframe, transformProps, valueAt } from '../../keyframeOps'
63
+ import { moveMarker } from '../markers'
63
64
  import { applyMoveDeltaToSelection, applyResizeDeltaToSelection } from '../multiSelectOps'
64
65
  import { AUDIO_LANE_HEIGHT_PX, CAPTION_ROW_HEIGHT_PX, computeDerivedTiming, groupAudioLanes, mapTrackItems, moveItemAcrossTracks, normalizeTracks, resolveTargetTrackIdx, trackItems, updateAudioTrack } from '../timeline-model'
65
66
  import { DRAG_THRESHOLD_PX, computeResizedItem, resizeWindowedItem, type Draggable } from '../useItemDragDrop'
@@ -167,6 +168,10 @@ export type PointerEffect =
167
168
  * sidebar, which is where caption text is edited now that the inline
168
169
  * contentEditable row is gone. */
169
170
  | { type: 'editCaption'; id: string }
171
+ /** Double-click on a marker: the host opens its inline rename box. Named
172
+ * separately from `editCaption` because it routes somewhere else entirely —
173
+ * an overlay input on the canvas, not the transcript sidebar. */
174
+ | { type: 'editMarker'; id: string }
170
175
  /** The surface's CSS cursor. Emitted only when it changes. */
171
176
  | { type: 'cursor'; cursor: Cursor }
172
177
  /** Where to draw the snap guide and how hard it is holding, or nulls to take
@@ -214,6 +219,8 @@ export type GestureKind =
214
219
  /** Dragging one caption block's in/out edge. Single-segment: multi-caption
215
220
  * trim is out of scope for v1. */
216
221
  | 'caption-trim'
222
+ /** Re-timing a marker in the strip above the ruler. See `applyMarkerMove`. */
223
+ | 'marker-move'
217
224
  /** Dragging the playhead. Lives on the ruler strip only. */
218
225
  | 'scrub'
219
226
  /** Dragging a rubber-band box across empty track area to select. */
@@ -330,6 +337,10 @@ export function cursorForHit(hit: HitResult): Cursor {
330
337
  // retime-in-place gesture a trim edge does, so it gets the same cursor.
331
338
  case 'keyframe':
332
339
  return 'ew-resize'
340
+ // A marker only ever moves horizontally along the strip — same
341
+ // retime-in-place gesture, same cursor.
342
+ case 'marker':
343
+ return 'ew-resize'
333
344
  case 'item-edge':
334
345
  case 'audio-edge':
335
346
  case 'caption-edge':
@@ -355,6 +366,7 @@ function cursorForGesture(gesture: GestureKind): Cursor {
355
366
  case 'caption-trim':
356
367
  case 'scrub':
357
368
  case 'keyframe-move':
369
+ case 'marker-move':
358
370
  return 'ew-resize'
359
371
  case 'marquee':
360
372
  return 'crosshair'
@@ -384,6 +396,10 @@ export function resolveGesture(hit: HitResult, modifiers: Modifiers): GestureKin
384
396
  // A keyframe diamond does exactly one thing too — retime.
385
397
  case 'keyframe':
386
398
  return 'keyframe-move'
399
+ // So does a marker: there is nothing to trim and no second dimension to
400
+ // drag it in, so no modifiers of its own either.
401
+ case 'marker':
402
+ return 'marker-move'
387
403
  // Captions have no roll/slip/slide either — there is no source window to
388
404
  // slip and no neighbour to roll against — so modifiers fall through here
389
405
  // exactly as they do for audio.
@@ -567,9 +583,19 @@ function isOverPlayhead(point: Point, ctx: PointerContext): boolean {
567
583
  * into `isEdgeHit` (whose contract is specifically "describes a trim grab",
568
584
  * read via `hit.edge`; a fade grip has no `edge`, only `side`). Keyframe
569
585
  * diamonds win for the identical reason — same size class as a fade grip,
570
- * same "smaller and more deliberate than a trim edge" case for the grab. */
586
+ * same "smaller and more deliberate than a trim edge" case for the grab.
587
+ *
588
+ * MARKERS win too, and for a stronger reason than any of the above (decision
589
+ * D3). A flag is a small target the operator aimed at deliberately, so the D1
590
+ * reasoning already covers it — but a marker is also routinely dropped AT the
591
+ * playhead: `M` places one at the current time, which puts it exactly on the
592
+ * line. Without this exclusion the freshest marker is always the one under the
593
+ * playhead, so the COMMONEST marker on the strip would be the one you can
594
+ * never pick up. */
571
595
  function grabsPlayhead(hit: HitResult, point: Point, ctx: PointerContext): boolean {
572
- return !isEdgeHit(hit) && hit.kind !== 'audio-fade' && hit.kind !== 'keyframe' && isOverPlayhead(point, ctx)
596
+ return !isEdgeHit(hit) && hit.kind !== 'audio-fade' && hit.kind !== 'keyframe'
597
+ && hit.kind !== 'marker'
598
+ && isOverPlayhead(point, ctx)
573
599
  }
574
600
 
575
601
  /**
@@ -1124,6 +1150,36 @@ function applyAudioFade(ctx: PointerContext, press: Press, point: Point, snap: S
1124
1150
  return { effects: [{ type: 'projectChange', project: next }], snap, lastProject: next, guide: null }
1125
1151
  }
1126
1152
 
1153
+ /**
1154
+ * Retime a marker under the pointer.
1155
+ *
1156
+ * Snapping is deliberately NOT applied: a marker is a free annotation, and the
1157
+ * snap boundaries are clip and audio edges — the moments a marker most often
1158
+ * needs to sit slightly OFF. Nothing else in the strip has edges to snap to.
1159
+ *
1160
+ * GRAB-RELATIVE, not absolute. The hit region is a flat `MARKER_HIT_WIDTH_PX`
1161
+ * wide (hit-test.ts) so that the LABEL is clickable and not just the 2px flag
1162
+ * stem — which means a press very often lands well right of the flag itself.
1163
+ * Retiming to the raw pointer time would teleport the marker up to that full
1164
+ * width to meet the cursor on the first move past threshold. Every other drag
1165
+ * on this surface preserves its grab offset through `dragDeltaSeconds`, and so
1166
+ * does this one. `applyScrub` is absolute and is NOT a precedent for it: when
1167
+ * you scrub, the playhead IS the cursor, so there is no offset to preserve.
1168
+ */
1169
+ function applyMarkerMove(ctx: PointerContext, press: Press, point: Point, snap: SnapState, lastProject: Project): Applied {
1170
+ const id = press.hit.markerId
1171
+ const from = press.hit.marker
1172
+ if (!id || !from) return noChange(snap, lastProject)
1173
+ // Anchor on the marker the HIT captured, the way `applyAudioFade` reads
1174
+ // `press.hit.track` — `hitTest` took it from the same project `baseProject`
1175
+ // holds, at the same instant, so it is that project's own marker and needs no
1176
+ // per-move lookup. `HitResult.marker` exists for exactly this.
1177
+ const t = Math.max(0, from.t + dragDeltaSeconds(ctx, press, point))
1178
+ const next = moveMarker(press.baseProject, id, t)
1179
+ if (next === press.baseProject && lastProject === press.baseProject) return noChange(snap, lastProject)
1180
+ return { effects: [{ type: 'projectChange', project: next }], snap, lastProject: next, guide: null }
1181
+ }
1182
+
1127
1183
  /**
1128
1184
  * Drag a keyframe-strip diamond to retime it (plan decision 5).
1129
1185
  *
@@ -1480,6 +1536,7 @@ function applyGesture(
1480
1536
  case 'audio-trim': return applyAudioTrim(ctx, press, point, snap, lastProject, escaped)
1481
1537
  case 'audio-fade': return applyAudioFade(ctx, press, point, snap, lastProject)
1482
1538
  case 'keyframe-move': return applyKeyframeMove(ctx, press, point, snap, lastProject, escaped)
1539
+ case 'marker-move': return applyMarkerMove(ctx, press, point, snap, lastProject)
1483
1540
  case 'caption-move': return applyCaptionMove(ctx, press, point, snap, lastProject, escaped)
1484
1541
  case 'caption-trim': return applyCaptionTrim(ctx, press, point, snap, lastProject, escaped)
1485
1542
  case 'scrub': return applyScrub(ctx, point, snap, lastProject)
@@ -1571,7 +1628,9 @@ export function pointerReducer(state: MachineState, event: PointerMachineEvent):
1571
1628
  // gets keyframe hit-testing for free the moment it has a selection —
1572
1629
  // exactly like `ctx.selectedIds` itself is already required on the context,
1573
1630
  // not an opt-in.
1574
- const hit = hitTest(point, ctx.layout, ctx.viewport, { ...ctx.hitTestOptions, selectedIds: ctx.selectedIds })
1631
+ const hit = hitTest(point, ctx.layout, ctx.viewport, {
1632
+ ...ctx.hitTestOptions, selectedIds: ctx.selectedIds, markers: ctx.project.markers,
1633
+ })
1575
1634
 
1576
1635
  switch (event.type) {
1577
1636
  case 'pointerDown': {
@@ -1582,6 +1641,12 @@ export function pointerReducer(state: MachineState, event: PointerMachineEvent):
1582
1641
  // the full-height red bar anywhere it is reachable does the same thing
1583
1642
  // (`grabsPlayhead`). Landing the seek on mousedown rather than mouseup is
1584
1643
  // what makes the drag continuous.
1644
+ //
1645
+ // A MARKER hit must never reach this branch: it presses like an item, not
1646
+ // like the ruler. Two things keep it out, and both have to hold — `hitTest`
1647
+ // returns 'marker' (never 'ruler') for a point in the strip, and
1648
+ // `grabsPlayhead` excludes 'marker' explicitly. Change either and every
1649
+ // marker press starts scrubbing again.
1585
1650
  const onRuler = hit.kind === 'ruler'
1586
1651
  if (onRuler || grabsPlayhead(hit, point, ctx)) {
1587
1652
  const applied = applyScrub(ctx, point, createSnapState(), ctx.project)
@@ -1683,7 +1748,15 @@ export function pointerReducer(state: MachineState, event: PointerMachineEvent):
1683
1748
  if (state.kind === 'pressed') {
1684
1749
  const { hit: pressed, wasSelected } = state.press
1685
1750
  const effects: PointerEffect[] = []
1686
- if (pressed.itemId !== undefined) {
1751
+ if (pressed.kind === 'marker' && pressed.markerId !== undefined) {
1752
+ // A marker click selects it, exactly as a clip click does — same
1753
+ // `select` effect, same host selection, which is what lets Delete
1754
+ // reach it. Its id lives in `markerId` rather than `itemId` (a marker
1755
+ // is not an item), so it needs its own branch rather than falling
1756
+ // into the one below. No seek: the playhead belongs to the ruler,
1757
+ // and a marker press is aimed at the flag, not at the time under it.
1758
+ effects.push({ type: 'select', id: pressed.markerId, additive: isAdditive(modifiers) })
1759
+ } else if (pressed.itemId !== undefined) {
1687
1760
  const additive = isAdditive(modifiers)
1688
1761
  effects.push({ type: 'select', id: pressed.itemId, additive })
1689
1762
  // VisualTrackRow seeks on a plain click that changes the selection;
@@ -1802,6 +1875,12 @@ export function pointerReducer(state: MachineState, event: PointerMachineEvent):
1802
1875
  if (hit.kind === 'keyframe' && hit.itemId !== undefined && hit.kfT !== undefined) {
1803
1876
  return { state, effects: [{ type: 'selectKeyframe', itemId: hit.itemId, t: hit.kfT }] }
1804
1877
  }
1878
+ // A marker opens its rename box. Checked up here with the diamond for the
1879
+ // same reason: it carries no `itemId`, so the generic branches below
1880
+ // would drop it on the floor rather than swallow it.
1881
+ if (hit.kind === 'marker' && hit.markerId !== undefined) {
1882
+ return { state, effects: [{ type: 'editMarker', id: hit.markerId }] }
1883
+ }
1805
1884
  // Key the SELECTED element, and only when the click landed ON it. Checked
1806
1885
  // against `ctx.selectedIds` rather than "is there a selection" so a
1807
1886
  // double-click on a different clip never keys the selected one.