@bycrux/editor 1.0.2 → 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.
- package/package.json +2 -2
- package/src/ControlsInfoModal.tsx +1 -0
- package/src/engine/__tests__/eligibility.test.ts +97 -0
- package/src/engine/__tests__/engine.test.ts +47 -2
- package/src/engine/__tests__/scheduler.test.ts +385 -4
- package/src/engine/__tests__/source-host.test.ts +165 -0
- package/src/engine/eligibility.ts +37 -1
- package/src/engine/index.ts +165 -29
- package/src/engine/scheduler.ts +345 -23
- package/src/index.ts +8 -0
- package/src/schema.ts +41 -1
- package/src/video/VideoEditor.tsx +106 -10
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +64 -0
- package/src/video/__tests__/VideoEditor.test.tsx +169 -0
- package/src/video/__tests__/cuts.test.ts +84 -0
- package/src/video/__tests__/exportDurationSec.test.ts +60 -0
- package/src/video/__tests__/markerDropTime.test.ts +25 -0
- package/src/video/captionStyleDefaults.ts +2 -2
- package/src/video/cuts.ts +30 -2
- package/src/video/preview/OverlayItemsLayer.tsx +85 -6
- package/src/video/preview/PreviewPlayer.tsx +28 -1
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +91 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +49 -1
- package/src/video/preview/__tests__/useVideoPlayback.canvasClock.test.ts +99 -0
- package/src/video/preview/useVideoPlayback.ts +18 -3
- package/src/video/timeline/Timeline.tsx +49 -1
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +16 -0
- package/src/video/timeline/__tests__/markers.test.ts +125 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +307 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +100 -4
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +106 -2
- package/src/video/timeline/canvas/__tests__/draw.test.ts +73 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +76 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +216 -1
- package/src/video/timeline/canvas/draw.ts +104 -7
- package/src/video/timeline/canvas/hit-test.ts +72 -1
- package/src/video/timeline/canvas/pointer-machine.ts +142 -5
- package/src/video/timeline/markers.ts +109 -0
- package/src/video/timeline/timeline-model.ts +286 -15
|
@@ -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. */
|
|
@@ -673,10 +717,14 @@ export interface CaptionRowLayout {
|
|
|
673
717
|
}
|
|
674
718
|
|
|
675
719
|
export interface TimelineLayout {
|
|
676
|
-
/** The time ruler strip
|
|
677
|
-
*
|
|
678
|
-
*
|
|
720
|
+
/** The time ruler strip. Always present; carried in the layout rather than
|
|
721
|
+
* assumed so hit-testing and painting read the same rectangle, the same
|
|
722
|
+
* contract the rows have. Sits at y=0 when the project has no markers, and
|
|
723
|
+
* below the marker strip (see `MARKER_STRIP_HEIGHT_PX`) when it does. */
|
|
679
724
|
ruler: { y: number; height: number }
|
|
725
|
+
/** The marker strip above the ruler. Absent — not a zero-height rect — when
|
|
726
|
+
* the project has no markers; see `MARKER_STRIP_HEIGHT_PX`. */
|
|
727
|
+
markers?: { y: number; height: number }
|
|
680
728
|
/** Visual rows in DRAW order (top of the surface first). */
|
|
681
729
|
rows: VisualRowLayout[]
|
|
682
730
|
lanes: AudioLaneLayout[]
|
|
@@ -717,9 +765,13 @@ export function computeTimelineLayout(project: Project): TimelineLayout {
|
|
|
717
765
|
// legacy-shaped project has no settings, so every row is enabled.
|
|
718
766
|
const trackSettings = normalizeTracks(project).tracks ?? []
|
|
719
767
|
const rows: VisualRowLayout[] = []
|
|
720
|
-
// The
|
|
721
|
-
|
|
722
|
-
|
|
768
|
+
// The marker strip owns the very top when there is anything to put in it;
|
|
769
|
+
// the ruler starts below it, and every row below that.
|
|
770
|
+
const hasMarkers = (project.markers?.length ?? 0) > 0
|
|
771
|
+
const markers = hasMarkers ? { y: 0, height: MARKER_STRIP_HEIGHT_PX } : undefined
|
|
772
|
+
const stripHeight = hasMarkers ? MARKER_STRIP_HEIGHT_PX : 0
|
|
773
|
+
const ruler = { y: stripHeight, height: RULER_HEIGHT_PX }
|
|
774
|
+
let y = stripHeight + RULER_HEIGHT_PX + ROW_GAP_PX
|
|
723
775
|
|
|
724
776
|
// Only stored when there is something to show — an empty `segments` (or no
|
|
725
777
|
// `project.captions` at all) means no bands at all, not an empty array, so a
|
|
@@ -811,6 +863,7 @@ export function computeTimelineLayout(project: Project): TimelineLayout {
|
|
|
811
863
|
})
|
|
812
864
|
|
|
813
865
|
const layout: TimelineLayout = { ruler, rows, lanes, height: Math.max(0, y - ROW_GAP_PX) }
|
|
866
|
+
if (markers) layout.markers = markers
|
|
814
867
|
if (captions.length > 0) layout.captions = captions
|
|
815
868
|
return layout
|
|
816
869
|
}
|
|
@@ -1752,6 +1805,47 @@ export function drawRuler(
|
|
|
1752
1805
|
ctx.restore()
|
|
1753
1806
|
}
|
|
1754
1807
|
|
|
1808
|
+
/**
|
|
1809
|
+
* The operator's markers, as small flags along the top strip.
|
|
1810
|
+
*
|
|
1811
|
+
* Content-layer, not overlay: markers are project data that changes on an edit,
|
|
1812
|
+
* not per frame. A drag repaints the content layer per move exactly the way a
|
|
1813
|
+
* clip drag already does (`applyMove` emits `projectChange`, the host redraws
|
|
1814
|
+
* content) — there is no ghost, because there is no ghost for clips either.
|
|
1815
|
+
*
|
|
1816
|
+
* `markers` is expected sorted by `t` (the model guarantees it), so this walks
|
|
1817
|
+
* in draw order and a later marker's label overprints an earlier one's when
|
|
1818
|
+
* they crowd. That is the correct z-order: the one further right is the one
|
|
1819
|
+
* whose flag you can still see.
|
|
1820
|
+
*/
|
|
1821
|
+
export function drawMarkers(
|
|
1822
|
+
ctx: DrawContext,
|
|
1823
|
+
markers: readonly Marker[],
|
|
1824
|
+
viewport: Viewport,
|
|
1825
|
+
rect: { y: number; height: number },
|
|
1826
|
+
surfaceWidth: number,
|
|
1827
|
+
selectedIds: readonly string[],
|
|
1828
|
+
palette: TimelinePalette = DARK_TIMELINE_PALETTE,
|
|
1829
|
+
): void {
|
|
1830
|
+
if (markers.length === 0) return
|
|
1831
|
+
ctx.save()
|
|
1832
|
+
ctx.font = LABEL_FONT
|
|
1833
|
+
ctx.textBaseline = 'alphabetic'
|
|
1834
|
+
for (const m of markers) {
|
|
1835
|
+
const x = Math.round(timeToX(m.t, viewport)) + 0.5
|
|
1836
|
+
if (x < 0 || x > surfaceWidth) continue
|
|
1837
|
+
const selected = selectedIds.includes(m.id)
|
|
1838
|
+
ctx.fillStyle = selected ? palette.colors.markerFlagSelected : palette.colors.markerFlag
|
|
1839
|
+
ctx.fillRect(x, rect.y + 2, MARKER_FLAG_WIDTH_PX, rect.height - 4)
|
|
1840
|
+
ctx.fillStyle = selected ? palette.colors.markerFlagSelected : palette.colors.rulerText
|
|
1841
|
+
const label = m.label.length > MARKER_LABEL_MAX_CHARS
|
|
1842
|
+
? `${m.label.slice(0, MARKER_LABEL_MAX_CHARS - 1)}…`
|
|
1843
|
+
: m.label
|
|
1844
|
+
ctx.fillText(label, x + MARKER_FLAG_WIDTH_PX + MARKER_LABEL_GAP_PX, rect.y + MARKER_LABEL_BASELINE_PX)
|
|
1845
|
+
}
|
|
1846
|
+
ctx.restore()
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1755
1849
|
// ── Pending-drop ghost ───────────────────────────────────────────────────
|
|
1756
1850
|
|
|
1757
1851
|
/** Corner radius of a ghost band. The same 4px a clip uses — the band stands
|
|
@@ -1886,6 +1980,9 @@ export function drawTimelineContent(ctx: DrawContext, scene: TimelineScene): Dra
|
|
|
1886
1980
|
|
|
1887
1981
|
ctx.clearRect(0, 0, surfaceWidth, surfaceHeight)
|
|
1888
1982
|
|
|
1983
|
+
if (layout.markers) {
|
|
1984
|
+
drawMarkers(ctx, scene.project.markers ?? [], viewport, layout.markers, surfaceWidth, selectedIds, themePalette)
|
|
1985
|
+
}
|
|
1889
1986
|
drawRuler(ctx, viewport, layout.ruler, surfaceWidth, themePalette)
|
|
1890
1987
|
|
|
1891
1988
|
|
|
@@ -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'
|
|
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
|
/**
|
|
@@ -637,6 +663,23 @@ function adjacentOnTrack(project: Project, item: VisualItem): { prev?: VisualIte
|
|
|
637
663
|
}
|
|
638
664
|
}
|
|
639
665
|
|
|
666
|
+
/** `adjacentOnTrack`'s twin for trim's containment guard — the nearest item
|
|
667
|
+
* in either direction on `item`'s own track, WHETHER OR NOT it touches.
|
|
668
|
+
* Roll needs a shared boundary to move (that's what it's rolling); a trim's
|
|
669
|
+
* containment guard needs the nearest neighbour regardless, because the
|
|
670
|
+
* overlap it is bounding is exactly the case where the two are no longer
|
|
671
|
+
* touching. */
|
|
672
|
+
function neighboursOnTrack(project: Project, item: VisualItem): { prev?: VisualItem; next?: VisualItem } {
|
|
673
|
+
const track = trackItems(project).find(t => t.some(other => other.id === item.id))
|
|
674
|
+
if (!track) return {}
|
|
675
|
+
const sorted = [...track].sort((a, b) => a.start - b.start)
|
|
676
|
+
const pos = sorted.findIndex(other => other.id === item.id)
|
|
677
|
+
return {
|
|
678
|
+
prev: pos > 0 ? sorted[pos - 1] : undefined,
|
|
679
|
+
next: pos >= 0 && pos < sorted.length - 1 ? sorted[pos + 1] : undefined,
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
640
683
|
/** Where a guide line goes, and how hard the magnet holding it pulls. */
|
|
641
684
|
export interface SnapGuide {
|
|
642
685
|
time: number
|
|
@@ -854,7 +897,48 @@ function applyTrim(ctx: PointerContext, press: Press, point: Point, snap: SnapSt
|
|
|
854
897
|
itemSnapPoints(ctx, press, [fixedEdge, ...originGuard(escaped, [initTime])]),
|
|
855
898
|
ctx.viewport, snap, ctx.snapConfig,
|
|
856
899
|
)
|
|
857
|
-
|
|
900
|
+
|
|
901
|
+
// Overlay tracks (anything but tracks[0]) may trim an edge PAST a
|
|
902
|
+
// neighbour's near boundary — see transitions.js's header: a partial
|
|
903
|
+
// overlap is a transition, containment is not — but not far enough to
|
|
904
|
+
// contain the neighbour outright, which `engine/validate.py` rejects.
|
|
905
|
+
// tracks[0] gets none of this: its own overlap is never checked at all
|
|
906
|
+
// (primary clips render in `itsoffset` order), so its trim stays exactly
|
|
907
|
+
// as it was — bounded only by `computeResizedItem`'s own floor below.
|
|
908
|
+
// Clamped on the TARGET time, before `computeResizedItem` runs, so a
|
|
909
|
+
// clamp that lands short of the drag still produces a consistent
|
|
910
|
+
// inPoint/outPoint for video items via `resizeWindowedItem`, rather than
|
|
911
|
+
// trimming the span after the fact and leaving the source window stale.
|
|
912
|
+
let targetTime = snapped.time
|
|
913
|
+
if ((press.hit.trackIdx ?? 0) !== 0) {
|
|
914
|
+
const { prev, next } = neighboursOnTrack(press.baseProject, item)
|
|
915
|
+
if (edge === 'end' && next) {
|
|
916
|
+
targetTime = Math.min(targetTime, next.end - EPSILON)
|
|
917
|
+
// `next` may ALREADY overlap its own next neighbour (a transition that
|
|
918
|
+
// predates this drag). The clamp above only keeps this item from
|
|
919
|
+
// containing `next` outright — it says nothing about that second
|
|
920
|
+
// overlap, so extending into `next` can still land inside next∩afterNext
|
|
921
|
+
// and put three items live at the same instant, a shape the two-item
|
|
922
|
+
// containment guard allows but `engine/validate.py` rejects. Clamp
|
|
923
|
+
// short of that second neighbour's start whenever the overlap exists.
|
|
924
|
+
const { next: afterNext } = neighboursOnTrack(press.baseProject, next)
|
|
925
|
+
if (afterNext && afterNext.start < next.end) {
|
|
926
|
+
targetTime = Math.min(targetTime, afterNext.start - EPSILON)
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
if (edge === 'start' && prev) {
|
|
930
|
+
targetTime = Math.max(targetTime, prev.start + EPSILON)
|
|
931
|
+
// Mirror of the `end`-edge guard above: `prev` may already overlap ITS
|
|
932
|
+
// own previous neighbour, and shrinking this item's start into `prev`
|
|
933
|
+
// can land inside beforePrev∩prev, making three items live at once.
|
|
934
|
+
const { prev: beforePrev } = neighboursOnTrack(press.baseProject, prev)
|
|
935
|
+
if (beforePrev && beforePrev.end > prev.start) {
|
|
936
|
+
targetTime = Math.max(targetTime, beforePrev.end + EPSILON)
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const resized = computeResizedItem(item as Draggable, edge, targetTime)
|
|
858
942
|
|
|
859
943
|
// Trims always rebuild from the pressed-at project, never from the running
|
|
860
944
|
// preview — otherwise ripple's gap collapse would compound move by move.
|
|
@@ -1066,6 +1150,36 @@ function applyAudioFade(ctx: PointerContext, press: Press, point: Point, snap: S
|
|
|
1066
1150
|
return { effects: [{ type: 'projectChange', project: next }], snap, lastProject: next, guide: null }
|
|
1067
1151
|
}
|
|
1068
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
|
+
|
|
1069
1183
|
/**
|
|
1070
1184
|
* Drag a keyframe-strip diamond to retime it (plan decision 5).
|
|
1071
1185
|
*
|
|
@@ -1422,6 +1536,7 @@ function applyGesture(
|
|
|
1422
1536
|
case 'audio-trim': return applyAudioTrim(ctx, press, point, snap, lastProject, escaped)
|
|
1423
1537
|
case 'audio-fade': return applyAudioFade(ctx, press, point, snap, lastProject)
|
|
1424
1538
|
case 'keyframe-move': return applyKeyframeMove(ctx, press, point, snap, lastProject, escaped)
|
|
1539
|
+
case 'marker-move': return applyMarkerMove(ctx, press, point, snap, lastProject)
|
|
1425
1540
|
case 'caption-move': return applyCaptionMove(ctx, press, point, snap, lastProject, escaped)
|
|
1426
1541
|
case 'caption-trim': return applyCaptionTrim(ctx, press, point, snap, lastProject, escaped)
|
|
1427
1542
|
case 'scrub': return applyScrub(ctx, point, snap, lastProject)
|
|
@@ -1513,7 +1628,9 @@ export function pointerReducer(state: MachineState, event: PointerMachineEvent):
|
|
|
1513
1628
|
// gets keyframe hit-testing for free the moment it has a selection —
|
|
1514
1629
|
// exactly like `ctx.selectedIds` itself is already required on the context,
|
|
1515
1630
|
// not an opt-in.
|
|
1516
|
-
const hit = hitTest(point, ctx.layout, ctx.viewport, {
|
|
1631
|
+
const hit = hitTest(point, ctx.layout, ctx.viewport, {
|
|
1632
|
+
...ctx.hitTestOptions, selectedIds: ctx.selectedIds, markers: ctx.project.markers,
|
|
1633
|
+
})
|
|
1517
1634
|
|
|
1518
1635
|
switch (event.type) {
|
|
1519
1636
|
case 'pointerDown': {
|
|
@@ -1524,6 +1641,12 @@ export function pointerReducer(state: MachineState, event: PointerMachineEvent):
|
|
|
1524
1641
|
// the full-height red bar anywhere it is reachable does the same thing
|
|
1525
1642
|
// (`grabsPlayhead`). Landing the seek on mousedown rather than mouseup is
|
|
1526
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.
|
|
1527
1650
|
const onRuler = hit.kind === 'ruler'
|
|
1528
1651
|
if (onRuler || grabsPlayhead(hit, point, ctx)) {
|
|
1529
1652
|
const applied = applyScrub(ctx, point, createSnapState(), ctx.project)
|
|
@@ -1625,7 +1748,15 @@ export function pointerReducer(state: MachineState, event: PointerMachineEvent):
|
|
|
1625
1748
|
if (state.kind === 'pressed') {
|
|
1626
1749
|
const { hit: pressed, wasSelected } = state.press
|
|
1627
1750
|
const effects: PointerEffect[] = []
|
|
1628
|
-
if (pressed.
|
|
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) {
|
|
1629
1760
|
const additive = isAdditive(modifiers)
|
|
1630
1761
|
effects.push({ type: 'select', id: pressed.itemId, additive })
|
|
1631
1762
|
// VisualTrackRow seeks on a plain click that changes the selection;
|
|
@@ -1744,6 +1875,12 @@ export function pointerReducer(state: MachineState, event: PointerMachineEvent):
|
|
|
1744
1875
|
if (hit.kind === 'keyframe' && hit.itemId !== undefined && hit.kfT !== undefined) {
|
|
1745
1876
|
return { state, effects: [{ type: 'selectKeyframe', itemId: hit.itemId, t: hit.kfT }] }
|
|
1746
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
|
+
}
|
|
1747
1884
|
// Key the SELECTED element, and only when the click landed ON it. Checked
|
|
1748
1885
|
// against `ctx.selectedIds` rather than "is there a selection" so a
|
|
1749
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
|
+
}
|