@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.
@@ -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.
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The marker model — every mutation markers can undergo, as pure functions.
3
+ *
4
+ * Same contract as the rest of the timeline model: a function returns the SAME
5
+ * project reference when it would change nothing, so callers can use `next ===
6
+ * base` as their no-op guard and never push an empty undo entry or queue a
7
+ * pointless save (the convention `splitAtTime`, `computeAutoCrossfade` and
8
+ * `normalizeCaptionLanes` all follow).
9
+ *
10
+ * Markers are stored SORTED BY TIME. Nothing downstream re-sorts: the painter
11
+ * walks the array in order, and `serve/context.py` hands it to the agent as-is.
12
+ * Sorting on write rather than on read means one rule in one place.
13
+ */
14
+ import type { EditorProject, Marker } from '../../schema'
15
+
16
+ /** Fallback frame rate when a project's settings omit one — matches the fps
17
+ * default `serve/context.py` and the renderer both use. */
18
+ const DEFAULT_FPS = 30
19
+
20
+ /** Fresh marker ids. Same shape as `cuts.ts`'s `uniqueId`: a time base plus
21
+ * randomness, so two markers dropped in the same millisecond still differ. */
22
+ function markerId(): string {
23
+ return `mk-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
24
+ }
25
+
26
+ /**
27
+ * The label a new marker gets: one past the highest PURELY NUMERIC label in
28
+ * use.
29
+ *
30
+ * Reading the max rather than counting the array is what stops a delete from
31
+ * handing out a number twice — delete "2" of 1/2/3 and the next marker must be
32
+ * "4", not "3". A renamed marker ("cut this") contributes nothing, so renaming
33
+ * never stalls or rewinds the counter.
34
+ */
35
+ export function nextMarkerLabel(markers: readonly Marker[]): string {
36
+ let max = 0
37
+ for (const m of markers) {
38
+ // `Number('')` is 0 and `Number('3px')` is NaN — require an all-digits
39
+ // label so neither can be mistaken for a counter value.
40
+ if (!/^\d+$/.test(m.label)) continue
41
+ const n = Number(m.label)
42
+ if (n > max) max = n
43
+ }
44
+ return String(max + 1)
45
+ }
46
+
47
+ const byTime = (a: Marker, b: Marker) => a.t - b.t
48
+
49
+ /** Write a marker list back, dropping the key entirely when it empties. */
50
+ function withMarkers(project: EditorProject, markers: Marker[]): EditorProject {
51
+ if (markers.length === 0) {
52
+ const { markers: _dropped, ...rest } = project
53
+ return rest as EditorProject
54
+ }
55
+ return { ...project, markers }
56
+ }
57
+
58
+ /**
59
+ * Drop a marker at `t`.
60
+ *
61
+ * Returns the same project when one already sits within HALF A FRAME: holding
62
+ * `M` down fires key repeat at the OS's repeat rate, and without this a held
63
+ * key buries the strip in stacked markers that then have to be deleted one by
64
+ * one. Half a frame is below the resolution the timeline can even distinguish,
65
+ * so nothing an operator could deliberately place is refused.
66
+ */
67
+ export function addMarker(project: EditorProject, t: number, fps = project.settings?.fps ?? DEFAULT_FPS): EditorProject {
68
+ const at = Math.max(0, t)
69
+ const existing = project.markers ?? []
70
+ const halfFrame = 0.5 / (fps > 0 ? fps : DEFAULT_FPS)
71
+ if (existing.some(m => Math.abs(m.t - at) < halfFrame)) return project
72
+ const next = [...existing, { id: markerId(), t: at, label: nextMarkerLabel(existing) }].sort(byTime)
73
+ return { ...project, markers: next }
74
+ }
75
+
76
+ /** Retime a marker. Same reference for an unknown id or an unchanged time. */
77
+ export function moveMarker(project: EditorProject, id: string, t: number): EditorProject {
78
+ const existing = project.markers
79
+ if (!existing) return project
80
+ const at = Math.max(0, t)
81
+ const found = existing.find(m => m.id === id)
82
+ if (!found || found.t === at) return project
83
+ return withMarkers(project, existing.map(m => (m.id === id ? { ...m, t: at } : m)).sort(byTime))
84
+ }
85
+
86
+ /**
87
+ * Rename a marker. The label is trimmed, and an all-whitespace one is refused
88
+ * rather than committed — a blank marker draws as an empty box and tells the
89
+ * agent nothing, so a cleared rename box means "leave it alone", not "erase the
90
+ * name". Deleting the marker is the way to get rid of it.
91
+ */
92
+ export function renameMarker(project: EditorProject, id: string, label: string): EditorProject {
93
+ const existing = project.markers
94
+ if (!existing) return project
95
+ const next = label.trim()
96
+ if (!next) return project
97
+ const found = existing.find(m => m.id === id)
98
+ if (!found || found.label === next) return project
99
+ return withMarkers(project, existing.map(m => (m.id === id ? { ...m, label: next } : m)))
100
+ }
101
+
102
+ /** Remove every marker whose id is in `ids`. Same reference when none matched. */
103
+ export function removeMarkers(project: EditorProject, ids: ReadonlySet<string>): EditorProject {
104
+ const existing = project.markers
105
+ if (!existing || existing.length === 0) return project
106
+ const kept = existing.filter(m => !ids.has(m.id))
107
+ if (kept.length === existing.length) return project
108
+ return withMarkers(project, kept)
109
+ }