@keplar-404/react-timeline-editor 1.0.6

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 (73) hide show
  1. package/dist/components/control_area/index.d.ts +0 -0
  2. package/dist/components/cursor/cursor.d.ts +20 -0
  3. package/dist/components/cut-overlay/CutOverlay.d.ts +202 -0
  4. package/dist/components/edit_area/cross_row_drag.d.ts +50 -0
  5. package/dist/components/edit_area/drag_lines.d.ts +11 -0
  6. package/dist/components/edit_area/drag_preview.d.ts +14 -0
  7. package/dist/components/edit_area/drag_utils.d.ts +39 -0
  8. package/dist/components/edit_area/edit_action.d.ts +19 -0
  9. package/dist/components/edit_area/edit_area.d.ts +56 -0
  10. package/dist/components/edit_area/edit_row.d.ts +27 -0
  11. package/dist/components/edit_area/hooks/use_drag_line.d.ts +33 -0
  12. package/dist/components/edit_area/insertion_line.d.ts +12 -0
  13. package/dist/components/loop-zone/LoopZoneOverlay.d.ts +243 -0
  14. package/dist/components/row_rnd/hooks/useAutoScroll.d.ts +7 -0
  15. package/dist/components/row_rnd/interactable.d.ts +14 -0
  16. package/dist/components/row_rnd/row_rnd.d.ts +3 -0
  17. package/dist/components/row_rnd/row_rnd_interface.d.ts +47 -0
  18. package/dist/components/time_area/time_area.d.ts +17 -0
  19. package/dist/components/timeline.d.ts +3 -0
  20. package/dist/components/transport/TransportBar.d.ts +132 -0
  21. package/dist/components/transport/useTimelinePlayer.d.ts +164 -0
  22. package/dist/index.cjs.js +13 -0
  23. package/dist/index.d.ts +12 -0
  24. package/dist/index.es.js +10102 -0
  25. package/dist/index.umd.js +13 -0
  26. package/dist/interface/common_prop.d.ts +12 -0
  27. package/dist/interface/const.d.ts +28 -0
  28. package/dist/interface/timeline.d.ts +342 -0
  29. package/dist/react-timeline-editor.css +1 -0
  30. package/dist/utils/check_props.d.ts +2 -0
  31. package/dist/utils/deal_class_prefix.d.ts +1 -0
  32. package/dist/utils/deal_data.d.ts +58 -0
  33. package/dist/utils/logger.d.ts +132 -0
  34. package/package.json +70 -0
  35. package/src/components/control_area/index.tsx +1 -0
  36. package/src/components/cursor/cursor.css +26 -0
  37. package/src/components/cursor/cursor.tsx +105 -0
  38. package/src/components/cut-overlay/CutOverlay.css +68 -0
  39. package/src/components/cut-overlay/CutOverlay.tsx +491 -0
  40. package/src/components/edit_area/cross_row_drag.tsx +174 -0
  41. package/src/components/edit_area/drag_lines.css +13 -0
  42. package/src/components/edit_area/drag_lines.tsx +31 -0
  43. package/src/components/edit_area/drag_preview.tsx +50 -0
  44. package/src/components/edit_area/drag_utils.ts +77 -0
  45. package/src/components/edit_area/edit_action.css +56 -0
  46. package/src/components/edit_area/edit_action.tsx +362 -0
  47. package/src/components/edit_area/edit_area.css +24 -0
  48. package/src/components/edit_area/edit_area.tsx +606 -0
  49. package/src/components/edit_area/edit_row.css +78 -0
  50. package/src/components/edit_area/edit_row.tsx +128 -0
  51. package/src/components/edit_area/hooks/use_drag_line.ts +93 -0
  52. package/src/components/edit_area/insertion_line.tsx +39 -0
  53. package/src/components/loop-zone/LoopZoneOverlay.css +65 -0
  54. package/src/components/loop-zone/LoopZoneOverlay.tsx +461 -0
  55. package/src/components/row_rnd/hooks/useAutoScroll.ts +81 -0
  56. package/src/components/row_rnd/interactable.tsx +55 -0
  57. package/src/components/row_rnd/row_rnd.tsx +365 -0
  58. package/src/components/row_rnd/row_rnd_interface.ts +59 -0
  59. package/src/components/time_area/time_area.css +35 -0
  60. package/src/components/time_area/time_area.tsx +93 -0
  61. package/src/components/timeline.css +12 -0
  62. package/src/components/timeline.tsx +227 -0
  63. package/src/components/transport/TransportBar.css +171 -0
  64. package/src/components/transport/TransportBar.tsx +322 -0
  65. package/src/components/transport/useTimelinePlayer.ts +319 -0
  66. package/src/index.tsx +17 -0
  67. package/src/interface/common_prop.ts +13 -0
  68. package/src/interface/const.ts +32 -0
  69. package/src/interface/timeline.ts +329 -0
  70. package/src/utils/check_props.ts +77 -0
  71. package/src/utils/deal_class_prefix.ts +6 -0
  72. package/src/utils/deal_data.ts +159 -0
  73. package/src/utils/logger.ts +239 -0
@@ -0,0 +1,26 @@
1
+ .timeline-editor-cursor {
2
+ cursor: ew-resize;
3
+ position: absolute;
4
+ top: 32px;
5
+ height: calc(100% - 32px);
6
+ box-sizing: border-box;
7
+ border-left: 1px solid #5297FF;
8
+ border-right: 1px solid #5297FF;
9
+ transform: translateX(-25%) scaleX(0.5);
10
+ }
11
+ .timeline-editor-cursor-top {
12
+ position: absolute;
13
+ top: 0;
14
+ left: 50%;
15
+ transform: translate(-50%, 0) scaleX(2);
16
+ margin: auto;
17
+ }
18
+ .timeline-editor-cursor-area {
19
+ width: 16px;
20
+ height: 100%;
21
+ cursor: ew-resize;
22
+ position: absolute;
23
+ top: 0;
24
+ left: 50%;
25
+ transform: translateX(-50%);
26
+ }
@@ -0,0 +1,105 @@
1
+ import React, { FC, useEffect, useRef } from 'react';
2
+ import { ScrollSync } from 'react-virtualized';
3
+ import { CommonProp } from '../../interface/common_prop';
4
+ import { prefix } from '../../utils/deal_class_prefix';
5
+ import { parserPixelToTime, parserTimeToPixel } from '../../utils/deal_data';
6
+ import { RowDnd } from '../row_rnd/row_rnd';
7
+ import { RowRndApi } from '../row_rnd/row_rnd_interface';
8
+ import './cursor.css';
9
+
10
+ /** Animation timeline component parameters */
11
+ export type CursorProps = CommonProp & {
12
+ /** Scroll distance from left */
13
+ scrollLeft: number;
14
+ /** Set cursor position */
15
+ setCursor: (param: { left?: number; time?: number }) => boolean;
16
+ /** Timeline area DOM ref */
17
+ areaRef: React.RefObject<HTMLDivElement>;
18
+ /** Set scroll left */
19
+ deltaScrollLeft?: (delta: number) => void;
20
+ /** Scroll sync ref (TODO: This data is used to temporarily solve the out-of-sync issue when scrollLeft is dragged) */
21
+ scrollSync: React.RefObject<ScrollSync>;
22
+ };
23
+
24
+ export const Cursor: FC<CursorProps> = ({
25
+ disableDrag,
26
+ cursorTime,
27
+ setCursor,
28
+ startLeft,
29
+ timelineWidth,
30
+ scaleWidth,
31
+ scale,
32
+ scrollLeft,
33
+ scrollSync,
34
+ areaRef,
35
+ maxScaleCount,
36
+ deltaScrollLeft,
37
+ onCursorDragStart,
38
+ onCursorDrag,
39
+ onCursorDragEnd,
40
+ }) => {
41
+ const rowRnd = useRef<RowRndApi>(null);
42
+ const draggingLeft = useRef<undefined | number>();
43
+
44
+ useEffect(() => {
45
+ if (typeof draggingLeft.current === 'undefined') {
46
+ // When not dragging, update cursor scale according to parameters
47
+ rowRnd.current?.updateLeft(parserTimeToPixel(cursorTime, { startLeft, scaleWidth, scale }) - scrollLeft);
48
+ }
49
+ }, [cursorTime, startLeft, scaleWidth, scale, scrollLeft]);
50
+
51
+ return (
52
+ <RowDnd
53
+ start={startLeft}
54
+ ref={rowRnd}
55
+ parentRef={areaRef}
56
+ bounds={{
57
+ left: 0,
58
+ right: Math.min(timelineWidth, maxScaleCount * scaleWidth + startLeft - scrollLeft),
59
+ }}
60
+ deltaScrollLeft={deltaScrollLeft}
61
+ enableDragging={!disableDrag}
62
+ enableResizing={false}
63
+ onDragStart={() => {
64
+ onCursorDragStart && onCursorDragStart(cursorTime);
65
+ draggingLeft.current = parserTimeToPixel(cursorTime, { startLeft, scaleWidth, scale }) - scrollLeft;
66
+ rowRnd.current?.updateLeft(draggingLeft.current);
67
+ }}
68
+ onDragEnd={() => {
69
+ const time = parserPixelToTime((draggingLeft.current || 0) + scrollLeft, { startLeft, scale, scaleWidth });
70
+ setCursor({ time });
71
+ onCursorDragEnd && onCursorDragEnd(time);
72
+ draggingLeft.current = undefined;
73
+ }}
74
+ onDrag={({ left }, scroll = 0) => {
75
+ const scrollLeft = scrollSync.current?.state.scrollLeft || 0;
76
+
77
+ if (!scroll || scrollLeft === 0) {
78
+ // When dragging, if current left < min left, set value to min left
79
+ if (left < startLeft - scrollLeft) draggingLeft.current = startLeft - scrollLeft;
80
+ else draggingLeft.current = left;
81
+ } else {
82
+ // During auto-scrolling, if current left < min left, set value to min left
83
+ if ((draggingLeft.current || 0) < startLeft - scrollLeft - scroll) {
84
+ draggingLeft.current = startLeft - scrollLeft - scroll;
85
+ }
86
+ }
87
+ rowRnd.current?.updateLeft(draggingLeft.current || 0);
88
+ const time = parserPixelToTime((draggingLeft.current || 0) + scrollLeft, { startLeft, scale, scaleWidth });
89
+ setCursor({ time });
90
+ onCursorDrag && onCursorDrag(time);
91
+ return false;
92
+ }}
93
+ >
94
+ <div className={prefix('cursor')}>
95
+ <svg className={prefix('cursor-top')} width="8" height="12" viewBox="0 0 8 12" fill="none">
96
+ <path
97
+ d="M0 1C0 0.447715 0.447715 0 1 0H7C7.55228 0 8 0.447715 8 1V9.38197C8 9.76074 7.786 10.107 7.44721 10.2764L4.44721 11.7764C4.16569 11.9172 3.83431 11.9172 3.55279 11.7764L0.552786 10.2764C0.214002 10.107 0 9.76074 0 9.38197V1Z"
98
+ fill="#5297FF"
99
+ />
100
+ </svg>
101
+ <div className={prefix('cursor-area')} />
102
+ </div>
103
+ </RowDnd>
104
+ );
105
+ };
@@ -0,0 +1,68 @@
1
+ /* ──────────────────────────────────────────
2
+ Cut Overlay — Structural Styles
3
+ (Colors are controlled via inline styles from CutOverlayConfig)
4
+ ────────────────────────────────────────── */
5
+ .cut-overlay {
6
+ position: absolute;
7
+ inset: 0;
8
+ z-index: 20;
9
+ pointer-events: auto;
10
+ user-select: none;
11
+ }
12
+ /* Block clip box: sized exactly to the action block.
13
+ background and border are injected inline via config. */
14
+ .cut-block-clip {
15
+ position: absolute;
16
+ pointer-events: none;
17
+ border-radius: 3px;
18
+ box-sizing: border-box;
19
+ overflow: visible;
20
+ }
21
+ /* Vertical blade line */
22
+ .cut-blade {
23
+ position: absolute;
24
+ top: 0;
25
+ width: 2px;
26
+ height: 100%;
27
+ transform: translateX(-50%);
28
+ border-radius: 1px;
29
+ pointer-events: none;
30
+ z-index: 2;
31
+ animation: cut-blade-in 0.1s ease-out;
32
+ }
33
+ @keyframes cut-blade-in {
34
+ from {
35
+ opacity: 0;
36
+ transform: translateX(-50%) scaleY(0.3);
37
+ }
38
+ to {
39
+ opacity: 1;
40
+ transform: translateX(-50%) scaleY(1);
41
+ }
42
+ }
43
+ /* Floating time pill above the block */
44
+ .cut-pill {
45
+ position: absolute;
46
+ bottom: calc(100% + 5px);
47
+ transform: translateX(-50%);
48
+ font-size: 10px;
49
+ font-weight: 700;
50
+ font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
51
+ letter-spacing: 0.04em;
52
+ padding: 2px 7px;
53
+ border-radius: 4px;
54
+ white-space: nowrap;
55
+ pointer-events: none;
56
+ animation: cut-pill-in 0.1s ease-out;
57
+ z-index: 25;
58
+ }
59
+ @keyframes cut-pill-in {
60
+ from {
61
+ opacity: 0;
62
+ transform: translateX(-50%) translateY(4px);
63
+ }
64
+ to {
65
+ opacity: 1;
66
+ transform: translateX(-50%) translateY(0);
67
+ }
68
+ }
@@ -0,0 +1,491 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { TimelineAction, TimelineRow } from '@keplar-404/timeline-engine';
3
+ import './CutOverlay.css';
4
+
5
+ // ─────────────────────────────────────────────
6
+ // Helpers
7
+ // ─────────────────────────────────────────────
8
+
9
+ function pixelToTime(px: number, startLeft: number, scale: number, scaleWidth: number): number {
10
+ return ((px - startLeft) / scaleWidth) * scale;
11
+ }
12
+
13
+ function timeToPixel(time: number, startLeft: number, scale: number, scaleWidth: number, scrollLeft: number): number {
14
+ return startLeft + (time / scale) * scaleWidth - scrollLeft;
15
+ }
16
+
17
+ function snapToGrid(time: number, scale: number, scaleSplitCount: number): number {
18
+ const unit = scale / scaleSplitCount;
19
+ return Math.round(time / unit) * unit;
20
+ }
21
+
22
+ // ─────────────────────────────────────────────
23
+ // Public Types
24
+ // ─────────────────────────────────────────────
25
+
26
+ /**
27
+ * Visual and behavioral configuration for the {@link CutOverlay} component.
28
+ * All properties are optional — defaults produce a red-themed blade.
29
+ *
30
+ * @example
31
+ * ```tsx
32
+ * const cutConfig: CutOverlayConfig = {
33
+ * bladeColor: '#3b82f6',
34
+ * showPill: true,
35
+ * formatPillLabel: (t) => `Split at ${t.toFixed(3)}s`,
36
+ * showBlockHighlight: true,
37
+ * blockHighlightColor: 'rgba(59,130,246,0.12)',
38
+ * };
39
+ * <CutOverlay config={cutConfig} ... />
40
+ * ```
41
+ */
42
+ export interface CutOverlayConfig {
43
+ // ── Blade ──────────────────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * CSS color of the vertical blade line.
47
+ * @default '#ef4444'
48
+ */
49
+ bladeColor?: string;
50
+
51
+ // ── Pill ───────────────────────────────────────────────────────────────────
52
+
53
+ /**
54
+ * Whether to display the floating time-label pill above the blade.
55
+ * Set to `false` to completely hide the pill.
56
+ * @default true
57
+ */
58
+ showPill?: boolean;
59
+
60
+ /**
61
+ * Custom formatter for the time label displayed inside the pill.
62
+ * Receives the cut time in **seconds** and should return a string.
63
+ *
64
+ * @param time - Cut time value in seconds.
65
+ * @returns The string to render inside the pill.
66
+ * @default (t) => `✂ ${t.toFixed(2)}s`
67
+ *
68
+ * @example
69
+ * ```tsx
70
+ * formatPillLabel={(t) => `Cut @ ${(t * 1000).toFixed(0)} ms`}
71
+ * ```
72
+ */
73
+ formatPillLabel?: (time: number) => string;
74
+
75
+ /**
76
+ * Background color of the pill label.
77
+ * @default '#ef4444'
78
+ */
79
+ pillColor?: string;
80
+
81
+ /**
82
+ * Text color of the pill label.
83
+ * @default '#ffffff'
84
+ */
85
+ pillTextColor?: string;
86
+
87
+ // ── Block highlight ────────────────────────────────────────────────────────
88
+
89
+ /**
90
+ * Whether to show the translucent highlight on the hovered action block.
91
+ * Set to `false` to remove all background and border from the clip container.
92
+ * @default true
93
+ */
94
+ showBlockHighlight?: boolean;
95
+
96
+ /**
97
+ * Background fill color for the block highlight.
98
+ * Accepts any valid CSS color value (e.g. `'rgba(99,102,241,0.15)'` or `'transparent'`).
99
+ * Only takes effect when `showBlockHighlight` is `true`.
100
+ * @default 'rgba(239,68,68,0.08)'
101
+ */
102
+ blockHighlightColor?: string;
103
+
104
+ /**
105
+ * Border color for the block highlight container.
106
+ * Only takes effect when `showBlockHighlight` is `true`.
107
+ * @default 'rgba(239,68,68,0.3)'
108
+ */
109
+ blockHighlightBorderColor?: string;
110
+
111
+ // ── Interactivity ──────────────────────────────────────────────────────────
112
+
113
+ /**
114
+ * Custom CSS cursor to display when hovering over the active cut overlay.
115
+ * Standard CSS cursors like 'crosshair', 'copy', or 'default' are supported.
116
+ * @default 'col-resize'
117
+ */
118
+ cursor?: string;
119
+
120
+ /**
121
+ * A specific keyboard key that must be held down to activate the cut blade.
122
+ * When defined, standard timeline interactions (dragging, selecting) pass through
123
+ * normally until this precise key is physically held down.
124
+ *
125
+ * Supports standard modifier keys ('Alt', 'Control', 'Shift', 'Meta') or any
126
+ * exact literal character key (e.g. 'c' or 'x').
127
+ *
128
+ * Note: The `string & {}` union hack preserves autocomplete for common keys
129
+ * while allowing custom exact string matches.
130
+ */
131
+ keyboardModifier?: 'Alt' | 'Control' | 'Shift' | 'Meta' | (string & {});
132
+ }
133
+
134
+ /**
135
+ * Props for the {@link CutOverlay} component.
136
+ *
137
+ * Mount this absolutely inside the same container as your `<Timeline>` to
138
+ * overlay a blade-cut interaction on top of the timeline's edit area.
139
+ */
140
+ export interface CutOverlayProps {
141
+ // ── Data ───────────────────────────────────────────────────────────────────
142
+
143
+ /**
144
+ * The current timeline row data — same array you pass to `<Timeline editorData>`.
145
+ * Used to hit-test which action block the cursor is over.
146
+ */
147
+ data: TimelineRow[];
148
+
149
+ // ── Timeline geometry (must match <Timeline> props) ────────────────────────
150
+
151
+ /**
152
+ * Duration represented by one scale unit (seconds).
153
+ * Must match the `scale` prop on `<Timeline>`.
154
+ * @default 1
155
+ */
156
+ scale: number;
157
+
158
+ /**
159
+ * Number of sub-divisions per scale unit used for grid snapping.
160
+ * Must match the `scaleSplitCount` prop on `<Timeline>`.
161
+ * @default 10
162
+ */
163
+ scaleSplitCount: number;
164
+
165
+ /**
166
+ * Width in pixels of one scale unit.
167
+ * Must match the `scaleWidth` prop on `<Timeline>`.
168
+ * @default 160
169
+ */
170
+ scaleWidth: number;
171
+
172
+ /**
173
+ * Left inset in pixels before the first scale mark begins.
174
+ * Must match the `startLeft` prop on `<Timeline>`.
175
+ * @default 20
176
+ */
177
+ startLeft: number;
178
+
179
+ /**
180
+ * Height in pixels of each row in the edit area.
181
+ * Must match the `rowHeight` prop on `<Timeline>`.
182
+ * @default 32
183
+ */
184
+ rowHeight: number;
185
+
186
+ /**
187
+ * Height in pixels of the time-ruler strip that sits above the edit rows.
188
+ * This is subtracted from the mouse Y coordinate to find the correct row index.
189
+ * The default timeline ruler is **32 px** tall, but adjust if your layout differs.
190
+ * @default 32
191
+ */
192
+ editAreaTopOffset: number;
193
+
194
+ // ── Behaviour ──────────────────────────────────────────────────────────────
195
+
196
+ /**
197
+ * When `true` (and `<Timeline gridSnap>` is also enabled), the cut point
198
+ * snaps to the nearest grid boundary as the cursor moves.
199
+ * @default false
200
+ */
201
+ gridSnap: boolean;
202
+
203
+ // ── Visual configuration ───────────────────────────────────────────────────
204
+
205
+ /**
206
+ * Fine-grained control over the blade, pill, and block-highlight visuals.
207
+ * All properties are optional and fall back to red-themed defaults.
208
+ * @see {@link CutOverlayConfig}
209
+ */
210
+ config?: CutOverlayConfig;
211
+
212
+ // ── Callbacks ──────────────────────────────────────────────────────────────
213
+
214
+ /**
215
+ * Called when the user clicks while the blade is positioned over an action block.
216
+ *
217
+ * @param rowId - `id` of the row that contains the cut action.
218
+ * @param actionId - `id` of the action block being cut.
219
+ * @param cutTime - The time value (in seconds) at the cut point.
220
+ */
221
+ onCut: (rowId: string, actionId: string, cutTime: number) => void;
222
+
223
+ /**
224
+ * Called whenever the keyboard modifier state changes.
225
+ * Fires with `true` when the modifier is pressed (blade active)
226
+ * and `false` when released (blade inactive, pointer events pass through).
227
+ *
228
+ * Use this to dynamically control `disableDrag` on the sibling `<Timeline>`:
229
+ * when the modifier is held → disable drag; when released → re-enable.
230
+ *
231
+ * Not called when no `keyboardModifier` is configured — in that case
232
+ * the blade is always active and drag should always be disabled.
233
+ *
234
+ * @param held - Whether the modifier key is currently pressed.
235
+ */
236
+ onModifierChange?: (held: boolean) => void;
237
+ }
238
+
239
+ // ─────────────────────────────────────────────
240
+ // Internal state
241
+ // ─────────────────────────────────────────────
242
+
243
+ interface BladeState {
244
+ time: number;
245
+ bladeX: number;
246
+ blockLeft: number;
247
+ blockWidth: number;
248
+ rowTop: number;
249
+ row: TimelineRow;
250
+ action: TimelineAction;
251
+ }
252
+
253
+ // ─────────────────────────────────────────────
254
+ // Defaults
255
+ // ─────────────────────────────────────────────
256
+
257
+ const DEFAULT_CONFIG: Omit<Required<CutOverlayConfig>, 'keyboardModifier'> & { keyboardModifier?: CutOverlayConfig['keyboardModifier'] } = {
258
+ bladeColor: '#ef4444',
259
+ showPill: true,
260
+ formatPillLabel: (t) => `✂ ${t.toFixed(2)}s`,
261
+ pillColor: '#ef4444',
262
+ pillTextColor: '#ffffff',
263
+ showBlockHighlight: true,
264
+ blockHighlightColor: 'rgba(239,68,68,0.08)',
265
+ blockHighlightBorderColor: 'rgba(239,68,68,0.3)',
266
+ cursor: 'col-resize',
267
+ };
268
+
269
+ // ─────────────────────────────────────────────
270
+ // Component
271
+ // ─────────────────────────────────────────────
272
+
273
+ /**
274
+ * `CutOverlay` adds a blade-cut interaction on top of a `<Timeline>` edit area.
275
+ *
276
+ * When enabled, hovering over an action block shows a vertical blade line and
277
+ * an optional floating time pill. Clicking splits the block at the cut point.
278
+ *
279
+ * @example
280
+ * ```tsx
281
+ * {cutModeActive && (
282
+ * <CutOverlay
283
+ * data={editorData}
284
+ * scale={1}
285
+ * scaleSplitCount={10}
286
+ * scaleWidth={160}
287
+ * startLeft={20}
288
+ * rowHeight={40}
289
+ * editAreaTopOffset={42}
290
+ * gridSnap={snapEnabled}
291
+ * config={{ bladeColor: '#3b82f6', formatPillLabel: (t) => `${t.toFixed(2)}s` }}
292
+ * onCut={(rowId, actionId, cutTime) => handleCut(rowId, actionId, cutTime)}
293
+ * />
294
+ * )}
295
+ * ```
296
+ */
297
+ const CutOverlay: React.FC<CutOverlayProps> = ({
298
+ data,
299
+ scale,
300
+ scaleSplitCount,
301
+ scaleWidth,
302
+ startLeft,
303
+ rowHeight,
304
+ gridSnap,
305
+ editAreaTopOffset,
306
+ config,
307
+ onCut,
308
+ onModifierChange,
309
+ }) => {
310
+ const overlayRef = useRef<HTMLDivElement>(null);
311
+ const [blade, setBlade] = useState<BladeState | null>(null);
312
+ const [isModifierHeld, setIsModifierHeld] = useState(false);
313
+ // Ref so handleMouseMove always reads the latest value without stale closure
314
+ const isModifierHeldRef = useRef(false);
315
+ // Ref for onModifierChange so it never needs to be in a useEffect dep array
316
+ const onModifierChangeRef = useRef(onModifierChange);
317
+
318
+ // Keep both refs in sync on every render (no effect needed — runs synchronously)
319
+ isModifierHeldRef.current = isModifierHeld;
320
+ onModifierChangeRef.current = onModifierChange;
321
+
322
+ // Keep isModifierHeldRef in sync with state for the memoised handleMouseMove callback
323
+ useEffect(() => {
324
+ isModifierHeldRef.current = isModifierHeld;
325
+ }, [isModifierHeld]);
326
+
327
+ // Merge user config with defaults
328
+ const cfg = {
329
+ ...DEFAULT_CONFIG,
330
+ ...config,
331
+ // We safely preserve undefined for keyboardModifier if not passed
332
+ keyboardModifier: config?.keyboardModifier,
333
+ };
334
+
335
+ React.useEffect(() => {
336
+ if (!cfg.keyboardModifier) {
337
+ // If no modifier is required, we treat it as always held
338
+ setIsModifierHeld(true);
339
+ // No need to fire onModifierChange — caller should keep drag disabled
340
+ return;
341
+ }
342
+
343
+ const modifierTarget = cfg.keyboardModifier.toLowerCase();
344
+ // Modifier just configured — key is not yet held
345
+ setIsModifierHeld(false);
346
+ onModifierChangeRef.current?.(false);
347
+
348
+ const handleKeyDown = (e: KeyboardEvent) => {
349
+ if (e.key.toLowerCase() === modifierTarget) {
350
+ setIsModifierHeld(true);
351
+ onModifierChangeRef.current?.(true);
352
+ }
353
+ };
354
+
355
+ const handleKeyUp = (e: KeyboardEvent) => {
356
+ if (e.key.toLowerCase() === modifierTarget) {
357
+ setIsModifierHeld(false);
358
+ setBlade(null);
359
+ onModifierChangeRef.current?.(false);
360
+ }
361
+ };
362
+
363
+ const handleBlur = () => {
364
+ setIsModifierHeld(false);
365
+ setBlade(null);
366
+ onModifierChangeRef.current?.(false);
367
+ };
368
+
369
+ window.addEventListener('keydown', handleKeyDown);
370
+ window.addEventListener('keyup', handleKeyUp);
371
+ window.addEventListener('blur', handleBlur);
372
+
373
+ return () => {
374
+ window.removeEventListener('keydown', handleKeyDown);
375
+ window.removeEventListener('keyup', handleKeyUp);
376
+ window.removeEventListener('blur', handleBlur);
377
+ };
378
+ }, [cfg.keyboardModifier]); // onModifierChange intentionally omitted — accessed via ref
379
+
380
+ const handleMouseMove = useCallback(
381
+ (e: React.MouseEvent<HTMLDivElement>) => {
382
+ if (!isModifierHeldRef.current) return;
383
+
384
+ const rect = overlayRef.current?.getBoundingClientRect();
385
+ if (!rect) return;
386
+
387
+ // Read actual horizontal scroll from the ReactVirtualized grid DOM element.
388
+ // Timeline's onScroll prop only fires for vertical scroll, so we query the DOM.
389
+ const editGrid = overlayRef.current
390
+ ?.closest('.timeline-wrapper')
391
+ ?.querySelector('.timeline-editor-edit-area .ReactVirtualized__Grid') as HTMLElement | null;
392
+ const actualScrollLeft = editGrid?.scrollLeft ?? 0;
393
+
394
+ const relativeX = e.clientX - rect.left;
395
+ const relativeY = e.clientY - rect.top;
396
+
397
+ const editY = relativeY - editAreaTopOffset;
398
+ if (editY < 0) { setBlade(null); return; }
399
+
400
+ let time = pixelToTime(relativeX + actualScrollLeft, startLeft, scale, scaleWidth);
401
+ if (gridSnap) {
402
+ time = snapToGrid(time, scale, scaleSplitCount);
403
+ }
404
+
405
+ const rowIndex = Math.floor(editY / rowHeight);
406
+ const row = data[rowIndex];
407
+ if (!row) { setBlade(null); return; }
408
+
409
+ const action = row.actions.find((a) => time > a.start && time < a.end) ?? null;
410
+ if (!action) { setBlade(null); return; }
411
+
412
+ const blockLeft = timeToPixel(action.start, startLeft, scale, scaleWidth, actualScrollLeft);
413
+ const blockRight = timeToPixel(action.end, startLeft, scale, scaleWidth, actualScrollLeft);
414
+ const blockWidth = blockRight - blockLeft;
415
+ const bladeX = timeToPixel(time, startLeft, scale, scaleWidth, actualScrollLeft);
416
+
417
+ setBlade({ time, bladeX, blockLeft, blockWidth, rowTop: editAreaTopOffset + rowIndex * rowHeight, row, action });
418
+ },
419
+ [data, scale, scaleSplitCount, scaleWidth, startLeft, rowHeight, gridSnap, editAreaTopOffset],
420
+ );
421
+
422
+ const handleMouseLeave = useCallback(() => setBlade(null), []);
423
+
424
+ const handleClick = useCallback(
425
+ (e: React.MouseEvent<HTMLDivElement>) => {
426
+ if (!blade) return;
427
+ e.stopPropagation();
428
+ onCut(blade.row.id, blade.action.id, blade.time);
429
+ },
430
+ [blade, onCut],
431
+ );
432
+
433
+ return (
434
+ <div
435
+ ref={overlayRef}
436
+ className={`cut-overlay${blade ? ' cut-overlay--active' : ''}`}
437
+ style={{
438
+ cursor: isModifierHeld ? cfg.cursor : 'default',
439
+ // Make the overlay pass-through mouse events when the modifier isn't held,
440
+ // so actual timeline interactions can happen natively.
441
+ pointerEvents: isModifierHeld ? 'auto' : 'none',
442
+ }}
443
+ onMouseMove={handleMouseMove}
444
+ onMouseLeave={handleMouseLeave}
445
+ onClick={handleClick}
446
+ >
447
+ {blade && (
448
+ <div
449
+ className="cut-block-clip"
450
+ style={{
451
+ left: blade.blockLeft,
452
+ top: blade.rowTop,
453
+ width: blade.blockWidth,
454
+ height: rowHeight,
455
+ // Highlight: fully controllable via config
456
+ background: cfg.showBlockHighlight ? cfg.blockHighlightColor : 'transparent',
457
+ border: cfg.showBlockHighlight
458
+ ? `1px solid ${cfg.blockHighlightBorderColor}`
459
+ : 'none',
460
+ }}
461
+ >
462
+ {/* Vertical blade line */}
463
+ <div
464
+ className="cut-blade"
465
+ style={{
466
+ left: blade.bladeX - blade.blockLeft,
467
+ background: cfg.bladeColor,
468
+ boxShadow: `0 0 8px ${cfg.bladeColor}cc, 0 0 2px ${cfg.bladeColor}`,
469
+ }}
470
+ />
471
+
472
+ {/* Time pill — optional */}
473
+ {cfg.showPill && (
474
+ <div
475
+ className="cut-pill"
476
+ style={{
477
+ left: blade.bladeX - blade.blockLeft,
478
+ background: cfg.pillColor,
479
+ color: cfg.pillTextColor,
480
+ }}
481
+ >
482
+ {cfg.formatPillLabel(blade.time)}
483
+ </div>
484
+ )}
485
+ </div>
486
+ )}
487
+ </div>
488
+ );
489
+ };
490
+
491
+ export default CutOverlay;